@webprodigies/morphite 0.2.1 → 0.2.3

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/app-update.mjs ADDED
@@ -0,0 +1,182 @@
1
+ /** SOURCE OF TRUTH KEYWORDS: appUpdateSteps, APP_SWAP, SWAP_HELPER_SCRIPT, verifyApp, STAGE_PREFIX
2
+ * WHAT: The steps that replace an installed Morphite with a release, for the terminal installer and
3
+ * the app's own Update alike: read the release manifest, download the zip for this CPU while
4
+ * measuring its SHA-256, unpack it with ditto, check the unpacked app (bundle id, version,
5
+ * signature), clear the download mark, and swap it in by renames: the installed app is set aside,
6
+ * the new one renamed into place, and on any failure the installed app is put back.
7
+ * WHY: Both paths must trust and replace an app the same way. After the app quits nothing of it may
8
+ * run, and packaged Electron cannot run as Node (hardenPackagedElectron's RunAsNode fuse), so the
9
+ * app's swap runs as SWAP_HELPER_SCRIPT under /bin/sh; it repeats swapApps's steps and is tested
10
+ * against the same cases. Its inputs are positional arguments; no path is ever put into its text.
11
+ * WHERE: bin/morphite.mjs (installer) and src/desktop/updates/release-install.ts (STAGED_UPDATE)
12
+ * import these; manifest.mjs owns the release format. Plain JavaScript: the installer has no
13
+ * build step or dependencies.
14
+ */
15
+ import { createHash } from 'node:crypto'
16
+ import { createReadStream, createWriteStream } from 'node:fs'
17
+ import { execFile } from 'node:child_process'
18
+ import { rename, stat, readFile } from 'node:fs/promises'
19
+ import { join } from 'node:path'
20
+ import { once } from 'node:events'
21
+ import { promisify } from 'node:util'
22
+ import { INSTALL_MANIFEST_FILE, latestManifestUrl, parseInstallManifest, releaseFileUrl } from './manifest.mjs'
23
+
24
+ const run = promisify(execFile)
25
+ /** Folders Morphite creates beside an installed app while updating it; only these are cleaned up. */
26
+ export const STAGE_PREFIX = '.morphite-update-'
27
+ /** The file inside such a folder that proves Morphite made it. */
28
+ export const STAGE_MARK = 'morphite-update.json'
29
+
30
+ export async function fetchWithRetry(url, { attempts = 3, userAgent = '@webprodigies/morphite installer', signal } = {}) {
31
+ let failure
32
+ for (let attempt = 1; attempt <= attempts; attempt++) {
33
+ try {
34
+ const response = await fetch(url, { headers: { 'user-agent': userAgent }, redirect: 'follow', signal })
35
+ if (response.status === 404) return response
36
+ if (!response.ok) throw Error(`HTTP ${response.status}`)
37
+ return response
38
+ } catch (error) {
39
+ if (signal?.aborted) throw error
40
+ failure = error
41
+ await new Promise((resolve) => setTimeout(resolve, 600 * attempt))
42
+ }
43
+ }
44
+ throw Error(`Could not reach GitHub (${failure?.message ?? 'network error'}). Check your connection and run the command again.`)
45
+ }
46
+
47
+ /** The checked manifest of the newest release, or of the release in the local folder `from`. */
48
+ export async function readManifest(from, options) {
49
+ if (from) return parseInstallManifest(JSON.parse(await readFile(join(from, INSTALL_MANIFEST_FILE), 'utf8')))
50
+ const response = await fetchWithRetry(latestManifestUrl(), options)
51
+ if (response.status === 404) throw Error('No Morphite release is available yet. Try again soon.')
52
+ return parseInstallManifest(await response.json())
53
+ }
54
+
55
+ /** Downloads (or copies from `from`) one archive to `destination`, measuring it as it arrives. */
56
+ export async function downloadArchive(manifest, archive, destination, { from, progress, signal, ...options } = {}) {
57
+ const hash = createHash('sha256')
58
+ let received = 0
59
+ const report = () => progress?.(received / archive.size, received)
60
+ const output = createWriteStream(destination, { mode: 0o600 })
61
+ const write = async (chunk) => {
62
+ hash.update(chunk)
63
+ received += chunk.length
64
+ if (received > archive.size) throw Error('The download is larger than the release says. Nothing was installed.')
65
+ if (!output.write(chunk)) await once(output, 'drain')
66
+ }
67
+ const timer = progress ? setInterval(report, 120) : undefined
68
+ try {
69
+ if (from) {
70
+ for await (const chunk of createReadStream(join(from, archive.file), { signal })) await write(chunk)
71
+ } else {
72
+ const response = await fetchWithRetry(releaseFileUrl(manifest.tag, archive.file), { ...options, signal })
73
+ if (!response.ok || !response.body) throw Error(`The release file is unavailable (HTTP ${response.status}).`)
74
+ for await (const chunk of response.body) await write(chunk)
75
+ }
76
+ output.end()
77
+ await once(output, 'finish')
78
+ } finally {
79
+ clearInterval(timer)
80
+ output.destroy()
81
+ }
82
+ report()
83
+ return { path: destination, sha256: hash.digest('hex'), size: received }
84
+ }
85
+ export const archiveMatches = (file, archive) => file.size === archive.size && file.sha256 === archive.sha256
86
+
87
+ /** Unpacks a release zip (made with ditto --keepParent) into `folder`. */
88
+ export async function unpackApp(zip, folder) {
89
+ await run('/usr/bin/ditto', ['-x', '-k', zip, folder])
90
+ }
91
+ const infoValue = (app, key) =>
92
+ run('/usr/bin/plutil', ['-extract', key, 'raw', '-o', '-', join(app, 'Contents/Info.plist')]).then(
93
+ (result) => result.stdout.trim(),
94
+ () => null
95
+ )
96
+ export class AppCheckError extends Error {
97
+ constructor(reason, message) {
98
+ super(message)
99
+ this.reason = reason
100
+ }
101
+ }
102
+ /** Throws an AppCheckError unless `app` is the expected Morphite build with an intact signature. */
103
+ export async function verifyApp(app, { bundleId, version }) {
104
+ if ((await infoValue(app, 'CFBundleIdentifier')) !== bundleId)
105
+ throw new AppCheckError('bundle-id', 'The downloaded app isn’t the expected Morphite build.')
106
+ if ((await infoValue(app, 'CFBundleShortVersionString')) !== version)
107
+ throw new AppCheckError('version', 'The downloaded app isn’t the version the release names.')
108
+ await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', app]).catch(() => {
109
+ throw new AppCheckError('signature', 'The downloaded app failed its signature check.')
110
+ })
111
+ }
112
+ /** A file fetched by Morphite carries no download mark; one is cleared defensively all the same. */
113
+ export async function clearQuarantine(app) {
114
+ await run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', app]).catch(() => undefined)
115
+ }
116
+
117
+ /** APP_SWAP: sets every installed copy aside (aside(index)), renames `candidate` to `destination`
118
+ * and returns what was set aside; on any failure puts every copy back and throws. */
119
+ export async function swapApps(installed, candidate, destination, aside) {
120
+ const replaced = []
121
+ try {
122
+ for (const previous of installed) {
123
+ const moved = aside(replaced.length)
124
+ await rename(previous, moved)
125
+ replaced.push([previous, moved])
126
+ }
127
+ if (await stat(destination).catch(() => null)) throw Error(`${destination} already exists and isn’t Morphite.`)
128
+ await rename(candidate, destination)
129
+ return replaced
130
+ } catch (error) {
131
+ for (const [original, moved] of replaced.reverse()) await rename(moved, original).catch(() => undefined)
132
+ throw error
133
+ }
134
+ }
135
+
136
+ /** APP_SWAP for the app's own Update, run detached by /bin/sh after Morphite starts quitting:
137
+ * sh -c SWAP_HELPER_SCRIPT morphite-update PID CURRENT STAGED ASIDE DESTINATION REOPEN ELEVATE
138
+ * Waits (up to 10 minutes) for PID to exit, sets CURRENT aside to ASIDE, renames STAGED to
139
+ * DESTINATION, puts CURRENT back on any failure, clears the download mark and, with REOPEN=1,
140
+ * opens whichever app is in place. With ELEVATE=1 only the swap runs through macOS's administrator
141
+ * prompt: the fixed AppleScript passes this same script (MORPHITE_SWAP_SCRIPT) and the paths as
142
+ * arguments, quoted by AppleScript's `quoted form of`. Exit codes: 0 updated, 3 Morphite did not
143
+ * quit, 4 nothing staged, 5 aside taken, 6 could not set aside, 7 rolled back, 8 not authorized. */
144
+ export const SWAP_HELPER_SCRIPT = String.raw`
145
+ set -u
146
+ say() { printf '%s %s\n' "$(/bin/date '+%Y-%m-%dT%H:%M:%S')" "$1"; }
147
+ swap() {
148
+ current=$1 staged=$2 aside=$3 destination=$4
149
+ if [ ! -d "$staged" ]; then say "The prepared update is missing. Nothing was changed."; return 4; fi
150
+ if [ -e "$aside" ]; then say "The previous app's place is taken. Nothing was changed."; return 5; fi
151
+ if ! /bin/mv "$current" "$aside"; then say "The installed app couldn't be set aside. Nothing was changed."; return 6; fi
152
+ if [ -e "$destination" ] || ! /bin/mv "$staged" "$destination"; then
153
+ /bin/mv "$aside" "$current" || say "The previous app is at $aside."
154
+ say "The update couldn't be moved into place; the previous app was put back."
155
+ return 7
156
+ fi
157
+ /usr/bin/xattr -dr com.apple.quarantine "$destination" 2>/dev/null
158
+ return 0
159
+ }
160
+ if [ "$1" = swap ]; then shift; swap "$@"; exit $?; fi
161
+ pid=$1 current=$2 staged=$3 aside=$4 destination=$5 reopen=$6 elevate=$7
162
+ waited=0
163
+ while /bin/kill -0 "$pid" 2>/dev/null; do
164
+ if [ "$waited" -ge 1200 ]; then say "Morphite didn't quit. Nothing was changed."; exit 3; fi
165
+ /bin/sleep 0.5
166
+ waited=$((waited + 1))
167
+ done
168
+ if [ "$elevate" = 1 ]; then
169
+ /usr/bin/osascript \
170
+ -e 'on run argv' \
171
+ -e 'do shell script "/bin/sh -c " & quoted form of (item 1 of argv) & " morphite-update swap " & quoted form of (item 2 of argv) & " " & quoted form of (item 3 of argv) & " " & quoted form of (item 4 of argv) & " " & quoted form of (item 5 of argv) with prompt "Morphite needs your permission to finish updating." with administrator privileges' \
172
+ -e 'end run' "$MORPHITE_SWAP_SCRIPT" "$current" "$staged" "$aside" "$destination"
173
+ result=$?
174
+ [ "$result" -eq 0 ] || { say "The update wasn't authorized or couldn't be moved into place. The previous app is in place."; result=8; }
175
+ else
176
+ swap "$current" "$staged" "$aside" "$destination"
177
+ result=$?
178
+ fi
179
+ if [ "$result" -eq 0 ]; then say "Updated $destination."; opened=$destination; else opened=$current; fi
180
+ if [ "$reopen" = 1 ]; then /usr/bin/open "$opened" || say "Morphite couldn't be reopened."; fi
181
+ exit "$result"
182
+ `
package/bin/morphite.mjs CHANGED
@@ -6,20 +6,18 @@
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.
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.
9
+ * WHERE: ../manifest.mjs owns the release file format and ../app-update.mjs the download, checks
10
+ * and swap (appUpdateSteps), shared with the desktop app's own Update, which offers this command
11
+ * only when it cannot prepare an update itself. scripts/release-publish.ts publishes releases.
12
+ * User data lives outside the app bundle and is never touched here.
12
13
  */
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'
14
+ import { access, constants, lstat, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises'
16
15
  import { execFile } from 'node:child_process'
17
16
  import { homedir, platform, tmpdir, arch as nodeArch } from 'node:os'
18
17
  import { join, basename, dirname } from 'node:path'
19
- import { once } from 'node:events'
20
18
  import { createInterface } from 'node:readline'
21
19
  import { promisify } from 'node:util'
22
- import { INSTALL_MANIFEST_FILE, latestManifestUrl, parseInstallManifest, releaseFileUrl } from '../manifest.mjs'
20
+ import { archiveMatches, clearQuarantine, downloadArchive, readManifest, swapApps, unpackApp, verifyApp } from '../app-update.mjs'
23
21
 
24
22
  const run = promisify(execFile)
25
23
  const args = process.argv.slice(2)
@@ -135,59 +133,6 @@ async function moveToTrash(path) {
135
133
  )
136
134
  }
