@gjsify/cli 0.43.0 → 0.45.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.
Files changed (87) hide show
  1. package/dist/cli.gjs.mjs +453 -425
  2. package/lib/actions/build.js +2 -1
  3. package/lib/commands/affected-classify.js +7 -5
  4. package/lib/commands/build.js +6 -0
  5. package/lib/commands/flatpak/ci.js +10 -2
  6. package/lib/commands/flatpak/init.js +15 -16
  7. package/lib/commands/flatpak/utils.d.ts +0 -26
  8. package/lib/commands/flatpak/utils.js +4 -29
  9. package/lib/commands/gettext.d.ts +2 -1
  10. package/lib/commands/gettext.js +105 -47
  11. package/lib/commands/onboard.d.ts +7 -0
  12. package/lib/commands/onboard.js +349 -96
  13. package/lib/commands/ship.d.ts +3 -0
  14. package/lib/commands/ship.js +911 -70
  15. package/lib/commands/trust.d.ts +6 -0
  16. package/lib/commands/trust.js +1 -1
  17. package/lib/config.js +25 -0
  18. package/lib/types/cli-build-options.d.ts +11 -0
  19. package/lib/types/config-data.d.ts +137 -3
  20. package/lib/utils/flatpak-runtime.d.ts +41 -0
  21. package/lib/utils/flatpak-runtime.js +54 -0
  22. package/lib/utils/msgfmt-merge.d.ts +35 -0
  23. package/lib/utils/msgfmt-merge.js +102 -0
  24. package/lib/utils/npm-otp.d.ts +27 -2
  25. package/lib/utils/npm-otp.js +82 -7
  26. package/lib/utils/onboard-discovery.d.ts +68 -0
  27. package/lib/utils/onboard-discovery.js +178 -0
  28. package/lib/utils/onboard-probe.d.ts +118 -1
  29. package/lib/utils/onboard-probe.js +153 -4
  30. package/lib/utils/prompt-output.d.ts +12 -0
  31. package/lib/utils/prompt-output.js +54 -0
  32. package/lib/utils/prompt.js +22 -2
  33. package/lib/utils/publishable-packages.d.ts +1 -1
  34. package/lib/utils/publishable-packages.js +1 -0
  35. package/lib/utils/resolve-npm-package.d.ts +56 -0
  36. package/lib/utils/resolve-npm-package.js +69 -0
  37. package/lib/utils/ship/app-runtime.d.ts +214 -0
  38. package/lib/utils/ship/app-runtime.js +429 -0
  39. package/lib/utils/ship/depends.d.ts +54 -7
  40. package/lib/utils/ship/depends.js +120 -6
  41. package/lib/utils/ship/discover.js +18 -1
  42. package/lib/utils/ship/dmg.d.ts +71 -0
  43. package/lib/utils/ship/dmg.js +181 -0
  44. package/lib/utils/ship/flatpak-config.d.ts +66 -0
  45. package/lib/utils/ship/flatpak-config.js +132 -0
  46. package/lib/utils/ship/flatpak.d.ts +45 -0
  47. package/lib/utils/ship/flatpak.js +148 -0
  48. package/lib/utils/ship/formats.d.ts +116 -3
  49. package/lib/utils/ship/formats.js +869 -11
  50. package/lib/utils/ship/launcher.d.ts +13 -3
  51. package/lib/utils/ship/launcher.js +362 -17
  52. package/lib/utils/ship/layout.d.ts +268 -0
  53. package/lib/utils/ship/layout.js +351 -0
  54. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  55. package/lib/utils/ship/localize-metadata.js +126 -0
  56. package/lib/utils/ship/mime.js +1 -9
  57. package/lib/utils/ship/msi.d.ts +126 -0
  58. package/lib/utils/ship/msi.js +417 -0
  59. package/lib/utils/ship/node-runtime.d.ts +71 -0
  60. package/lib/utils/ship/node-runtime.js +122 -0
  61. package/lib/utils/ship/payload.d.ts +156 -5
  62. package/lib/utils/ship/payload.js +580 -14
  63. package/lib/utils/ship/plan.d.ts +21 -0
  64. package/lib/utils/ship/plan.js +20 -7
  65. package/lib/utils/ship/plist.d.ts +58 -0
  66. package/lib/utils/ship/plist.js +198 -0
  67. package/lib/utils/ship/rpm.js +9 -3
  68. package/lib/utils/ship/schemas.d.ts +61 -0
  69. package/lib/utils/ship/schemas.js +118 -0
  70. package/lib/utils/ship/scripts.js +7 -3
  71. package/lib/utils/ship/settings.d.ts +19 -1
  72. package/lib/utils/ship/settings.js +19 -1
  73. package/lib/utils/ship/share-dirs.d.ts +23 -0
  74. package/lib/utils/ship/share-dirs.js +46 -0
  75. package/lib/utils/ship/signing.d.ts +195 -0
  76. package/lib/utils/ship/signing.js +317 -0
  77. package/lib/utils/ship/stage-manifest.d.ts +50 -13
  78. package/lib/utils/ship/stage-manifest.js +137 -14
  79. package/lib/utils/ship/stage-writer.d.ts +48 -0
  80. package/lib/utils/ship/stage-writer.js +71 -1
  81. package/lib/utils/ship/types.d.ts +264 -16
  82. package/lib/utils/ship/xml.d.ts +7 -0
  83. package/lib/utils/ship/xml.js +25 -0
  84. package/lib/utils/ship/zip.d.ts +69 -0
  85. package/lib/utils/ship/zip.js +245 -0
  86. package/lib/utils/trust-registry.js +5 -0
  87. package/package.json +19 -19
