@gjsify/cli 0.41.0 → 0.43.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 (80) hide show
  1. package/README.md +79 -33
  2. package/dist/cli.gjs.mjs +1068 -405
  3. package/lib/actions/build.d.ts +20 -0
  4. package/lib/actions/build.js +118 -15
  5. package/lib/bundler-pick.js +10 -2
  6. package/lib/cli-app.js +2 -1
  7. package/lib/commands/browse.js +1 -1
  8. package/lib/commands/debug.js +1 -1
  9. package/lib/commands/dev.d.ts +13 -0
  10. package/lib/commands/dev.js +132 -0
  11. package/lib/commands/dlx.js +1 -1
  12. package/lib/commands/index.d.ts +1 -0
  13. package/lib/commands/index.js +1 -0
  14. package/lib/commands/run.js +38 -85
  15. package/lib/commands/ship.d.ts +2 -0
  16. package/lib/commands/ship.js +285 -88
  17. package/lib/commands/showcase.js +1 -1
  18. package/lib/commands/storybook.js +17 -74
  19. package/lib/commands/test.js +12 -16
  20. package/lib/commands/tsc.js +96 -45
  21. package/lib/config.js +4 -0
  22. package/lib/types/config-data.d.ts +59 -0
  23. package/lib/utils/app-metadata.js +27 -6
  24. package/lib/utils/check-system-deps.js +49 -7
  25. package/lib/utils/daemon-command.d.ts +7 -0
  26. package/lib/utils/daemon-command.js +30 -0
  27. package/lib/utils/dev-plan.d.ts +52 -0
  28. package/lib/utils/dev-plan.js +117 -0
  29. package/lib/utils/gi-runtime-paths.d.ts +27 -0
  30. package/lib/utils/gi-runtime-paths.js +84 -0
  31. package/lib/utils/gi-typelib.d.ts +49 -0
  32. package/lib/utils/gi-typelib.js +160 -0
  33. package/lib/utils/gjs-bundle-guard.d.ts +57 -3
  34. package/lib/utils/gjs-bundle-guard.js +122 -13
  35. package/lib/utils/jsx-config.d.ts +45 -0
  36. package/lib/utils/jsx-config.js +98 -0
  37. package/lib/utils/node-script.d.ts +4 -1
  38. package/lib/utils/node-script.js +27 -3
  39. package/lib/utils/npm-oidc.js +8 -5
  40. package/lib/utils/onboard-probe.d.ts +23 -0
  41. package/lib/utils/onboard-probe.js +65 -0
  42. package/lib/utils/run-gjs.d.ts +12 -1
  43. package/lib/utils/run-gjs.js +24 -31
  44. package/lib/utils/run-node.d.ts +9 -4
  45. package/lib/utils/run-node.js +34 -41
  46. package/lib/utils/ship/deb.d.ts +3 -3
  47. package/lib/utils/ship/deb.js +6 -2
  48. package/lib/utils/ship/depends.d.ts +14 -6
  49. package/lib/utils/ship/depends.js +45 -4
  50. package/lib/utils/ship/discover.js +178 -7
  51. package/lib/utils/ship/formats.js +9 -1
  52. package/lib/utils/ship/launcher.js +27 -4
  53. package/lib/utils/ship/mime.d.ts +19 -0
  54. package/lib/utils/ship/mime.js +89 -0
  55. package/lib/utils/ship/payload.d.ts +111 -0
  56. package/lib/utils/ship/payload.js +174 -0
  57. package/lib/utils/ship/plan.d.ts +30 -1
  58. package/lib/utils/ship/plan.js +74 -2
  59. package/lib/utils/ship/rpm.d.ts +3 -3
  60. package/lib/utils/ship/rpm.js +20 -3
  61. package/lib/utils/ship/scripts.d.ts +14 -4
  62. package/lib/utils/ship/scripts.js +26 -8
  63. package/lib/utils/ship/settings.d.ts +8 -1
  64. package/lib/utils/ship/settings.js +26 -1
  65. package/lib/utils/ship/stage-manifest.d.ts +154 -0
  66. package/lib/utils/ship/stage-manifest.js +408 -0
  67. package/lib/utils/ship/stage-writer.d.ts +30 -3
  68. package/lib/utils/ship/stage-writer.js +76 -9
  69. package/lib/utils/ship/types.d.ts +110 -18
  70. package/lib/utils/ship/types.js +5 -0
  71. package/lib/utils/simple-command.d.ts +16 -0
  72. package/lib/utils/simple-command.js +77 -0
  73. package/lib/utils/spawn.d.ts +7 -1
  74. package/lib/utils/spawn.js +34 -7
  75. package/lib/utils/system-gi.d.ts +24 -0
  76. package/lib/utils/system-gi.js +14 -3
  77. package/lib/utils/watch-loop.d.ts +91 -0
  78. package/lib/utils/watch-loop.js +207 -0
  79. package/package.json +19 -18
  80. package/lib/templates/app/desktop.tmpl +0 -10
