@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
@@ -37,6 +37,29 @@
37
37
  // links the module, so one bad specifier kills the whole bundle. A dynamic
38
38
  // `import('node:x')` throws lazily at its call site — a real bug, but not "the artifact
39
39
  // cannot load", and not what this gate is for.
40
+ // ## `node:` was never the general case
41
+ //
42
+ // A bare `node:` specifier is one shape of ONE defect: the emitted module imports
43
+ // something the GJS loader cannot resolve. GJS's ESM loader has no node_modules walker
44
+ // and does not follow `package.json#exports`, so ANY bare specifier that survives into a
45
+ // `--app gjs` bundle is dead on arrival — `ImportError: Module not found: <specifier>`,
46
+ // again before one line runs. MEASURED: a `.tsx` entry with no JSX configuration makes
47
+ // oxc default to the automatic runtime with `importSource: 'react'`, the bundle carries
48
+ // `import { jsx } from "react/jsx-runtime"`, rolldown reports that as an
49
+ // `UNRESOLVED_IMPORT` **warning**, `gjsify build` exits 0, and `gjs -m` on the artifact
50
+ // dies with `ImportError: Module not found: react/jsx-runtime`. The `node:`-only guard
51
+ // saw none of it.
52
+ //
53
+ // The legitimate-vs-broken line is the same one `plugins/unresolved-workspace-import.ts`
54
+ // draws one layer up, and for the same reason a blanket "fail on any external" cannot be
55
+ // used: a DECLARED external is a promise the caller made and must be honoured. So the
56
+ // guard asks the target's OWN externals predicate (`createGjsExternalsPredicate`, the one
57
+ // `externalsPlugin` enforces) rather than a second list that can drift, and it ignores
58
+ // everything that is not a bare specifier — `gi://Gtk?version=4.0`, `file:`/`resource:`
59
+ // URLs and relative sibling-chunk paths all name something GJS can resolve on its own.
60
+ // `chunk.imports` also carries the fileNames of other EMITTED chunks, un-prefixed, so the
61
+ // emitted set is passed in and excluded by name.
62
+ import { locateSurvivingJsx, formatSurvivingJsx } from '@gjsify/rolldown-plugin-gjsify';
40
63
  /**
41
64
  * Bare `node:` specifiers a `--app gjs` bundle may keep as static imports.
42
65
  *
@@ -64,18 +87,104 @@ export function findDisallowedNodeImports(chunks, allowed = GJS_ALLOWED_NODE_IMP
64
87
  }
65
88
  return [...found].sort();
66
89
  }
67
- /** Throw when a built `--app gjs` bundle statically imports Node builtins. */
68
- export function assertGjsBundleLoadable(chunks, outfile) {
69
- const offenders = findDisallowedNodeImports(chunks);
70
- if (offenders.length === 0)
90
+ /**
91
+ * Is `id` a BARE package specifier — the only shape GJS's ESM loader cannot resolve on
92
+ * its own?
93
+ *
94
+ * Relative and absolute paths name a file; anything carrying a URL scheme names a
95
+ * loader GJS either has (`gi:`, `file:`, `resource:`) or will report itself. `node:` is
96
+ * classified BEFORE this, because it has its own message and its own symptom.
97
+ * A win32 drive path (`C:\…`) matches the scheme test, which is the intended answer.
98
+ */
99
+ export function isBareSpecifier(id) {
100
+ if (id.startsWith('.') || id.startsWith('/') || id.startsWith('\\'))
101
+ return false;
102
+ return !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(id);
103
+ }
104
+ /**
105
+ * The distinct bare specifiers `chunks` statically import that nothing in this build
106
+ * promised to provide. Sorted, so the error message and any test are stable.
107
+ */
108
+ export function findUnresolvableBareImports(chunks, policy = {}) {
109
+ const emitted = new Set(policy.emitted ?? []);
110
+ const found = new Set();
111
+ for (const chunk of chunks) {
112
+ for (const specifier of chunk.imports ?? []) {
113
+ // `node:` has its own diagnosis; classifying it here too would report one
114
+ // defect twice with two different fixes.
115
+ if (specifier.startsWith('node:'))
116
+ continue;
117
+ if (!isBareSpecifier(specifier))
118
+ continue;
119
+ if (emitted.has(specifier))
120
+ continue;
121
+ if (policy.isExternal?.(specifier))
122
+ continue;
123
+ found.add(specifier);
124
+ }
125
+ }
126
+ return [...found].sort();
127
+ }
128
+ /**
129
+ * Throw when a built `--app gjs` bundle statically imports something GJS cannot
130
+ * resolve — a bare `node:` builtin, or any other bare specifier this build did not
131
+ * declare external.
132
+ *
133
+ * The two are reported separately because they have different symptoms and different
134
+ * fixes: a `node:` specifier aborts with "Unsupported URI scheme", any other bare one
135
+ * with "Module not found", and the first means an alias target failed while the second
136
+ * means the dependency is simply not there.
137
+ */
138
+ export function assertGjsBundleLoadable(chunks, outfile, policy = {}) {
139
+ const nodeOffenders = findDisallowedNodeImports(chunks);
140
+ if (nodeOffenders.length > 0) {
141
+ throw new Error(`gjsify build --app gjs: ${outfile} still imports ${nodeOffenders.length} bare \`node:\` module(s), ` +
142
+ 'which stock GJS cannot resolve — it would fail at load with ' +
143
+ '"ImportError: Unsupported URI scheme for importing: node".\n' +
144
+ nodeOffenders.map((s) => ` - ${s}`).join('\n') +
145
+ '\n\nThese should have been aliased to their `@gjsify/*` polyfills. The usual cause is that the ' +
146
+ 'alias TARGET could not be resolved, so the bundler externalised the import instead (look for ' +
147
+ 'UNRESOLVED_IMPORT warnings above): e.g. a workspace checkout whose `@gjsify/*` packages have no ' +
148
+ 'built `lib/`. Build the workspace first (`gjsify run build:infra`), or fix the resolution, then ' +
149
+ 'rebuild.');
150
+ }
151
+ const bareOffenders = findUnresolvableBareImports(chunks, policy);
152
+ if (bareOffenders.length === 0)
71
153
  return;
72
- throw new Error(`gjsify build --app gjs: ${outfile} still imports ${offenders.length} bare \`node:\` module(s), ` +
73
- 'which stock GJS cannot resolve — it would fail at load with ' +
74
- '"ImportError: Unsupported URI scheme for importing: node".\n' +
75
- offenders.map((s) => ` - ${s}`).join('\n') +
76
- '\n\nThese should have been aliased to their `@gjsify/*` polyfills. The usual cause is that the ' +
77
- 'alias TARGET could not be resolved, so the bundler externalised the import instead (look for ' +
78
- 'UNRESOLVED_IMPORT warnings above): e.g. a workspace checkout whose `@gjsify/*` packages have no ' +
79
- 'built `lib/`. Build the workspace first (`gjsify run build:infra`), or fix the resolution, then ' +
80
- 'rebuild.');
154
+ throw new Error(`gjsify build --app gjs: ${outfile} statically imports ${bareOffenders.length} bare package ` +
155
+ 'specifier(s) nothing in this build resolves or declares external. GJS has no node_modules walker ' +
156
+ 'and does not follow `package.json#exports`, so it aborts the whole module graph at load with ' +
157
+ '"ImportError: Module not found: <specifier>".\n' +
158
+ bareOffenders.map((s) => ` - ${s}`).join('\n') +
159
+ '\n\nRolldown reports these as UNRESOLVED_IMPORT WARNINGS and re-emits the original specifier, ' +
160
+ 'which is why the build reached this point at all. Likely causes:\n' +
161
+ ' - the package is not installed — add it as a dependency and `gjsify install`.\n' +
162
+ ' - a JSX entry with no JSX configuration: oxc then defaults to the automatic runtime with ' +
163
+ '`importSource: "react"`, so the bundle imports `react/jsx-runtime`. Point ' +
164
+ '`gjsify.bundler.transform.jsx.importSource` (or tsconfig `"jsxImportSource"`) at a runtime ' +
165
+ 'this project HAS, or use `"jsx": "preserve"` plus the framework compiler that consumes it.\n' +
166
+ ' - the specifier is meant to be provided by the host at runtime — then say so, with ' +
167
+ '`gjsify.bundler.external`.');
168
+ }
169
+ /**
170
+ * Throw when a written `--app gjs` chunk is not parseable JavaScript because raw JSX
171
+ * survived into it. See `@gjsify/rolldown-plugin-gjsify`'s `utils/jsx-survival.ts` for
172
+ * why the oracle is a parse and not a pattern, and for the measurement.
173
+ *
174
+ * Called ONLY when the effective JSX setting PRESERVES JSX, which is the only way it can
175
+ * reach an artifact: oxc compiles it otherwise. That is not a cost dodge but the precise
176
+ * condition — though the cost is real, and measured: parsing the repo's largest bundle
177
+ * (`dist/cli.gjs.mjs`, 6.8 MB) takes ~850 ms, which is not worth paying on every GJS
178
+ * build to re-answer a question the transform already settled.
179
+ */
180
+ export function assertGjsBundleParses(chunks, label) {
181
+ for (const chunk of chunks) {
182
+ const code = chunk.code;
183
+ if (typeof code !== 'string' || code.length === 0)
184
+ continue;
185
+ const found = locateSurvivingJsx(code);
186
+ if (found === null)
187
+ continue;
188
+ throw new Error(formatSurvivingJsx(found, chunk.fileName ? `${label} (${chunk.fileName})` : label));
189
+ }
81
190
  }
@@ -0,0 +1,45 @@
1
+ export interface JsxConfigInput {
2
+ /** `gjsify.bundler.transform.jsx`, exactly as configured (any of oxc's shapes). */
3
+ transformJsx?: unknown;
4
+ /** tsconfig `compilerOptions.jsx`. */
5
+ tsconfigJsx?: string | undefined;
6
+ /** tsconfig `compilerOptions.jsxImportSource` — names the runtime on its own. */
7
+ tsconfigJsxImportSource?: string | undefined;
8
+ }
9
+ export interface JsxConfigVerdict {
10
+ /** Did anything at all name a JSX policy? `false` means oxc's react default applies. */
11
+ configured: boolean;
12
+ /** Will JSX survive the transform, so that a compiler downstream must consume it? */
13
+ preserves: boolean;
14
+ }
15
+ /**
16
+ * Read the effective JSX policy.
17
+ *
18
+ * `configured` is deliberately satisfied by ANY value, `false` and an unrecognised string
19
+ * included: the gate's subject is a project that said nothing, not one whose setting this
20
+ * function fails to recognise. `transform.jsx: false` already fails the build loudly from
21
+ * inside oxc (measured: exit 1), which is a legitimate way to answer the question.
22
+ */
23
+ export declare function describeJsxConfig(input: JsxConfigInput): JsxConfigVerdict;
24
+ /**
25
+ * Flatten Rolldown's three `input` shapes (string, array, name→path record) into the
26
+ * paths themselves. Takes `unknown` on purpose: the caller holds a post-glob-expansion
27
+ * `EntryPoints` from the bundler package, and this module has no business importing that
28
+ * type to look at strings.
29
+ */
30
+ export declare function collectEntryPaths(entryPoints: unknown): string[];
31
+ /** The first entry point whose extension carries JSX, or `undefined`. */
32
+ export declare function findJsxEntryPoint(entryPoints: readonly string[]): string | undefined;
33
+ /**
34
+ * The refusal. Names the file, what the default would do to it, and the routes that answer
35
+ * the question — PRESERVE-plus-compiler first, because that is the one a GTK host wants:
36
+ * `@gjsify/gtk-host/jsx-runtime` is a TYPE surface whose `jsx()`/`jsxs()` THROW on purpose
37
+ * ("a TYPE surface, not an automatic JSX runtime"), so naming it as an `importSource`
38
+ * trades a build that fails for a build that dies at the first element. An error message
39
+ * that recommends the wrong fix is worse than one that recommends none.
40
+ *
41
+ * Scoped to `--app gjs` by its caller: on `--app node`/`--app browser` the react default
42
+ * is a legitimate answer for a project that has react installed, and refusing there would
43
+ * break real builds for a mistake they did not make.
44
+ */
45
+ export declare function jsxConfigMissingError(entry: string): Error;
@@ -0,0 +1,98 @@
1
+ // The pre-build JSX question: has this project configured JSX AT ALL, and does what it
2
+ // configured PRESERVE JSX?
3
+ //
4
+ // Neither answer is available from the bundler, and both decide whether a `--app gjs`
5
+ // build can produce anything loadable:
6
+ //
7
+ // - NOTHING configured. oxc's transformer then applies its own default — the AUTOMATIC
8
+ // runtime with `importSource: 'react'` — so a `.tsx` entry emits
9
+ // `import { jsx } from "react/jsx-runtime"`. Rolldown reports the miss as an
10
+ // `UNRESOLVED_IMPORT` WARNING, re-emits the specifier, and the build exits 0; `gjs -m`
11
+ // on the artifact then dies with `ImportError: Module not found: react/jsx-runtime`.
12
+ // MEASURED end to end on a two-line `.tsx` (exit 0, then exit 1 under gjs). The
13
+ // artifact gate in `gjs-bundle-guard.ts` catches it too — but AFTER a full bundle, and
14
+ // it can only name `react/jsx-runtime`, not the `.tsx` the reader has to fix. And the
15
+ // default is never right for this target: `react/jsx-runtime` builds React elements,
16
+ // which render NOTHING on a GTK host even on the project that happens to have react
17
+ // installed.
18
+ // - `preserve` configured. JSX is meant to survive the transform so a framework compiler
19
+ // can take it (`babel-preset-solid`, the Vue SFC compiler). Whether one is REGISTERED
20
+ // is not knowable here — a plugin declares no JSX capability — so this only reports
21
+ // that JSX may survive, and the post-bundle syntax check decides.
22
+ //
23
+ // Both `gjsify.bundler.transform.jsx` and tsconfig `compilerOptions.jsx` reach oxc, and
24
+ // the explicit `transform.jsx` WINS: measured with tsconfig `"jsx": "react-jsx"` +
25
+ // `"jsxImportSource": "solid-js"` and `bundler.transform.jsx: "preserve"` — the artifact
26
+ // kept raw JSX. Measured too, and the reason `preserve` is matched literally rather than
27
+ // by "TS spellings that keep JSX": tsconfig `"jsx": "react-native"`, which TypeScript
28
+ // documents as preserving, is NOT preserved by oxc — it compiled to runtime calls.
29
+ /** The JSX-bearing extensions oxc classifies as JSX-capable. */
30
+ const JSX_SOURCE_RE = /\.(?:jsx|tsx|mjsx|cjsx|mtsx|ctsx)$/i;
31
+ /**
32
+ * Read the effective JSX policy.
33
+ *
34
+ * `configured` is deliberately satisfied by ANY value, `false` and an unrecognised string
35
+ * included: the gate's subject is a project that said nothing, not one whose setting this
36
+ * function fails to recognise. `transform.jsx: false` already fails the build loudly from
37
+ * inside oxc (measured: exit 1), which is a legitimate way to answer the question.
38
+ */
39
+ export function describeJsxConfig(input) {
40
+ const { transformJsx, tsconfigJsx, tsconfigJsxImportSource } = input;
41
+ const configured = transformJsx !== undefined || tsconfigJsx !== undefined || tsconfigJsxImportSource !== undefined;
42
+ const preserves = transformJsx !== undefined ? transformJsx === 'preserve' : tsconfigJsx === 'preserve';
43
+ return { configured, preserves };
44
+ }
45
+ /**
46
+ * Flatten Rolldown's three `input` shapes (string, array, name→path record) into the
47
+ * paths themselves. Takes `unknown` on purpose: the caller holds a post-glob-expansion
48
+ * `EntryPoints` from the bundler package, and this module has no business importing that
49
+ * type to look at strings.
50
+ */
51
+ export function collectEntryPaths(entryPoints) {
52
+ if (typeof entryPoints === 'string')
53
+ return [entryPoints];
54
+ if (Array.isArray(entryPoints))
55
+ return entryPoints.filter((e) => typeof e === 'string');
56
+ if (entryPoints !== null && typeof entryPoints === 'object') {
57
+ return Object.values(entryPoints).filter((e) => typeof e === 'string');
58
+ }
59
+ return [];
60
+ }
61
+ /** The first entry point whose extension carries JSX, or `undefined`. */
62
+ export function findJsxEntryPoint(entryPoints) {
63
+ return entryPoints.find((entry) => JSX_SOURCE_RE.test(entry));
64
+ }
65
+ /**
66
+ * The refusal. Names the file, what the default would do to it, and the routes that answer
67
+ * the question — PRESERVE-plus-compiler first, because that is the one a GTK host wants:
68
+ * `@gjsify/gtk-host/jsx-runtime` is a TYPE surface whose `jsx()`/`jsxs()` THROW on purpose
69
+ * ("a TYPE surface, not an automatic JSX runtime"), so naming it as an `importSource`
70
+ * trades a build that fails for a build that dies at the first element. An error message
71
+ * that recommends the wrong fix is worse than one that recommends none.
72
+ *
73
+ * Scoped to `--app gjs` by its caller: on `--app node`/`--app browser` the react default
74
+ * is a legitimate answer for a project that has react installed, and refusing there would
75
+ * break real builds for a mistake they did not make.
76
+ */
77
+ export function jsxConfigMissingError(entry) {
78
+ return new Error(`gjsify build --app gjs: ${entry} contains JSX, but nothing configures a JSX transform — not ` +
79
+ '`gjsify.bundler.transform.jsx`, and not tsconfig `compilerOptions.jsx`.\n\n' +
80
+ "Left unset, the bundler's transformer defaults to the automatic runtime with " +
81
+ '`importSource: "react"`, so the bundle would import `react/jsx-runtime`. GJS cannot resolve a ' +
82
+ 'bare specifier at all (no node_modules walker, no `exports` map), so the build would exit 0 with ' +
83
+ 'an UNRESOLVED_IMPORT warning and the artifact would abort at load with "ImportError: Module not ' +
84
+ 'found: react/jsx-runtime" — and where react IS installed it would render React elements, which a ' +
85
+ 'GTK host does nothing with.\n\n' +
86
+ 'Configure one of:\n' +
87
+ ' - PRESERVED JSX plus the framework compiler that consumes it — the route a GTK host wants.\n' +
88
+ ' Set `"jsx": "preserve"` (in `gjsify.bundler.transform.jsx` or tsconfig\n' +
89
+ ' `compilerOptions.jsx`) and register the compiler under `gjsify.bundler.plugins`:\n' +
90
+ ' `babel-preset-solid` for Solid, the Vue SFC compiler for Vue. Pair it with tsconfig\n' +
91
+ ' `"jsxImportSource": "@gjsify/gtk-host"` for the TYPES.\n' +
92
+ ' - an automatic JSX runtime this project actually has:\n' +
93
+ ' "bundler": { "transform": { "jsx": { "importSource": "<pkg exporting ./jsx-runtime>" } } }\n' +
94
+ ' or the same in tsconfig: `"jsx": "react-jsx"` + `"jsxImportSource": "<pkg>"`. NOT\n' +
95
+ ' `@gjsify/gtk-host` — its `/jsx-runtime` is a TYPE surface and throws when called.\n' +
96
+ ' - `"jsx": false`, if this entry should contain no JSX at all: the transformer then\n' +
97
+ ' reports the JSX itself instead of compiling it for a runtime that is not there.');
98
+ }
@@ -1,4 +1,7 @@
1
+ import type { SpawnCompletionContract } from './spawn.js';
1
2
  export interface RunNodeScriptOptions {
3
+ /** The caller's teardown contract, forwarded verbatim — see `RunGjsBundleOptions.completion`. */
4
+ completion: SpawnCompletionContract;
2
5
  /** Exit the process when the script succeeds. See `RunGjsBundleOptions.exitOnSuccess`. */
3
6
  exitOnSuccess?: boolean;
4
7
  }
@@ -9,4 +12,4 @@ export interface RunNodeScriptOptions {
9
12
  * Terminal callers pass `exitOnSuccess` (a GJS main loop armed by the spawn
10
13
  * parks the process otherwise); see {@link runGjsBundle}.
11
14
  */
12
- export declare function runNodeScript(file: string, extraArgs?: string[], options?: RunNodeScriptOptions): Promise<void>;
15
+ export declare function runNodeScript(file: string, extraArgs: string[] | undefined, options: RunNodeScriptOptions): Promise<void>;
@@ -59,7 +59,7 @@ function scriptCacheEnabled() {
59
59
  * Terminal callers pass `exitOnSuccess` (a GJS main loop armed by the spawn
60
60
  * parks the process otherwise); see {@link runGjsBundle}.
61
61
  */
62
- export async function runNodeScript(file, extraArgs = [], options = {}) {
62
+ export async function runNodeScript(file, extraArgs = [], options) {
63
63
  const scriptPath = resolve(file);
64
64
  if (!existsSync(scriptPath) || !statSync(scriptPath).isFile()) {
65
65
  console.error(`gjsify run --node-script: no such file: ${scriptPath}`);
@@ -72,7 +72,11 @@ export async function runNodeScript(file, extraArgs = [], options = {}) {
72
72
  // differ from what the runtime would have done itself. `quiet` because the echo
73
73
  // would print `node <file>` — what the package.json script already reads — behind
74
74
  // ~2 kB of native-library env, a dozen times per build chain.
75
- await runRuntimeBundle(host, scriptPath, extraArgs, { exitOnSuccess: options.exitOnSuccess, quiet: true });
75
+ await runRuntimeBundle(host, scriptPath, extraArgs, {
76
+ completion: options.completion,
77
+ exitOnSuccess: options.exitOnSuccess,
78
+ quiet: true,
79
+ });
76
80
  return;
77
81
  }
78
82
  // Dynamic import keeps the bundler machinery out of the eager module graph
@@ -90,6 +94,22 @@ export async function runNodeScript(file, extraArgs = [], options = {}) {
90
94
  define: { 'import.meta.url': JSON.stringify(pathToFileURL(scriptPath).href) },
91
95
  globals,
92
96
  excludeGlobals,
97
+ // OPT-IN, and the widest of the three call sites — say what it does and
98
+ // does not mean. This function is reached through the `node` shim `gjsify
99
+ // run` puts on the PATH, so it also bundles a consumer's
100
+ // `"start": "node ./src/main.mjs"`, not only a repo build script. The
101
+ // discriminator is therefore "the CLI is bundling this on the project's
102
+ // behalf", NOT "this is toolchain".
103
+ //
104
+ // Narrowing it to the CLI's own tree was considered and does not work: in
105
+ // the ADR 0002 bootstrap the CLI is a GLOBAL install (`install.mjs` →
106
+ // `gjsify install -g`) and the script is in the user's clone, so no
107
+ // containment test relates them. What contains the hazard instead is
108
+ // PROJECT FIRST (a resolvable dependency is never shadowed) plus the
109
+ // `this.warn` the guard emits on every rescue, which names the package and
110
+ // the anchor — so a mixed artifact is visible in the build log rather than
111
+ // byte-identical to a healthy one.
112
+ resolveFromToolchain: true,
93
113
  });
94
114
  }
95
115
  catch (err) {
@@ -99,5 +119,9 @@ export async function runNodeScript(file, extraArgs = [], options = {}) {
99
119
  }
100
120
  // Quiet for the same reason as the node path above; the bundle's location is
101
121
  // deterministic (`node_modules/.cache/gjsify/node-scripts/<name>-<hash>/<name>.mjs`).
102
- await runGjsBundle(bundlePath, extraArgs, { exitOnSuccess: options.exitOnSuccess, quiet: true });
122
+ await runGjsBundle(bundlePath, extraArgs, {
123
+ completion: options.completion,
124
+ exitOnSuccess: options.exitOnSuccess,
125
+ quiet: true,
126
+ });
103
127
  }
@@ -80,11 +80,14 @@ export async function fetchGithubOidcToken(audience, log) {
80
80
  throw new OidcUnavailableError('GitHub Actions OIDC env vars (ACTIONS_ID_TOKEN_REQUEST_{URL,TOKEN}) not set. ' +
81
81
  'The calling workflow needs `permissions: id-token: write`.', 'no-env');
82
82
  }
83
- // Append the audience by STRING CONCATENATION, as @actions/core's getIDToken does. Do NOT
84
- // use `URL.searchParams.set` + `.href`: under GJS that serialization dropped the `audience`
85
- // param, so GitHub issued an id-token with the DEFAULT audience (the repo API URL) and npm
86
- // rejected an otherwise-valid token with 401 — repository/workflow_ref/sub all matched the
87
- // Trusted Publisher, only `aud` was wrong.
83
+ // Append the audience by STRING CONCATENATION, as @actions/core's getIDToken does.
84
+ //
85
+ // fixed upstream in gjsify: `URL.searchParams.set` + `.href` used to DROP the parameter under
86
+ // GJS — the mutation never reached the serialisation — so GitHub issued an id-token with the
87
+ // DEFAULT audience (the repo API URL) and npm rejected an otherwise-valid token with 401:
88
+ // repository/workflow_ref/sub all matched the Trusted Publisher, only `aud` was wrong. The
89
+ // URL live view now works, so the ban is lifted; the concatenation stays because it is what
90
+ // the reference implementation does, not because the alternative is broken.
88
91
  const sep = url.includes('?') ? '&' : '?';
89
92
  const requestUrl = `${url}${sep}audience=${encodeURIComponent(audience)}`;
90
93
  log?.(`gjsify oidc: GET ${requestUrl.replace(bearer, '<bearer>')}`);
@@ -26,8 +26,31 @@ export interface ProbeOptions {
26
26
  retryDelayMs?: number;
27
27
  /** Injected delay (tests pass a no-op). Default: real `setTimeout`. */
28
28
  sleep?: (ms: number) => Promise<void>;
29
+ /**
30
+ * Does this name exist on the registry? The PACKUMENT oracle — see
31
+ * {@link packumentExists}. Tests inject it; production uses the real fetch.
32
+ */
33
+ exists?: (registry: string, name: string) => Promise<boolean | null>;
29
34
  }
30
35
  export declare const PROBE_RETRY_DELAY_MS = 400;
36
+ /**
37
+ * Does the registry serve a packument for this name?
38
+ *
39
+ * THE ORACLE, and the reason it exists: npm's trust endpoint answers `2xx` with an
40
+ * EMPTY LIST for a name that was never published. `classifyTrustList` reads that as
41
+ * `untrusted`, so `gjsify onboard` planned "already on npm, just needs trust" for a
42
+ * package that did not exist — it trusted the name, reported `0 to publish+trust`
43
+ * and published NOTHING. A bootstrap command reporting its own gap as closed, and
44
+ * it is why the v0.36.0 repair failed twice before anyone looked at the registry.
45
+ *
46
+ * `GET <registry>/<name>` is unauthenticated and needs no OTP, so it answers in the
47
+ * one situation that matters: a `--dry-run` before the maintainer has an OTP to
48
+ * hand, where every trust read is `401` and the plan is otherwise empty.
49
+ *
50
+ * `null` means UNDECIDED (network or an unexpected status) — never "absent". A
51
+ * bootstrap that publishes on a failed probe is worse than one that reports blocked.
52
+ */
53
+ export declare function packumentExists(registry: string, name: string): Promise<boolean | null>;
31
54
  /** Default probe concurrency — kept SMALL so a single token never bursts npm. */
32
55
  export declare const DEFAULT_PROBE_CONCURRENCY = 4;
33
56
  /** Map a classified trust state to the action the onboard sweep should take. */
@@ -11,6 +11,43 @@
11
11
  import { DEFAULT_REGISTRY, registryFor } from '@gjsify/npm-registry';
12
12
  import { classifyTrustList, trustUrl } from './trust-registry.js';
13
13
  export const PROBE_RETRY_DELAY_MS = 400;
14
+ /**
15
+ * Does the registry serve a packument for this name?
16
+ *
17
+ * THE ORACLE, and the reason it exists: npm's trust endpoint answers `2xx` with an
18
+ * EMPTY LIST for a name that was never published. `classifyTrustList` reads that as
19
+ * `untrusted`, so `gjsify onboard` planned "already on npm, just needs trust" for a
20
+ * package that did not exist — it trusted the name, reported `0 to publish+trust`
21
+ * and published NOTHING. A bootstrap command reporting its own gap as closed, and
22
+ * it is why the v0.36.0 repair failed twice before anyone looked at the registry.
23
+ *
24
+ * `GET <registry>/<name>` is unauthenticated and needs no OTP, so it answers in the
25
+ * one situation that matters: a `--dry-run` before the maintainer has an OTP to
26
+ * hand, where every trust read is `401` and the plan is otherwise empty.
27
+ *
28
+ * `null` means UNDECIDED (network or an unexpected status) — never "absent". A
29
+ * bootstrap that publishes on a failed probe is worse than one that reports blocked.
30
+ */
31
+ export async function packumentExists(registry, name) {
32
+ const base = registry.endsWith('/') ? registry.slice(0, -1) : registry;
33
+ try {
34
+ // `Accept` narrows the response to the abbreviated packument: the full one
35
+ // for a package like `@gjsify/cli` is megabytes, and existence is all we ask.
36
+ const res = await fetch(`${base}/${name.replace('/', '%2f')}`, {
37
+ method: 'GET',
38
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
39
+ });
40
+ if (res.status === 404)
41
+ return false;
42
+ if (res.ok)
43
+ return true;
44
+ return null;
45
+ }
46
+ catch {
47
+ // A DNS or TLS failure is not evidence of absence.
48
+ return null;
49
+ }
50
+ }
14
51
  /** Default probe concurrency — kept SMALL so a single token never bursts npm. */
15
52
  export const DEFAULT_PROBE_CONCURRENCY = 4;
16
53
  /** Map a classified trust state to the action the onboard sweep should take. */
@@ -48,6 +85,34 @@ export async function probeTrustState(request, ws, ctx, opts = {}) {
48
85
  res = await request('GET', url);
49
86
  state = classifyTrustList(res.status, res.json, classifyOpts);
50
87
  }
88
+ // `untrusted` is the ONE ambiguous answer: the trust endpoint returns `2xx` with
89
+ // an empty list both for "published, nobody trusted it" and for "no such name".
90
+ // Only that case pays for the extra read — `trusted` cannot be a missing package
91
+ // and `unpublished` already has its answer.
92
+ // Two questions, two endpoints. The trust list answers "who may publish this",
93
+ // and onboard also needs "does this name exist at all" — which it can only
94
+ // INFER from the trust list, and infers wrongly in both ambiguous states:
95
+ //
96
+ // untrusted npm serves 2xx and an EMPTY list for a name that was never
97
+ // published — byte-identical to a real package that simply has
98
+ // no trusted publisher configured.
99
+ // auth-required a 401 says nothing whatsoever about existence. It is also
100
+ // where EVERY package lands in a `--dry-run` run before the
101
+ // maintainer has an OTP to hand, which is exactly the moment
102
+ // the plan is being read to decide whether to proceed.
103
+ //
104
+ // The packument answers the question directly and unauthenticated, and a 404
105
+ // there is proof no OTP could change. Only these two states pay for the extra
106
+ // read: `trusted` cannot be a missing package, and `unpublished` already has
107
+ // its answer from the endpoint it just asked.
108
+ if (state === 'untrusted' || state === 'auth-required') {
109
+ const exists = await (opts.exists ?? packumentExists)(registry, ws.name);
110
+ // ONLY a definitive 404 reclassifies. `null` — network failure, unexpected
111
+ // status — leaves the state exactly as it was: a bootstrap that publishes
112
+ // on a failed read is worse than one that reports itself blocked.
113
+ if (exists === false)
114
+ state = 'unpublished';
115
+ }
51
116
  return { ws, registry, url, state, httpStatus: res.status, action: actionForState(state) };
52
117
  }
53
118
  /**
@@ -1,4 +1,5 @@
1
1
  import { type NativeEnv } from './detect-native-packages.js';
2
+ import { type SpawnCompletionContract } from './spawn.js';
2
3
  /**
3
4
  * Pure env computation: the typelib + shared-library search paths {@link runGjsBundle} would
4
5
  * inject into the spawned `gjs`, plus the formatted prefix for the `$ …` echo. Which library
@@ -15,6 +16,16 @@ export declare function computeNativeEnvForBundle(bundlePath: string, cwd?: stri
15
16
  * bundle's own node_modules tree.
16
17
  */
17
18
  export interface RunGjsBundleOptions {
19
+ /**
20
+ * The teardown contract of the command that will end this process — the same field,
21
+ * and the same reason, `spawnToCompletion` demands. Required rather than defaulted:
22
+ * absent, a caller silently inherited the side that ARMS the GJS main loop.
23
+ *
24
+ * Distinct from {@link exitOnSuccess}, which asks WHO performs the exit. `gjsify test`
25
+ * is `'exit'` — its handler exits at the end of `commands/test.ts` — while leaving
26
+ * `exitOnSuccess` off, because an exit HERE would truncate its multi-runtime loop.
27
+ */
28
+ completion: SpawnCompletionContract;
18
29
  /**
19
30
  * Exit this process with code 0 once the child succeeds. Under GJS, `ensureMainLoop()`
20
31
  * (armed by spawn) keeps the parent's GLib loop alive after the child exits, so a TERMINAL
@@ -31,4 +42,4 @@ export interface RunGjsBundleOptions {
31
42
  */
32
43
  quiet?: boolean;
33
44
  }
34
- export declare function runGjsBundle(bundlePath: string, extraArgs?: string[], options?: RunGjsBundleOptions): Promise<void>;
45
+ export declare function runGjsBundle(bundlePath: string, extraArgs: string[] | undefined, options: RunGjsBundleOptions): Promise<void>;
@@ -9,9 +9,9 @@
9
9
  //
10
10
  // Env composition is split out as the pure `computeNativeEnvForBundle()` so the e2e tests can
11
11
  // assert the env without spawning gjs.
12
- import { spawn } from 'node:child_process';
13
12
  import { dirname, resolve } from 'node:path';
14
13
  import { detectNativePackages, buildNativeEnv } from './detect-native-packages.js';
14
+ import { describeExit, spawnToCompletion } from './spawn.js';
15
15
  /**
16
16
  * Pure env computation: the typelib + shared-library search paths {@link runGjsBundle} would
17
17
  * inject into the spawned `gjs`, plus the formatted prefix for the `$ …` echo. Which library
@@ -40,7 +40,7 @@ export function computeNativeEnvForBundle(bundlePath, cwd = process.cwd(), inher
40
40
  .join(' ');
41
41
  return { env, envPrefix };
42
42
  }
43
- export async function runGjsBundle(bundlePath, extraArgs = [], options = {}) {
43
+ export async function runGjsBundle(bundlePath, extraArgs = [], options) {
44
44
  const { env: nativeEnv, envPrefix } = computeNativeEnvForBundle(bundlePath);
45
45
  const env = {
46
46
  ...process.env,
@@ -52,36 +52,29 @@ export async function runGjsBundle(bundlePath, extraArgs = [], options = {}) {
52
52
  const gjsCommand = ['gjs', ...gjsArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
53
53
  if (!options.quiet)
54
54
  console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${gjsCommand}`);
55
- const child = spawn('gjs', gjsArgs, { env, stdio: 'inherit' });
56
- let failed = false;
57
- // The CHILD's own exit code, re-raised verbatim; a spawn error (no `gjs` on PATH) has none
58
- // of its own and falls back to 1. Collapsing every failure to 1 lost information callers
59
- // act on — a chain reading `$?` to tell "tool failed" from "tool says no" saw 1 for both.
60
- let childCode = 1;
61
- await new Promise((resolvePromise, reject) => {
62
- child.on('close', (code) => {
63
- if (code !== 0) {
64
- childCode = code ?? 1;
65
- reject(new Error(`gjs exited with code ${code}`));
66
- }
67
- else {
68
- resolvePromise();
69
- }
70
- });
71
- child.on('error', reject);
72
- }).catch((err) => {
55
+ // The CHILD's own exit code is re-raised verbatim; a spawn error (no `gjs` on PATH) has
56
+ // none of its own and falls back to 1. Collapsing every failure to 1 lost information
57
+ // callers act on — a chain reading `$?` to tell "tool failed" from "tool says no" saw 1
58
+ // for both.
59
+ const failed = (code) => {
60
+ // `process.exitCode` is set BEFORE exiting because `gjsify run <script>` dispatches a
61
+ // nested `gjsify run <bundle>` IN PROCESS: the outer command reads `process.exitCode`
62
+ // to propagate. Without it a failing `test:gjs` returned 0 through the `test` chain,
63
+ // and through `gjsify foreach test` in CI. The `return` guards a fall-through exit.
64
+ process.exitCode = code;
65
+ return process.exit(code);
66
+ };
67
+ let result;
68
+ try {
69
+ result = await spawnToCompletion('gjs', gjsArgs, { completion: options.completion, env });
70
+ }
71
+ catch (err) {
73
72
  console.error(err.message);
74
- failed = true;
75
- });
76
- if (failed) {
77
- // Set `process.exitCode` synchronously BEFORE exiting: under GJS `process.exit()` is
78
- // deferred (the GLib main loop is armed), so under run.ts's in-process script dispatch
79
- // execution returns to the caller, which propagates by reading `process.exitCode`.
80
- // Without it, `gjsify run <script>` wrapping `gjsify run <bundle>` swallowed a non-zero
81
- // gjs exit and returned 0 — masking a failing `test:gjs` in the `test` chain, and via
82
- // `gjsify foreach test` in CI. The `return` guards a fall-through second `exit`.
83
- process.exitCode = childCode;
84
- return process.exit(childCode);
73
+ return failed(1);
74
+ }
75
+ if (result.code !== 0) {
76
+ console.error(`gjs exited with ${describeExit(result)}`);
77
+ return failed(result.code ?? 1);
85
78
  }
86
79
  // See RunGjsBundleOptions.exitOnSuccess — terminal callers opt in,
87
80
  // mid-flow callers (test.ts's runtime loop) keep the process alive.