@gjsify/cli 0.42.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.
@@ -1,9 +1,11 @@
1
- /** The one bundler-output field this guard needs, from either engine. */
1
+ /** The bundler-output fields this guard needs, from either engine. */
2
2
  export interface GuardedChunk {
3
3
  /** Emitted chunk name, for the error message. */
4
4
  fileName?: string;
5
5
  /** External specifiers the chunk STATICALLY imports (rollup/rolldown contract). */
6
6
  imports?: readonly string[];
7
+ /** Emitted source, for the syntax check. Present on a written chunk on both engines. */
8
+ code?: string;
7
9
  }
8
10
  /**
9
11
  * Bare `node:` specifiers a `--app gjs` bundle may keep as static imports.
@@ -20,5 +22,57 @@ export declare const GJS_ALLOWED_NODE_IMPORTS: readonly string[];
20
22
  * allowlist. Sorted, so the error message and any test are stable.
21
23
  */
22
24
  export declare function findDisallowedNodeImports(chunks: readonly GuardedChunk[], allowed?: readonly string[]): string[];
23
- /** Throw when a built `--app gjs` bundle statically imports Node builtins. */
24
- export declare function assertGjsBundleLoadable(chunks: readonly GuardedChunk[], outfile: string): void;
25
+ /**
26
+ * Is `id` a BARE package specifier — the only shape GJS's ESM loader cannot resolve on
27
+ * its own?
28
+ *
29
+ * Relative and absolute paths name a file; anything carrying a URL scheme names a
30
+ * loader GJS either has (`gi:`, `file:`, `resource:`) or will report itself. `node:` is
31
+ * classified BEFORE this, because it has its own message and its own symptom.
32
+ * A win32 drive path (`C:\…`) matches the scheme test, which is the intended answer.
33
+ */
34
+ export declare function isBareSpecifier(id: string): boolean;
35
+ /** What the caller must tell the guard about this build's own promises. */
36
+ export interface GjsImportPolicy {
37
+ /**
38
+ * The target's own externals predicate — pass
39
+ * `createGjsExternalsPredicate(userExternal)`, the same one `externalsPlugin`
40
+ * enforces, so the guard and the build cannot disagree about what was declared.
41
+ * Omitted, nothing counts as declared.
42
+ */
43
+ isExternal?: (id: string) => boolean;
44
+ /**
45
+ * Every fileName the build emitted. `chunk.imports` lists sibling CHUNKS by fileName
46
+ * with no `./` prefix, which is indistinguishable from a bare package specifier — so
47
+ * the set is excluded by name rather than guessed at by shape.
48
+ */
49
+ emitted?: readonly string[];
50
+ }
51
+ /**
52
+ * The distinct bare specifiers `chunks` statically import that nothing in this build
53
+ * promised to provide. Sorted, so the error message and any test are stable.
54
+ */
55
+ export declare function findUnresolvableBareImports(chunks: readonly GuardedChunk[], policy?: GjsImportPolicy): string[];
56
+ /**
57
+ * Throw when a built `--app gjs` bundle statically imports something GJS cannot
58
+ * resolve — a bare `node:` builtin, or any other bare specifier this build did not
59
+ * declare external.
60
+ *
61
+ * The two are reported separately because they have different symptoms and different
62
+ * fixes: a `node:` specifier aborts with "Unsupported URI scheme", any other bare one
63
+ * with "Module not found", and the first means an alias target failed while the second
64
+ * means the dependency is simply not there.
65
+ */
66
+ export declare function assertGjsBundleLoadable(chunks: readonly GuardedChunk[], outfile: string, policy?: GjsImportPolicy): void;
67
+ /**
68
+ * Throw when a written `--app gjs` chunk is not parseable JavaScript because raw JSX
69
+ * survived into it. See `@gjsify/rolldown-plugin-gjsify`'s `utils/jsx-survival.ts` for
70
+ * why the oracle is a parse and not a pattern, and for the measurement.
71
+ *
72
+ * Called ONLY when the effective JSX setting PRESERVES JSX, which is the only way it can
73
+ * reach an artifact: oxc compiles it otherwise. That is not a cost dodge but the precise
74
+ * condition — though the cost is real, and measured: parsing the repo's largest bundle
75
+ * (`dist/cli.gjs.mjs`, 6.8 MB) takes ~850 ms, which is not worth paying on every GJS
76
+ * build to re-answer a question the transform already settled.
77
+ */
78
+ export declare function assertGjsBundleParses(chunks: readonly GuardedChunk[], label: string): void;
@@ -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
+ }
@@ -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,7 +1,7 @@
1
- import type { PayloadEntry } from './payload.js';
2
- import type { ShipSettings } from './types.js';
1
+ import { type PayloadEntry } from './payload.js';
2
+ import type { PackSettings } from './types.js';
3
3
  export interface DebInputs {
4
- settings: ShipSettings;
4
+ settings: PackSettings;
5
5
  /** The staged payload, prefix-relative. */
6
6
  payload: readonly PayloadEntry[];
7
7
  /** Install prefix, e.g. `/usr`. */
@@ -20,8 +20,9 @@ import { createArArchive } from './ar.js';
20
20
  import { formatDebDepend } from './depends.js';
21
21
  import { gzipDeterministic } from './gzip.js';
22
22
  import { renderDebScripts } from './scripts.js';
23
+ import { readPayloadFacts } from './payload.js';
23
24
  export async function buildDeb(inputs) {
24
- const { settings, payload, mtime } = inputs;
25
+ const { payload, mtime } = inputs;
25
26
  const prefix = inputs.prefix.replace(/^\/+/, '');
26
27
  const named = payload.map((entry) => ({ ...entry, tarPath: `./${posix.join(prefix, entry.path)}` }));
27
28
  const directories = ancestorDirectories(named.map((entry) => entry.tarPath));
@@ -44,7 +45,10 @@ export async function buildDeb(inputs) {
44
45
  { name: './control', body: control, mode: 0o644, mtime },
45
46
  { name: './md5sums', body: md5sums, mode: 0o644, mtime },
46
47
  ];
47
- const maintainerScripts = renderDebScripts(settings, inputs.prefix);
48
+ // From the PAYLOAD, not the settings: the maintainer scripts refresh exactly the
49
+ // directories this package wrote into, and since ADR 0024 § A2 this packer also runs on a
50
+ // host that has the staged tree and no project to ask.
51
+ const maintainerScripts = renderDebScripts(readPayloadFacts(payload), inputs.prefix);
48
52
  for (const [name, body] of Object.entries(maintainerScripts)) {
49
53
  // dpkg refuses a maintainer script outside 0555–0775 with the mode in
50
54
  // the error, so this is 0755 rather than "whatever the source had".
@@ -14,16 +14,19 @@ import type { FormatId } from './types.js';
14
14
  * is how a project whose bundle really does run on older GJS lowers it.
15
15
  */
16
16
  export declare const DEFAULT_GJS_FLOOR = "1.86";
17
- /**
18
- * Warn when the derived floor cannot be satisfied by a released Debian.
19
- * Returns the warning lines; empty when the floor is fine.
20
- */
21
17
  export declare function warnAboutGjsFloor(format: FormatId, floor: string): string[];
22
18
  export interface DependsInputs {
23
19
  /** GI namespaces the bundle imports, in `Ns-Version` spelling. */
24
20
  namespaces: readonly string[];
25
- /** `'app'` installs a desktop entry and icons; `'cli'` does not. */
26
- kind: 'app' | 'cli';
21
+ /**
22
+ * Whether the payload installs into `share/icons/hicolor/`.
23
+ *
24
+ * Was `kind: 'app' | 'cli'`, which is a proxy for the question and not the question:
25
+ * `hicolor-icon-theme` is depended on because it OWNS that directory, so an app that
26
+ * installs no icon used to declare a dependency it never touched. Derived from the payload
27
+ * (`readPayloadFacts`), which is also the only thing the packing host has (ADR 0024 § A2).
28
+ */
29
+ hasIcons: boolean;
27
30
  /** Whether the payload installs GSettings schemas. */
28
31
  hasSchemas: boolean;
29
32
  /** User-supplied additions for this format. */
@@ -35,14 +35,34 @@
35
35
  * is how a project whose bundle really does run on older GJS lowers it.
36
36
  */
37
37
  export const DEFAULT_GJS_FLOOR = '1.86';
38
+ /**
39
+ * The package providing `glib-compile-schemas`, per format.
40
+ *
41
+ * A record, not `format === 'deb' ? … : …`. The ternary reads as a two-way
42
+ * choice and is really "deb, or ELSE rpm's package name" — measured on a third
43
+ * format, `dmg` got `glib2`, at exit 0, in the `Depends:` of a package that has
44
+ * no such thing. Adding a `FormatId` now fails to build here instead.
45
+ */
46
+ const SCHEMA_COMPILER_PACKAGE = { deb: 'libglib2.0-bin', rpm: 'glib2' };
38
47
  /** Debian's `gjs` versions per suite, as measured 2026-08-15 — see {@link warnAboutGjsFloor}. */
39
48
  const DEBIAN_GJS = 'trixie ships 1.82.3 and forky 1.88.1; 1.84 and 1.86 were skipped';
40
49
  /**
41
50
  * Warn when the derived floor cannot be satisfied by a released Debian.
42
51
  * Returns the warning lines; empty when the floor is fine.
43
52
  */
53
+ /**
54
+ * Which formats this Debian-suite warning is ABOUT.
55
+ *
56
+ * A `Record<FormatId, …>` and not `format !== 'deb'`: the negative form answers
57
+ * for every format that will ever exist, and answers "stay quiet" — so a third
58
+ * format would inherit silence about a floor nobody has checked for its distro,
59
+ * with no compile error. As a record, adding a `FormatId` fails to build until
60
+ * someone decides. `rpm` is false because Fedora ships a current GJS, which is a
61
+ * measured fact about Fedora, not a default.
62
+ */
63
+ const GJS_FLOOR_IS_DEBIAN_NEWS = { deb: true, rpm: false };
44
64
  export function warnAboutGjsFloor(format, floor) {
45
- if (format !== 'deb')
65
+ if (!GJS_FLOOR_IS_DEBIAN_NEWS[format])
46
66
  return [];
47
67
  // Only a floor forky ACTUALLY satisfies is quiet. The first cut tested
48
68
  // `>= 1.88.1` and therefore went silent for 1.90 and 2.0 as well — the
@@ -171,7 +191,9 @@ export function deriveDepends(format, inputs) {
171
191
  'have to. (`gjsify.ship.depends.<format>` is for dependencies that are not typelibs at all — it ' +
172
192
  'appends, and deliberately does not silence this.)');
173
193
  }
174
- if (inputs.kind === 'app')
194
+ // The package that owns `/usr/share/icons/hicolor` — so this follows the icons, not the
195
+ // app/cli distinction that used to stand in for them.
196
+ if (inputs.hasIcons)
175
197
  out.push('hicolor-icon-theme');
176
198
  // The package that ships `glib-compile-schemas`, NOT `gsettings-desktop-schemas`
177
199
  // (which ships GNOME's own `org.gnome.desktop.*` schemas and has nothing to
@@ -182,7 +204,7 @@ export function deriveDepends(format, inputs) {
182
204
  // `Gio.Settings.new()` aborts the app — an install that succeeds and an app
183
205
  // that does not start.
184
206
  if (inputs.hasSchemas)
185
- out.push(format === 'deb' ? 'libglib2.0-bin' : 'glib2');
207
+ out.push(SCHEMA_COMPILER_PACKAGE[format]);
186
208
  out.push(...inputs.extra);
187
209
  // Set-dedupe keeps first-insertion order, so `gjs` stays first and the
188
210
  // list is stable across runs.