@webprodigies/morphite 0.2.0
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.
- package/README.md +11 -0
- package/bin/morphite.mjs +434 -0
- package/manifest.mjs +49 -0
- package/package.json +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Morphite
|
|
2
|
+
|
|
3
|
+
Install or update Morphite on your Mac:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @webprodigies/morphite
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The installer checks your Mac, downloads the latest release for Apple silicon or Intel, verifies its SHA-256 checksum, puts the app in Applications and opens it. Run the same command again to update. Your projects and settings live outside the app and are never changed by the installer.
|
|
10
|
+
|
|
11
|
+
Requires macOS 12 or later and Node.js 18 or later. Windows and Linux are on the way.
|
package/bin/morphite.mjs
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** SOURCE OF TRUTH KEYWORDS: terminalInstaller, installMorphite, INSTALLER_STEPS
|
|
3
|
+
* WHAT: `npx @webprodigies/morphite` installs or updates the Mac app: checks the Mac, reads the
|
|
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.
|
|
6
|
+
* WHY: Until Morphite has a Developer ID, a browser download is stopped by Gatekeeper and people must
|
|
7
|
+
* allow it in Privacy & Security. A file fetched by this program is not marked as downloaded, so the
|
|
8
|
+
* verified app opens directly. Running the same command again is the update.
|
|
9
|
+
* WHERE: ../manifest.mjs owns the release file format; scripts/release-publish.ts publishes releases;
|
|
10
|
+
* the desktop app's Update action opens this command in Terminal. User data lives outside the app
|
|
11
|
+
* bundle and is never touched here.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { createWriteStream } from 'node:fs'
|
|
15
|
+
import { access, constants, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, rmdir, stat } from 'node:fs/promises'
|
|
16
|
+
import { execFile } from 'node:child_process'
|
|
17
|
+
import { homedir, platform, tmpdir, arch as nodeArch } from 'node:os'
|
|
18
|
+
import { join, basename, dirname } from 'node:path'
|
|
19
|
+
import { once } from 'node:events'
|
|
20
|
+
import { createInterface } from 'node:readline'
|
|
21
|
+
import { promisify } from 'node:util'
|
|
22
|
+
import { INSTALL_MANIFEST_FILE, latestManifestUrl, parseInstallManifest, releaseFileUrl } from '../manifest.mjs'
|
|
23
|
+
|
|
24
|
+
const run = promisify(execFile)
|
|
25
|
+
const args = process.argv.slice(2)
|
|
26
|
+
const flag = (name) => args.includes(name)
|
|
27
|
+
const option = (name) => {
|
|
28
|
+
const index = args.indexOf(name)
|
|
29
|
+
return index >= 0 ? args[index + 1] : undefined
|
|
30
|
+
}
|
|
31
|
+
const PACKAGE = '@webprodigies/morphite'
|
|
32
|
+
const EARLIER_APP_ID = 'com.webprodigies.morphite'
|
|
33
|
+
|
|
34
|
+
// ── Presentation ────────────────────────────────────────────────────────────
|
|
35
|
+
const tty = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.TERM !== 'dumb'
|
|
36
|
+
const paint = (code) => (value) => (tty ? `\x1b[${code}m${value}\x1b[0m` : String(value))
|
|
37
|
+
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')
|
|
43
|
+
const width = () => process.stdout.columns || 80
|
|
44
|
+
|
|
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))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
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))
|
|
98
|
+
}
|
|
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 })
|
|
107
|
+
|
|
108
|
+
/** A yes only when the person types y (or --yes); Return alone, anything else or no keyboard cancels. */
|
|
109
|
+
async function confirm(question) {
|
|
110
|
+
if (flag('--yes')) return true
|
|
111
|
+
if (!process.stdin.isTTY) {
|
|
112
|
+
console.log(` ${dim('Run this command in Terminal to answer, or add --yes.')}`)
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout })
|
|
116
|
+
const answer = await new Promise((resolve) => prompt.question(` ${yellow('?')} ${question} `, resolve))
|
|
117
|
+
prompt.close()
|
|
118
|
+
return ['y', 'yes'].includes(answer.trim().toLowerCase())
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── The Mac ─────────────────────────────────────────────────────────────────
|
|
122
|
+
async function macCpu() {
|
|
123
|
+
if (nodeArch() === 'arm64') return 'arm64'
|
|
124
|
+
// An Intel build of Node under Rosetta still runs on Apple silicon; install the native app.
|
|
125
|
+
const translated = await run('/usr/sbin/sysctl', ['-in', 'sysctl.proc_translated']).then((r) => r.stdout.trim(), () => '0')
|
|
126
|
+
return translated === '1' ? 'arm64' : 'x64'
|
|
127
|
+
}
|
|
128
|
+
const versionAtLeast = (actual, minimum) => {
|
|
129
|
+
const [a, b] = [actual, minimum].map((value) => value.split('.').map(Number))
|
|
130
|
+
for (let index = 0; index < 3; index++) if ((a[index] ?? 0) !== (b[index] ?? 0)) return (a[index] ?? 0) > (b[index] ?? 0)
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
async function bundleIdOf(app) {
|
|
134
|
+
return run('/usr/bin/plutil', ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', join(app, 'Contents/Info.plist')]).then(
|
|
135
|
+
(r) => r.stdout.trim(),
|
|
136
|
+
() => null
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
async function appsWithId(folders, id) {
|
|
140
|
+
const found = []
|
|
141
|
+
for (const folder of folders) {
|
|
142
|
+
const entries = await readdir(folder).catch(() => [])
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (!entry.endsWith('.app')) continue
|
|
145
|
+
const path = join(folder, entry)
|
|
146
|
+
const info = await lstat(path).catch(() => null)
|
|
147
|
+
if (info?.isDirectory() && (await bundleIdOf(path)) === id) found.push(path)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return found
|
|
151
|
+
}
|
|
152
|
+
const versionOf = (app) =>
|
|
153
|
+
run('/usr/bin/plutil', ['-extract', 'CFBundleShortVersionString', 'raw', '-o', '-', join(app, 'Contents/Info.plist')]).then(
|
|
154
|
+
(r) => r.stdout.trim(),
|
|
155
|
+
() => null
|
|
156
|
+
)
|
|
157
|
+
const tilde = (path) => path.replace(homedir(), '~')
|
|
158
|
+
async function runningFrom(apps) {
|
|
159
|
+
const { stdout } = await run('/bin/ps', ['-axo', 'command='])
|
|
160
|
+
return apps.filter((app) => stdout.split('\n').some((command) => command.startsWith(join(app, 'Contents/MacOS') + '/')))
|
|
161
|
+
}
|
|
162
|
+
/** Asks Finder to move an app to the Trash (recoverable). macOS may first ask the person to let the
|
|
163
|
+
* terminal use Finder; false when that is declined or Finder cannot move it. */
|
|
164
|
+
async function moveToTrash(path) {
|
|
165
|
+
const script = `tell application "Finder" to delete (POSIX file ${JSON.stringify(path)} as alias)`
|
|
166
|
+
return run('/usr/bin/osascript', ['-e', script]).then(
|
|
167
|
+
() => true,
|
|
168
|
+
() => false
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Release ─────────────────────────────────────────────────────────────────
|
|
173
|
+
async function fetchWithRetry(url, attempts = 3) {
|
|
174
|
+
let failure
|
|
175
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
176
|
+
try {
|
|
177
|
+
const response = await fetch(url, { headers: { 'user-agent': `${PACKAGE} installer` }, redirect: 'follow' })
|
|
178
|
+
if (response.status === 404) return response
|
|
179
|
+
if (!response.ok) throw Error(`HTTP ${response.status}`)
|
|
180
|
+
return response
|
|
181
|
+
} catch (error) {
|
|
182
|
+
failure = error
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, 600 * attempt))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
throw Error(`Could not reach GitHub (${failure?.message ?? 'network error'}). Check your connection and run the command again.`)
|
|
187
|
+
}
|
|
188
|
+
async function readManifest(from) {
|
|
189
|
+
if (from) return parseInstallManifest(JSON.parse(await readFile(join(from, INSTALL_MANIFEST_FILE), 'utf8')))
|
|
190
|
+
const response = await fetchWithRetry(latestManifestUrl())
|
|
191
|
+
if (response.status === 404) throw Error('No Morphite release is available yet. Try again soon.')
|
|
192
|
+
return parseInstallManifest(await response.json())
|
|
193
|
+
}
|
|
194
|
+
async function download(manifest, archive, folder, from, progress) {
|
|
195
|
+
const destination = join(folder, archive.file)
|
|
196
|
+
const hash = createHash('sha256')
|
|
197
|
+
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
|
+
}
|
|
206
|
+
const output = createWriteStream(destination, { mode: 0o600 })
|
|
207
|
+
const write = async (chunk) => {
|
|
208
|
+
hash.update(chunk)
|
|
209
|
+
received += chunk.length
|
|
210
|
+
if (received > archive.size) throw Error('The download is larger than the release says. Nothing was installed.')
|
|
211
|
+
if (!output.write(chunk)) await once(output, 'drain')
|
|
212
|
+
}
|
|
213
|
+
const timer = setInterval(report, 120)
|
|
214
|
+
try {
|
|
215
|
+
if (from) {
|
|
216
|
+
const { createReadStream } = await import('node:fs')
|
|
217
|
+
for await (const chunk of createReadStream(join(from, archive.file))) await write(chunk)
|
|
218
|
+
} else {
|
|
219
|
+
const response = await fetchWithRetry(releaseFileUrl(manifest.tag, archive.file))
|
|
220
|
+
if (!response.ok || !response.body) throw Error(`The release file is unavailable (HTTP ${response.status}).`)
|
|
221
|
+
for await (const chunk of response.body) await write(chunk)
|
|
222
|
+
}
|
|
223
|
+
output.end()
|
|
224
|
+
await once(output, 'finish')
|
|
225
|
+
} finally {
|
|
226
|
+
clearInterval(timer)
|
|
227
|
+
output.destroy()
|
|
228
|
+
}
|
|
229
|
+
report()
|
|
230
|
+
return { path: destination, sha256: hash.digest('hex'), size: received }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Install ─────────────────────────────────────────────────────────────────
|
|
234
|
+
async function writable(folder) {
|
|
235
|
+
return access(folder, constants.W_OK).then(
|
|
236
|
+
() => true,
|
|
237
|
+
() => false
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
async function main() {
|
|
241
|
+
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`)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
const from = option('--from')
|
|
246
|
+
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`)
|
|
250
|
+
process.exitCode = 1
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const checking = step('Checking your Mac')
|
|
255
|
+
const macOS = (await run('/usr/bin/sw_vers', ['-productVersion'])).stdout.trim()
|
|
256
|
+
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.
|
|
260
|
+
let manifest
|
|
261
|
+
try {
|
|
262
|
+
manifest = await readManifest(from)
|
|
263
|
+
} catch (error) {
|
|
264
|
+
checking.skip()
|
|
265
|
+
banner(bold('Morphite') + dim(' installer'))
|
|
266
|
+
console.log(` ${red('✕')} ${error.message}\n`)
|
|
267
|
+
process.exitCode = 1
|
|
268
|
+
return
|
|
269
|
+
}
|
|
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'}`)
|
|
273
|
+
if (!versionAtLeast(macOS, manifest.minimumMacOS)) {
|
|
274
|
+
console.log(`\n ${red('✕')} ${manifest.name} needs macOS ${manifest.minimumMacOS} or later. This Mac has ${macOS}.\n`)
|
|
275
|
+
process.exitCode = 1
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
const archive = manifest.archives[cpu]
|
|
279
|
+
const release = step('Finding the latest release')
|
|
280
|
+
if (!archive) {
|
|
281
|
+
release.fail(`No ${cpu === 'arm64' ? 'Apple silicon' : 'Intel'} build in this release.`)
|
|
282
|
+
process.exitCode = 1
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
const applications = '/Applications'
|
|
286
|
+
const personal = join(homedir(), 'Applications')
|
|
287
|
+
const installed = await appsWithId([applications, personal], manifest.bundleId)
|
|
288
|
+
const current = installed.length ? await versionOf(installed[0]) : null
|
|
289
|
+
release.done(`${manifest.name} ${manifest.version}${current ? ` (installed: ${current})` : ''}`)
|
|
290
|
+
const earlier = await appsWithId([applications, personal], EARLIER_APP_ID)
|
|
291
|
+
|
|
292
|
+
// Already current (and nothing earlier to retire): say so and open it, without downloading again.
|
|
293
|
+
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`)
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The earlier Morphite app (com.webprodigies.morphite): a new app replaces it and starts fresh.
|
|
300
|
+
// Promise nothing about its data; this installer only moves the app itself to the Trash.
|
|
301
|
+
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`)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const target = (await writable(applications)) ? applications : personal
|
|
320
|
+
const destination = join(target, `${manifest.name}.app`)
|
|
321
|
+
const work = await mkdtemp(join(tmpdir(), 'morphite-installer-'))
|
|
322
|
+
const cleanup = () => rm(work, { recursive: true, force: true }).catch(() => undefined)
|
|
323
|
+
process.once('SIGINT', () => {
|
|
324
|
+
process.stdout.write('\n')
|
|
325
|
+
void cleanup().then(() => process.exit(130))
|
|
326
|
+
})
|
|
327
|
+
try {
|
|
328
|
+
const downloading = step('Downloading')
|
|
329
|
+
let file
|
|
330
|
+
try {
|
|
331
|
+
file = await download(manifest, archive, work, from, (text) => downloading.update(text))
|
|
332
|
+
} catch (error) {
|
|
333
|
+
downloading.fail(error.message)
|
|
334
|
+
throw Object.assign(error, { reported: true })
|
|
335
|
+
}
|
|
336
|
+
downloading.done(`${megabytes(file.size)} MB`)
|
|
337
|
+
|
|
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')
|
|
346
|
+
await mkdir(target, { recursive: true })
|
|
347
|
+
// Stage beside the destination so the final move is a rename on the same disk.
|
|
348
|
+
const stage = await mkdtemp(join(target, '.morphite-install-'))
|
|
349
|
+
try {
|
|
350
|
+
await run('/usr/bin/ditto', ['-x', '-k', file.path, stage])
|
|
351
|
+
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.')
|
|
353
|
+
await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', candidate]).catch(() => {
|
|
354
|
+
throw Error('The downloaded app failed its signature check. Nothing was installed.')
|
|
355
|
+
})
|
|
356
|
+
// Fetched by this program, the app carries no download mark; clear one defensively all the same.
|
|
357
|
+
await run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', candidate]).catch(() => undefined)
|
|
358
|
+
unpacking.done(manifest.name + '.app')
|
|
359
|
+
|
|
360
|
+
// Morphite must be closed before its app is replaced; the person closes it, never this script.
|
|
361
|
+
let open = await runningFrom([...installed, ...earlier])
|
|
362
|
+
if (open.length) {
|
|
363
|
+
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])
|
|
369
|
+
}
|
|
370
|
+
waiting.done('closed')
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const installing = step(current ? 'Updating' : 'Installing')
|
|
374
|
+
const replaced = []
|
|
375
|
+
try {
|
|
376
|
+
// Set the old app aside in the staging folder; it is removed with that folder only once the
|
|
377
|
+
// new app is in place, as any Mac updater does. The bundle holds no data: projects, settings
|
|
378
|
+
// and sign-in live outside it.
|
|
379
|
+
for (const previous of installed) {
|
|
380
|
+
const aside = join(stage, `previous-${replaced.length}.app`)
|
|
381
|
+
await rename(previous, aside)
|
|
382
|
+
replaced.push([previous, aside])
|
|
383
|
+
}
|
|
384
|
+
if (await stat(destination).catch(() => null)) throw Error(`${destination} already exists and is not ${manifest.name}.`)
|
|
385
|
+
await rename(candidate, destination)
|
|
386
|
+
} catch (error) {
|
|
387
|
+
// Put every earlier copy back where it was; the install did not happen.
|
|
388
|
+
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 })
|
|
391
|
+
}
|
|
392
|
+
installing.done(destination.replace(homedir(), '~'))
|
|
393
|
+
} finally {
|
|
394
|
+
await rm(stage, { recursive: true, force: true }).catch(() => undefined)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 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
|
+
}
|
|
403
|
+
|
|
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()
|
|
420
|
+
} finally {
|
|
421
|
+
await cleanup()
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const [major] = process.versions.node.split('.').map(Number)
|
|
426
|
+
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`)
|
|
428
|
+
process.exit(1)
|
|
429
|
+
}
|
|
430
|
+
main().catch((error) => {
|
|
431
|
+
if (!error?.reported) console.error(`\n ${red('✕')} ${error?.message ?? error}\n`)
|
|
432
|
+
else console.log()
|
|
433
|
+
process.exitCode = 1
|
|
434
|
+
})
|
package/manifest.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** SOURCE OF TRUTH KEYWORDS: installManifest, INSTALL_MANIFEST, parseInstallManifest, INSTALL_MANIFEST_FILE
|
|
2
|
+
* WHAT: The one file a Morphite release carries for the terminal installer: the app's name, bundle
|
|
3
|
+
* id and version, and for each Mac CPU the zipped app with its size and SHA-256.
|
|
4
|
+
* WHY: The installer trusts nothing it has not checked: it installs only releases that carry this
|
|
5
|
+
* file (earlier releases without it are never picked up), checks every field before a path or URL
|
|
6
|
+
* is built from it, and refuses an archive whose size or hash differs.
|
|
7
|
+
* WHERE: scripts/release-publish.ts writes it beside the zips; bin/morphite.mjs reads it from the
|
|
8
|
+
* releases repository (or a local folder with --from). Plain JavaScript so the published installer
|
|
9
|
+
* has no dependencies.
|
|
10
|
+
*/
|
|
11
|
+
export const INSTALL_MANIFEST_FILE = 'morphite-install.json'
|
|
12
|
+
export const RELEASES_REPOSITORY = 'webprodigies-org/morphite-releases'
|
|
13
|
+
export const MAC_ARCHES = ['arm64', 'x64']
|
|
14
|
+
|
|
15
|
+
const text = (value, pattern, field) => {
|
|
16
|
+
if (typeof value !== 'string' || !pattern.test(value)) throw Error(`Release manifest: invalid ${field}.`)
|
|
17
|
+
return value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Returns a checked copy of the manifest, or throws naming the first invalid field. */
|
|
21
|
+
export function parseInstallManifest(input) {
|
|
22
|
+
if (!input || typeof input !== 'object' || input.format !== 1) throw Error('Release manifest: unsupported format.')
|
|
23
|
+
const archives = {}
|
|
24
|
+
for (const arch of MAC_ARCHES) {
|
|
25
|
+
const entry = input.archives?.[arch]
|
|
26
|
+
if (!entry) continue
|
|
27
|
+
archives[arch] = {
|
|
28
|
+
file: text(entry.file, /^[A-Za-z0-9._-]+\.zip$/, `${arch} file`),
|
|
29
|
+
sha256: text(entry.sha256, /^[a-f0-9]{64}$/, `${arch} sha256`),
|
|
30
|
+
size: Number.isSafeInteger(entry.size) && entry.size > 0 ? entry.size : text('', /x/, `${arch} size`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!Object.keys(archives).length) throw Error('Release manifest: no Mac archives.')
|
|
34
|
+
return {
|
|
35
|
+
format: 1,
|
|
36
|
+
// The app's folder name in Applications, e.g. "Morphite (Alpha)".
|
|
37
|
+
name: text(input.name, /^Morphite( \([A-Za-z0-9. ]{1,24}\))?$/, 'name'),
|
|
38
|
+
bundleId: text(input.bundleId, /^[a-z0-9]+(\.[a-z0-9-]+)+$/, 'bundle id'),
|
|
39
|
+
version: text(input.version, /^\d+\.\d+\.\d+$/, 'version'),
|
|
40
|
+
tag: text(input.tag, /^v\d+\.\d+\.\d+[A-Za-z0-9.-]*$/, 'tag'),
|
|
41
|
+
minimumMacOS: text(input.minimumMacOS, /^\d+(\.\d+)?$/, 'minimum macOS'),
|
|
42
|
+
archives
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const releaseFileUrl = (tag, file) =>
|
|
47
|
+
`https://github.com/${RELEASES_REPOSITORY}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(file)}`
|
|
48
|
+
export const latestManifestUrl = () =>
|
|
49
|
+
`https://github.com/${RELEASES_REPOSITORY}/releases/latest/download/${INSTALL_MANIFEST_FILE}`
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@webprodigies/morphite",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Install or update Morphite on your Mac: npx @webprodigies/morphite",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"morphite": "bin/morphite.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/morphite.mjs",
|
|
11
|
+
"manifest.mjs",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://morphite.build",
|
|
18
|
+
"license": "UNLICENSED",
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
}
|
|
22
|
+
}
|