137
135
 
138
- // ── Release ─────────────────────────────────────────────────────────────────
139
- async function fetchWithRetry(url, attempts = 3) {
140
- let failure
141
- for (let attempt = 1; attempt <= attempts; attempt++) {
142
- try {
143
- const response = await fetch(url, { headers: { 'user-agent': `${PACKAGE} installer` }, redirect: 'follow' })
144
- if (response.status === 404) return response
145
- if (!response.ok) throw Error(`HTTP ${response.status}`)
146
- return response
147
- } catch (error) {
148
- failure = error
149
- await new Promise((resolve) => setTimeout(resolve, 600 * attempt))
150
- }
151
- }
152
- throw Error(`Could not reach GitHub (${failure?.message ?? 'network error'}). Check your connection and run the command again.`)
153
- }
154
- async function readManifest(from) {
155
- if (from) return parseInstallManifest(JSON.parse(await readFile(join(from, INSTALL_MANIFEST_FILE), 'utf8')))
156
- const response = await fetchWithRetry(latestManifestUrl())
157
- if (response.status === 404) throw Error('No Morphite release is available yet. Try again soon.')
158
- return parseInstallManifest(await response.json())
159
- }
160
- async function download(manifest, archive, folder, from, progress) {
161
- const destination = join(folder, archive.file)
162
- const hash = createHash('sha256')
163
- let received = 0
164
- const report = () => progress(received / archive.size, `${megabytes(received)} of ${megabytes(archive.size)} MB`)
165
- const output = createWriteStream(destination, { mode: 0o600 })
166
- const write = async (chunk) => {
167
- hash.update(chunk)
168
- received += chunk.length
169
- if (received > archive.size) throw Error('The download is larger than the release says. Nothing was installed.')
170
- if (!output.write(chunk)) await once(output, 'drain')
171
- }
172
- const timer = setInterval(report, 120)
173
- try {
174
- if (from) {
175
- const { createReadStream } = await import('node:fs')
176
- for await (const chunk of createReadStream(join(from, archive.file))) await write(chunk)
177
- } else {
178
- const response = await fetchWithRetry(releaseFileUrl(manifest.tag, archive.file))
179
- if (!response.ok || !response.body) throw Error(`The release file is unavailable (HTTP ${response.status}).`)
180
- for await (const chunk of response.body) await write(chunk)
181
- }
182
- output.end()
183
- await once(output, 'finish')
184
- } finally {
185
- clearInterval(timer)
186
- output.destroy()
187
- }
188
- return { path: destination, sha256: hash.digest('hex'), size: received }
189
- }
190
-
191
136
  // ── Install ─────────────────────────────────────────────────────────────────