@@ -15,13 +15,15 @@
15
15
  // to disambiguate can pass `./<file>` explicitly.
16
16
  import { readFileSync, statSync } from 'node:fs';
17
17
  import { basename, delimiter, join, resolve } from 'node:path';
18
- import { spawn } from 'node:child_process';
18
+ import { describeExit, spawnToCompletion } from '../utils/spawn.js';
19
19
  import { runGjsBundle } from '../utils/run-gjs.js';
20
20
  import { runRuntimeBundle } from '../utils/run-node.js';
21
21
  import { readPackageJson } from '../utils/pkg-json-edit.js';
22
22
  import { runNodeScript } from '../utils/node-script.js';
23
23
  import { nodeShimDir } from '../utils/gjsify-shim.js';
24
24
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
25
+ import { gjsifyCommandArgv } from '../utils/simple-command.js';
26
+ import { isDaemonCommand } from '../utils/daemon-command.js';
25
27
  import { discoverWorkspaces } from '@gjsify/workspace';
26
28
  import { isGjs, hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
27
29
  import { EXAMPLE_RUNTIMES, isExampleRuntime, readDeclaredRuntimes, checkRuntimeSupported, } from '../utils/runtimes.js';
@@ -124,7 +126,7 @@ export const runCommand = {
124
126
  console.error(`gjsify run: --node-script cannot be combined with ${clash}.`);
125
127
  return process.exit(1);
126
128
  }
127
- await runNodeScript(target, extraArgs, { exitOnSuccess: true });
129
+ await runNodeScript(target, extraArgs, { completion: 'exit', exitOnSuccess: true });
128
130
  return;
129
131
  }
130
132
  // `--runtime <gjs|node|bun|deno>` (explicit) → run a BUNDLE FILE on the
@@ -174,10 +176,10 @@ export const runCommand = {
174
176
  // Terminal calls — exit on success, or the GJS main loop parks
175
177
  // this process forever (see RunGjsBundleOptions.exitOnSuccess).
176
178
  if (runtime === 'gjs') {
177
- await runGjsBundle(file, extraArgs, { exitOnSuccess: true });
179
+ await runGjsBundle(file, extraArgs, { completion: 'exit', exitOnSuccess: true });
178
180
  }
179
181
  else {
180
- await runRuntimeBundle(runtime, file, extraArgs, { exitOnSuccess: true });
182
+ await runRuntimeBundle(runtime, file, extraArgs, { completion: 'exit', exitOnSuccess: true });
181
183
  }
182
184
  return;
183
185
  }
@@ -209,10 +211,10 @@ async function runTargetOnRuntime(runtime, target, extraArgs) {
209
211
  const file = resolve(target);
210
212
  // Terminal call — exit on success (the GJS/GLib loop would otherwise park).
211
213
  if (runtime === 'gjs') {
212
- await runGjsBundle(file, extraArgs, { exitOnSuccess: true });
214
+ await runGjsBundle(file, extraArgs, { completion: 'exit', exitOnSuccess: true });
213
215
  }
214
216
  else {
215
- await runRuntimeBundle(runtime, file, extraArgs, { exitOnSuccess: true });
217
+ await runRuntimeBundle(runtime, file, extraArgs, { completion: 'exit', exitOnSuccess: true });
216
218
  }
217
219
  }
