@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
@@ -18,6 +18,13 @@ export interface StageInputs {
18
18
  * The result is deduplicated by path (last write wins, so `extraFiles` can
19
19
  * override anything the defaults staged) and sorted, which makes the tree —
20
20
  * and therefore every artifact built from it — deterministic.
21
+ *
22
+ * ONE plan, in the Linux/XDG shape, for every OS. Where those paths end up is
23
+ * `utils/ship/layout.ts`'s `placeStage`, applied afterwards by the caller, and
24
+ * keeping the two steps apart is what makes ADR 0024 § 2's "one payload, a
25
+ * handful of layouts" checkable: `tests/e2e/ship-layout` asserts the three
26
+ * staged trees agree modulo that map. A planner that took the layout would have
27
+ * three code paths to keep in agreement instead.
21
28
  */
22
29
  export declare function planStage(settings: ShipSettings, inputs: StageInputs): StagedFile[];
23
30
  /**
@@ -61,3 +68,17 @@ export declare function assertOverlayIsLicensed(formatId: FormatId, binaryName:
61
68
  * filename.
62
69
  */
63
70
  export declare function iconSizeDir(iconPath: string): string;
71
+ /**
72
+ * A native module inside the payload has to keep its executable bit.
73
+ *
74
+ * By NAME here, unlike `isArchIndependent`, which reads the file's magic: the
75
+ * planner is pure and has no bytes to look at. The two answer different
76
+ * questions — "should this be 0755" versus "is this package portable" — and
77
+ * getting this one wrong costs a mode bit, not an unusable package.
78
+ *
79
+ * EXPORTED for `utils/ship/app-runtime.ts`, which stages a relocated GTK closure
80
+ * that never passes through this plan and needs the same answer for the same
81
+ * files. A second copy of the pattern there would be a second answer to "is this
82
+ * a shared library", and the drifted one is the one that stages a `.dylib` 0644.
83
+ */
84
+ export declare function isExecutableAsset(rel: string): boolean;
@@ -3,6 +3,7 @@
3
3
  // as `utils/copy-targets.ts`, and for the same reason: a stager that discovers
4
4
  // its own inputs can only be tested by building a real project.
5
5
  import { renderMimePackage } from './mime.js';
6
+ import { SHARE } from './share-dirs.js';
6
7
  import { basename, extname, posix } from 'node:path';
7
8
  /**
8
9
  * Build the prefix-relative payload.
@@ -10,6 +11,13 @@ import { basename, extname, posix } from 'node:path';
10
11
  * The result is deduplicated by path (last write wins, so `extraFiles` can
11
12
  * override anything the defaults staged) and sorted, which makes the tree —
12
13
  * and therefore every artifact built from it — deterministic.
14
+ *
15
+ * ONE plan, in the Linux/XDG shape, for every OS. Where those paths end up is
16
+ * `utils/ship/layout.ts`'s `placeStage`, applied afterwards by the caller, and
17
+ * keeping the two steps apart is what makes ADR 0024 § 2's "one payload, a
18
+ * handful of layouts" checkable: `tests/e2e/ship-layout` asserts the three
19
+ * staged trees agree modulo that map. A planner that took the layout would have
20
+ * three code paths to keep in agreement instead.
13
21
  */
14
22
  export function planStage(settings, inputs) {
15
23
  const files = [];
@@ -23,7 +31,7 @@ export function planStage(settings, inputs) {
23
31
  });
24
32
  }
25
33
  files.push({
26
- path: `share/metainfo/${settings.appId}.metainfo.xml`,
34
+ path: `${SHARE.metainfo}/${settings.appId}.metainfo.xml`,
27
35
  mode: 0o644,
28
36
  source: { kind: 'text', text: inputs.metainfo },
29
37
  });
@@ -32,7 +40,7 @@ export function planStage(settings, inputs) {
32
40
  throw new Error('gjsify ship: internal error — a desktop entry is required for `kind: "app"`.');
33
41
  }
34
42
  files.push({
35
- path: `share/applications/${settings.appId}.desktop`,
43
+ path: `${SHARE.applications}/${settings.appId}.desktop`,
36
44
  mode: 0o644,
37
45
  source: { kind: 'text', text: inputs.desktopEntry },
38
46
  });
@@ -50,7 +58,7 @@ export function planStage(settings, inputs) {
50
58
  'Every installed schema shares one system directory, so the app id is what keeps it unique.');
51
59
  }
52
60
  files.push({
53
- path: `share/glib-2.0/schemas/${name}`,
61
+ path: `${SHARE.schemas}/${name}`,
54
62
  mode: 0o644,
55
63
  source: { kind: 'file', path: schema },
56
64
  });