@@ -0,0 +1,317 @@
1
+ // Signing the payload, and submitting the artifact for notarisation
2
+ // (ADR 0024 § A12–§ A17).
3
+ //
4
+ // FOUR PROPERTIES, each one a decision the ADR settles and this file implements:
5
+ //
6
+ // 1. `--sign` takes an IDENTITY, not a certificate (§ A12). `codesign` is handed
7
+ // a STRING and looks the private key up itself; `-` is the reserved value for
8
+ // ad-hoc, and this tree already writes `codesign --force --sign -` in five
9
+ // places. So there is no `--certificate`, no `--p12` and no `--password`
10
+ // anywhere on this surface — getting a key into a keychain is the signing
11
+ // HOST's job (§ A14), and nothing here can leak a secret it is never given.
12
+ // 2. An absent identity SKIPS, loudly, at exit 0 (§ A13). Unsigned is the
13
+ // default path and a legitimate deliverable; what § 5 refuses is the other
14
+ // direction — claiming a signature that was not made.
15
+ // 3. Signing is a payload MUTATION, not a wrapper (§ A4). Under hardened
16
+ // runtime a Developer-ID-signed main executable will not load ad-hoc-signed
17
+ // dylibs, and all 106 Mach-O images in the shipped darwin GTK closure carry
18
+ // an ad-hoc `LC_CODE_SIGNATURE` already (they must — `install_name_tool`
19
+ // invalidates the original during relocation). So the darwin leg re-signs
20
+ // every image, and the result is new BYTES for the packer rather than a
21
+ // container around the old ones.
22
+ // 4. THE ORDER IS STRUCTURAL, not conventional (§ A17). `readStage` compares
23
+ // each file's SIZE against `.gjsify-ship-stage.json`, and a size is no more
24
+ // re-sign-proof than a digest. BOTH halves are measured and they agree:
25
+ // append one byte to a staged file and `readStage` refuses with "… is 6
26
+ // bytes in the stage and 5 in its manifest"; and an ad-hoc `codesign
27
+ // --force --sign -` over one staged image took it from 34 816 to 34 848
28
+ // bytes (+32) on macos-latest/arm64. {@link signPayload} therefore TAKES what `readStage`
29
+ // returned and RETURNS what the packer consumes: the signed bytes cannot
30
+ // exist before the validation, because they are computed from its output,
31
+ // and the arriving stage is never written to at all.
32
+ //
33
+ // WHAT THIS DELIBERATELY DOES NOT DO, each with its reason:
34
+ //
35
+ // * It does not seal the `<App>.app` BUNDLE. `codesign` on a bundle writes
36
+ // `Contents/_CodeSignature/CodeResources` and — for a main executable that is
37
+ // a shell script, which every layout's launcher is — stores that script's own
38
+ // signature in an extended attribute (Apple's documented behaviour, not
39
+ // measured here). What IS measured here is our side of it: the payload round
40
+ // trip is `readStage` → bytes + mode → `writePayload`, and it carries no
41
+ // extended attributes at all, so a bundle seal made now would not survive into
42
+ // the `.zip` beside the `.app`. The images the dynamic loader validates are
43
+ // the ones signed, which is what § A4's argument is about.
44
+ // * It passes no `--options runtime`, no `--entitlements` and no `--timestamp`.
45
+ // § A16 leaves library validation vs. hardened runtime explicitly unmeasured,
46
+ // and a secure timestamp needs a real Developer ID plus network. Adding any of
47
+ // them would be code no run in this repository has ever executed.
48
+ // * It does not staple, and the reason is NOT that stapling is unevidenced.
49
+ // Measured here on `refs/node` at the pinned `0618e9f0`, against the ADR's own
50
+ // control of 16 files for `codesign`: `stapler` returns 4 files, of which
51
+ // `tools/osx-notarize.sh:58` is real code — `xcrun stapler staple
52
+ // "node-$pkgid.pkg"`, three lines past where § A15 stopped quoting; the other
53
+ // three are changelog entries. What is NOT measured is whether it accepts
54
+ // OUR container: the reference staples a `.pkg`, and the only file-shaped
55
+ // darwin artifact this command produces is a `.zip`. Adding a call that may
56
+ // refuse the one artifact it would run on is code no run in this repository
57
+ // has exercised, deciding something it cannot justify. Recorded in
58
+ // `status/open-todos.md` instead.
59
+ import { readFileSync } from 'node:fs';
60
+ import { join, sep } from 'node:path';
61
+ import { isOnPath } from '../check-system-deps.js';
62
+ import { describeExit, spawnToCompletion } from '../spawn.js';
63
+ import { listFilesRecursive } from './discover.js';
64
+ import { classifyBinary } from './payload.js';
65
+ import { writePayload } from './stage-writer.js';
66
+ /**
67
+ * The signers, keyed by the LAYOUT they sign — never by the host they run on.
68
+ *
69
+ * Linux is deliberately absent rather than present-and-empty. A `.deb` and an
70
+ * `.rpm` carry no per-file signature: the artifact is signed as a whole, by the
71
+ * repository that serves it (`debsigs`, `rpmsign`), with a key that belongs to
72
+ * that repository and not to this build. A row here would make `--sign` on Linux
73
+ * look like it did something.
74
+ */
75
+ export const SIGNERS = {
76
+ darwin: {
77
+ layoutOs: 'darwin',
78
+ tool: 'codesign',
79
+ // The tool is Apple's and ships with the Command Line Tools. There is no
80
+ // Linux `codesign`, so unlike every other step of the darwin leg this one
81
+ // really is host-bound — which is why it lives on the FINISH phase and
82
+ // `gjsify ship darwin --stage` needs none of it.
83
+ signOn: ['darwin'],
84
+ installHint: 'install the Xcode Command Line Tools (`xcode-select --install`) on a macOS host',
85
+ signs: 'macho',
86
+ // `--force` because every image in the shipped closure is ALREADY signed
87
+ // ad-hoc (§ A4: 106 of 106) — `install_name_tool` invalidates the original
88
+ // during relocation and the relocator re-signs. Without it `codesign`
89
+ // refuses a file that already carries a signature.
90
+ args: (identity, file) => ['--force', '--sign', identity, file],
91
+ configKey: 'gjsify.ship.sign.darwin.identity',
92
+ gap: 'the images are signed; the `.app` bundle itself is not sealed and no entitlements are ' +
93
+ 'granted (ADR 0024 § A16 leaves library validation unmeasured)',
94
+ },
95
+ win32: {
96
+ layoutOs: 'win32',
97
+ tool: 'signtool',
98
+ signOn: ['win32'],
99
+ installHint: 'install the Windows SDK (signtool.exe ships in its `bin/<version>/x64` directory)',
100
+ signs: 'pe',
101
+ // `/n` takes a SUBJECT NAME and looks the certificate up in a store, which
102
+ // is the same shape as `codesign --sign <identity>` and the reason § A12's
103
+ // interface is one flag for both OSes. `/fd sha256` names the file digest;
104
+ // Authenticode's default is SHA-1, which no current Windows accepts.
105
+ //
106
+ // ⚠️ UNVERIFIED. Nothing in this repository has ever run `signtool`: it
107
+ // needs a certificate, there is no ad-hoc mode on Windows, and § A5
108
+ // records that SmartScreen only WARNS until per-file-hash reputation
109
+ // accrues — signed or not. The argv is unit-tested, the skip and the
110
+ // refusals are e2e-tested, and the invocation itself has no proof.
111
+ args: (identity, file) => ['sign', '/n', identity, '/fd', 'sha256', file],
112
+ configKey: 'gjsify.ship.sign.win32.identity',
113
+ gap: 'no timestamp countersignature is requested, so the signature expires with the certificate ' +
114
+ '(UNVERIFIED: no run in this repository has ever invoked signtool)',
115
+ },
116
+ };
117
+ /**
118
+ * Resolve the identity for this run, or say why there is none.
119
+ *
120
+ * `flag` beats `config` and neither is a yargs `default:` — the uniform
121
+ * precedence `resolveShipSettings` documents. `config` is `undefined` on the
122
+ * `--from-stage` path and that is not an omission: § A14 amends § A1 to *a
123
+ * format declares where it can be packed; the RUN declares what it can sign
124
+ * with*, and the finishing host has no project to read a default out of.
125
+ */
126
+ export function resolveSignPlan(input) {
127
+ const signer = SIGNERS[input.layoutOs];
128
+ if (signer === undefined) {
129
+ if (input.flag !== undefined) {
130
+ throw new Error(`gjsify ship: --sign has nothing to sign in the ${input.layoutOs} layout. A .deb and an .rpm ` +
131
+ 'carry no per-file signature — the artifact is signed as a whole by the repository that ' +
132
+ "serves it (`debsigs`, `rpmsign`), with that repository's key and not with a build-time " +
133
+ 'identity. Drop --sign, or assemble the darwin or windows layout.');
134
+ }
135
+ return { kind: 'unsupported' };
136
+ }
137
+ const configured = input.config?.[input.layoutOs]?.identity;
138
+ const [identity, source] = input.flag !== undefined ? [input.flag, 'flag'] : [configured, 'config'];
139
+ // The reference's `[ -z "$SIGN" ]` — an EMPTY identity is a skip and not an
140
+ // error, and the skip is printed rather than silent so a pipeline that
141
+ // captures the artifact list still shows it (§ A13). Ours names the step it
142
+ // skipped, which the reference's own copy-pasted message does not.
143
+ if (identity === undefined || identity === '') {
144
+ const said = identity === ''
145
+ ? `an empty identity was ${source === 'flag' ? 'passed to --sign' : `set in \`${signer.configKey}\``}`
146
+ : 'no identity was given';
147
+ return {
148
+ kind: 'skip',
149
+ message: `${said} — skipping ${signer.tool}. An unsigned artifact is the default path and a ` +
150
+ `legitimate deliverable (ADR 0024 § A13). Pass \`--sign <identity>\` or set ` +
151
+ `\`${signer.configKey}\` to sign; \`--sign -\` signs ad-hoc and needs no developer identity.`,
152
+ };
153
+ }
154
+ return { kind: 'sign', identity, signer, source };
155
+ }
156
+ /**
157
+ * Refuse a signing run this host cannot make, and say which half is missing.
158
+ *
159
+ * Two checks and not one, for the same reason `assertCanPack` splits them: the
160
+ * wrong OS needs another machine, an absent tool needs a package. Both are
161
+ * knowable before anything is packed.
162
+ */
163
+ export function assertHostCanSign(signer) {
164
+ const host = process.platform;
165
+ if (!signer.signOn.includes(host)) {
166
+ throw new Error(`gjsify ship: signing the ${signer.layoutOs} layout needs ${signer.tool}, which runs on ` +
167
+ `${signer.signOn.join(', ')} and this host is ${host}. Assembly is not host-bound — ` +
168
+ '`gjsify ship <os> --stage` here, then `gjsify ship --from-stage <dir> --sign <identity>` ' +
169
+ 'on a host that has both the tool and the identity (ADR 0024 § A1, § A14).');
170
+ }
171
+ if (!isOnPath(signer.tool)) {
172
+ throw new Error(`gjsify ship: --sign was given and \`${signer.tool}\` is not on PATH, so nothing can make the ` +
173
+ `signature — ${signer.installHint}. Dropping --sign produces an unsigned artifact, which is ` +
174
+ 'a legitimate output (ADR 0024 § A13); silently producing one while --sign was asked for is not.');
175
+ }
176
+ }
177
+ /**
178
+ * Re-sign every signable image in the payload, and hand back the new bytes.
179
+ *
180
+ * The signature is made on FILES because that is the only interface `codesign`
181
+ * and `signtool` have, so the payload is materialised into `workDir`, signed
182
+ * there, and read back. `workDir` is a scratch directory rather than the stage:
183
+ * writing into an arriving stage would make the next `--from-stage` run of the
184
+ * same tree fail `readStage`'s size comparison (measured — see the header), and
185
+ * `writeStage`'s wipe would delete the payload it was about to pack.
186
+ *
187
+ * THE FILE SET MAY NOT CHANGE, and that is asserted rather than assumed. Both
188
+ * tools rewrite an image in place, so a payload that gained or lost a path here
189
+ * means something else ran — a bundle seal writing `_CodeSignature/`, say, whose
190
+ * files would then be dropped on the way back into the payload without anyone
191
+ * noticing.
192
+ */
193
+ export async function signPayload(input) {
194
+ const { payload, signer, workDir } = input;
195
+ writePayload(workDir, payload, '');
196
+ const signable = payload.filter((entry) => classifyBinary(entry.data) === signer.signs);
197
+ if (signable.length === 0) {
198
+ // NOT silent, and not an error either. A `--app gjs` payload really is
199
+ // JavaScript and a launcher, so there is nothing for the loader to
200
+ // validate — but a run that asked for a signature and made none has to
201
+ // say so, or "signed" and "signed nothing" look identical in the log.
202
+ input.log(`nothing in this payload is a ${signer.signs === 'macho' ? 'Mach-O' : 'PE'} image, so ` +
203
+ `${signer.tool} signed 0 file(s). The artifact carries no signature.`);
204
+ return [...payload];
205
+ }
206
+ for (const entry of signable) {
207
+ const file = join(workDir, entry.path.split('/').join(sep));
208
+ const args = signer.args(input.identity, file);
209
+ if (input.verbose)
210
+ input.log(`${signer.tool} ${args.join(' ')}`);
211
+ const result = await spawnToCompletion(signer.tool, args, {
212
+ completion: 'return',
213
+ cwd: workDir,
214
+ stdio: 'inherit',
215
+ notFound: () => new Error(`gjsify ship: \`${signer.tool}\` is not on PATH — ${signer.installHint}.`),
216
+ });
217
+ if (result.code !== 0) {
218
+ throw new Error(`gjsify ship: ${signer.tool} failed on ${entry.path} with ${describeExit(result)}. ` +
219
+ 'Nothing is packed from a payload whose signature failed: an artifact that is half ' +
220
+ 'signed installs and is refused at first launch, which is worse than not shipping.');
221
+ }
222
+ }
223
+ const arrived = new Set(listFilesRecursive(workDir));
224
+ const planned = new Set(payload.map((entry) => entry.path));
225
+ const added = [...arrived].filter((path) => !planned.has(path));
226
+ const removed = [...planned].filter((path) => !arrived.has(path));
227
+ if (added.length > 0 || removed.length > 0) {
228
+ throw new Error(`gjsify ship: ${signer.tool} changed the payload's file set — ` +
229
+ `${added.length} added (${added.slice(0, 3).join(', ') || '—'}), ` +
230
+ `${removed.length} removed (${removed.slice(0, 3).join(', ') || '—'}). ` +
231
+ 'Signing is a mutation of the files that are already in the payload (ADR 0024 § A4); a ' +
232
+ 'file that appeared here would be packed with no mode from the plan, and one that ' +
233
+ 'vanished would be packed as absent.');
234
+ }
235
+ input.log(`${signer.tool} signed ${signable.length} of ${payload.length} payload file(s) as ` +
236
+ `${input.identity === '-' ? 'ad-hoc (`-`)' : `\`${input.identity}\``} — ${signer.gap}.`);
237
+ // Modes come from the PLAN, exactly as `readStage` insists: the signer
238
+ // rewrites bytes and this re-read must not turn a 0755 launcher into
239
+ // whatever the scratch directory's umask produced.
240
+ return payload.map((entry) => ({
241
+ path: entry.path,
242
+ mode: entry.mode,
243
+ data: new Uint8Array(readFileSync(join(workDir, entry.path.split('/').join(sep)))),
244
+ }));
245
+ }
246
+ export const NOTARIES = {
247
+ darwin: {
248
+ layoutOs: 'darwin',
249
+ tool: 'xcrun',
250
+ notarizeOn: ['darwin'],
251
+ installHint: 'install the Xcode Command Line Tools (`xcode-select --install`) on a macOS host',
252
+ args: (credential, artifact) => ['notarytool', 'submit', '--keychain-profile', credential, '--wait', artifact],
253
+ },
254
+ };
255
+ /**
256
+ * Resolve the notarisation credential, or say why there is none.
257
+ *
258
+ * Deliberately NOT defaultable from the project. An identity is a name a machine
259
+ * may hold for years and is worth writing into a repository; a notarisation
260
+ * credential is a per-run account credential, and a config key for it is an
261
+ * invitation to commit one.
262
+ */
263
+ export function resolveNotaryPlan(input) {
264
+ const notary = NOTARIES[input.layoutOs];
265
+ if (notary === undefined) {
266
+ if (input.flag !== undefined) {
267
+ throw new Error(`gjsify ship: --notarize has no meaning in the ${input.layoutOs} layout. Notarisation is ` +
268
+ "Apple's pre-clearance of a signed artifact and nothing else asks for it — Windows " +
269
+ 'SmartScreen accrues reputation per file hash instead (ADR 0024 § A5).');
270
+ }
271
+ return { kind: 'unsupported' };
272
+ }
273
+ if (input.flag === undefined || input.flag === '') {
274
+ return {
275
+ kind: 'skip',
276
+ message: `no notarisation credential was given — skipping ${notary.tool} notarytool. Pass ` +
277
+ '`--notarize <keychain-profile>`, naming a profile a prior `notarytool store-credentials` ' +
278
+ "put in this host's keychain.",
279
+ };
280
+ }
281
+ if (input.sign.kind !== 'sign') {
282
+ throw new Error('gjsify ship: --notarize was given without an identity to sign with. Notarisation is a check ' +
283
+ 'ON a signature, so submitting an unsigned artifact has nothing to clear. Pass ' +
284
+ '`--sign <identity>` as well, or drop --notarize.');
285
+ }
286
+ return { kind: 'notarize', credential: input.flag, notary };
287
+ }
288
+ /**
289
+ * Submit one written artifact for notarisation.
290
+ *
291
+ * ⚠️ UNVERIFIED END TO END. Everything up to the `spawnToCompletion` is covered
292
+ * — the argv by `signing.spec.ts`, the guard and both refusals by
293
+ * `tests/e2e/ship-signing` — and the invocation itself has never run: it needs
294
+ * an Apple Developer account, and § A17's whole point is that M6's proof does
295
+ * not. Whoever first runs this on a real credential should record what it did
296
+ * beside § A15, which is where the question was left open.
297
+ */
298
+ export async function notarizeArtifact(input) {
299
+ const { notary, credential } = input.plan;
300
+ const host = process.platform;
301
+ if (!notary.notarizeOn.includes(host)) {
302
+ throw new Error(`gjsify ship: notarising the ${notary.layoutOs} layout needs ${notary.tool}, which runs on ` +
303
+ `${notary.notarizeOn.join(', ')} and this host is ${host}.`);
304
+ }
305
+ const args = notary.args(credential, input.artifact);
306
+ if (input.verbose)
307
+ input.log(`${notary.tool} ${args.join(' ')}`);
308
+ const result = await spawnToCompletion(notary.tool, args, {
309
+ completion: 'return',
310
+ stdio: 'inherit',
311
+ notFound: () => new Error(`gjsify ship: \`${notary.tool}\` is not on PATH — ${notary.installHint}.`),
312
+ });
313
+ if (result.code !== 0) {
314
+ throw new Error(`gjsify ship: ${notary.tool} notarytool failed with ${describeExit(result)}.`);
315
+ }
316
+ input.log(`${notary.tool} notarytool accepted ${input.artifact}. The artifact is NOT stapled.`);
317
+ }
@@ -1,4 +1,4 @@
1
- import type { FormatDescriptor, FormatId, PackSettings, ShipSettings, StagedFile, StagedMode } from './types.js';
1
+ import type { FormatDescriptor, FormatId, HostOs, PackSettings, ShipSettings, StagedFile, StagedMode } from './types.js';
2
2
  /** The one file a stage carries that is not payload. Lives at the stage ROOT. */