218
220
  /**
@@ -420,6 +422,16 @@ async function runScript(script, extraArgs, cwd = process.cwd()) {
420
422
  console.error(err.message);
421
423
  dispatchFailed = true;
422
424
  }
425
+ // A watch loop RESOLVES as soon as it is armed and then keeps running,
426
+ // so its resolution is not an ending to exit on: `gjsify run dev` used
427
+ // to take down the loop it had just started, the instant the first
428
+ // build+launch finished. Node never saw it — there the same script takes
429
+ // the shell-spawn path below — and it is the DOCUMENTED entry point:
430
+ // `gjsify create` prints `gjsify run dev`, and every template's `dev`
431
+ // script is a bare `gjsify dev`. Returning leaves the process on the
432
+ // main loop `runWatchLoop` holds, where Ctrl+C ends it.
433
+ if (!dispatchFailed && isDaemonCommand())
434
+ return;
423
435
  // A thrown command wins over `process.exitCode`; otherwise honour a
424
436
  // non-zero code the handler set instead of throwing (e.g. a failing
425
437
  // check) — a bare exit(0) here masked those as success too.
@@ -436,23 +448,23 @@ async function runScript(script, extraArgs, cwd = process.cwd()) {
436
448
  return process.exit(dispatchCode);
437
449
  }
438
450
  const fullCmd = extraArgs.length > 0 ? `${literal} ${extraArgs.map(shellEscape).join(' ')}` : literal;
439
- // ensureMainLoop() (called inside spawn) keeps GJS alive after the
440
- // child exits — without an explicit process.exit() the success path
441
- // would park the loop forever. The error path already exits.
442
- await new Promise((resolveOk, reject) => {
443
- const child = spawn(fullCmd, [], { cwd, env, stdio: 'inherit', shell: true });
444
- child.on('close', (code) => {
445
- if (code === 0)
446
- resolveOk();
447
- else
448
- reject(new Error(`script "${script}" exited with code ${code}`));
449
- });
450
- child.on('error', reject);
451
- }).catch((err) => {
451
+ // `completion: 'exit'` — ensureMainLoop() (called inside the async spawn) keeps
452
+ // GJS alive after the child exits, and both paths below end in `process.exit`,
453
+ // which is what tears it down. `shell: true` deliberately skips the win32
454
+ // rewrite: this is a command LINE, and Node routes it through %COMSPEC%.
455
+ let result;
456
+ try {
457
+ result = await spawnToCompletion(fullCmd, [], { completion: 'exit', cwd, env, shell: true });
458
+ }
459
+ catch (err) {
452
460
  console.error(err.message);
453
- process.exit(1);
454
- });
455
- process.exit(0);
461
+ return process.exit(1);
462
+ }
463
+ if (result.code !== 0) {
464
+ console.error(`script "${script}" exited with ${describeExit(result)}`);
465
+ return process.exit(1);
466
+ }
467
+ return process.exit(0);
456
468
  }
457
469
  function shellEscape(arg) {
458
470
  if (/^[a-zA-Z0-9_\-./=:@,]+$/.test(arg))
@@ -463,70 +475,11 @@ function shellEscape(arg) {
463
475
  * If `literal` is a SINGLE `gjsify <subcommand> …` command with no shell
464
476
  * operators / substitutions / unquoted globs, return the argv to feed `runCli`
465
477
  * (the subcommand + its args, with `extraArgs` appended). Otherwise null — the
466
- * caller falls back to the shell spawn path. Quotes are honoured so a quoted
467
- * glob (`'src/**'`) survives as a single literal token, exactly as the shell
468
- * would hand it to `gjsify` (which does its own glob expansion).
478
+ * caller falls back to the shell spawn path.
469
479
  */
470
480
  function gjsifyInProcessArgv(literal, extraArgs) {
471
- const tokens = tokenizeSimpleCommand(literal);
472
- if (!tokens || tokens.length < 2 || tokens[0] !== 'gjsify')
481
+ const argv = gjsifyCommandArgv(literal);
482
+ if (!argv)
473
483
  return null;
474
- return [...tokens.slice(1), ...extraArgs];
475
- }
476
- /**
477
- * Quote-aware tokenizer for a SIMPLE command line (no shell features beyond
478
- * single/double quoting). Returns the tokens, or null if the string contains
479
- * anything the shell would treat specially — operators (`&& | ; < > &`),
480
- * substitutions (`$(...)` / backticks / `$VAR`), unquoted globs/expansions
481
- * (`* ? { } [ ] ~`), comments (`#`), or an unterminated quote — in which case
482
- * the caller MUST use the real shell instead.
483
- */
484
- function tokenizeSimpleCommand(cmd) {
485
- const tokens = [];
486
- let cur = '';
487
- let has = false;
488
- let quote = null;
489
- for (let i = 0; i < cmd.length; i++) {
490
- const c = cmd[i];
491
- if (quote === "'") {
492
- if (c === "'")
493
- quote = null;
494
- else
495
- cur += c;
496
- continue;
497
- }
498
- if (quote === '"') {
499
- if (c === '"')
500
- quote = null;
501
- else if (c === '\\' && (cmd[i + 1] === '"' || cmd[i + 1] === '\\'))
502
- cur += cmd[++i];
503
- else if (c === '$' || c === '`')
504
- return null; // substitution inside "…"
505
- else
506
- cur += c;
507
- continue;
508
- }
509
- if (c === "'" || c === '"') {
510
- quote = c;
511
- has = true;
512
- continue;
513
- }
514
- if (c === ' ' || c === '\t') {
515
- if (has) {
516
- tokens.push(cur);
517
- cur = '';
518
- has = false;
519
- }
520
- continue;
521
- }
522
- if ('|&;<>`$()\\\n\r*?{}[]~#!'.includes(c))
523
- return null;
524
- cur += c;
525
- has = true;
526
- }
527
- if (quote)
528
- return null; // unterminated quote
529
- if (has)
530
- tokens.push(cur);
531
- return tokens;
484
+ return [...argv, ...extraArgs];
532
485
  }
