@gjsify/cli 0.44.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 (73) hide show
  1. package/dist/cli.gjs.mjs +192 -165
  2. package/lib/commands/gettext.d.ts +2 -1
  3. package/lib/commands/gettext.js +105 -47
  4. package/lib/commands/onboard.d.ts +7 -0
  5. package/lib/commands/onboard.js +349 -96
  6. package/lib/commands/ship.d.ts +3 -0
  7. package/lib/commands/ship.js +833 -54
  8. package/lib/commands/trust.d.ts +6 -0
  9. package/lib/commands/trust.js +1 -1
  10. package/lib/types/config-data.d.ts +63 -1
  11. package/lib/utils/msgfmt-merge.d.ts +35 -0
  12. package/lib/utils/msgfmt-merge.js +102 -0
  13. package/lib/utils/npm-otp.d.ts +27 -2
  14. package/lib/utils/npm-otp.js +82 -7
  15. package/lib/utils/onboard-discovery.d.ts +68 -0
  16. package/lib/utils/onboard-discovery.js +178 -0
  17. package/lib/utils/onboard-probe.d.ts +118 -1
  18. package/lib/utils/onboard-probe.js +153 -4
  19. package/lib/utils/prompt-output.d.ts +12 -0
  20. package/lib/utils/prompt-output.js +54 -0
  21. package/lib/utils/prompt.js +22 -2
  22. package/lib/utils/publishable-packages.d.ts +1 -1
  23. package/lib/utils/publishable-packages.js +1 -0
  24. package/lib/utils/resolve-npm-package.d.ts +56 -0
  25. package/lib/utils/resolve-npm-package.js +69 -0
  26. package/lib/utils/ship/app-runtime.d.ts +214 -0
  27. package/lib/utils/ship/app-runtime.js +429 -0
  28. package/lib/utils/ship/depends.d.ts +50 -0
  29. package/lib/utils/ship/depends.js +117 -3
  30. package/lib/utils/ship/discover.js +18 -1
  31. package/lib/utils/ship/dmg.d.ts +71 -0
  32. package/lib/utils/ship/dmg.js +181 -0
  33. package/lib/utils/ship/flatpak.js +1 -1
  34. package/lib/utils/ship/formats.d.ts +85 -7
  35. package/lib/utils/ship/formats.js +734 -15
  36. package/lib/utils/ship/launcher.d.ts +13 -3
  37. package/lib/utils/ship/launcher.js +362 -17
  38. package/lib/utils/ship/layout.d.ts +268 -0
  39. package/lib/utils/ship/layout.js +351 -0
  40. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  41. package/lib/utils/ship/localize-metadata.js +126 -0
  42. package/lib/utils/ship/mime.js +1 -9
  43. package/lib/utils/ship/msi.d.ts +126 -0
  44. package/lib/utils/ship/msi.js +417 -0
  45. package/lib/utils/ship/node-runtime.d.ts +71 -0
  46. package/lib/utils/ship/node-runtime.js +122 -0
  47. package/lib/utils/ship/payload.d.ts +156 -5
  48. package/lib/utils/ship/payload.js +580 -14
  49. package/lib/utils/ship/plan.d.ts +21 -0
  50. package/lib/utils/ship/plan.js +20 -7
  51. package/lib/utils/ship/plist.d.ts +58 -0
  52. package/lib/utils/ship/plist.js +198 -0
  53. package/lib/utils/ship/rpm.js +9 -3
  54. package/lib/utils/ship/schemas.d.ts +61 -0
  55. package/lib/utils/ship/schemas.js +118 -0
  56. package/lib/utils/ship/scripts.js +7 -3
  57. package/lib/utils/ship/settings.d.ts +19 -1
  58. package/lib/utils/ship/settings.js +12 -1
  59. package/lib/utils/ship/share-dirs.d.ts +23 -0
  60. package/lib/utils/ship/share-dirs.js +46 -0
  61. package/lib/utils/ship/signing.d.ts +195 -0
  62. package/lib/utils/ship/signing.js +317 -0
  63. package/lib/utils/ship/stage-manifest.d.ts +40 -12
  64. package/lib/utils/ship/stage-manifest.js +99 -13
  65. package/lib/utils/ship/stage-writer.d.ts +48 -0
  66. package/lib/utils/ship/stage-writer.js +71 -1
  67. package/lib/utils/ship/types.d.ts +158 -13
  68. package/lib/utils/ship/xml.d.ts +7 -0
  69. package/lib/utils/ship/xml.js +25 -0
  70. package/lib/utils/ship/zip.d.ts +69 -0
  71. package/lib/utils/ship/zip.js +245 -0
  72. package/lib/utils/trust-registry.js +5 -0
  73. package/package.json +19 -19