3
3
  export declare const STAGE_MANIFEST_FILE = ".gjsify-ship-stage.json";
4
4
  /**
@@ -10,21 +10,55 @@ export declare const STAGE_MANIFEST_FILE = ".gjsify-ship-stage.json";
10
10
  * Bump it whenever a field's MEANING changes, not when one is added that an
11
11
  * older reader would simply ignore; a reader that ignores a field it does not
12
12
  * know is exactly how a mode plan silently loses an entry.
13
- */
14
- export declare const STAGE_SCHEMA_VERSION = 1;
15
- /**
16
- * The OS whose layout `planStage` produces.
17
13
  *
18
- * A constant rather than a measurement, because it is a property of the PLAN
19
- * (`bin/`, `lib/<name>/`, `share/…` is the Linux/XDG layout), not of the host
20
- * that ran it — assembling is cross-platform (ADR 0024 § A1). When
21
- * `gjsify ship darwin --stage` lands this becomes the positional's value, and
22
- * this constant is where the compiler will point.
14
+ * 2 added `settings.appId` and `settings.flatpak` for the Flatpak target. That
15
+ * is the second case and not the first: an older reader ignoring
16
+ * `settings.flatpak` would not skip a field, it would pack a Flatpak against
17
+ * whatever runtime IT defaults to — a different `org.gnome.Platform` version
18
+ * than the project asked for, at exit 0.
19
+ *
20
+ * 3 added `settings.minNodeVersion` and 4 added `settings.app`. Same case as 2
21
+ * for the same reason: an older reader ignoring `app` does not skip a field, it
22
+ * has no concept of an interpreter choice at all, so it seeds `gjs >= …` for a
23
+ * stage whose launcher — already rendered, already in the tree it is packing —
24
+ * execs `node`. The package then declares one interpreter and runs another.
25
+ *
26
+ * Stated as a consequence and not as an incident, because it never happened:
27
+ * `app` and the launcher landed together. An earlier version of this comment
28
+ * claimed the incident anyway, which is worth leaving on the record — a rule
29
+ * with an invented reason gets "simplified" back out the first time somebody
30
+ * notices the reason is not real.
31
+ *
32
+ * WHY TWO BUMPS IN ONE CHANGE, when neither was ever released. Because 3 and 4
33
+ * are DIFFERENT SHAPES and 3 briefly existed: the intermediate commit wrote
34
+ * `schema: 3` with no `app`. Folding `app` into 3 would have made this reader
35
+ * meet that stage and fail on `settings.app must be "gjs" or "node", got
36
+ * undefined` — fail-closed, but naming a field instead of the one thing the
37
+ * reader can do about it. At 4 the schema check above catches it first and says
38
+ * "re-run the `--stage` phase with this gjsify", which is the whole reason this
39
+ * constant's header is an argument for bumping whenever a reader could mis-read.
40
+ *
41
+ * 5 added `settings.name`, and it was decided against the criterion above rather
42
+ * than reflexively — #1354 M2a changed two things and only one of them warrants a
43
+ * bump:
44
+ *
45
+ * - NOT the two new format ids. `formats[]` and `overlay`'s keys carry new
46
+ * VALUES in fields whose meaning is unchanged, and `readStageManifest` already
47
+ * refuses a `formats[i]` it does not know BY NAME. An older gjsify meeting a
48
+ * macOS stage therefore fails closed with a message about the format, which is
49
+ * the right message. Adding a member to a validated enum is the case this
50
+ * header says not to bump for.
51
+ * - YES `settings.name`. It is REQUIRED, so this reader meeting a schema-4 stage
52
+ * would fail on `settings.name must be a string` — naming a field instead of
53
+ * the one thing the reader can do about it. That is the same shape the 3-to-4
54
+ * paragraph describes, and at 5 the schema check above fires first and says
55
+ * "re-run the `--stage` phase with this gjsify".
23
56
  */