@@ -71,7 +79,7 @@ export function planStage(settings, inputs) {
71
79
  // reason it is for the GSettings schema: a generic name collides with another package's file.
72
80
  if (settings.mimeTypes.length > 0) {
73
81
  files.push({
74
- path: `share/mime/packages/${settings.appId}.xml`,
82
+ path: `${SHARE.mime}/${settings.appId}.xml`,
75
83
  mode: 0o644,
76
84
  source: { kind: 'text', text: renderMimePackage(settings.mimeTypes) },
77
85
  });
@@ -80,7 +88,7 @@ export function planStage(settings, inputs) {
80
88
  // is where `bindtextdomain` looks, and nowhere else.
81
89
  for (const locale of settings.localeFiles) {
82
90
  files.push({
83
- path: posix.join('share/locale', locale.rel),
91
+ path: posix.join(SHARE.locale, locale.rel),
84
92
  mode: 0o644,
85
93
  source: { kind: 'file', path: locale.abs },
86
94
  });
@@ -178,7 +186,7 @@ function planIcons(settings) {
178
186
  for (const icon of settings.iconFiles) {
179
187
  const ext = extname(icon);
180
188
  const dir = iconSizeDir(icon);
181
- const path = `share/icons/hicolor/${dir}/apps/${settings.appId}${ext}`;
189
+ const path = `${SHARE.icons}/${dir}/apps/${settings.appId}${ext}`;
182
190
  const previous = seen.get(path);
183
191
  if (previous !== undefined) {
184
192
  throw new Error(`gjsify ship: ${icon} and ${previous} both install as ${path}. ` +
@@ -226,8 +234,13 @@ function assertInsidePrefix(dest) {
226
234
  * planner is pure and has no bytes to look at. The two answer different
227
235
  * questions — "should this be 0755" versus "is this package portable" — and
228
236
  * getting this one wrong costs a mode bit, not an unusable package.
237
+ *
238
+ * EXPORTED for `utils/ship/app-runtime.ts`, which stages a relocated GTK closure
239
+ * that never passes through this plan and needs the same answer for the same
240
+ * files. A second copy of the pattern there would be a second answer to "is this
241
+ * a shared library", and the drifted one is the one that stages a `.dylib` 0644.
229
242
  */
230
- function isExecutableAsset(rel) {
243
+ export function isExecutableAsset(rel) {
231
244
  return /\.(so|node|dylib)(\.\d+)*$/.test(rel);
232
245
  }
233
246
  /** Last write wins, then sort — deterministic output for a deterministic artifact. */
@@ -0,0 +1,58 @@
1
+ import type { LayoutMetadataInput } from './layout.js';
2
+ /** Where the two files live inside a bundle. Apple's names, not ours. */
3
+ export declare const BUNDLE_INFO_PLIST = "Contents/Info.plist";
4
+ export declare const BUNDLE_PKGINFO = "Contents/PkgInfo";
5
+ /**
6
+ * Render `Contents/Info.plist`.
7
+ *
8
+ * The eleven keys, each with the file in `refs/` it was read off (all paths
9
+ * relative to the repository root, all read-only submodules):
10
+ *
11
+ * | key | value | citation |
12
+ * |---|---|---|
13
+ * | `CFBundleExecutable` | `binaryName` | `refs/node/test/fixtures/macos-app-sandbox/Info.plist:5` |
14
+ * | `CFBundleIdentifier` | `appId` | same file, line 7 |
15
+ * | `CFBundleInfoDictionaryVersion` | `6.0` | same file, line 9 |
16
+ * | `CFBundleName` | `name` | same file, line 11 |
17
+ * | `CFBundlePackageType` | `APPL` | same file, line 13 |
18
+ * | `CFBundleShortVersionString` | `version` | same file, line 15 |
19
+ * | `CFBundleSupportedPlatforms` | `["MacOSX"]` | same file, lines 17-20 |
20
+ * | `CFBundleVersion` | `version-release` | same file, line 21 |
21
+ * | `CFBundleDevelopmentRegion` | `en` | `refs/node/deps/v8/gni/Info.plist:5` |
22
+ * | `CFBundleDisplayName` | `name` | same file, line 7 |
23
+ * | `CFBundleSignature` | `????` | same file, line 21 |
24
+ *
25
+ * `CFBundleVersion` carries the RELEASE and `CFBundleShortVersionString` does
26
+ * not, which is the one place the two differ. Apple's rule is that the short
27
+ * string is the marketing version a user sees and `CFBundleVersion` is the build
28
+ * — and this command already has that exact pair: `version` is upstream's,
29
+ * `release` is the packaging revision within it (the deb revision, the rpm
30
+ * release). Emitting `version` twice would throw the distinction away on the one
31
+ * OS that has a field for it.
32
+ *
33
+ * KEYS DELIBERATELY ABSENT are listed in this module's header. `CFBundleIconFile`
34
+ * is the one a reader will reach for first, and it is absent for a reason that
35
+ * outlives the key: M2a ships NO icon. `png2icns`, `icnsutil` and `iconutil` are
36
+ * all absent from this workstation and from the CI image, so an `.icns` written
37
+ * here could only be read back by a reader written here — `selfReading: true`,
38
+ * which `flatpak.spec.ts` reds. The unblocker is an independent Linux ICNS reader
39
+ * entering the CI image; until one does, the hicolor PNG/SVG the payload already
40
+ * carries stays the only icon, unread on macOS.
41
+ */
42
+ export declare function renderInfoPlist(input: LayoutMetadataInput): string;
43
+ /**
44
+ * Render `Contents/PkgInfo`: the package type and the signature, eight bytes, no
45
+ * newline.
46
+ *
47
+ * `refs/node/tools/gyp/pylib/gyp/mac_tool.py:236-245` is the whole
48
+ * specification — "eight characters, representing the bundle type and bundle
49
+ * signature, each four characters" — and its
50
+ * `fp.write(f"{package_type}{signature_code}")` writes no terminator. A trailing
51
+ * newline would make the file nine bytes, which is the kind of difference
52
+ * nothing complains about and every byte comparison notices.
53
+ *
54
+ * Redundant with `Info.plist` by design: it is the pre-plist Carbon record that
55
+ * LaunchServices still reads, and gyp writes both from one source for exactly
56
+ * the same reason this function takes none — both values are constants here.
57
+ */
58
+ export declare function renderPkgInfo(): string;
@@ -0,0 +1,198 @@
1
+ // `Contents/Info.plist` and `Contents/PkgInfo` — what makes a `*.app` directory
2
+ // an application BUNDLE rather than a directory whose name ends in `.app`.
3
+ //
4
+ // M1 staged `<Name>.app/Contents/{MacOS,Resources}` with no `Info.plist` at all.
5
+ // LaunchServices reads that file to learn which file under `Contents/MacOS` to
6
+ // exec and what identity to register the bundle under, so without it the tree is
7
+ // a directory the Finder shows as a folder — the hole #1354 M2a closes.
8
+ //
9
+ // XML, never the binary variant, and that is an ORACLE decision rather than a
10
+ // taste one. `plistlib` reads both, but a binary plist this tree wrote and then
11
+ // read back with a reader this tree also wrote is `selfReading: true` — ADR 0024
12
+ // § A3 calls that legal to declare and illegal to release. CPython's `plistlib`
13
+ // is a different implementation family and is already precedent here
14
+ // (`.github/ship-oracle/verify-modes.py`), and it parses the XML form.
15
+ //
16
+ // EVERY KEY BELOW IS CITED to a file in `refs/` that a real macOS toolchain
17
+ // produced or consumes. Keys that are merely plausible — `CFBundleIconFile`,
18
+ // `LSMinimumSystemVersion`, `NSHighResolutionCapable`, `LSApplicationCategoryType`,
19
+ // `NSHumanReadableCopyright` — are not emitted, because nothing a reader here can
20
+ // open contains them.
21
+ //
22
+ // AND THE SCOPE OF THAT MEASUREMENT IS PART OF IT, because `grep -r … refs/` is a
23
+ // command that answers cheerfully after reading almost nothing: 89 of the 95
24
+ // submodules `.gitmodules` declares are not checked out in a working tree, and an
25
+ // empty directory produces zero hits with no error. Measured on a checkout with
26
+ // SIX of them present (`cambalache`, `gtkx`, `libadwaita`, `metro`, `node`,
27
+ // `peachy`): 0 files for each of the five keys above, against 3 files for
28
+ // `CFBundleSignature` as the CONTROL — a string known to be there, which is what
29
+ // makes the zeros mean anything. So the claim is "absent from the six pools a
30
+ // reader can open", not "absent from the pool", and a key that turns up in a
31
+ // seventh is a citation waiting to be written, not a contradiction.
32
+ /** Where the two files live inside a bundle. Apple's names, not ours. */
33
+ export const BUNDLE_INFO_PLIST = 'Contents/Info.plist';
34
+ export const BUNDLE_PKGINFO = 'Contents/PkgInfo';
35
+ /**
36
+ * The four-character bundle type of an application, and the signature that says
37
+ * "none".
38
+ *
39
+ * `refs/node/tools/gyp/pylib/gyp/mac_tool.py:232` reads `CFBundlePackageType` and
40
+ * writes a `PkgInfo` only when it is `APPL`; line 239 defaults
41
+ * `CFBundleSignature` to `????` and line 240 resets a signature of the wrong
42
+ * length to the same four question marks. `refs/node/deps/v8/gni/Info.plist:22`
43
+ * spells `????` out.
44
+ */
45
+ const PACKAGE_TYPE = 'APPL';
46
+ const SIGNATURE = '????';
47
+ /**
48
+ * The one XML escape set a plist needs.
49
+ *
50
+ * `&` first, or the ampersands introduced by the later replacements get escaped
51
+ * twice and `CFBundleName` comes out as `Ship &amp;amp; Co`. `<` and `>` are the
52
+ * element delimiters; `"` and `'` are not special in element CONTENT, which is
53
+ * the only place this function's output ever lands, so they are left alone
54
+ * rather than turned into entities a human reading the file has to decode.
55
+ */
56
+ function xmlText(value) {
57
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
58
+ }
59
+ /**
60
+ * Refuse a value a plist cannot carry, naming the field.
61
+ *
62
+ * XML 1.0 has no escape for most C0 control characters — they are not
63
+ * representable in a document at all, not merely inconvenient — so a name
64
+ * carrying one produces a file `plistlib` and `CFPropertyList` both reject. The
65
+ * refusal is here rather than at parse time on a Mac three milestones away.
66
+ *
67
+ * C0 MINUS TAB/LF/CR, AND NOTHING ELSE — measured against the oracle rather than
68
+ * reasoned from the spec, because the first cut of this guard also refused DEL
69
+ * and the C1 range on the stated grounds that XML forbids them, and XML 1.0 does
70
+ * not. Probed through `plistlib` on CPython 3.14, one code point per document:
71
+ *
72
+ * U+0001, U+0008, U+000B ExpatError: not well-formed (invalid token)
73
+ * U+0009 (TAB) accepted
74
+ * U+007F (DEL) accepted
75
+ * U+0085, U+009F (C1) accepted
76
+ *
77
+ * So refusing those four would reject a name every reader in the chain reads
78
+ * back correctly — a guard that turns working packages into failures, which buys
79
+ * nothing over the defect it prevents. The rule that survives is the measured
80
+ * one; the reason that did not is recorded here rather than quietly dropped,
81
+ * because a rule carrying an invented reason is one that gets "simplified" back
82
+ * into the bug.
83
+ */
84
+ function assertPlistText(value, field, configKey) {
85
+ // A SCAN rather than a character class, and not only to keep `no-control-regex`
86
+ // quiet: the code point is what the message needs, and lifting it back out of
87
+ // a match is a second step that can disagree with the first.
88
+ let point = -1;
89
+ for (const char of value) {
90
+ const code = char.codePointAt(0) ?? 0;
91
+ // Tab, LF and CR are the three C0 code points XML 1.0 does carry.
92
+ if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) {
93
+ point = code;
94
+ break;
95
+ }
96
+ }
97
+ if (point !== -1) {
98
+ throw new Error(`gjsify ship: ${field} would carry the control character ` +
99
+ `U+${point.toString(16).padStart(4, '0').toUpperCase()}, and XML has no escape for it — the ` +
100
+ 'Info.plist would be unparseable by every reader macOS has. Set ' +
101
+ `\`${configKey}\` to a value without it.`);
102
+ }
103
+ return value;
104
+ }
105
+ /** One `<key>…</key><string>…</string>` pair, indented to sit inside the top-level `<dict>`. */
106
+ function stringEntry(key, value) {
107
+ return `\t<key>${key}</key>\n\t<string>${xmlText(value)}</string>`;
108
+ }
109
+ /** `CFBundleSupportedPlatforms` is the one array here — see the key table below. */
110
+ function arrayEntry(key, values) {
111
+ const items = values.map((value) => `\t\t<string>${xmlText(value)}</string>`).join('\n');
112
+ return `\t<key>${key}</key>\n\t<array>\n${items}\n\t</array>`;
113
+ }
114
+ /**
115
+ * Render `Contents/Info.plist`.
116
+ *
117
+ * The eleven keys, each with the file in `refs/` it was read off (all paths
118
+ * relative to the repository root, all read-only submodules):
119
+ *
120
+ * | key | value | citation |
121
+ * |---|---|---|
122
+ * | `CFBundleExecutable` | `binaryName` | `refs/node/test/fixtures/macos-app-sandbox/Info.plist:5` |
123
+ * | `CFBundleIdentifier` | `appId` | same file, line 7 |
124
+ * | `CFBundleInfoDictionaryVersion` | `6.0` | same file, line 9 |
125
+ * | `CFBundleName` | `name` | same file, line 11 |
126
+ * | `CFBundlePackageType` | `APPL` | same file, line 13 |
127
+ * | `CFBundleShortVersionString` | `version` | same file, line 15 |
128
+ * | `CFBundleSupportedPlatforms` | `["MacOSX"]` | same file, lines 17-20 |
129
+ * | `CFBundleVersion` | `version-release` | same file, line 21 |
130
+ * | `CFBundleDevelopmentRegion` | `en` | `refs/node/deps/v8/gni/Info.plist:5` |
131
+ * | `CFBundleDisplayName` | `name` | same file, line 7 |
132
+ * | `CFBundleSignature` | `????` | same file, line 21 |
133
+ *
134
+ * `CFBundleVersion` carries the RELEASE and `CFBundleShortVersionString` does
135
+ * not, which is the one place the two differ. Apple's rule is that the short
136
+ * string is the marketing version a user sees and `CFBundleVersion` is the build
137
+ * — and this command already has that exact pair: `version` is upstream's,
138
+ * `release` is the packaging revision within it (the deb revision, the rpm
139
+ * release). Emitting `version` twice would throw the distinction away on the one
140
+ * OS that has a field for it.
141
+ *
142
+ * KEYS DELIBERATELY ABSENT are listed in this module's header. `CFBundleIconFile`
143
+ * is the one a reader will reach for first, and it is absent for a reason that
144
+ * outlives the key: M2a ships NO icon. `png2icns`, `icnsutil` and `iconutil` are
145
+ * all absent from this workstation and from the CI image, so an `.icns` written
146
+ * here could only be read back by a reader written here — `selfReading: true`,
147
+ * which `flatpak.spec.ts` reds. The unblocker is an independent Linux ICNS reader
148
+ * entering the CI image; until one does, the hicolor PNG/SVG the payload already
149
+ * carries stays the only icon, unread on macOS.
150
+ */
151
+ export function renderInfoPlist(input) {
152
+ const name = assertPlistText(input.name, 'CFBundleName', 'gjsify.ship.name');
153
+ const executable = assertPlistText(input.binaryName, 'CFBundleExecutable', 'gjsify.ship.binaryName');
154
+ const appId = assertPlistText(input.appId, 'CFBundleIdentifier', 'gjsify.ship.appId');
155
+ return [
156
+ '<?xml version="1.0" encoding="UTF-8"?>',
157
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
158
+ '<plist version="1.0">',
159
+ '<dict>',
160
+ // Alphabetical, because both cited files are: `macos-app-sandbox/Info.plist`
161
+ // runs Executable → Identifier → InfoDictionaryVersion → Name → PackageType
162
+ // → ShortVersionString → SupportedPlatforms → Version, and `v8/gni`'s
163
+ // CFBundle keys sort the same way. Matching them keeps a diff against a
164
+ // real bundle readable; no claim is made about what Xcode emits.
165
+ stringEntry('CFBundleDevelopmentRegion', 'en'),
166
+ stringEntry('CFBundleDisplayName', name),
167
+ stringEntry('CFBundleExecutable', executable),
168
+ stringEntry('CFBundleIdentifier', appId),
169
+ stringEntry('CFBundleInfoDictionaryVersion', '6.0'),
170
+ stringEntry('CFBundleName', name),
171
+ stringEntry('CFBundlePackageType', PACKAGE_TYPE),
172
+ stringEntry('CFBundleShortVersionString', input.version),
173
+ stringEntry('CFBundleSignature', SIGNATURE),
174
+ arrayEntry('CFBundleSupportedPlatforms', ['MacOSX']),
175
+ stringEntry('CFBundleVersion', `${input.version}-${input.release}`),
176
+ '</dict>',
177
+ '</plist>',
178
+ '',
179
+ ].join('\n');
180
+ }
181
+ /**
182
+ * Render `Contents/PkgInfo`: the package type and the signature, eight bytes, no
183
+ * newline.
184
+ *
185
+ * `refs/node/tools/gyp/pylib/gyp/mac_tool.py:236-245` is the whole
186
+ * specification — "eight characters, representing the bundle type and bundle
187
+ * signature, each four characters" — and its
188
+ * `fp.write(f"{package_type}{signature_code}")` writes no terminator. A trailing
189
+ * newline would make the file nine bytes, which is the kind of difference
190
+ * nothing complains about and every byte comparison notices.
191
+ *
192
+ * Redundant with `Info.plist` by design: it is the pre-plist Carbon record that
193
+ * LaunchServices still reads, and gyp writes both from one source for exactly
194
+ * the same reason this function takes none — both values are constants here.
195
+ */
196
+ export function renderPkgInfo() {
197
+ return `${PACKAGE_TYPE}${SIGNATURE}`;
198
+ }
@@ -19,6 +19,7 @@ import { readPayloadFacts, readShebangInterpreters } from './payload.js';
19
19
  import { buildRpmHeader, buildRpmLead, padToEight, RpmType, RPM_TAG_HEADERIMMUTABLE, RPM_TAG_HEADERSIGNATURES, } from './rpm-header.js';
20
20
  import { gzipDeterministic } from './gzip.js';
21
21
  import { renderRpmScriptlets } from './scripts.js';
22
+ import { SHARE } from './share-dirs.js';
22
23
  // Header tags. Named rather than inlined because a wrong NUMBER produces a
23
24
  // package that parses and is quietly wrong, which is the hardest kind to spot.
24
25
  const TAG = {
@@ -115,6 +116,11 @@ const RPMLIB_REQUIRES = [
115
116
  * rpmlint noise, not a broken install. The entries that matter are the ones a
116
117
  * package would otherwise claim on every install.
117
118
  */
119
+ // The three that name a directory `plan.ts` actually stages into are DERIVED, so
120
+ // they cannot drift from it. The rest stay literal on purpose: they are PARENTS
121
+ // (`/usr/share/icons`, `/usr/share/glib-2.0`) or directories this packer never
122
+ // writes (`/etc`, `/opt`, `/var`, `/usr/share/man`), which is a different question
123
+ // from "where does the payload install" and would be wrong to fold into one list.
118
124
  const SYSTEM_OWNED_DIRECTORIES = new Set([
119
125
  '/usr',
120
126
  '/usr/bin',
@@ -123,16 +129,16 @@ const SYSTEM_OWNED_DIRECTORIES = new Set([
123
129
  '/usr/libexec',
124
130
  '/usr/sbin',
125
131
  '/usr/share',
126
- '/usr/share/applications',
132
+ `/usr/${SHARE.applications}`,
127
133
  '/usr/share/doc',
128
134
  '/usr/share/icons',
129
135
  '/usr/share/licenses',
130
136
  '/usr/share/man',
131
- '/usr/share/metainfo',
137
+ `/usr/${SHARE.metainfo}`,
132
138
  // Owned by glib2 — an app that installs a schema must not claim the
133
139
  // directory the whole system's schemas live in. Verified with `rpm -qf`.
134
140
  '/usr/share/glib-2.0',
135
- '/usr/share/glib-2.0/schemas',
141
+ `/usr/${SHARE.schemas}`,
136
142
  '/etc',
137
143
  '/opt',
138
144
  '/var',
@@ -0,0 +1,61 @@
1
+ import type { StagedFile } from './types.js';
2
+ /** The one spelling of the tool, read by this module AND by the format rows. */
3
+ export declare const SCHEMA_COMPILER = "glib-compile-schemas";
4
+ /**
5
+ * How to install it, in the two distributions' words.
6
+ *
7
+ * The same two package names `depends.ts`'s `SCHEMA_COMPILER_PACKAGE` emits into
8
+ * a `Depends:`/`Requires:`, so the advice a user gets here and the dependency a
9
+ * `.deb` declares cannot name different packages.
10
+ */
11
+ export declare const SCHEMA_COMPILER_HINT = "Fedora: `sudo dnf install glib2`, Debian/Ubuntu: `sudo apt install libglib2.0-bin`";
12
+ /** The compiled cache's prefix-relative path — one definition, several readers. */
13
+ export declare const SCHEMA_CACHE: string;
14
+ export interface CompileSchemasInput {
15
+ /** Absolute paths of the project's `*.gschema.xml` files. */
16
+ schemaFiles: readonly string[];
17
+ /** A scratch directory this function owns; wiped and rewritten on every run. */
18
+ workDir: string;
19
+ }
20
+ /**
21
+ * Compile the payload's schemas and return the cache as ONE prefix-relative
22
+ * planned file, or nothing when the payload has no schema.
23
+ *
24
+ * PREFIX-RELATIVE, so `place()` maps it into `Contents/Resources/share/…` or a
25
+ * Windows `share\` with no rule of its own. That is the whole reason this returns
26
+ * a `StagedFile` rather than writing into the stage directly: the layout map stays
27
+ * the single place that knows where `share/` goes, and the e2e's hand-written map
28
+ * needs no new entry to cover it.
29
+ *
30
+ * `--strict` IS LOAD-BEARING and was measured, not assumed. Against a schema whose
31
+ * `path` attribute lacks its leading slash, on glib 2.88.3 — the observable is the
32
+ * exit code and the file, not the wording, because the diagnostic is translated
33
+ * and was read here in German:
34
+ *
35
+ * glib-compile-schemas in --targetdir out → exit 0, warns that the
36
+ * whole file was ignored,
37
+ * gschemas.compiled WRITTEN
38
+ * glib-compile-schemas in --targetdir out --strict → exit 1, nothing written
39
+ *
40
+ * Without the flag a broken schema is skipped at exit 0 and a cache is produced
41
+ * that simply does not contain it — so the stage looks compiled, the warning goes
42
+ * away, and `g_settings_new()` still aborts on the schema that was dropped. That
43
+ * is a green that checked nothing, produced by the step meant to close the hole.
44
+ *
45
+ * A DIRECTORY, because that is the only input the tool takes: the schemas are
46
+ * copied into `workDir` under their own basenames first. `plan.ts` has already
47
+ * refused any basename not prefixed with the app id, so two schemas cannot
48
+ * collide here.
49
+ *
50
+ * ENDIANNESS, stated rather than assumed away. The output is GVDB, and the file
51
+ * this tree produces is HOST-endian — measured on x86-64 with glib 2.88.3: in a
52
+ * 256-byte `gschemas.compiled` compiled from one schema with one key, the eight
53
+ * bytes at offset 16 read `18 00 00 00 58 00 00 00`, i.e. the pointer pair 24 and
54
+ * 88 stored little-endian. Whether GLib's reader byte-swaps a foreign-endian
55
+ * cache is UNVERIFIED here — nothing in this tree tested it and nothing below
56
+ * assumes an answer. It does not bite today: both darwin targets (x64, arm64) and
57
+ * every runner in this project's CI are little-endian, so no cache yet crosses
58
+ * that boundary. A big-endian assembling host is the case to measure before
59
+ * anyone claims otherwise.
60
+ */
61
+ export declare function compileSchemasForStage(input: CompileSchemasInput): Promise<StagedFile[]>;
@@ -0,0 +1,118 @@
1
+ // Compiling `gschemas.compiled` into a stage that has no install step.
2
+ //
3
+ // THE DEFECT THIS CLOSES is not cosmetic and `payload.ts` already named it:
4
+ // every launcher form exports `XDG_DATA_DIRS` at the staged `share/`, and
5
+ // GSettings ABORTS on a schema directory holding a `.gschema.xml` with no
6
+ // `gschemas.compiled` beside it. On Linux the `.deb`/`.rpm` postinst runs
7
+ // `glib-compile-schemas` at install (`scripts.ts`); a `.app` has no postinst and
8
+ // nothing else was going to run it. So `gjsify ship darwin --stage` produced a
9
+ // bundle whose first `Gio.Settings.new()` kills the process — an artifact that
10
+ // installs and does not start, which is the class this whole command exists
11
+ // against.
12
+ //
13
+ // AT STAGE TIME, not at pack time, and that is the decision worth stating.
14
+ // A stage is a deliverable in its own right — it is what crosses to the host
15
+ // that finishes a `.dmg` or an `.msi` — and the warning `gjsify ship` prints
16
+ // about it is a claim about the TREE. Leaving the compile to a packer would keep
17
+ // `--stage` producing an aborting bundle while the message said otherwise.
18
+ //
19
+ // The cost is that assembly now execs one tool for a non-Linux layout with
20
+ // schemas in the payload. That does not make assembly host-BOUND (ADR 0024 § A1):
21
+ // `glib-compile-schemas` is GLib's and runs on all three OSes, which is why the
22
+ // four rows that ASSEMBLE a non-Linux tree — the `.app`, its zip, the Windows
23
+ // program directory and its zip — stay `finishOn: 'any'` and declare the tool
24
+ // through `requiredTools` instead. The `.dmg` and the `.msi` deliberately do not
25
+ // declare it: they wrap a tree that is already assembled, so a `--from-stage`
26
+ // pack of one needs no GLib at all (the `macos-app-dmg` row says so).
27
+ import { execFile } from 'node:child_process';
28
+ import { basename, join } from 'node:path';
29
+ import { copyFileSync, mkdirSync, rmSync } from 'node:fs';
30
+ import { promisify } from 'node:util';
31
+ import { SHARE } from './share-dirs.js';
32
+ const execFileAsync = promisify(execFile);
33
+ /** The one spelling of the tool, read by this module AND by the format rows. */
34
+ export const SCHEMA_COMPILER = 'glib-compile-schemas';
35
+ /**
36
+ * How to install it, in the two distributions' words.
37
+ *
38
+ * The same two package names `depends.ts`'s `SCHEMA_COMPILER_PACKAGE` emits into
39
+ * a `Depends:`/`Requires:`, so the advice a user gets here and the dependency a
40
+ * `.deb` declares cannot name different packages.
41
+ */
42
+ export const SCHEMA_COMPILER_HINT = 'Fedora: `sudo dnf install glib2`, Debian/Ubuntu: `sudo apt install libglib2.0-bin`';
43
+ /** The compiled cache's prefix-relative path — one definition, several readers. */
44
+ export const SCHEMA_CACHE = `${SHARE.schemas}/gschemas.compiled`;
45
+ /**
46
+ * Compile the payload's schemas and return the cache as ONE prefix-relative
47
+ * planned file, or nothing when the payload has no schema.
48
+ *
49
+ * PREFIX-RELATIVE, so `place()` maps it into `Contents/Resources/share/…` or a
50
+ * Windows `share\` with no rule of its own. That is the whole reason this returns
51
+ * a `StagedFile` rather than writing into the stage directly: the layout map stays
52
+ * the single place that knows where `share/` goes, and the e2e's hand-written map
53
+ * needs no new entry to cover it.
54
+ *
55
+ * `--strict` IS LOAD-BEARING and was measured, not assumed. Against a schema whose
56
+ * `path` attribute lacks its leading slash, on glib 2.88.3 — the observable is the
57
+ * exit code and the file, not the wording, because the diagnostic is translated
58
+ * and was read here in German:
59
+ *
60
+ * glib-compile-schemas in --targetdir out → exit 0, warns that the
61
+ * whole file was ignored,
62
+ * gschemas.compiled WRITTEN
63
+ * glib-compile-schemas in --targetdir out --strict → exit 1, nothing written
64
+ *
65
+ * Without the flag a broken schema is skipped at exit 0 and a cache is produced
66
+ * that simply does not contain it — so the stage looks compiled, the warning goes
67
+ * away, and `g_settings_new()` still aborts on the schema that was dropped. That
68
+ * is a green that checked nothing, produced by the step meant to close the hole.
69
+ *
70
+ * A DIRECTORY, because that is the only input the tool takes: the schemas are
71
+ * copied into `workDir` under their own basenames first. `plan.ts` has already
72
+ * refused any basename not prefixed with the app id, so two schemas cannot
73
+ * collide here.
74
+ *
75
+ * ENDIANNESS, stated rather than assumed away. The output is GVDB, and the file
76
+ * this tree produces is HOST-endian — measured on x86-64 with glib 2.88.3: in a
77
+ * 256-byte `gschemas.compiled` compiled from one schema with one key, the eight
78
+ * bytes at offset 16 read `18 00 00 00 58 00 00 00`, i.e. the pointer pair 24 and
79
+ * 88 stored little-endian. Whether GLib's reader byte-swaps a foreign-endian
80
+ * cache is UNVERIFIED here — nothing in this tree tested it and nothing below
81
+ * assumes an answer. It does not bite today: both darwin targets (x64, arm64) and
82
+ * every runner in this project's CI are little-endian, so no cache yet crosses
83
+ * that boundary. A big-endian assembling host is the case to measure before
84
+ * anyone claims otherwise.
85
+ */
86
+ export async function compileSchemasForStage(input) {
87
+ if (input.schemaFiles.length === 0)
88
+ return [];
89
+ rmSync(input.workDir, { recursive: true, force: true, maxRetries: 5 });
90
+ mkdirSync(input.workDir, { recursive: true });
91
+ for (const schema of input.schemaFiles)
92
+ copyFileSync(schema, join(input.workDir, basename(schema)));
93
+ try {
94
+ await execFileAsync(SCHEMA_COMPILER, ['--strict', `--targetdir=${input.workDir}`, input.workDir]);
95
+ }
96
+ catch (error) {
97
+ const failure = error;
98
+ if (failure.code === 'ENOENT') {
99
+ throw new Error(`gjsify ship: this layout has no install step, so \`${SCHEMA_COMPILER}\` has to run HERE to ` +
100
+ `turn the payload's schemas into \`${SCHEMA_CACHE}\` — and it is not on PATH. ` +
101
+ `Install it (${SCHEMA_COMPILER_HINT}). Without the compiled cache the launcher points ` +
102
+ 'XDG_DATA_DIRS at a schema directory GSettings aborts on, so the bundle would build and ' +
103
+ 'then die at its first `Gio.Settings.new()`.');
104
+ }
105
+ throw new Error(`gjsify ship: \`${SCHEMA_COMPILER} --strict\` refused this project's schemas.\n` +
106
+ `${(failure.stderr ?? '').trimEnd()}\n` +
107
+ ' `--strict` is deliberate: without it a malformed schema is SKIPPED at exit 0 and a ' +
108
+ 'cache\n is written without it, so the app still aborts on the schema that was dropped. ' +
109
+ 'Fix the\n file it names, or drop it from `gjsify.ship.schemas`.');
110
+ }
111
+ return [
112
+ {
113
+ path: SCHEMA_CACHE,
114
+ mode: 0o644,
115
+ source: { kind: 'file', path: join(input.workDir, 'gschemas.compiled') },
116
+ },
117
+ ];
118
+ }
@@ -11,6 +11,7 @@
11
11
  // reading `rpm -qp --scripts` on the first artifact this packer produced —
12
12
  // which is exactly the class of defect a rendered-output assertion would have
13
13
  // waved through.
14
+ import { SHARE } from './share-dirs.js';
14
15
  /**
15
16
  * The cache refreshes this payload makes necessary, each guarded by the tool's
16
17
  * presence and `|| true`.
@@ -34,13 +35,13 @@ export function cacheRefreshCommands(facts, prefix) {
34
35
  if (facts.hasDesktopEntry) {
35
36
  refreshes.push({
36
37
  probe: 'update-desktop-database',
37
- run: `update-desktop-database -q ${prefix}/share/applications`,
38
+ run: `update-desktop-database -q ${prefix}/${SHARE.applications}`,
38
39
  });
39
40
  }
40
41
  if (facts.hasIcons) {
41
42
  refreshes.push({
42
43
  probe: 'gtk-update-icon-cache',
43
- run: `gtk-update-icon-cache -q -t -f ${prefix}/share/icons/hicolor`,
44
+ run: `gtk-update-icon-cache -q -t -f ${prefix}/${SHARE.icons}`,
44
45
  });
45
46
  }
46
47
  if (facts.hasMimeTypes) {
@@ -48,13 +49,16 @@ export function cacheRefreshCommands(facts, prefix) {
48
49
  // off the compiled cache in `share/mime/`, not off the packages directory.
49
50
  refreshes.push({
50
51
  probe: 'update-mime-database',
52
+ // The PARENT of `SHARE.mime`, not that directory: `update-mime-database` is
53
+ // given the mime ROOT and reads `packages/` inside it. Deriving it from
54
+ // `SHARE.mime` would pass the wrong path, so it stays spelled out.
51
55
  run: `update-mime-database ${prefix}/share/mime`,
52
56
  });
53
57
  }
54
58
  if (facts.hasSchemas) {
55
59
  refreshes.push({
56
60
  probe: 'glib-compile-schemas',
57
- run: `glib-compile-schemas ${prefix}/share/glib-2.0/schemas`,
61
+ run: `glib-compile-schemas ${prefix}/${SHARE.schemas}`,
58
62
  });
59
63
  }
60
64
  return refreshes.map(({ probe, run }) => `if command -v ${probe} >/dev/null 2>&1; then ${run} || true; fi`);