@@ -1,5 +1,5 @@
1
1
  import type { AppMetadata, ConfigDataFlatpak, ConfigDataShip, DescriptionBlock } from '../../types/config-data.js';
2
- import type { ShipSettings } from './types.js';
2
+ import type { HostOs, ShipSettings } from './types.js';
3
3
  /** The subset of `package.json` the resolver reads. */
4
4
  export interface ShipPackageManifest {
5
5
  name?: string;
@@ -42,11 +42,29 @@ export interface SettingsInput {
42
42
  ship: ConfigDataShip;
43
43
  /** Metadata fallback — `gjsify.flatpak` already carries these fields for many projects. */
44
44
  flatpak: ConfigDataFlatpak;
45
+ /**
46
+ * What the command line decided before any config was read.
47
+ *
48
+ * `layoutOs` is REQUIRED, unlike its two neighbours, and the asymmetry is the
49
+ * point: `outDir` and `arch` have derived defaults that are correct when
50
+ * nobody says anything, while the layout is chosen by `gjsify ship <os>` (or
51
+ * by the host) before this function is reached. Making it optional would put
52
+ * a fourth default for it in here, and a second place that decides the layout
53
+ * is how the manifest's `target.os` and the staged paths come apart.
54
+ */
45
55
  cli: {
46
56
  outDir?: string;
47
57
  arch?: string;
58
+ layoutOs: HostOs;
48
59
  };
49
60
  discovered: DiscoveredPayload;
61
+ /**
62
+ * `gjsify.app` as DECLARED — `undefined` when the project declares nothing.
63
+ *
64
+ * Deliberately the raw config value and not `Config.forBuild`'s resolved one:
65
+ * see where it is defaulted below.
66
+ */
67
+ app?: string;
50
68
  }
51
69
  export interface ResolvedSettings {
52
70
  settings: ShipSettings;
@@ -11,7 +11,7 @@
11
11
  // indistinguishable from a value the user typed and would clobber the config.
12
12
  import { validateMimeTypes } from './mime.js';
13
13
  import { basename } from 'node:path';
14
- import { DEFAULT_GJS_FLOOR } from './depends.js';
14
+ import { DEFAULT_GJS_FLOOR, DEFAULT_NODE_FLOOR } from './depends.js';
15
15
  import { resolveShipFlatpakSettings } from './flatpak-config.js';
16
16
  import { assertRelease, normaliseVersion } from './version.js';
17
17
  /** Freedesktop main category → deb `Section:` / rpm `Group:`. First match wins. */
@@ -49,6 +49,14 @@ export function resolveShipSettings(input) {
49
49
  const appId = ship.appId ?? flatpak.appId ?? reverseDnsOrThrow(pkg.name, binaryName);
50
50
  const name = metadata.name ?? titleCase(binaryName);
51
51
  const kind = metadata.kind ?? 'app';
52
+ // `gjsify.app`, defaulted HERE to `'gjs'` rather than taken from
53
+ // `Config.forBuild`'s resolution, which falls back to the HOST runtime. That
54
+ // fallback is right for a build (`gjsify build` under Node should produce a
55
+ // Node bundle by default) and catastrophic for a package: running this
56
+ // command under Node would silently turn every undeclared GJS project into
57
+ // one whose launcher execs `node`. An undeclared target is the common case
58
+ // for a GJS app, and it means GJS.
59
+ const app = input.app === 'node' ? 'node' : 'gjs';
52
60
  const rawVersion = ship.version ?? pkg.version;
53
61
  if (!rawVersion) {
54
62
  throw new Error('gjsify ship: no version — set `version` in package.json or `gjsify.ship.version`.');
@@ -106,7 +114,10 @@ export function resolveShipSettings(input) {
106
114
  execArgs: ship.execArgs ?? [],
107
115
  outDir: input.cli.outDir ?? ship.outDir ?? 'ship',
108
116
  arch: input.cli.arch ?? process.arch,
117
+ app,
118
+ layoutOs: input.cli.layoutOs,
109
119
  minGjsVersion: ship.minGjsVersion ?? DEFAULT_GJS_FLOOR,
120
+ minNodeVersion: ship.minNodeVersion ?? DEFAULT_NODE_FLOOR,
110
121
  flatpak: flatpakSettings.settings,
111
122
  };
112
123
  return { settings, metadata, warnings };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Each entry is a directory the freedesktop/GNOME stack looks in BY NAME. None of
3
+ * them is ours to choose: `bindtextdomain` reads `share/locale/<lang>/LC_MESSAGES`,
4
+ * `glib-compile-schemas` compiles `share/glib-2.0/schemas`, and the icon theme
5
+ * spec fixes `share/icons/hicolor`. That is also why this is a closed set rather
6
+ * than a convention — a typo in any of them installs a file nothing ever reads.
7
+ */
8
+ export declare const SHARE: {
9
+ /** freedesktop desktop entries. */
10
+ readonly applications: "share/applications";
11
+ /** The hicolor icon theme, whose cache `gtk-update-icon-cache` builds. */
12
+ readonly icons: "share/icons/hicolor";
13
+ /** GSettings schema sources, compiled into `gschemas.compiled` at install. */
14
+ readonly schemas: "share/glib-2.0/schemas";
15
+ /** shared-mime-info documents, folded into the mime cache at install. */
16
+ readonly mime: "share/mime/packages";
17
+ /** AppStream components. */
18
+ readonly metainfo: "share/metainfo";
19
+ /** Compiled gettext catalogues, read directly — no install step. */
20
+ readonly locale: "share/locale";
21
+ };
22
+ /** Is `path` inside `dir`? Directory-boundary aware, so `share/mimetypes` is not inside `share/mime`. */
23
+ export declare function isUnder(path: string, dir: string): boolean;
@@ -0,0 +1,46 @@
1
+ // The `share/` subdirectories a payload installs into — ONE definition.
2
+ //
3
+ // WHY THIS FILE EXISTS, and it is a repair rather than a tidy-up. Four call
4
+ // sites answer questions about the same directories and every one of them spelled
5
+ // them out again: `plan.ts` stages a file there, `readPayloadFacts` asks whether
6
+ // the payload put anything there, `cacheRefreshCommands` emits the install step
7
+ // that makes it WORK, and `linuxInstallDependent` names the ones a non-Linux
8
+ // layout carries without that step. `share/glib-2.0/schemas` alone existed as
9
+ // five independent string literals across the tree.
10
+ //
11
+ // A comment claiming they "cannot drift" was measured FALSE: pointing one rule in
12
+ // `linuxInstallDependent` at a directory that matches nothing silently dropped a
13
+ // file from the warning `gjsify ship` prints, and the whole suite stayed green at
14
+ // exit 0. The prose was the mechanism, which is to say there was none. Importing
15
+ // one constant makes the compiler the mechanism instead.
16
+ //
17
+ // PREFIX-RELATIVE and POSIX-separated, like every path in the plan. The layout
18
+ // map (`layout.ts`) is what turns these into `Contents/Resources/share/…` or a
19
+ // Windows program directory's `share\`; nothing here knows about that, and
20
+ // nothing here is absolute — `scripts.ts` prepends the format's install prefix,
21
+ // which is `/usr` for a `.deb` and `/app` for a Flatpak.
22
+ /**
23
+ * Each entry is a directory the freedesktop/GNOME stack looks in BY NAME. None of
24
+ * them is ours to choose: `bindtextdomain` reads `share/locale/<lang>/LC_MESSAGES`,
25
+ * `glib-compile-schemas` compiles `share/glib-2.0/schemas`, and the icon theme
26
+ * spec fixes `share/icons/hicolor`. That is also why this is a closed set rather
27
+ * than a convention — a typo in any of them installs a file nothing ever reads.
28
+ */
29
+ export const SHARE = {
30
+ /** freedesktop desktop entries. */
31
+ applications: 'share/applications',
32
+ /** The hicolor icon theme, whose cache `gtk-update-icon-cache` builds. */
33
+ icons: 'share/icons/hicolor',
34
+ /** GSettings schema sources, compiled into `gschemas.compiled` at install. */
35
+ schemas: 'share/glib-2.0/schemas',
36
+ /** shared-mime-info documents, folded into the mime cache at install. */
37
+ mime: 'share/mime/packages',
38
+ /** AppStream components. */
39
+ metainfo: 'share/metainfo',
40
+ /** Compiled gettext catalogues, read directly — no install step. */
41
+ locale: 'share/locale',
42
+ };
43
+ /** Is `path` inside `dir`? Directory-boundary aware, so `share/mimetypes` is not inside `share/mime`. */
44
+ export function isUnder(path, dir) {
45
+ return path.startsWith(`${dir}/`);
46
+ }
@@ -0,0 +1,195 @@
1
+ import { type PayloadEntry } from './payload.js';
2
+ import type { HostOs } from './types.js';
3
+ /**
4
+ * Everything that is per-OS about making a signature, as data.
5
+ *
6
+ * The same shape argument `FormatDescriptor` makes one directory over: a second
7
+ * OS is a row, not a branch. It is a separate table from `FORMATS` on purpose —
8
+ * § A14 measured that a format declares where it can be PACKED and never what it
9
+ * can be signed with, and darwin has two format rows that must not be able to
10
+ * disagree about their signature.
11
+ */
12
+ export interface SignerDescriptor {
13
+ /** The layout whose payload this signer signs. */
14
+ layoutOs: HostOs;
15
+ /** The command that makes the signature. */
16
+ tool: string;
17
+ /** OSes whose tooling can run {@link tool}. */
18
+ signOn: readonly HostOs[];
19
+ /** Where {@link tool} comes from, in this signer's own words. */
20
+ installHint: string;
21
+ /**
22
+ * Which payload files carry a signature on this OS.
23
+ *
24
+ * `classifyBinary`'s vocabulary, so the predicate is the magic number and
25
+ * not a filename pattern: a darwin closure names its images `.dylib`,
26
+ * `.so`, `.node` and nothing at all (the interpreter), and a suffix list
27
+ * would sign three of those four.
28
+ */
29
+ signs: 'macho' | 'pe';
30
+ /** argv after {@link tool}, for ONE file. */
31
+ args: (identity: string, file: string) => readonly string[];
32
+ /** The config key a project spells its default identity under. */
33
+ configKey: string;
34
+ /** What a signature from this signer does NOT yet cover — printed once per run. */
35
+ gap: string;
36
+ }
37
+ /**
38
+ * The signers, keyed by the LAYOUT they sign — never by the host they run on.
39
+ *
40
+ * Linux is deliberately absent rather than present-and-empty. A `.deb` and an
41
+ * `.rpm` carry no per-file signature: the artifact is signed as a whole, by the
42
+ * repository that serves it (`debsigs`, `rpmsign`), with a key that belongs to
43
+ * that repository and not to this build. A row here would make `--sign` on Linux
44
+ * look like it did something.
45
+ */
46
+ export declare const SIGNERS: Partial<Record<HostOs, SignerDescriptor>>;
47
+ /** `gjsify.ship.sign` as declared — one entry per {@link HostOs}. */
48
+ export type SignConfig = Partial<Record<HostOs, {
49
+ identity?: string;
50
+ }>>;
51
+ /**
52
+ * What this run will do about signatures.
53
+ *
54
+ * A union rather than an optional string, because the three states need three
55
+ * different words and collapsing them is exactly what § A13 warns about: "no
56
+ * signer for this layout" is silence, "a slot exists and is empty" is a printed
57
+ * skip, and "here is an identity" is work.
58
+ */
59
+ export type SignPlan = {
60
+ kind: 'unsupported';
61
+ } | {
62
+ kind: 'skip';
63
+ message: string;
64
+ } | {
65
+ kind: 'sign';
66
+ identity: string;
67
+ signer: SignerDescriptor;
68
+ source: 'flag' | 'config';
69
+ };
70
+ /**
71
+ * Resolve the identity for this run, or say why there is none.
72
+ *
73
+ * `flag` beats `config` and neither is a yargs `default:` — the uniform
74
+ * precedence `resolveShipSettings` documents. `config` is `undefined` on the
75
+ * `--from-stage` path and that is not an omission: § A14 amends § A1 to *a
76
+ * format declares where it can be packed; the RUN declares what it can sign
77
+ * with*, and the finishing host has no project to read a default out of.
78
+ */
79
+ export declare function resolveSignPlan(input: {
80
+ /** `--sign` as typed. `''` is a value: it is `[ -z "$SIGN" ]` (§ A13). */
81
+ flag?: string;
82
+ /** `gjsify.ship.sign`, when a project is in reach. */
83
+ config?: SignConfig;
84
+ layoutOs: HostOs;
85
+ }): SignPlan;
86
+ /**
87
+ * Refuse a signing run this host cannot make, and say which half is missing.
88
+ *
89
+ * Two checks and not one, for the same reason `assertCanPack` splits them: the
90
+ * wrong OS needs another machine, an absent tool needs a package. Both are
91
+ * knowable before anything is packed.
92
+ */
93
+ export declare function assertHostCanSign(signer: SignerDescriptor): void;
94
+ export interface SignPayloadInput {
95
+ /** What `readStage` returned — the PRE-sign tree, already held against the manifest. */
96
+ payload: readonly PayloadEntry[];
97
+ identity: string;
98
+ signer: SignerDescriptor;
99
+ /** A scratch directory this function owns and WIPES. Never the arriving stage. */
100
+ workDir: string;
101
+ /** Print every invocation. */
102
+ verbose: boolean;
103
+ log: (line: string) => void;
104
+ }
105
+ /**
106
+ * Re-sign every signable image in the payload, and hand back the new bytes.
107
+ *
108
+ * The signature is made on FILES because that is the only interface `codesign`
109
+ * and `signtool` have, so the payload is materialised into `workDir`, signed
110
+ * there, and read back. `workDir` is a scratch directory rather than the stage:
111
+ * writing into an arriving stage would make the next `--from-stage` run of the
112
+ * same tree fail `readStage`'s size comparison (measured — see the header), and
113
+ * `writeStage`'s wipe would delete the payload it was about to pack.
114
+ *
115
+ * THE FILE SET MAY NOT CHANGE, and that is asserted rather than assumed. Both
116
+ * tools rewrite an image in place, so a payload that gained or lost a path here
117
+ * means something else ran — a bundle seal writing `_CodeSignature/`, say, whose
118
+ * files would then be dropped on the way back into the payload without anyone
119
+ * noticing.
120
+ */
121
+ export declare function signPayload(input: SignPayloadInput): Promise<PayloadEntry[]>;
122
+ /**
123
+ * What this run will do about notarisation.
124
+ *
125
+ * A SECOND credential and an unrelated one (§ A15): `--sign` names an identity
126
+ * the signing machine holds, `--notarize` names an account credential. They are
127
+ * two flags because they are two inputs, and the reference keeps them in two
128
+ * scripts.
129
+ */
130
+ export interface NotaryDescriptor {
131
+ layoutOs: HostOs;
132
+ tool: string;
133
+ notarizeOn: readonly HostOs[];
134
+ installHint: string;
135
+ /**
136
+ * argv after {@link tool}.
137
+ *
138
+ * `credential` is threaded straight into the flag the command consumes, and
139
+ * that is the whole design of {@link resolveNotaryPlan}'s guard. § A15
140
+ * measured the trap: `refs/node/tools/osx-notarize.sh` guards on three
141
+ * environment variables it never uses and then submits with a
142
+ * `--keychain-profile` nothing checked, so the skip is reachable from an
143
+ * input the next line does not read. Here the guarded value IS the argument.
144
+ *
145
+ * ⚠️ `--keychain-profile` is the ONLY shape § A15 found evidenced, and the
146
+ * App Store Connect API-key form (`--key`/`--key-id`/`--issuer`) is NOT
147
+ * implemented because nothing measured it. UNVERIFIED, and marked as such:
148
+ * no run in this repository has ever invoked `notarytool` — notarisation
149
+ * needs an Apple account, which ad-hoc signing deliberately does not.
150
+ */
151
+ args: (credential: string, artifact: string) => readonly string[];
152
+ }
153
+ export declare const NOTARIES: Partial<Record<HostOs, NotaryDescriptor>>;
154
+ export type NotaryPlan = {
155
+ kind: 'unsupported';
156
+ } | {
157
+ kind: 'skip';
158
+ message: string;
159
+ } | {
160
+ kind: 'notarize';
161
+ credential: string;
162
+ notary: NotaryDescriptor;
163
+ };
164
+ /**
165
+ * Resolve the notarisation credential, or say why there is none.
166
+ *
167
+ * Deliberately NOT defaultable from the project. An identity is a name a machine
168
+ * may hold for years and is worth writing into a repository; a notarisation
169
+ * credential is a per-run account credential, and a config key for it is an
170
+ * invitation to commit one.
171
+ */
172
+ export declare function resolveNotaryPlan(input: {
173
+ flag?: string;
174
+ layoutOs: HostOs;
175
+ sign: SignPlan;
176
+ }): NotaryPlan;
177
+ /**
178
+ * Submit one written artifact for notarisation.
179
+ *
180
+ * ⚠️ UNVERIFIED END TO END. Everything up to the `spawnToCompletion` is covered
181
+ * — the argv by `signing.spec.ts`, the guard and both refusals by
182
+ * `tests/e2e/ship-signing` — and the invocation itself has never run: it needs
183
+ * an Apple Developer account, and § A17's whole point is that M6's proof does
184
+ * not. Whoever first runs this on a real credential should record what it did
185
+ * beside § A15, which is where the question was left open.
186
+ */
187
+ export declare function notarizeArtifact(input: {
188
+ plan: Extract<NotaryPlan, {
189
+ kind: 'notarize';
190
+ }>;
191
+ /** The artifact to submit. Must be a FILE — notarytool takes an archive or an installer. */
192
+ artifact: string;
193
+ verbose: boolean;
194
+ log: (line: string) => void;
195
+ }): Promise<void>;
@@ -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
+ }