@@ -3,6 +3,8 @@ interface ShipOptions {
3
3
  target?: string[];
4
4
  out?: string;
5
5
  stage: boolean;
6
+ 'from-stage'?: string;
7
+ 'expect-target'?: string;
6
8
  'skip-build': boolean;
7
9
  arch?: string;
8
10
  verbose: boolean;
@@ -5,12 +5,23 @@
5
5
  // thin on purpose: every decision worth testing lives in `utils/ship/*` as a
6
6
  // pure function, and what is left here is filesystem work and printing.
7
7
  //
8
+ // TWO PHASES, one verb each (ADR 0024 § A2). Phase one ASSEMBLES: it resolves
9
+ // the settings, plans the payload, writes `stage/`, and writes the closure that
10
+ // makes that tree packable somewhere else (`.gjsify-ship-stage.json`, see
11
+ // `utils/ship/stage-manifest.ts`). Phase two FINISHES: `--from-stage <dir>`
12
+ // packs a stage that arrived from another host, with no project, no config and
13
+ // no built bundle in reach. With neither flag both phases run in one process,
14
+ // which is what a developer on one machine wants and what the e2e suite
15
+ // compares the split against byte for byte.
16
+ //
8
17
  // Scope today is Linux and `--app gjs`: no runtime is bundled, because GJS and
9
18
  // GTK come from the distribution and shipping ~100 MiB of them would be cargo
10
19
  // cult (ADR 0024 § 4). macOS and Windows layouts, and Flatpak as a target under
11
- // this command, are the later stages of the same ADR.
20
+ // this command, are the later stages of the same ADR — and they are the reason
21
+ // the split exists at all: a `.dmg` is a UDIF image over a real HFS+/APFS
22
+ // volume and no Linux host in this tree can write one (§ A1).
12
23
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
- import { basename, join, relative, resolve } from 'node:path';
24
+ import { basename, dirname, join, relative, resolve } from 'node:path';
14
25
  import { Config } from '../config.js';
15
26
  import { renderDesktopEntry, renderMetainfoApp, renderMetainfoCli, validateAppMetadata, } from '../utils/app-metadata.js';
16
27
  import { readPackageJson } from '../utils/pkg-json.js';
@@ -20,11 +31,12 @@ import { deriveDepends, warnAboutGjsFloor } from '../utils/ship/depends.js';
20
31
  import { discoverPayload } from '../utils/ship/discover.js';
21
32
  import { resolveFormats, FORMAT_IDS } from '../utils/ship/formats.js';
22
33
  import { scanGiNamespaces } from '../utils/ship/gi-namespaces.js';
23
- import { buildTimestamp, isArchIndependent } from '../utils/ship/payload.js';
24
- import { planOverlay, planStage } from '../utils/ship/plan.js';
34
+ import { assertPayloadMatchesArch, buildTimestamp, isArchIndependent, readPayloadFacts, } from '../utils/ship/payload.js';
35
+ import { assertOverlayIsLicensed, planOverlay, planStage } from '../utils/ship/plan.js';
25
36
  import { renderLauncher } from '../utils/ship/launcher.js';
26
37
  import { buildRpm } from '../utils/ship/rpm.js';
27
38
  import { declaredBundlePath, resolveShipSettings } from '../utils/ship/settings.js';
39
+ import { assertExpectedTarget, assertFormatsStaged, formatTarget, overlayFiles, readStageManifest, resolveStageMtime, warnOnToolSkew, writeStageManifest, } from '../utils/ship/stage-manifest.js';
28
40
  import { readStage, writeStage } from '../utils/ship/stage-writer.js';
29
41
  const LOG = '[gjsify ship]';
30
42
  export const shipCommand = {
@@ -45,6 +57,17 @@ export const shipCommand = {
45
57
  type: 'boolean',
46
58
  default: false,
47
59
  description: 'Produce the staged payload and stop, without packing anything.',
60
+ })
61
+ .option('from-stage', {
62
+ type: 'string',
63
+ normalize: true,
64
+ description: 'Pack a staged payload written by an earlier `--stage` run. Needs no project: the stage ' +
65
+ 'carries its own closure.',
66
+ })
67
+ .option('expect-target', {
68
+ type: 'string',
69
+ description: 'With --from-stage: refuse unless the stage was assembled for this `<os>-<arch>`, e.g. ' +
70
+ '`linux-arm64`.',
48
71
  })
49
72
  .option('skip-build', {
50
73
  type: 'boolean',
@@ -61,109 +84,279 @@ export const shipCommand = {
61
84
  description: 'Print each staged file and the GI namespaces the bundle imports.',
62
85
  }),
63
86
  handler: async (args) => {
64
- const projectDir = process.cwd();
65
- const config = new Config();
66
- // No `.catch(() => ({}))` here, unlike the flatpak commands: a config
67
- // file that fails to load would silently become "no config", and the
68
- // result is a shipped artifact carrying default metadata the author
69
- // never chose. An unreadable config is an error worth printing.
70
- const configData = await config.forCommand(projectDir);
71
- const ship = configData.ship ?? {};
72
- const flatpak = configData.flatpak ?? {};
73
- assertShippableTarget(configData.app);
74
- const pkg = (readPackageJson(join(projectDir, 'package.json')) ?? {});
75
- // Resolved before anything is built or written: a typo'd `--target`
76
- // should not cost a build and leave a half-written `ship/stage/`.
77
- const formats = resolveFormats(args.target ?? ship.targets ?? FORMAT_IDS);
78
- if (!args['skip-build'])
79
- await runProjectBuild(projectDir);
80
- const discovered = discoverPayload({
81
- projectDir,
82
- pkg,
83
- ship,
84
- flatpakIcon: flatpak.icon,
85
- declaredBundle: declaredBundlePath(pkg, ship),
86
- });
87
- const { settings, metadata, warnings } = resolveShipSettings({
88
- projectDir,
89
- pkg,
90
- ship,
91
- flatpak,
92
- cli: { outDir: args.out, arch: args.arch },
93
- discovered,
87
+ // The two phases share nothing but `packOne`, and the branch is first
88
+ // so that `--from-stage` never touches the cwd: a finishing host has no
89
+ // project there, and reading one would make the artifact depend on
90
+ // whatever directory the job happened to start in.
91
+ if (args['from-stage'] !== undefined)
92
+ return await finishFromStage(args, args['from-stage']);
93
+ await assemble(args);
94
+ },
95
+ };
96
+ /**
97
+ * Phase one: resolve, plan, stage, and write the closure — then, unless
98
+ * `--stage` was passed, pack right here.
99
+ */
100
+ async function assemble(args) {
101
+ const projectDir = process.cwd();
102
+ const config = new Config();
103
+ // No `.catch(() => ({}))` here, unlike the flatpak commands: a config
104
+ // file that fails to load would silently become "no config", and the
105
+ // result is a shipped artifact carrying default metadata the author
106
+ // never chose. An unreadable config is an error worth printing.
107
+ const configData = await config.forCommand(projectDir);
108
+ const ship = configData.ship ?? {};
109
+ const flatpak = configData.flatpak ?? {};
110
+ assertShippableTarget(configData.app);
111
+ const pkg = (readPackageJson(join(projectDir, 'package.json')) ?? {});
112
+ // Resolved before anything is built or written: a typo'd `--target`
113
+ // should not cost a build and leave a half-written `ship/stage/`.
114
+ const formats = resolveFormats(args.target ?? ship.targets ?? FORMAT_IDS);
115
+ if (!args['skip-build'])
116
+ await runProjectBuild(projectDir);
117
+ const discovered = discoverPayload({
118
+ projectDir,
119
+ pkg,
120
+ ship,
121
+ flatpakIcon: flatpak.icon,
122
+ declaredBundle: declaredBundlePath(pkg, ship),
123
+ });
124
+ const { settings, metadata, warnings } = resolveShipSettings({
125
+ projectDir,
126
+ pkg,
127
+ ship,
128
+ flatpak,
129
+ cli: { outDir: args.out, arch: args.arch },
130
+ discovered,
131
+ });
132
+ for (const warning of warnings)
133
+ console.warn(`${LOG} ${warning}`);
134
+ const mtime = buildTimestamp(settings.bundlePath);
135
+ const metadataInputs = {
136
+ appId: settings.appId,
137
+ name: settings.name,
138
+ command: settings.binaryName,
139
+ kind: settings.kind,
140
+ metadata,
141
+ configKey: 'gjsify.ship',
142
+ copyrightYear: new Date(mtime * 1000).getUTCFullYear(),
143
+ };
144
+ // Warn rather than fail: an incomplete AppStream component still
145
+ // installs and still runs. It is app STORES that will reject it, and
146
+ // that is a different day's problem than getting the package built.
147
+ for (const missing of validateAppMetadata(metadataInputs)) {
148
+ console.warn(`${LOG} ${missing.field} is not set — ${missing.hint}`);
149
+ }
150
+ // Printed, not assumed. The text may come from an ancestor — one `LICENSE`
151
+ // at a monorepo root covers every package under it (see `discoverLicense`)
152
+ // — and which file was baked into the copyright is not recoverable from the
153
+ // artifact afterwards without unpacking it.
154
+ if (settings.licenseFile !== undefined && dirname(settings.licenseFile) !== projectDir) {
155
+ console.log(`${LOG} licence text from ${relative(projectDir, settings.licenseFile)} ` +
156
+ '(this package carries none of its own)');
157
+ }
158
+ const stageInputs = {
159
+ bundleFiles: discovered.bundleFiles,
160
+ launcher: renderLauncher(settings, basename(settings.bundlePath)),
161
+ metainfo: settings.kind === 'app' ? renderMetainfoApp(metadataInputs) : renderMetainfoCli(metadataInputs),
162
+ desktopEntry: settings.kind === 'app' ? renderDesktopEntry(metadataInputs) : undefined,
163
+ licenseText: settings.licenseFile === undefined ? undefined : readFileSync(settings.licenseFile, 'utf-8'),
164
+ };
165
+ const outRoot = resolve(projectDir, settings.outDir);
166
+ const stageDir = join(outRoot, 'stage');
167
+ const staged = planStage(settings, stageInputs);
168
+ writeStage(stageDir, staged);
169
+ console.log(`${LOG} staged ${staged.length} file(s) in ${relative(projectDir, stageDir)}/`);
170
+ if (args.verbose)
171
+ for (const file of staged)
172
+ console.log(`${LOG} ${file.path}`);
173
+ // Scanned from the BUILD TREE's bundle, which is why it has to be recorded:
174
+ // `gi://` specifiers are what the emitted `Depends:` is derived from
175
+ // (ADR 0024 § 6), and the packing host has the staged copy but no way to
176
+ // tell which staged file is the entry.
177
+ const namespaces = scanGiNamespaces(readFileSync(settings.bundlePath, 'utf-8'));
178
+ if (args.verbose)
179
+ console.log(`${LOG} gi namespaces: ${namespaces.join(', ') || '(none)'}`);
180
+ const overlay = new Map();
181
+ for (const format of formats)
182
+ overlay.set(format.id, planOverlay(settings, format, stageInputs));
183
+ // The dependency derivation runs HERE as well, and its result is thrown
184
+ // away. It is the only check in the pipeline that can refuse a project
185
+ // (`gi://Nautilus` with no known package, ADR 0024 § 6), and this is the
186
+ // host that has the `package.json` the refusal tells you to edit — letting
187
+ // it fire two jobs later, on a machine with no project, would name a fix
188
+ // nobody there can apply. Facts come from the PLAN, which has the same
189
+ // paths the payload will have.
190
+ const facts = readPayloadFacts(staged);
191
+ for (const format of formats) {
192
+ deriveDepends(format.id, {
193
+ namespaces,
194
+ hasIcons: facts.hasIcons,
195
+ hasSchemas: facts.hasSchemas,
196
+ extra: settings.extraDepends[format.id],
197
+ typelibPackages: settings.typelibPackages,
198
+ bundledTypelibs: facts.bundledTypelibs,
199
+ minGjsVersion: settings.minGjsVersion,
94
200
  });
95
- for (const warning of warnings)
201
+ for (const warning of warnAboutGjsFloor(format.id, settings.minGjsVersion))
202
+ console.warn(`${LOG} ${warning}`);
203
+ }
204
+ // Written unconditionally, not only under `--stage`: a `ship/stage/` that
205
+ // is sometimes packable elsewhere and sometimes not is a worse contract
206
+ // than one that always is, and it is what lets the e2e suite compare the
207
+ // one-process artifact with the two-phase one.
208
+ const manifest = writeStageManifest({ stageDir, settings, formats, mtime, namespaces, staged, overlay });
209
+ if (args.stage) {
210
+ console.log(`${LOG} stage manifest: ${formatTarget(manifest.target)}, formats ${manifest.formats.join(', ')}`);
211
+ return;
212
+ }
213
+ const artifacts = [];
214
+ for (const format of formats) {
215
+ artifacts.push(await packOne({
216
+ format,
217
+ settings,
218
+ arch: settings.arch,
219
+ staged,
220
+ overlay: overlay.get(format.id) ?? [],
221
+ stageDir,
222
+ outRoot,
223
+ namespaces,
224
+ mtime,
225
+ }));
226
+ }
227
+ printArtifacts(projectDir, artifacts);
228
+ }
229
+ /**
230
+ * Phase two: pack a stage that was assembled somewhere else.
231
+ *
232
+ * Everything this needs is in the stage. Nothing under the cwd is read, and
233
+ * that is the property under test — `tests/e2e/ship-from-stage` deletes the
234
+ * project tree between the two phases, because without the deletion every
235
+ * accidental reach-back succeeds and the suite proves nothing.
236
+ */
237
+ async function finishFromStage(args, fromStage) {
238
+ if (args.stage) {
239
+ throw new Error('gjsify ship: --stage and --from-stage are the two halves of one split and cannot run together. ' +
240
+ '`--stage` assembles a payload and stops; `--from-stage <dir>` packs one that is already ' +
241
+ 'assembled. Run them as two commands, on the host each belongs on (ADR 0024 § A2).');
242
+ }
243
+ if (args['skip-build']) {
244
+ throw new Error('gjsify ship: --from-stage never runs a build — the payload it packs was built and staged by an ' +
245
+ 'earlier run — so --skip-build has nothing to skip. Drop it.');
246
+ }
247
+ if (args.arch !== undefined) {
248
+ throw new Error('gjsify ship: --arch is decided when the stage is assembled, not when it is packed: the launcher, ' +
249
+ 'the payload and the recorded target were all fixed then. Pass --arch to ' +
250
+ '`gjsify ship --stage`, and use `--expect-target <os>-<arch>` here to assert which leg you are ' +
251
+ 'packing.');
252
+ }
253
+ const cwd = process.cwd();
254
+ const stageDir = resolve(cwd, fromStage);
255
+ const manifest = readStageManifest(stageDir);
256
+ assertExpectedTarget(manifest, args['expect-target']);
257
+ const formats = resolveFormats(args.target ?? manifest.formats);
258
+ assertFormatsStaged(manifest, formats);
259
+ for (const warning of warnOnToolSkew(manifest))
260
+ console.warn(`${LOG} ${warning}`);
261
+ const { mtime, warnings } = resolveStageMtime(manifest);
262
+ for (const warning of warnings)
263
+ console.warn(`${LOG} ${warning}`);
264
+ console.log(`${LOG} packing ${manifest.settings.binaryName} ${manifest.settings.version}-${manifest.settings.release} ` +
265
+ `for ${formatTarget(manifest.target)} from ${relative(cwd, stageDir) || '.'}/`);
266
+ if (args.verbose) {
267
+ for (const file of manifest.staged)
268
+ console.log(`${LOG} ${file.path}`);
269
+ console.log(`${LOG} gi namespaces: ${manifest.namespaces.join(', ') || '(none)'}`);
270
+ }
271
+ // The stage's own directory is not written to — `writeStage` wipes what it
272
+ // is pointed at, and pointing it at an arriving stage would delete the
273
+ // payload. The overlay is the only thing this phase materialises, and it
274
+ // goes beside the artifacts.
275
+ const outRoot = args.out === undefined ? dirname(stageDir) : resolve(cwd, args.out);
276
+ const artifacts = [];
277
+ for (const format of formats) {
278
+ for (const warning of warnAboutGjsFloor(format.id, manifest.settings.minGjsVersion)) {
96
279
  console.warn(`${LOG} ${warning}`);
97
- const mtime = buildTimestamp(settings.bundlePath);
98
- const metadataInputs = {
99
- appId: settings.appId,
100
- name: settings.name,
101
- command: settings.binaryName,
102
- kind: settings.kind,
103
- metadata,
104
- configKey: 'gjsify.ship',
105
- copyrightYear: new Date(mtime * 1000).getUTCFullYear(),
106
- };
107
- // Warn rather than fail: an incomplete AppStream component still
108
- // installs and still runs. It is app STORES that will reject it, and
109
- // that is a different day's problem than getting the package built.
110
- for (const missing of validateAppMetadata(metadataInputs)) {
111
- console.warn(`${LOG} ${missing.field} is not set — ${missing.hint}`);
112
- }
113
- const stageInputs = {
114
- bundleFiles: discovered.bundleFiles,
115
- launcher: renderLauncher(settings, basename(settings.bundlePath)),
116
- metainfo: settings.kind === 'app' ? renderMetainfoApp(metadataInputs) : renderMetainfoCli(metadataInputs),
117
- desktopEntry: settings.kind === 'app' ? renderDesktopEntry(metadataInputs) : undefined,
118
- licenseText: settings.licenseFile === undefined ? undefined : readFileSync(settings.licenseFile, 'utf-8'),
119
- };
120
- const outRoot = resolve(projectDir, settings.outDir);
121
- const stageDir = join(outRoot, 'stage');
122
- const staged = planStage(settings, stageInputs);
123
- writeStage(stageDir, staged);
124
- console.log(`${LOG} staged ${staged.length} file(s) in ${relative(projectDir, stageDir)}/`);
125
- if (args.verbose)
126
- for (const file of staged)
127
- console.log(`${LOG} ${file.path}`);
128
- if (args.stage)
129
- return;
130
- const namespaces = scanGiNamespaces(readFileSync(settings.bundlePath, 'utf-8'));
131
- if (args.verbose)
132
- console.log(`${LOG} gi namespaces: ${namespaces.join(', ') || '(none)'}`);
133
- const artifacts = [];
134
- for (const format of formats) {
135
- artifacts.push(await packOne({ format, settings, stageInputs, staged, stageDir, outRoot, namespaces, mtime }));
136
- }
137
- for (const artifact of artifacts) {
138
- console.log(`${LOG} ${artifact.format}: ${relative(projectDir, artifact.path)} (${artifact.size} bytes)`);
139
280
  }
140
- },
141
- };
281
+ artifacts.push(await packOne({
282
+ format,
283
+ settings: manifest.settings,
284
+ arch: manifest.target.arch,
285
+ staged: manifest.staged,
286
+ overlay: overlayFiles(manifest, format.id),
287
+ stageDir,
288
+ outRoot,
289
+ namespaces: manifest.namespaces,
290
+ mtime,
291
+ }));
292
+ }
293
+ printArtifacts(cwd, artifacts);
294
+ }
142
295
  async function packOne(input) {
143
296
  const { format, settings, stageDir, outRoot, mtime } = input;
144
- const overlay = planOverlay(settings, format, input.stageInputs);
297
+ assertOverlayIsLicensed(format.id, settings.binaryName, input.overlay);
145
298
  const overlayDir = join(outRoot, 'overlay', format.id);
146
- writeStage(overlayDir, overlay);
147
- const payload = readStage([stageDir, overlayDir], [...input.staged, ...overlay]);
299
+ writeStage(overlayDir, input.overlay);
300
+ const payload = readStage([stageDir, overlayDir], [...input.staged, ...input.overlay]);
301
+ // What the package installs, read off what it installs. The three questions
302
+ // this answers used to be answered from the project's file lists, which are
303
+ // absolute paths on the build host and therefore unavailable here.
304
+ const facts = readPayloadFacts(payload);
148
305
  const depends = deriveDepends(format.id, {
149
306
  namespaces: input.namespaces,
150
- kind: settings.kind,
151
- hasSchemas: settings.schemaFiles.length > 0,
307
+ hasIcons: facts.hasIcons,
308
+ hasSchemas: facts.hasSchemas,
152
309
  extra: settings.extraDepends[format.id],
153
310
  typelibPackages: settings.typelibPackages,
311
+ bundledTypelibs: facts.bundledTypelibs,
154
312
  minGjsVersion: settings.minGjsVersion,
155
313
  });
156
- for (const warning of warnAboutGjsFloor(format.id, settings.minGjsVersion))
157
- console.warn(`${LOG} ${warning}`);
158
- const archLabel = format.archName(settings.arch, isArchIndependent(payload));
314
+ // `--arch` is a CLAIM about the payload and nothing else compared it to one:
315
+ // an x86-64 `.so` staged with `--arch arm64` packed at exit 0 and `rpm -qp
316
+ // --qf %{ARCH}` confirmed `aarch64`, because rpm reads the header and the
317
+ // header was written from the claim. Read the two bytes further that settle
318
+ // it before the label reaches a header. Payload-versus-LABEL, never
319
+ // payload-versus-HOST — a host check would refuse the cross-host pack this
320
+ // whole split exists for.
321
+ //
322
+ // And BEFORE the label is chosen, not after — this ordering is load-bearing:
323
+ // `archName` collapses an arch-independent payload to `all`/`noarch`, so
324
+ // asserting afterwards would compare against a label that no longer names an
325
+ // architecture, and the mismatch that matters is exactly the one on a payload
326
+ // that IS architecture-specific.
327
+ assertPayloadMatchesArch(payload, input.arch);
328
+ const archLabel = format.archName(input.arch, isArchIndependent(payload));
159
329
  const common = { settings, payload, prefix: format.prefix, depends, archLabel, mtime };
160
- const bytes = format.id === 'deb' ? await buildDeb(common) : await buildRpm(common);
330
+ // A SWITCH with a `never` guard, not a ternary. `format.id === 'deb' ? deb : rpm`
331
+ // read as a dispatch and behaved as one only while there were exactly two
332
+ // formats: a third would have taken the else-branch and written an RPM under a
333
+ // `.dmg` name, at exit 0. This is the same closed-vocabulary hazard the format
334
+ // list has, one level worse — a wrong ARTIFACT rather than a rejected
335
+ // declaration — so the compiler owns it now.
336
+ let bytes;
337
+ switch (format.id) {
338
+ case 'deb':
339
+ bytes = await buildDeb(common);
340
+ break;
341
+ case 'rpm':
342
+ bytes = await buildRpm(common);
343
+ break;
344
+ default: {
345
+ const unhandled = format.id;
346
+ throw new Error(`gjsify ship: no packer is wired for format "${String(unhandled)}".`);
347
+ }
348
+ }
161
349
  const outDir = join(outRoot, 'out');
162
350
  mkdirSync(outDir, { recursive: true });
163
351
  const target = join(outDir, format.fileName(settings, archLabel));
164
352
  writeFileSync(target, bytes);
165
353
  return { format: format.id, path: target, size: bytes.byteLength };
166
354
  }
355
+ function printArtifacts(base, artifacts) {
356
+ for (const artifact of artifacts) {
357
+ console.log(`${LOG} ${artifact.format}: ${relative(base, artifact.path)} (${artifact.size} bytes)`);
358
+ }
359
+ }
167
360
  /**
168
361
  * Refuse a build target this command cannot package correctly.
169
362
  *
@@ -176,6 +369,10 @@ async function packOne(input) {
176
369
  * An undeclared target is the common case for a GJS app and is allowed: `app`
177
370
  * otherwise defaults to the HOST runtime, which would refuse a perfectly good
178
371
  * GJS project merely for running this command under Node.
372
+ *
373
+ * Phase one only, and nothing checks it again in phase two: a stage can only
374
+ * exist because this passed, and the staged `bin/<name>` carries the
375
+ * `exec gjs -m` line that proves it.
179
376
  */
180
377
  function assertShippableTarget(app) {
181
378
  if (app === undefined || app === 'gjs')
@@ -411,7 +411,7 @@ async function runShowcaseOnRuntime(showcase, runtime, cliVersion) {
411
411
  // thrown: an exception escaping the handler makes yargs dump the command's
412
412
  // full `--help` above the message, which buries it.
413
413
  try {
414
- await runRuntimeBundle(runtime, entry, [], { exitOnSuccess: true });
414
+ await runRuntimeBundle(runtime, entry, [], { completion: 'exit', exitOnSuccess: true });
415
415
  }
416
416
  catch (err) {
417
417
  console.error(`${err.message}\n\n` +