/ cli.js
cli.js
  1  #!/usr/bin/env node
  2  'use strict';
  3  const path = require('path');
  4  const fs = require('fs');
  5  const meow = require('meow');
  6  const appdmg = require('appdmg');
  7  const plist = require('plist');
  8  const Ora = require('ora');
  9  const execa = require('execa');
 10  const addLicenseAgreementIfNeeded = require('./sla.js');
 11  const composeIcon = require('./compose-icon');
 12  
 13  if (process.platform !== 'darwin') {
 14  	console.error('macOS only');
 15  	process.exit(1);
 16  }
 17  
 18  const cli = meow(`
 19  	Usage
 20  	  $ create-dmg <app> [destination]
 21  
 22  	Options
 23  	  --overwrite          Overwrite existing DMG with the same name
 24  	  --identity=<value>   Manually set code signing identity (automatic by default)
 25  	  --dmg-title=<value>  Manually set DMG title (must be <=27 characters) [default: App name]
 26  
 27  	Examples
 28  	  $ create-dmg 'Lungo.app'
 29  	  $ create-dmg 'Lungo.app' Build/Releases
 30  `, {
 31  	flags: {
 32  		overwrite: {
 33  			type: 'boolean'
 34  		},
 35  		identity: {
 36  			type: 'string'
 37  		},
 38  		dmgTitle: {
 39  			type: 'string'
 40  		}
 41  	}
 42  });
 43  
 44  let [appPath, destinationPath] = cli.input;
 45  
 46  if (!appPath) {
 47  	console.error('Specify an app');
 48  	process.exit(1);
 49  }
 50  
 51  if (!destinationPath) {
 52  	destinationPath = process.cwd();
 53  }
 54  
 55  const infoPlistPath = path.join(appPath, 'Contents/Info.plist');
 56  
 57  let infoPlist;
 58  try {
 59  	infoPlist = fs.readFileSync(infoPlistPath, 'utf8');
 60  } catch (error) {
 61  	if (error.code === 'ENOENT') {
 62  		console.error(`Could not find \`${path.relative(process.cwd(), appPath)}\``);
 63  		process.exit(1);
 64  	}
 65  
 66  	throw error;
 67  }
 68  
 69  const ora = new Ora('Creating DMG');
 70  ora.start();
 71  
 72  async function init() {
 73  	let appInfo;
 74  	try {
 75  		appInfo = plist.parse(infoPlist);
 76  	} catch (_) {
 77  		const {stdout} = await execa('/usr/bin/plutil', ['-convert', 'xml1', '-o', '-', infoPlistPath]);
 78  		appInfo = plist.parse(stdout);
 79  	}
 80  
 81  	const appName = appInfo.CFBundleDisplayName || appInfo.CFBundleName;
 82  	if (!appName) {
 83  		throw new Error('The app must have `CFBundleDisplayName` or `CFBundleName` defined in its `Info.plist`.');
 84  	}
 85  
 86  	const dmgTitle = cli.flags.dmgTitle || appName;
 87  	const dmgFilename = `${appName} ${appInfo.CFBundleShortVersionString}.dmg`;
 88  	const dmgPath = path.join(destinationPath, dmgFilename);
 89  
 90  	if (dmgTitle > 27) {
 91  		ora.fail('The disk image title cannot exceed 27 characters. This is a limitation in a dependency: https://github.com/LinusU/node-alias/issues/7');
 92  		process.exit(1);
 93  	}
 94  
 95  	if (cli.flags.overwrite) {
 96  		try {
 97  			fs.unlinkSync(dmgPath);
 98  		} catch (_) {}
 99  	}
100  
101  	const hasAppIcon = appInfo.CFBundleIconFile;
102  	let composedIconPath;
103  	if (hasAppIcon) {
104  		ora.text = 'Creating icon';
105  		const appIconName = appInfo.CFBundleIconFile.replace(/\.icns/, '');
106  		composedIconPath = await composeIcon(path.join(appPath, 'Contents/Resources', `${appIconName}.icns`));
107  	}
108  
109  	const minSystemVersion = (Object.prototype.hasOwnProperty.call(appInfo, 'LSMinimumSystemVersion') && appInfo.LSMinimumSystemVersion.length > 0) ? appInfo.LSMinimumSystemVersion.toString() : '10.11';
110  	const minorVersion = Number(minSystemVersion.split('.')[1]) || 0;
111  	const dmgFormat = (minorVersion >= 11) ? 'ULFO' : 'UDZO'; // ULFO requires 10.11+
112  	ora.info(`Minimum runtime ${minSystemVersion} detected, using ${dmgFormat} format`).start();
113  
114  	const ee = appdmg({
115  		target: dmgPath,
116  		basepath: process.cwd(),
117  		specification: {
118  			title: dmgTitle,
119  			icon: composedIconPath,
120  			//
121  			// Use transparent background and `background-color` option when this is fixed:
122  			// https://github.com/LinusU/node-appdmg/issues/135
123  			background: path.join(__dirname, 'assets/dmg-background.png'),
124  			'icon-size': 75,
125  			format: dmgFormat,
126  			window: {
127  				size: {
128  					width: 660,
129  					height: 420
130  				}
131  			},
132  			contents: [
133  				{
134  					x: 200,
135  					y: 140,
136  					type: 'file',
137  					path: appPath
138  				},
139  				{
140  					x: 440,
141  					y: 140,
142  					type: 'link',
143  					path: '/Applications'
144  				},
145  				{
146  					x: 320,
147  					y: 315,
148  					type: 'file',
149  					path: path.join(__dirname, 'assets/OPEN ME.webloc')
150  				},
151  				{
152  					x: 280,
153  					y: 450,
154  					type: 'position',
155  					path: '.background'
156  				},
157  				{
158  					x: 380,
159  					y: 450,
160  					type: 'position',
161  					path: '.VolumeIcon.icns'
162  				}
163  			]
164  		}
165  	});
166  
167  	ee.on('progress', info => {
168  		if (info.type === 'step-begin') {
169  			ora.text = info.title;
170  		}
171  	});
172  
173  	ee.on('finish', async () => {
174  		try {
175  			ora.text = 'Adding Software License Agreement if needed';
176  			await addLicenseAgreementIfNeeded(dmgPath, dmgFormat);
177  
178  			if (hasAppIcon) {
179  				ora.text = 'Replacing DMG icon';
180  				// `seticon`` is a native tool to change files icons (Source: https://github.com/sveinbjornt/osxiconutils)
181  				await execa(path.join(__dirname, 'seticon'), [composedIconPath, dmgPath]);
182  			}
183  
184  			ora.text = 'Code signing DMG';
185  			let identity;
186  			const {stdout} = await execa('/usr/bin/security', ['find-identity', '-v', '-p', 'codesigning']);
187  			if (cli.flags.identity && stdout.includes(`"${cli.flags.identity}"`)) {
188  				identity = cli.flags.identity;
189  			} else if (!cli.flags.identity && stdout.includes('Developer ID Application:')) {
190  				identity = 'Developer ID Application';
191  			} else if (!cli.flags.identity && stdout.includes('Mac Developer:')) {
192  				identity = 'Mac Developer';
193  			}
194  
195  			if (!identity) {
196  				const error = new Error();
197  				error.stderr = 'No suitable code signing identity found';
198  				throw error;
199  			}
200  
201  			await execa('/usr/bin/codesign', ['--sign', identity, dmgPath]);
202  			const {stderr} = await execa('/usr/bin/codesign', [dmgPath, '--display', '--verbose=2']);
203  
204  			const match = /^Authority=(.*)$/m.exec(stderr);
205  			if (!match) {
206  				ora.fail('Not code signed');
207  				process.exit(1);
208  			}
209  
210  			ora.info(`Code signing identity: ${match[1]}`).start();
211  			ora.succeed(`Created “${dmgFilename}”`);
212  		} catch (error) {
213  			ora.fail(`Code signing failed. The DMG is fine, just not code signed.\n${Object.prototype.hasOwnProperty.call(error, 'stderr') ? error.stderr.trim() : error}`);
214  			process.exit(2);
215  		}
216  	});
217  
218  	ee.on('error', error => {
219  		ora.fail(`Building the DMG failed. ${error}`);
220  		process.exit(1);
221  	});
222  }
223  
224  init().catch(error => {
225  	ora.fail((error && error.stack) || error);
226  	process.exit(1);
227  });