/ 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 });