24
- export declare const STAGE_LAYOUT_OS = "linux";
57
+ export declare const STAGE_SCHEMA_VERSION = 5;
25
58
  /** What this stage was assembled FOR, in the repo-wide `${process.platform}-${process.arch}` spelling. */
26
59
  export interface StageTarget {
27
- os: string;
60
+ /** The layout the stage carries, in the `process.platform` spelling — `win32`, never `windows`. */
61
+ os: HostOs;
28
62
  arch: string;
29
63
  }
30
64
  /** One staged path, its planned mode, and the size it had when the stage was written. */
@@ -72,7 +106,10 @@ export interface StageManifestInput {
72
106
  * `extraFiles` and `licenseFile` — seven absolute paths from the assembling
73
107
  * host — into a file whose whole purpose is to be read somewhere else. The
74
108
  * explicit list is also what makes adding a field a decision: see the comment
75
- * on {@link PackSettings} for what belongs on each side.
109
+ * on {@link PackSettings} for what belongs on each side. `appId` and `flatpak`
110
+ * were that decision for the Flatpak target, and the `--from-stage` e2e is
111
+ * where an omission would have shown: it deletes the project, so a field the
112
+ * packer needs and the manifest lacks has nowhere to be read from.
76
113
  */
77
114
  export declare function toPackSettings(settings: ShipSettings): PackSettings;
78
115
  /** Absolute path of a stage's manifest. */
@@ -38,6 +38,7 @@ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
38
38
  import { join, sep } from 'node:path';
39
39
  import { cliVersion } from '../publish-headers.js';
40
40
  import { FORMAT_IDS } from './formats.js';
41
+ import { layoutForOs, LAYOUTS, LAYOUT_NAMES } from './layout.js';
41
42
  /** The one file a stage carries that is not payload. Lives at the stage ROOT. */
42
43
  export const STAGE_MANIFEST_FILE = '.gjsify-ship-stage.json';
43
44
  /**
@@ -49,18 +50,51 @@ export const STAGE_MANIFEST_FILE = '.gjsify-ship-stage.json';
49
50
  * Bump it whenever a field's MEANING changes, not when one is added that an
50
51
  * older reader would simply ignore; a reader that ignores a field it does not
51
52
  * know is exactly how a mode plan silently loses an entry.
52
- */
53
- export const STAGE_SCHEMA_VERSION = 1;
54
- /**
55
- * The OS whose layout `planStage` produces.
56
53
  *
57
- * A constant rather than a measurement, because it is a property of the PLAN
58
- * (`bin/`, `lib/<name>/`, `share/…` is the Linux/XDG layout), not of the host
59
- * that ran it — assembling is cross-platform (ADR 0024 § A1). When
60
- * `gjsify ship darwin --stage` lands this becomes the positional's value, and
61
- * this constant is where the compiler will point.
54
+ * 2 added `settings.appId` and `settings.flatpak` for the Flatpak target. That
55
+ * is the second case and not the first: an older reader ignoring
56
+ * `settings.flatpak` would not skip a field, it would pack a Flatpak against
57
+ * whatever runtime IT defaults to — a different `org.gnome.Platform` version
58
+ * than the project asked for, at exit 0.
59
+ *
60
+ * 3 added `settings.minNodeVersion` and 4 added `settings.app`. Same case as 2
61
+ * for the same reason: an older reader ignoring `app` does not skip a field, it
62
+ * has no concept of an interpreter choice at all, so it seeds `gjs >= …` for a
63
+ * stage whose launcher — already rendered, already in the tree it is packing —
64
+ * execs `node`. The package then declares one interpreter and runs another.
65
+ *
66
+ * Stated as a consequence and not as an incident, because it never happened:
67
+ * `app` and the launcher landed together. An earlier version of this comment
68
+ * claimed the incident anyway, which is worth leaving on the record — a rule
69
+ * with an invented reason gets "simplified" back out the first time somebody
70
+ * notices the reason is not real.
71
+ *
72
+ * WHY TWO BUMPS IN ONE CHANGE, when neither was ever released. Because 3 and 4
73
+ * are DIFFERENT SHAPES and 3 briefly existed: the intermediate commit wrote
74
+ * `schema: 3` with no `app`. Folding `app` into 3 would have made this reader
75
+ * meet that stage and fail on `settings.app must be "gjs" or "node", got
76
+ * undefined` — fail-closed, but naming a field instead of the one thing the
77
+ * reader can do about it. At 4 the schema check above catches it first and says
78
+ * "re-run the `--stage` phase with this gjsify", which is the whole reason this
79
+ * constant's header is an argument for bumping whenever a reader could mis-read.
80
+ *
81
+ * 5 added `settings.name`, and it was decided against the criterion above rather
82
+ * than reflexively — #1354 M2a changed two things and only one of them warrants a
83
+ * bump:
84
+ *
85
+ * - NOT the two new format ids. `formats[]` and `overlay`'s keys carry new
86
+ * VALUES in fields whose meaning is unchanged, and `readStageManifest` already
87
+ * refuses a `formats[i]` it does not know BY NAME. An older gjsify meeting a
88
+ * macOS stage therefore fails closed with a message about the format, which is
89
+ * the right message. Adding a member to a validated enum is the case this
90
+ * header says not to bump for.
91
+ * - YES `settings.name`. It is REQUIRED, so this reader meeting a schema-4 stage
92
+ * would fail on `settings.name must be a string` — naming a field instead of
93
+ * the one thing the reader can do about it. That is the same shape the 3-to-4
94
+ * paragraph describes, and at 5 the schema check above fires first and says
95
+ * "re-run the `--stage` phase with this gjsify".
62
96
  */
63
- export const STAGE_LAYOUT_OS = 'linux';
97
+ export const STAGE_SCHEMA_VERSION = 5;
64
98
  /**
65
99
  * The half of {@link ShipSettings} that crosses the host boundary.
66
100
  *
@@ -69,11 +103,16 @@ export const STAGE_LAYOUT_OS = 'linux';
69
103
  * `extraFiles` and `licenseFile` — seven absolute paths from the assembling
70
104
  * host — into a file whose whole purpose is to be read somewhere else. The
71
105
  * explicit list is also what makes adding a field a decision: see the comment
72
- * on {@link PackSettings} for what belongs on each side.
106
+ * on {@link PackSettings} for what belongs on each side. `appId` and `flatpak`
107
+ * were that decision for the Flatpak target, and the `--from-stage` e2e is
108
+ * where an omission would have shown: it deletes the project, so a field the
109
+ * packer needs and the manifest lacks has nowhere to be read from.
73
110
  */
74
111
  export function toPackSettings(settings) {
75
112
  return {
76
113
  binaryName: settings.binaryName,
114
+ appId: settings.appId,
115
+ name: settings.name,
77
116
  version: settings.version,
78
117
  release: settings.release,
79
118
  maintainer: settings.maintainer,
@@ -85,7 +124,10 @@ export function toPackSettings(settings) {
85
124
  group: settings.group,
86
125
  extraDepends: settings.extraDepends,
87
126
  typelibPackages: settings.typelibPackages,
127
+ app: settings.app,
88
128
  minGjsVersion: settings.minGjsVersion,
129
+ minNodeVersion: settings.minNodeVersion,
130
+ flatpak: settings.flatpak,
89
131
  };
90
132
  }
91
133
  /** Absolute path of a stage's manifest. */
@@ -121,7 +163,13 @@ export function writeStageManifest(input) {
121
163
  const manifest = {
122
164
  schema: STAGE_SCHEMA_VERSION,
123
165
  tool: { name: '@gjsify/cli', version: cliVersion() },
124
- target: { os: STAGE_LAYOUT_OS, arch: input.settings.arch },
166
+ // The POSITIONAL's value, not a constant. It used to be the literal
167
+ // `'linux'`, correct while the planner produced exactly one layout and
168
+ // wrong the moment it produced three — and wrong SILENTLY, because a
169
+ // darwin stage labelled `linux-arm64` passes every structural check in
170
+ // `readStageManifest` and would be accepted by `--expect-target
171
+ // linux-arm64` on a leg that wanted the Linux one.
172
+ target: { os: input.settings.layoutOs, arch: input.settings.arch },
125
173
  formats: input.formats.map((format) => format.id),
126
174
  mtime: input.mtime,
127
175
  namespaces: [...input.namespaces],
@@ -192,7 +240,16 @@ export function readStageManifest(stageDir) {
192
240
  version: expectString(tool.version, at('tool.version')),
193
241
  },
194
242
  target: {
195
- os: expectString(target.os, at('target.os')),
243
+ // `layoutForOs`, which takes the `process.platform` spelling and NO
244
+ // alias. `resolveLayout` — the positional's resolver — also accepts
245
+ // `windows`, and routing this field through it would mean a manifest
246
+ // literally saying `"windows"` was accepted and then COMPARED as
247
+ // `win32`: two files with different bytes in the one field
248
+ // `--expect-target` exists to compare would both match it, on a wire
249
+ // format whose entire job is to catch a job that downloaded the wrong
250
+ // artifact. Refused here rather than reaching the layout lookup as a
251
+ // lie, and named the way every other field in this function is.
252
+ os: expectLayoutOs(expectString(target.os, at('target.os')), at('target.os')),
196
253
  arch: expectString(target.arch, at('target.arch')),
197
254
  },
198
255
  formats: expectArray(data.formats, at('formats')).map((entry, index) => {
@@ -247,6 +304,26 @@ export function readStageManifest(stageDir) {
247
304
  }
248
305
  return manifest;
249
306
  }
307
+ /**
308
+ * `target.os`, resolved to a layout, in the one spelling this field may carry.
309
+ *
310
+ * The catch REPLACES the message rather than swallowing it: `layoutForOs` speaks
311
+ * about a token, and this is a named field in a file that arrived from another
312
+ * host, so the fix is different and so is the sentence. Every other field in
313
+ * `readStageManifest` names its own location the same way.
314
+ */
315
+ function expectLayoutOs(value, where) {
316
+ try {
317
+ return layoutForOs(value).os;
318
+ }
319
+ catch {
320
+ const known = LAYOUT_NAMES.map((name) => LAYOUTS[name].os).join(', ');
321
+ throw new Error(`gjsify ship: ${where} is "${value}", which is not a layout this gjsify can pack. It carries ` +
322
+ `the \`process.platform\` spelling and only that — ${known}, so \`win32\` and never ` +
323
+ "`windows`. A stage holds exactly one OS's layout and every path in it has that OS's shape, " +
324
+ 'so there is nothing to pack here. Re-run the `--stage` phase.');
325
+ }
326
+ }
250
327
  /** The overlay for one format, in the shape `writeStage` takes. */
251
328
  export function overlayFiles(manifest, format) {
252
329
  return (manifest.overlay[format] ?? []).map((file) => ({
@@ -307,7 +384,11 @@ export function assertFormatsStaged(manifest, formats) {
307
384
  const missing = formats.filter((format) => !manifest.formats.includes(format.id)).map((format) => format.id);
308
385
  if (missing.length === 0)
309
386
  return;
310
- throw new Error(`gjsify ship: this stage was assembled for ${manifest.formats.join(', ')}, and --target names ` +
387
+ throw new Error(
388
+ // `no format at all` and not an empty join: a stage CAN record zero formats
389
+ // (`gjsify.ship.targets` filtered to a layout none of them wrap), and the
390
+ // join printed that as nothing — "assembled for , and --target names …".
391
+ `gjsify ship: this stage was assembled for ${manifest.formats.length === 0 ? 'no format at all' : manifest.formats.join(', ')}, and --target names ` +
311
392
  `${missing.join(', ')}. Phase one renders each format's overlay — rpm wants the licence at ` +
312
393
  '`share/licenses/<pkg>/LICENSE`, deb wants a machine-readable copyright at ' +
313
394
  '`share/doc/<pkg>/copyright` — so a format the stage never saw would pack without it, and Debian ' +
@@ -355,6 +436,8 @@ function readPackSettings(data, at) {
355
436
  const typelibPackages = record(data.typelibPackages, field('typelibPackages'));
356
437
  return {
357
438
  binaryName: expectString(data.binaryName, field('binaryName')),
439
+ appId: expectString(data.appId, field('appId')),
440
+ name: expectString(data.name, field('name')),
358
441
  version: expectString(data.version, field('version')),
359
442
  release: expectString(data.release, field('release')),
360
443
  maintainer: expectString(data.maintainer, field('maintainer')),
@@ -378,7 +461,34 @@ function readPackSettings(data, at) {
378
461
  },
379
462
  ];
380
463
  })),
464
+ app: expectApp(data.app, field('app')),
381
465
  minGjsVersion: expectString(data.minGjsVersion, field('minGjsVersion')),
466
+ minNodeVersion: expectString(data.minNodeVersion, field('minNodeVersion')),
467
+ flatpak: readFlatpakSettings(record(data.flatpak, field('flatpak')), field('flatpak')),
468
+ };
469
+ }
470
+ /**
471
+ * The Flatpak half, validated the same way as everything else here.
472
+ *
473
+ * Every field is required and none is defaulted, deliberately: a `?? []` on
474
+ * `finishArgs` would turn a mutilated manifest into an app with no display
475
+ * socket — it installs, it starts, and it draws nothing. The defaults belong on
476
+ * the assembling host, where `gjsify.ship` and `kind` are in reach; by the time
477
+ * a manifest exists the answer has already been decided and writing it down
478
+ * twice is how the two hosts disagree.
479
+ */
480
+ function readFlatpakSettings(data, at) {
481
+ const field = (name) => `${at}.${name}`;
482
+ const strings = (value, name) => expectArray(value, field(name)).map((entry, index) => expectString(entry, field(`${name}[${index}]`)));
483
+ return {
484
+ runtime: expectString(data.runtime, field('runtime')),
485
+ runtimeVersion: expectString(data.runtimeVersion, field('runtimeVersion')),
486
+ sdk: expectString(data.sdk, field('sdk')),
487
+ branch: expectString(data.branch, field('branch')),
488
+ sdkExtensions: strings(data.sdkExtensions, 'sdkExtensions'),
489
+ appendPath: strings(data.appendPath, 'appendPath'),
490
+ finishArgs: strings(data.finishArgs, 'finishArgs'),
491
+ cleanup: strings(data.cleanup, 'cleanup'),
382
492
  };
383
493
  }
384
494
  function record(value, at) {
@@ -393,6 +503,19 @@ function expectArray(value, at) {
393
503
  }
394
504
  return value;
395
505
  }
506
+ /**
507
+ * The interpreter field, checked against the two values that exist.
508
+ *
509
+ * Not `expectString`: this one decides which interpreter the package DEPENDS on,
510
+ * so an unknown value must stop the pack rather than flow into a `Depends:` line
511
+ * as itself. The only way to reach it is a stage from a gjsify that knows a third
512
+ * target, which the schema check above already refuses — this is the second wall.
513
+ */
514
+ function expectApp(value, at) {
515
+ if (value === 'gjs' || value === 'node')
516
+ return value;
517
+ throw new Error(`gjsify ship: ${at} must be "gjs" or "node", got ${JSON.stringify(value)}.`);
518
+ }
396
519
  function expectString(value, at) {
397
520
  if (typeof value !== 'string') {
398
521
  throw new Error(`gjsify ship: ${at} must be a string. Re-run the \`--stage\` phase.`);