@webprodigies/morphite 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/morphite.mjs +148 -164
  2. package/package.json +1 -1
package/bin/morphite.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  /** SOURCE OF TRUTH KEYWORDS: terminalInstaller, installMorphite, INSTALLER_STEPS
3
3
  * WHAT: `npx @webprodigies/morphite` installs or updates the Mac app: checks the Mac, reads the
4
4
  * latest release manifest, downloads the zip for this CPU with progress, checks its SHA-256,
5
- * replaces the app in Applications and opens it with a short getting-started guide.
5
+ * replaces the app in Applications and opens it. It prints a few quiet lines (INSTALLER_LOOK).
6
6
  * WHY: Until Morphite has a Developer ID, a browser download is stopped by Gatekeeper and people must
7
7
  * allow it in Privacy & Security. A file fetched by this program is not marked as downloaded, so the
8
8
  * verified app opens directly. Running the same command again is the update.
@@ -32,88 +32,54 @@ const PACKAGE = '@webprodigies/morphite'
32
32
  const EARLIER_APP_ID = 'com.webprodigies.morphite'
33
33
 
34
34
  // ── Presentation ────────────────────────────────────────────────────────────
35
+ // INSTALLER_LOOK: quiet, Apple-like lines. A bold name, grey details, one thin progress bar with a
36
+ // bold percentage; no logo art, boxes or check marks. One live line updates in place while work runs.
35
37
  const tty = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.TERM !== 'dumb'
36
38
  const paint = (code) => (value) => (tty ? `\x1b[${code}m${value}\x1b[0m` : String(value))
37
39
  const bold = paint('1')
38
- const dim = paint('2')
39
- const grey = (shade) => paint(`38;5;${shade}`)
40
- const green = paint('38;5;114')
41
- const yellow = paint('38;5;221')
42
- const red = paint('38;5;203')
40
+ const grey = paint('38;5;245')
43
41
  const width = () => process.stdout.columns || 80
42
+ const say = (...lines) => { for (const line of lines) console.log(line) }
43
+ /** A heading and a grey explanation, used for results and problems alike. */
44
+ const note = (title, ...details) => say('', bold(title), ...details.map((detail) => grey(detail)))
44
45
 