192
137
  async function writable(folder) {
193
138
  return access(folder, constants.W_OK).then(
@@ -282,10 +227,13 @@ async function main() {
282
227
  const destination = join(target, `${manifest.name}.app`)
283
228
  const work = await mkdtemp(join(tmpdir(), 'morphite-installer-'))
284
229
  const cleanup = () => rm(work, { recursive: true, force: true }).catch(() => undefined)
285
- process.once('SIGINT', () => {
230
+ const stopped = () => {
286
231
  live.done()
232
+ note('Stopped. Nothing was changed.', `Run npx ${PACKAGE}@latest again when you’re ready.`)
233
+ say('')
287
234
  void cleanup().then(() => process.exit(130))
288
- })
235
+ }
236
+ process.once('SIGINT', stopped)
289
237
  // A problem after the download started: keep the last line where it stopped, then say what happened.
290
238
  const stop = (title, detail) => {
291
239
  live.done()
@@ -295,11 +243,15 @@ async function main() {
295
243
  try {
296
244
  let file
297
245
  try {
298
- file = await download(manifest, archive, work, from, (ratio, detail) => live.set(progressLine(ratio, detail)))
246
+ file = await downloadArchive(manifest, archive, join(work, archive.file), {
247
+ from,
248
+ userAgent: `${PACKAGE} installer`,
249
+ progress: (ratio, received) => live.set(progressLine(ratio, `${megabytes(received)} of ${megabytes(archive.size)} MB`))
250
+ })
299
251
  } catch (error) {
300
252
  throw stop('The download stopped.', `${error.message} Nothing was changed.`)
301
253
  }
302
- if (file.size !== archive.size || file.sha256 !== archive.sha256)
254
+ if (!archiveMatches(file, archive))
303
255
  throw stop('The download doesn’t match the release.', 'Nothing was changed. Run the command again.')
304
256
  live.done(progressLine(1, `${megabytes(file.size)} MB · verified`))
305
257
 
@@ -308,44 +260,66 @@ async function main() {
308
260
  // Stage beside the destination so the final move is a rename on the same disk.
309
261
  const stage = await mkdtemp(join(target, '.morphite-install-'))
310
262
  try {
311
- await run('/usr/bin/ditto', ['-x', '-k', file.path, stage])
263
+ await unpackApp(file.path, stage)
312
264
  const candidate = join(stage, `${manifest.name}.app`)
313
- if ((await bundleIdOf(candidate)) !== manifest.bundleId) throw stop('The downloaded app isn’t the expected Morphite build.', 'Nothing was changed.')
314
- await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', candidate]).catch(() => {
315
- throw stop('The downloaded app failed its signature check.', 'Nothing was changed.')
265
+ await verifyApp(candidate, { bundleId: manifest.bundleId, version: manifest.version }).catch((error) => {
266
+ throw stop(error.message, 'Nothing was changed.')
316
267
  })
317
- // Fetched by this program, the app carries no download mark; clear one defensively all the same.
318
- await run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', candidate]).catch(() => undefined)
268
+ await clearQuarantine(candidate)
319
269
 
320
- // Morphite must be closed before its app is replaced; the person closes it, never this script.
270
+ // Morphite must be closed before its app is replaced. The person quits it from its menu, or
271
+ // presses Enter and the installer asks it to quit the normal way (never a forced stop, so its
272
+ // work ends cleanly). No keyboard shortcut is suggested: ⌘Q here would quit Terminal.
321
273
  let open = await runningFrom([...installed, ...earlier])
322
274
  if (open.length) {
323
275
  const names = open.map((app) => basename(app, '.app')).join(' and ')
324
- live.set(`${bold(`Quit ${names} to continue`)} ${grey('(⌘Q)')}`)
325
- while (open.length) {
326
- await new Promise((resolve) => setTimeout(resolve, 700))
327
- open = await runningFrom([...installed, ...earlier])
276
+ live.clear()
277
+ note(
278
+ `${names} is open.`,
279
+ `Quit it from its menu bar (${names} → Quit), and this ${current ? 'update' : 'install'} continues on its own.`,
280
+ tty ? 'Or press Return to quit it now. Control-C stops without changing anything.' : 'Control-C stops without changing anything.'
281
+ )
282
+ say('')
283
+ const input = tty && process.stdin.isTTY ? process.stdin : null
284
+ const key = (data) => {
285
+ const text = String(data)
286
+ if (text.includes('\u0003')) return stopped()
287
+ if (!/[\r\n]/.test(text)) return
288
+ live.set(grey(`Quitting ${names}…`))
289
+ for (const app of open) {
290
+ const id = earlier.includes(app) ? EARLIER_APP_ID : manifest.bundleId
291
+ void run('/usr/bin/osascript', ['-e', `tell application id ${JSON.stringify(id)} to quit`]).catch(() => undefined)
292
+ }
293
+ }
294
+ if (input) {
295
+ input.setRawMode(true)
296
+ input.resume()
297
+ input.on('data', key)
328
298
  }
299
+ live.set(grey(`Waiting for ${names} to quit…`))
300
+ try {
301
+ while (open.length) {
302
+ await new Promise((resolve) => setTimeout(resolve, 700))
303
+ open = await runningFrom([...installed, ...earlier])
304
+ }
305
+ } finally {
306
+ if (input) {
307
+ input.off('data', key)
308
+ input.setRawMode(false)
309
+ input.pause()
310
+ }
311
+ }
312
+ live.done(grey(`${names} quit`))
329
313
  live.set(grey(`${current ? 'Updating' : 'Installing'} in ${place}…`))
330
314
  }
331
315
 
332
- const replaced = []
333
316
  try {
334
317
  // Set the old app aside in the staging folder; it is removed with that folder only once the
335
- // new app is in place, as any Mac updater does. The bundle holds no data: projects, settings
336
- // and sign-in live outside it.
337
- for (const previous of installed) {
338
- const aside = join(stage, `previous-${replaced.length}.app`)
339
- await rename(previous, aside)
340
- replaced.push([previous, aside])
341
- }
342
- if (await stat(destination).catch(() => null)) throw Error(`${tilde(destination)} already exists and isn’t ${manifest.name}.`)
343
- await rename(candidate, destination)
318
+ // new app is in place, as any Mac updater does. On failure every earlier copy is put back
319
+ // (APP_SWAP). The bundle holds no data: projects, settings and sign-in live outside it.
320
+ await swapApps(installed, candidate, destination, (index) => join(stage, `previous-${index}.app`))
344
321
  } catch (error) {
345
- // Put every earlier copy back where it was; the install did not happen.
346
- for (const [original, moved] of replaced.reverse()) await rename(moved, original).catch(() => undefined)
347
- if (error.reported) throw error
348
- throw stop(`${manifest.name} couldn’t be installed.`, `${error.message} Nothing was changed.`)
322
+ throw stop(`${manifest.name} couldn’t be installed.`, `${error.message.replace(destination, tilde(destination))} Nothing was changed.`)
349
323
  }
350
324
  live.done(grey(`${current ? 'Updated' : 'Installed'} in ${place}`))
351
325
  } finally {
package/manifest.mjs CHANGED
@@ -4,10 +4,16 @@
4
4
  * WHY: The installer trusts nothing it has not checked: it installs only releases that carry this
5
5
  * file (earlier releases without it are never picked up), checks every field before a path or URL
6
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.
7
+ * WHERE: writeReleaseFiles writes it beside the zips (scripts/release-publish.ts, and the local
8
+ * update test kit scripts/update-try.ts); bin/morphite.mjs and the app's own Update
9
+ * (app-update.mjs readManifest → src/desktop/updates/release-install.ts) read it from the releases
10
+ * repository or a local folder. Plain JavaScript so the published installer has no dependencies.
10
11
  */
12
+ import { createHash } from 'node:crypto'
13
+ import { createReadStream } from 'node:fs'
14
+ import { stat, writeFile } from 'node:fs/promises'
15
+ import { join } from 'node:path'
16
+
11
17
  export const INSTALL_MANIFEST_FILE = 'morphite-install.json'
12
18
  export const RELEASES_REPOSITORY = 'webprodigies-org/morphite-releases'
13
19
  export const MAC_ARCHES = ['arm64', 'x64']
@@ -47,3 +53,24 @@ export const releaseFileUrl = (tag, file) =>
47
53
  `https://github.com/${RELEASES_REPOSITORY}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(file)}`
48
54
  export const latestManifestUrl = () =>
49
55
  `https://github.com/${RELEASES_REPOSITORY}/releases/latest/download/${INSTALL_MANIFEST_FILE}`
56
+
57
+ /** Describes the zips already in `folder` for a release: measures each, writes morphite-install.json
58
+ * and SHA256SUMS beside them and returns the checked manifest. Used by release:publish and the
59
+ * local update test kit (update:try), so a local release has the published format. */
60
+ export async function writeReleaseFiles(folder, { files, ...release }) {
61
+ const archives = {}
62
+ for (const arch of MAC_ARCHES) {
63
+ const file = files[arch]
64
+ if (!file) continue
65
+ const hash = createHash('sha256')
66
+ for await (const chunk of createReadStream(join(folder, file))) hash.update(chunk)
67
+ archives[arch] = { file, sha256: hash.digest('hex'), size: (await stat(join(folder, file))).size }
68
+ }
69
+ const manifest = parseInstallManifest({ format: 1, ...release, archives })
70
+ await writeFile(join(folder, INSTALL_MANIFEST_FILE), JSON.stringify(manifest, null, 2) + '\n')
71
+ await writeFile(
72
+ join(folder, 'SHA256SUMS'),
73
+ Object.values(manifest.archives).map((entry) => `${entry.sha256} ${entry.file}`).join('\n') + '\n'
74
+ )
75
+ return manifest
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webprodigies/morphite",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Install or update Morphite on your Mac: npx @webprodigies/morphite",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "bin/morphite.mjs",
11
11
  "manifest.mjs",
12
+ "app-update.mjs",
12
13
  "README.md"
13
14
  ],
14
15
  "engines": {