45
- const LOGO = [
46
- '███╗ ███╗ ██████╗ ██████╗ ██████╗ ██╗ ██╗██╗████████╗███████╗',
47
- '████╗ ████║██╔═══██╗██╔══██╗██╔══██╗██║ ██║██║╚══██╔══╝██╔════╝',
48
- '██╔████╔██║██║ ██║██████╔╝██████╔╝███████║██║ ██║ █████╗ ',
49
- '██║╚██╔╝██║██║ ██║██╔══██╗██╔═══╝ ██╔══██║██║ ██║ ██╔══╝ ',
50
- '██║ ╚═╝ ██║╚██████╔╝██║ ██║██║ ██║ ██║██║ ██║ ███████╗',
51
- '╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝'
52
- ]
53
- function banner(subtitle) {
54
- console.log()
55
- if (width() >= LOGO[0].length + 4) {
56
- const shades = [255, 253, 251, 249, 246, 243]
57
- LOGO.forEach((line, index) => console.log(' ' + grey(shades[index])(line)))
58
- } else console.log(' ' + bold('M O R P H I T E'))
59
- console.log()
60
- console.log(' ' + subtitle)
61
- console.log()
62
- }
63
-
64
- const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
65
- const LABEL = 27
66
- /** One step line: a spinner while it runs, then ✓ with its result (or ✕ and the reason). */
67
- function step(label) {
68
- let frame = 0
69
- let detail = ''
70
- let timer
71
- const line = (mark) => ` ${mark} ${label.padEnd(LABEL)} ${detail}`
72
- const draw = () => {
73
- if (!tty) return
74
- const text = line(grey(245)(FRAMES[(frame = (frame + 1) % FRAMES.length)]))
75
- process.stdout.write('\r\x1b[2K' + text.slice(0, width() + 40))
76
- }
77
- if (tty) timer = setInterval(draw, 80)
78
- draw()
79
- const end = (mark, text) => {
80
- clearInterval(timer)
81
- detail = text ?? detail
82
- process.stdout.write((tty ? '\r\x1b[2K' : '') + line(mark) + '\n')
83
- }
84
- return {
85
- update(text) {
86
- detail = text
87
- draw()
88
- },
89
- done: (text) => end(green('✓'), text && dim(text)),
90
- fail: (text) => end(red('✕'), text && red(text)),
91
- skip: (text) => end(grey(243)('–'), text && dim(text))
46
+ /** The one line that changes while work runs; `done` keeps its final text, `clear` removes it. */
47
+ const live = {
48
+ text: '',
49
+ set(text) {
50
+ this.text = text
51
+ if (tty) process.stdout.write('\r\x1b[2K' + text)
52
+ },
53
+ done(text = this.text) {
54
+ if (tty) process.stdout.write('\r\x1b[2K')
55
+ if (text) console.log(text)
56
+ this.text = ''
57
+ },
58
+ clear() {
59
+ if (tty && this.text) process.stdout.write('\r\x1b[2K')
60
+ this.text = ''
92
61
  }
93
62
  }
94
- const megabytes = (bytes) => (bytes / 1e6).toFixed(1)
95
- function bar(ratio, size = Math.max(10, Math.min(28, width() - 64))) {
96
- const filled = Math.round(ratio * size)
97
- return grey(252)('━'.repeat(filled)) + grey(238)('━'.repeat(size - filled))
63
+ const megabytes = (bytes) => Math.round(bytes / 1e6).toString()
64
+ const BAR = 26
65
+ function bar(ratio) {
66
+ const filled = Math.round(Math.min(1, ratio) * BAR)
67
+ return paint('38;5;253')('━'.repeat(filled)) + paint('38;5;239')('━'.repeat(BAR - filled))
98
68
  }
99
- function box(title, lines) {
100
- const inner = Math.max(title.length + 2, ...lines.map((entry) => entry.plain.length)) + 2
101
- console.log(' ' + grey(243)('╭─ ') + bold(title) + ' ' + grey(243)('─'.repeat(Math.max(0, inner - title.length - 2)) + '╮'))
102
- for (const entry of lines)
103
- console.log(' ' + grey(243)('│') + ' ' + entry.text + ' '.repeat(inner - entry.plain.length - 1) + grey(243)('│'))
104
- console.log(' ' + grey(243)('╰' + '─'.repeat(inner + 1) + '╯'))
105
- }
106
- const row = (plain, text = plain) => ({ plain, text })
69
+ const progressLine = (ratio, detail) => `${bar(ratio)} ${bold(`${Math.floor(Math.min(1, ratio) * 100)}%`)} ${grey(detail)}`
70
+ /** "Morphite (Alpha)" is an Alpha release; a name without a stage is the latest release. */
71
+ const stageOf = (name) => (name.match(/\(([^)]+)\)\s*$/)?.[1] ?? 'Latest') + ' release'
72
+ const header = (name, version) => say('', bold(name), grey(version ? `${version} · ${stageOf(name)}` : 'Installer'), '')
107
73
 
108
74
  /** A yes only when the person types y (or --yes); Return alone, anything else or no keyboard cancels. */
109
75
  async function confirm(question) {
110
76
  if (flag('--yes')) return true
111
77
  if (!process.stdin.isTTY) {
112
- console.log(` ${dim('Run this command in Terminal to answer, or add --yes.')}`)
78
+ say(grey('Run this command in Terminal to answer, or add --yes.'))
113
79
  return false
114
80
  }
115
81
  const prompt = createInterface({ input: process.stdin, output: process.stdout })
116
- const answer = await new Promise((resolve) => prompt.question(` ${yellow('?')} ${question} `, resolve))
82
+ const answer = await new Promise((resolve) => prompt.question(`${bold(question)} ${grey('Type y to continue, anything else cancels')} `, resolve))
117
83
  prompt.close()
118
84
  return ['y', 'yes'].includes(answer.trim().toLowerCase())
119
85
  }
@@ -195,14 +161,7 @@ async function download(manifest, archive, folder, from, progress) {
195
161
  const destination = join(folder, archive.file)
196
162
  const hash = createHash('sha256')
197
163
  let received = 0
198
- const started = Date.now()
199
- const report = () => {
200
- const seconds = Math.max(0.25, (Date.now() - started) / 1000)
201
- progress(
202
- `${bar(received / archive.size)} ${String(Math.floor((received / archive.size) * 100)).padStart(3)}% ` +
203
- dim(`${megabytes(received)} / ${megabytes(archive.size)} MB · ${megabytes(received / seconds)} MB/s`)
204
- )
205
- }
164
+ const report = () => progress(received / archive.size, `${megabytes(received)} of ${megabytes(archive.size)} MB`)
206
165
  const output = createWriteStream(destination, { mode: 0o600 })
207
166
  const write = async (chunk) => {
208
167
  hash.update(chunk)
@@ -226,7 +185,6 @@ async function download(manifest, archive, folder, from, progress) {
226
185
  clearInterval(timer)
227
186
  output.destroy()
228
187
  }
229
- report()
230
188
  return { path: destination, sha256: hash.digest('hex'), size: received }
231
189
  }
232
190
 
@@ -239,46 +197,51 @@ async function writable(folder) {
239
197
  }
240
198
  async function main() {
241
199
  if (flag('--help') || flag('-h')) {
242
- console.log(`\n ${bold('npx ' + PACKAGE)} Install or update Morphite on this Mac.\n\n --yes Answer yes to every question\n --no-open Do not open Morphite afterwards\n --reinstall Install again even when this version is already installed\n --from DIR Install from a local release folder (for testing a release before publishing)\n`)
200
+ say('', bold('npx ' + PACKAGE), grey('Installs or updates Morphite on this Mac.'), '',
201
+ `--yes ${grey('Answer yes to every question')}`,
202
+ `--no-open ${grey('Do not open Morphite afterwards')}`,
203
+ `--reinstall ${grey('Install again even when this version is installed')}`,
204
+ `--from DIR ${grey('Install from a local release folder, to test a release before publishing')}`, '')
243
205
  return
244
206
  }
245
207
  const from = option('--from')
246
208
  if (platform() !== 'darwin') {
247
- banner(bold('Morphite') + dim(' installer'))
248
- console.log(` ${yellow('!')} Morphite runs on macOS for now. Windows and Linux are on the way.`)
249
- console.log(` ${dim('Follow along at https://morphite.build')}\n`)
209
+ header('Morphite')
210
+ note('Morphite runs on macOS for now.', 'Windows and Linux are on the way. Follow along at https://morphite.build')
211
+ say('')
250
212
  process.exitCode = 1
251
213
  return
252
214
  }
253
215
 
254
- const checking = step('Checking your Mac')
216
+ live.set(grey('Checking for the latest release…'))
255
217
  const macOS = (await run('/usr/bin/sw_vers', ['-productVersion'])).stdout.trim()
256
218
  const cpu = await macCpu()
257
- checking.update(dim(`macOS ${macOS} · ${cpu === 'arm64' ? 'Apple silicon' : 'Intel'}`))
258
-
259
- // The banner names the app from the release, so read it before printing anything else.
219
+ const chip = cpu === 'arm64' ? 'Apple silicon' : 'Intel'
220
+ // The header names the app and version from the release, so read it before printing anything else.
260
221
  let manifest
261
222
  try {
262
223
  manifest = await readManifest(from)
263
224
  } catch (error) {
264
- checking.skip()
265
- banner(bold('Morphite') + dim(' installer'))
266
- console.log(` ${red('✕')} ${error.message}\n`)
225
+ live.clear()
226
+ header('Morphite')
227
+ note('Morphite couldn’t be installed.', error.message)
228
+ say('')
267
229
  process.exitCode = 1
268
230
  return
269
231
  }
270
- process.stdout.write(tty ? '\r\x1b[2K' : '')
271
- banner(`${bold(manifest.name)} ${dim(`${manifest.version} · installer`)}`)
272
- checking.done(`macOS ${macOS} · ${cpu === 'arm64' ? 'Apple silicon' : 'Intel'}`)
232
+ live.clear()
233
+ header(manifest.name, manifest.version)
234
+ say(grey(`macOS ${macOS} · ${chip}`))
273
235
  if (!versionAtLeast(macOS, manifest.minimumMacOS)) {
274
- console.log(`\n ${red('✕')} ${manifest.name} needs macOS ${manifest.minimumMacOS} or later. This Mac has ${macOS}.\n`)
236
+ note(`${manifest.name} needs macOS ${manifest.minimumMacOS} or later.`, `This Mac has macOS ${macOS}.`)
237
+ say('')
275
238
  process.exitCode = 1
276
239
  return
277
240
  }
278
241
  const archive = manifest.archives[cpu]
279
- const release = step('Finding the latest release')
280
242
  if (!archive) {
281
- release.fail(`No ${cpu === 'arm64' ? 'Apple silicon' : 'Intel'} build in this release.`)
243
+ note(`This release has no ${chip} build yet.`, 'Nothing was changed.')
244
+ say('')
282
245
  process.exitCode = 1
283
246
  return
284
247
  }
@@ -286,91 +249,123 @@ async function main() {
286
249
  const personal = join(homedir(), 'Applications')
287
250
  const installed = await appsWithId([applications, personal], manifest.bundleId)
288
251
  const current = installed.length ? await versionOf(installed[0]) : null
289
- release.done(`${manifest.name} ${manifest.version}${current ? ` (installed: ${current})` : ''}`)
290
252
  const earlier = await appsWithId([applications, personal], EARLIER_APP_ID)
291
253
 
292
254
  // Already current (and nothing earlier to retire): say so and open it, without downloading again.
293
255
  if (current === manifest.version && !earlier.length && !flag('--reinstall')) {
294
- if (!flag('--no-open')) await run('/usr/bin/open', [installed[0]]).catch(() => undefined)
295
- console.log(`\n ${green('✓')} ${manifest.name} ${manifest.version} is already installed ${dim(`(${tilde(installed[0])})`)}\n`)
256
+ const opened = !flag('--no-open') && (await run('/usr/bin/open', [installed[0]]).then(() => true, () => false))
257
+ note('You’re up to date.', `${manifest.name} ${manifest.version} is installed in ${dirname(installed[0]) === '/Applications' ? 'Applications' : tilde(dirname(installed[0]))}.${opened ? ' It’s open now.' : ''}`)
258
+ say('')
296
259
  return
297
260
  }
298
261
 
299
262
  // The earlier Morphite app (com.webprodigies.morphite): a new app replaces it and starts fresh.
300
263
  // Promise nothing about its data; this installer only moves the app itself to the Trash.
301
264
  if (earlier.length) {
302
- const version = await versionOf(earlier[0])
303
- console.log()
304
- console.log(` ${yellow('!')} Found ${bold(['Morphite', version].filter(Boolean).join(' '))} ${dim(`in ${earlier.map((app) => tilde(dirname(app))).join(', ')}`)}`)
305
- console.log()
306
- console.log(` ${manifest.name} is a brand-new app. It replaces the earlier Morphite and starts fresh:`)
307
- console.log(` ${dim('•')} projects, chats and settings from the earlier app do not carry over`)
308
- console.log(` ${dim('•')} the earlier app moves to the Trash`)
309
- console.log(` ${dim('•')} your code and project folders are not touched`)
310
- console.log()
311
- console.log(` If there is anything from the earlier app you want to keep, back it up before continuing.`)
312
- console.log()
313
- if (!(await confirm(`Type ${bold('y')} and press Return to continue, or anything else to cancel:`))) {
314
- console.log(`\n Cancelled. Nothing was changed.\n`)
265
+ note(
266
+ 'An earlier Morphite is on this Mac.',
267
+ `${manifest.name} is a new app and starts fresh. Projects, chats and`,
268
+ 'settings from the earlier app don’t carry over; it moves to the Trash.',
269
+ 'Your code and project folders aren’t touched. Back up anything first.'
270
+ )
271
+ say('')
272
+ if (!(await confirm('Continue?'))) {
273
+ note('Cancelled.', 'Nothing was changed.')
274
+ say('')
315
275
  return
316
276
  }
277
+ say('')
317
278
  }
318
279
 
319
280
  const target = (await writable(applications)) ? applications : personal
281
+ const place = target === applications ? 'Applications' : tilde(target)
320
282
  const destination = join(target, `${manifest.name}.app`)
321
283
  const work = await mkdtemp(join(tmpdir(), 'morphite-installer-'))
322
284
  const cleanup = () => rm(work, { recursive: true, force: true }).catch(() => undefined)
323
- process.once('SIGINT', () => {
324
- process.stdout.write('\n')
285
+ const stopped = () => {
286
+ live.done()
287
+ note('Stopped. Nothing was changed.', `Run npx ${PACKAGE}@latest again when you’re ready.`)
288
+ say('')
325
289
  void cleanup().then(() => process.exit(130))
326
- })
290
+ }
291
+ process.once('SIGINT', stopped)
292
+ // A problem after the download started: keep the last line where it stopped, then say what happened.
293
+ const stop = (title, detail) => {
294
+ live.done()
295
+ note(title, detail)
296
+ return Object.assign(Error(title), { reported: true })
297
+ }
327
298
  try {
328
- const downloading = step('Downloading')
329
299
  let file
330
300
  try {
331
- file = await download(manifest, archive, work, from, (text) => downloading.update(text))
301
+ file = await download(manifest, archive, work, from, (ratio, detail) => live.set(progressLine(ratio, detail)))
332
302
  } catch (error) {
333
- downloading.fail(error.message)
334
- throw Object.assign(error, { reported: true })
303
+ throw stop('The download stopped.', `${error.message} Nothing was changed.`)
335
304
  }
336
- downloading.done(`${megabytes(file.size)} MB`)
305
+ if (file.size !== archive.size || file.sha256 !== archive.sha256)
306
+ throw stop('The download doesn’t match the release.', 'Nothing was changed. Run the command again.')
307
+ live.done(progressLine(1, `${megabytes(file.size)} MB · verified`))
337
308
 
338
- const verifying = step('Verifying')
339
- if (file.size !== archive.size || file.sha256 !== archive.sha256) {
340
- verifying.fail('The download does not match the release checksum. Nothing was installed.')
341
- throw Object.assign(Error('checksum'), { reported: true })
342
- }
343
- verifying.done('SHA-256 matches the release')
344
-
345
- const unpacking = step('Unpacking')
309
+ live.set(grey(`${current ? 'Updating' : 'Installing'} in ${place}…`))
346
310
  await mkdir(target, { recursive: true })
347
311
  // Stage beside the destination so the final move is a rename on the same disk.
348
312
  const stage = await mkdtemp(join(target, '.morphite-install-'))
349
313
  try {
350
314
  await run('/usr/bin/ditto', ['-x', '-k', file.path, stage])
351
315
  const candidate = join(stage, `${manifest.name}.app`)
352
- if ((await bundleIdOf(candidate)) !== manifest.bundleId) throw Error('The downloaded app is not the expected Morphite build.')
316
+ if ((await bundleIdOf(candidate)) !== manifest.bundleId) throw stop('The downloaded app isn’t the expected Morphite build.', 'Nothing was changed.')
353
317
  await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', candidate]).catch(() => {
354
- throw Error('The downloaded app failed its signature check. Nothing was installed.')
318
+ throw stop('The downloaded app failed its signature check.', 'Nothing was changed.')
355
319
  })
356
320
  // Fetched by this program, the app carries no download mark; clear one defensively all the same.
357
321
  await run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', candidate]).catch(() => undefined)
358
- unpacking.done(manifest.name + '.app')
359
322
 
360
- // Morphite must be closed before its app is replaced; the person closes it, never this script.
323
+ // Morphite must be closed before its app is replaced. The person quits it from its menu, or
324
+ // presses Enter and the installer asks it to quit the normal way (never a forced stop, so its
325
+ // work ends cleanly). No keyboard shortcut is suggested: ⌘Q here would quit Terminal.
361
326
  let open = await runningFrom([...installed, ...earlier])
362
327
  if (open.length) {
363
328
  const names = open.map((app) => basename(app, '.app')).join(' and ')
364
- const waiting = step(`Closing ${names}`)
365
- waiting.update(yellow(`Quit ${names} (⌘Q) to continue`))
366
- while (open.length) {
367
- await new Promise((resolve) => setTimeout(resolve, 700))
368
- open = await runningFrom([...installed, ...earlier])
329
+ live.clear()
330
+ note(
331
+ `${names} is open.`,
332
+ `Quit it from its menu bar (${names} → Quit), and this ${current ? 'update' : 'install'} continues on its own.`,
333
+ tty ? 'Or press Return to quit it now. Control-C stops without changing anything.' : 'Control-C stops without changing anything.'
334
+ )
335
+ say('')
336
+ const input = tty && process.stdin.isTTY ? process.stdin : null
337
+ const key = (data) => {
338
+ const text = String(data)
339
+ if (text.includes('\u0003')) return stopped()
340
+ if (!/[\r\n]/.test(text)) return
341
+ live.set(grey(`Quitting ${names}…`))
342
+ for (const app of open) {
343
+ const id = earlier.includes(app) ? EARLIER_APP_ID : manifest.bundleId
344
+ void run('/usr/bin/osascript', ['-e', `tell application id ${JSON.stringify(id)} to quit`]).catch(() => undefined)
345
+ }
346
+ }
347
+ if (input) {
348
+ input.setRawMode(true)
349
+ input.resume()
350
+ input.on('data', key)
369
351
  }
370
- waiting.done('closed')
352
+ live.set(grey(`Waiting for ${names} to quit…`))
353
+ try {
354
+ while (open.length) {
355
+ await new Promise((resolve) => setTimeout(resolve, 700))
356
+ open = await runningFrom([...installed, ...earlier])
357
+ }
358
+ } finally {
359
+ if (input) {
360
+ input.off('data', key)
361
+ input.setRawMode(false)
362
+ input.pause()
363
+ }
364
+ }
365
+ live.done(grey(`${names} quit`))
366
+ live.set(grey(`${current ? 'Updating' : 'Installing'} in ${place}…`))
371
367
  }
372
368
 
373
- const installing = step(current ? 'Updating' : 'Installing')
374
369
  const replaced = []
375
370
  try {
376
371
  // Set the old app aside in the staging folder; it is removed with that folder only once the
@@ -381,42 +376,30 @@ async function main() {
381
376
  await rename(previous, aside)
382
377
  replaced.push([previous, aside])
383
378
  }
384
- if (await stat(destination).catch(() => null)) throw Error(`${destination} already exists and is not ${manifest.name}.`)
379
+ if (await stat(destination).catch(() => null)) throw Error(`${tilde(destination)} already exists and isn’t ${manifest.name}.`)
385
380
  await rename(candidate, destination)
386
381
  } catch (error) {
387
382
  // Put every earlier copy back where it was; the install did not happen.
388
383
  for (const [original, moved] of replaced.reverse()) await rename(moved, original).catch(() => undefined)
389
- installing.fail(error.message)
390
- throw Object.assign(error, { reported: true })
384
+ if (error.reported) throw error
385
+ throw stop(`${manifest.name} couldn’t be installed.`, `${error.message} Nothing was changed.`)
391
386
  }
392
- installing.done(destination.replace(homedir(), '~'))
387
+ live.done(grey(`${current ? 'Updated' : 'Installed'} in ${place}`))
393
388
  } finally {
394
389
  await rm(stage, { recursive: true, force: true }).catch(() => undefined)
395
390
  }
396
391
 
397
392
  // Agreed above: the earlier app goes to the Trash (recoverable); its data folder stays.
398
- for (const app of earlier) {
399
- const retiring = step('Retiring the earlier app')
400
- if (await moveToTrash(app)) retiring.done(`${basename(app)} moved to the Trash`)
401
- else retiring.skip(`drag ${basename(app)} from Applications to the Trash`)
402
- }
393
+ for (const app of earlier)
394
+ say(grey((await moveToTrash(app)) ? 'Moved the earlier Morphite to the Trash' : `Drag ${basename(app)} from ${tilde(dirname(app))} to the Trash`))
403
395
 
404
- if (!flag('--no-open')) {
405
- const opening = step(`Opening ${manifest.name}`)
406
- await run('/usr/bin/open', [destination]).then(
407
- () => opening.done(),
408
- () => opening.skip('open it from Applications')
409
- )
410
- }
411
- console.log()
412
- box(current ? `${manifest.name} is up to date` : `Welcome to ${manifest.name}`, [
413
- row('1 Sign in with your Morphite account (your browser opens).', `${bold('1')} Sign in with your Morphite account ${dim('(your browser opens)')}.`),
414
- row('2 Connect Claude Code, Codex or another coding agent.', `${bold('2')} Connect Claude Code, Codex or another coding agent.`),
415
- row('3 Open a project folder, start a room and say what to build.', `${bold('3')} Open a project folder, start a room and say what to build.`),
416
- row(''),
417
- row(`Update any time: npx ${PACKAGE}@latest`, `${dim('Update any time:')} ${bold(`npx ${PACKAGE}@latest`)}`)
418
- ])
419
- console.log()
396
+ const opened = !flag('--no-open') && (await run('/usr/bin/open', [destination]).then(() => true, () => false))
397
+ note(
398
+ `${manifest.name} is ready.`,
399
+ opened ? `It’s open now, and in ${place} and Spotlight.` : `Open it from ${place} or Spotlight.`,
400
+ 'Run this command again to update.'
401
+ )
402
+ say('')
420
403
  } finally {
421
404
  await cleanup()
422
405
  }
@@ -424,11 +407,12 @@ async function main() {
424
407
 
425
408
  const [major] = process.versions.node.split('.').map(Number)
426
409
  if (major < 18) {
427
- console.error(`\n Morphite's installer needs Node.js 18 or later (you have ${process.versions.node}). Get it from https://nodejs.org\n`)
410
+ console.error(`\nMorphite’s installer needs Node.js 18 or later (you have ${process.versions.node}). Get it from https://nodejs.org\n`)
428
411
  process.exit(1)
429
412
  }
430
413
  main().catch((error) => {
431
- if (!error?.reported) console.error(`\n ${red('✕')} ${error?.message ?? error}\n`)
432
- else console.log()
414
+ live.done()
415
+ if (!error?.reported) note('Morphite couldn’t be installed.', String(error?.message ?? error))
416
+ say('')
433
417
  process.exitCode = 1
434
418
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webprodigies/morphite",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Install or update Morphite on your Mac: npx @webprodigies/morphite",
5
5
  "type": "module",
6
6
  "bin": {