@gjsify/cli 0.24.2 → 0.26.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 (39) hide show
  1. package/dist/cli.gjs.mjs +189 -187
  2. package/lib/actions/build.d.ts +13 -0
  3. package/lib/actions/build.js +57 -11
  4. package/lib/cli-app.js +7 -10
  5. package/lib/commands/affected.js +14 -2
  6. package/lib/commands/check.js +25 -8
  7. package/lib/commands/create.js +3 -1
  8. package/lib/commands/debug.js +3 -1
  9. package/lib/commands/flatpak/check.js +4 -3
  10. package/lib/commands/flatpak/diff.js +3 -1
  11. package/lib/commands/flatpak/sources.js +4 -7
  12. package/lib/commands/foreach.js +25 -7
  13. package/lib/commands/generate-installer.js +2 -4
  14. package/lib/commands/install.js +15 -7
  15. package/lib/commands/login.js +3 -1
  16. package/lib/commands/onboard.js +3 -6
  17. package/lib/commands/publish.js +8 -11
  18. package/lib/commands/self-update.js +4 -8
  19. package/lib/commands/showcase.d.ts +16 -0
  20. package/lib/commands/showcase.js +57 -26
  21. package/lib/commands/storybook.js +16 -2
  22. package/lib/commands/system-check.js +1 -2
  23. package/lib/commands/test.js +6 -2
  24. package/lib/commands/trust.js +12 -7
  25. package/lib/commands/tsc.js +3 -1
  26. package/lib/commands/uninstall.js +1 -2
  27. package/lib/commands/whoami.js +6 -3
  28. package/lib/utils/dlx-cache.js +4 -5
  29. package/lib/utils/install-backend-native.js +1 -1
  30. package/lib/utils/install-lock.js +5 -7
  31. package/lib/utils/install-tarball-cache.js +5 -8
  32. package/lib/utils/node-bundle-guard.d.ts +16 -0
  33. package/lib/utils/node-bundle-guard.js +69 -0
  34. package/lib/utils/oxc-resolve.js +1 -1
  35. package/lib/utils/prompt.js +6 -1
  36. package/lib/utils/runtimes.d.ts +18 -0
  37. package/lib/utils/runtimes.js +20 -0
  38. package/lib/utils/stdin.js +6 -7
  39. package/package.json +17 -17
@@ -1,4 +1,20 @@
1
1
  import type { Command } from '../types/index.js';
2
+ /**
3
+ * Pinned invocations for the three runners that cache an unpinned bin.
4
+ *
5
+ * The deno line carries TWO flags because deno stacks two independent refusals,
6
+ * and dropping either one leaves the hint broken in exactly the window where it
7
+ * is reached most: `--reload` busts the `DENO_DIR` copy, and `--min-dep-age 0`
8
+ * waives deno's supply-chain policy of refusing any version published within
9
+ * the last 24 h (`minimumDependencyAge`, default `24h`). Without the second
10
+ * flag `@latest` silently resolves to the newest release OLDER THAN A DAY — so
11
+ * a user who hits a bug, is told to run `@latest`, and does, gets the SAME
12
+ * pre-fix binary back and a stale error message with it. Measured twice on one
13
+ * day against 0.25.1 (minutes old) falling back to 0.24.1. `@latest` is kept
14
+ * rather than a baked version so the hint cannot go stale itself; the age
15
+ * waiver is what makes `@latest` mean latest.
16
+ */
17
+ export declare const PIN_HINT: string;
2
18
  interface ShowcaseOptions {
3
19
  name?: string;
4
20
  json: boolean;
@@ -7,7 +7,7 @@ import { ensurePkgDir } from './dlx.js';
7
7
  import { parseSpec } from '../utils/parse-spec.js';
8
8
  import { resolveNodeEntry } from '../utils/resolve-gjs-entry.js';
9
9
  import { runRuntimeBundle } from '../utils/run-node.js';
10
- import { EXAMPLE_RUNTIMES, isExampleRuntime, readDeclaredRuntimes, checkRuntimeSupported, defaultExampleRuntime, } from '../utils/runtimes.js';
10
+ import { EXAMPLE_RUNTIMES, isExampleRuntime, readDeclaredRuntimes, checkRuntimeSupported, defaultExampleRuntime, requiresGjsSystemDeps, } from '../utils/runtimes.js';
11
11
  function readCliVersion() {
12
12
  try {
13
13
  const pkgUrl = new URL('../../package.json', import.meta.url);
@@ -34,11 +34,25 @@ function readCliVersion() {
34
34
  function runningAs(cliVersion) {
35
35
  return cliVersion ? `[gjsify ${cliVersion}] ` : '';
36
36
  }
37
- /** Pinned invocations for the three runners that cache an unpinned bin. */
38
- const PIN_HINT = 'A package runner reuses a CACHED copy of an unpinned bin. Force the current one:\n' +
37
+ /**
38
+ * Pinned invocations for the three runners that cache an unpinned bin.
39
+ *
40
+ * The deno line carries TWO flags because deno stacks two independent refusals,
41
+ * and dropping either one leaves the hint broken in exactly the window where it
42
+ * is reached most: `--reload` busts the `DENO_DIR` copy, and `--min-dep-age 0`
43
+ * waives deno's supply-chain policy of refusing any version published within
44
+ * the last 24 h (`minimumDependencyAge`, default `24h`). Without the second
45
+ * flag `@latest` silently resolves to the newest release OLDER THAN A DAY — so
46
+ * a user who hits a bug, is told to run `@latest`, and does, gets the SAME
47
+ * pre-fix binary back and a stale error message with it. Measured twice on one
48
+ * day against 0.25.1 (minutes old) falling back to 0.24.1. `@latest` is kept
49
+ * rather than a baked version so the hint cannot go stale itself; the age
50
+ * waiver is what makes `@latest` mean latest.
51
+ */
52
+ export const PIN_HINT = 'A package runner reuses a CACHED copy of an unpinned bin. Force the current one:\n' +
39
53
  ' npx @gjsify/cli@latest showcase <name>\n' +
40
54
  ' bunx @gjsify/cli@latest showcase <name>\n' +
41
- ' deno run -A --reload npm:@gjsify/cli@latest showcase <name>';
55
+ ' deno run -A --reload --min-dep-age 0 npm:@gjsify/cli@latest showcase <name>';
42
56
  export const showcaseCommand = {
43
57
  command: 'showcase [name]',
44
58
  description: 'List or run curated gjsify showcase applications.',
@@ -119,33 +133,39 @@ export const showcaseCommand = {
119
133
  // `m_should_exit` core dump. Returning halts the handler here.
120
134
  return process.exit(1);
121
135
  }
136
+ // Resolve the runtime BEFORE the system-dependency gate: that gate is a
137
+ // question ABOUT the runtime, and asking it first gated every showcase
138
+ // on a gjs binary (see `requiresGjsSystemDeps`).
139
+ const runtime = (args.runtime ?? defaultExampleRuntime());
140
+ if (!isExampleRuntime(runtime)) {
141
+ console.error(`Unknown --runtime "${runtime}" (expected: ${EXAMPLE_RUNTIMES.join(', ')}).`);
142
+ // `return` — bare `process.exit` falls through under GJS (see above).
143
+ return process.exit(1);
144
+ }
122
145
  // System dependency check before delegating — only system libs (gjs,
123
- // gtk4, …). The showcase's npm deps (incl. `@gjsify/webgl` with the
146
+ // gtk4, …), and only for a runtime that consumes the `--app gjs`
147
+ // bundle. The showcase's npm deps (incl. `@gjsify/webgl` with the
124
148
  // gwebgl Vala prebuild) are fetched by `gjsify dlx` into the npm
125
149
  // cache, and `runGjsBundle()` picks the prebuild up from the bundle
126
150
  // dir via `detectNativePackages()`. Pre-flight-checking npm deps
127
151
  // here would fail for `npx @gjsify/cli showcase` (no project
128
152
  // node_modules, CLI doesn't dep on the showcase libs).
129
- const results = runMinimalChecks();
130
- const missingHard = results.filter((r) => !r.found && r.severity === 'required');
131
- if (missingHard.length > 0) {
132
- console.error('Missing system dependencies:\n');
133
- for (const dep of missingHard) {
134
- console.error(` ✗ ${dep.name}`);
135
- }
136
- const pm = detectPackageManager();
137
- const cmd = buildInstallCommand(pm, missingHard);
138
- if (cmd) {
139
- console.error(`\nInstall with:\n ${cmd}`);
153
+ if (requiresGjsSystemDeps(runtime)) {
154
+ const results = runMinimalChecks();
155
+ const missingHard = results.filter((r) => !r.found && r.severity === 'required');
156
+ if (missingHard.length > 0) {
157
+ console.error('Missing system dependencies:\n');
158
+ for (const dep of missingHard) {
159
+ console.error(` ✗ ${dep.name}`);
160
+ }
161
+ const pm = detectPackageManager();
162
+ const cmd = buildInstallCommand(pm, missingHard);
163
+ if (cmd) {
164
+ console.error(`\nInstall with:\n ${cmd}`);
165
+ }
166
+ // `return` — bare `process.exit` falls through under GJS (see above).
167
+ return process.exit(1);
140
168
  }
141
- // `return` — bare `process.exit` falls through under GJS (see above).
142
- return process.exit(1);
143
- }
144
- const runtime = (args.runtime ?? defaultExampleRuntime());
145
- if (!isExampleRuntime(runtime)) {
146
- console.error(`Unknown --runtime "${runtime}" (expected: ${EXAMPLE_RUNTIMES.join(', ')}).`);
147
- // `return` — bare `process.exit` falls through under GJS (see above).
148
- return process.exit(1);
149
169
  }
150
170
  const cliVersion = readCliVersion();
151
171
  // Non-gjs runtime: resolve the showcase's `--app node` bundle and run it
@@ -266,6 +286,17 @@ async function runShowcaseOnRuntime(showcase, runtime, cliVersion) {
266
286
  return process.exit(1);
267
287
  }
268
288
  console.log(`Running showcase: ${showcase.name} ${runningAs(cliVersion)}on ${runtime} (${entry})\n`);
269
- // Terminal call — exit on success.
270
- await runRuntimeBundle(runtime, entry, [], { exitOnSuccess: true });
289
+ // Terminal call — exit on success. Failures are reported here rather than
290
+ // thrown: an exception escaping the handler makes yargs dump the command's
291
+ // full `--help` above the message, which buries it.
292
+ try {
293
+ await runRuntimeBundle(runtime, entry, [], { exitOnSuccess: true });
294
+ }
295
+ catch (err) {
296
+ console.error(`${err.message}\n\n` +
297
+ ` The showcase and the \`@gjsify/node-gi\` bridge it needs are both installed by THIS CLI\n` +
298
+ ` (gjsify ${cliVersion ?? '(unknown)'}) into its own cache, so a missing bridge points at the CLI\n` +
299
+ ` rather than at anything in your project.\n\n ${PIN_HINT}`);
300
+ return process.exit(1);
301
+ }
271
302
  }
@@ -136,8 +136,22 @@ export const storybookCommand = {
136
136
  };
137
137
  const initialCount = writeEntry();
138
138
  if (initialCount === 0) {
139
- console.error(`No *.story.ts files found under ${relative(cwd, storiesDir) || storiesDir}`);
140
- process.exit(1);
139
+ // `return process.exit()`: a bare `process.exit()` does NOT halt
140
+ // synchronously under GJS (no atexit, the GLib loop is still
141
+ // armed), so execution fell through to the "Found 0 story file(s)"
142
+ // log and on into the build — and the deferred exit never carried
143
+ // its code, so `gjsify storybook` in a directory with no stories
144
+ // printed BOTH messages and exited 0. Measured: exit 1 under bun,
145
+ // exit 0 under gjs, same command. Same trap as `showcase.ts`.
146
+ console.error(`No *.story.ts files found under ${relative(cwd, storiesDir) || storiesDir}\n\n` +
147
+ ` \`gjsify storybook\` renders the stories of the CURRENT project: it discovers\n` +
148
+ ` \`*.story.ts\` files and launches a component browser over them. Point it at a\n` +
149
+ ` different directory with --stories <dir> (or \`gjsify.storybook.stories\` in\n` +
150
+ ` package.json) if yours live elsewhere.\n\n` +
151
+ ` To SEE a storybook without authoring one first, run the curated Libadwaita\n` +
152
+ ` component browser — it is a showcase, so it needs no project at all:\n\n` +
153
+ ` gjsify showcase adwaita-storybook`);
154
+ return process.exit(1);
141
155
  }
142
156
  console.log(`Found ${initialCount} story file(s) under ${relative(cwd, storiesDir) || '.'} (runtime: ${runtime})`);
143
157
  // In-process build — runtime-agnostic (Node uses npm rolldown, GJS uses
@@ -18,8 +18,7 @@ export const systemCheckCommand = {
18
18
  if (args.json) {
19
19
  console.log(JSON.stringify({ packageManager: pm, deps: results }, null, 2));
20
20
  // Only required deps influence the exit code.
21
- process.exit(missingRequired.length > 0 ? 1 : 0);
22
- return;
21
+ return process.exit(missingRequired.length > 0 ? 1 : 0);
23
22
  }
24
23
  console.log('System dependency check\n');
25
24
  const required = results.filter((r) => r.severity === 'required');
@@ -60,7 +60,9 @@ export const testCommand = {
60
60
  console.error(`[gjsify test] no test entry at ${relative(cwd, entry)} — ` +
61
61
  `add an \`src/test.mts\` that aggregates your \`@gjsify/unit\` suites, ` +
62
62
  `or set \`gjsify.test.entry\` in package.json.`);
63
- process.exit(1);
63
+ // `return` — a bare `process.exit()` is deferred under GJS and the
64
+ // handler would try to build the missing entry anyway.
65
+ return process.exit(1);
64
66
  }
65
67
  const requested = args.runtime === 'gjs'
66
68
  ? ['gjs']
@@ -124,7 +126,9 @@ export const testCommand = {
124
126
  console.log(`[gjsify test] ${summary}`);
125
127
  const anyFailed = results.some((r) => !r.ok);
126
128
  if (anyFailed) {
127
- process.exit(1);
129
+ // `return` — the deferred GJS exit otherwise fell through to the
130
+ // success `process.exit(0)` below, clobbering the failure code.
131
+ return process.exit(1);
128
132
  }
129
133
  // Explicit success exit: under GJS the spawn-armed main loop would
130
134
  // otherwise park this process after the summary. (runGjsBundle
@@ -79,7 +79,9 @@ export const trustCommand = {
79
79
  const npmrc = await loadNpmrc(cwd);
80
80
  if (!hasAnyCredential(npmrc)) {
81
81
  console.error('gjsify trust: no npm token configured. Run `gjsify login` first.');
82
- process.exit(1);
82
+ // `return` — a bare `process.exit()` is deferred under GJS and the
83
+ // handler would keep going without credentials.
84
+ return process.exit(1);
83
85
  }
84
86
  // Pre-flight: a present-but-DEAD token would otherwise surface as a
85
87
  // confusing per-package `401 "You must be logged in to publish
@@ -97,13 +99,13 @@ export const trustCommand = {
97
99
  console.error(`gjsify trust: npm auth check failed (${msg}).\n` +
98
100
  'Your ~/.npmrc token is dead or revoked — refresh it with `npm login`, then re-run.\n' +
99
101
  '(`gjsify trust` uses the ~/.npmrc bearer token directly — unlike `npm trust`, which logs in via the browser.)');
100
- process.exit(1);
101
- return;
102
+ return process.exit(1);
102
103
  }
103
104
  if (!who.username) {
104
105
  console.error('gjsify trust: your npm token appears dead or revoked (registry returned an empty whoami).\n' +
105
106
  'Refresh it with `npm login`, then re-run.');
106
- process.exit(1);
107
+ // `return` — see the deferred-exit note on the guard above.
108
+ return process.exit(1);
107
109
  }
108
110
  console.log(`gjsify trust: authenticated as ${who.username}`);
109
111
  }
@@ -117,11 +119,13 @@ export const trustCommand = {
117
119
  if (!repository) {
118
120
  console.error('gjsify trust: could not determine the GitHub repository.\n' +
119
121
  'Pass --repository owner/repo (no `origin` remote to infer from).');
120
- process.exit(1);
122
+ // `return` — see the deferred-exit note on the guard above.
123
+ return process.exit(1);
121
124
  }
122
125
  if (!validateRepository(repository)) {
123
126
  console.error(`gjsify trust: --repository must be "owner/repo", got "${repository}".`);
124
- process.exit(1);
127
+ // `return` — see the deferred-exit note on the guard above.
128
+ return process.exit(1);
125
129
  }
126
130
  const workflow = normalizeWorkflowFile(args.workflow);
127
131
  const environment = args.environment;
@@ -148,7 +152,8 @@ export const trustCommand = {
148
152
  }).filter((ws) => ws.name);
149
153
  if (selected.length === 0) {
150
154
  console.error('gjsify trust: no publishable packages matched.');
151
- process.exit(1);
155
+ // `return` — see the deferred-exit note on the guard above.
156
+ return process.exit(1);
152
157
  }
153
158
  console.log(`gjsify trust: ${selected.length} package(s) | repo=${repository} workflow=${workflow}`);
154
159
  if (dryRun)
@@ -104,7 +104,9 @@ export const tscCommand = {
104
104
  if (!bundlePath && !nodeTscPath) {
105
105
  console.error('gjsify tsc: neither @gjsify/tsc (GJS bundle) nor npm `typescript` (Node) is installed.');
106
106
  console.error(' Install with: gjsify install --save-dev @gjsify/tsc (or add `typescript`).');
107
- process.exit(1);
107
+ // `return` — a bare `process.exit()` is deferred under GJS and the
108
+ // handler would try to spawn a compiler that is not there.
109
+ return process.exit(1);
108
110
  }
109
111
  // Mirror `gjsify run`'s native-env composition so any future
110
112
  // typescript-the-bundle code path that reaches a native bridge
@@ -46,8 +46,7 @@ export const uninstallCommand = {
46
46
  if (!args.global) {
47
47
  console.error('gjsify uninstall currently only supports --global. ' +
48
48
  'For project-local removal, edit package.json + re-run `gjsify install`.');
49
- process.exit(1);
50
- return;
49
+ return process.exit(1);
51
50
  }
52
51
  const layout = defaultGlobalLayout();
53
52
  const dryRun = args['dry-run'] ?? false;
@@ -69,7 +69,9 @@ export const whoamiCommand = {
69
69
  'Then verify: gjsify whoami',
70
70
  ].join('\n') + '\n');
71
71
  }
72
- process.exit(1);
72
+ // `return` — a bare `process.exit()` is deferred under GJS and the
73
+ // handler would probe the registry without a token.
74
+ return process.exit(1);
73
75
  }
74
76
  let result;
75
77
  try {
@@ -85,8 +87,9 @@ export const whoamiCommand = {
85
87
  process.stderr.write(`gjsify whoami: ${message}\n`);
86
88
  process.stderr.write(`Registry: ${registryClean}\n`);
87
89
  }
88
- process.exit(1);
89
- return; // unreachable but pleases the type narrower
90
+ // `return process.exit` also keeps the type narrower happy: the
91
+ // never-typed call marks this branch as terminated.
92
+ return process.exit(1);
90
93
  }
91
94
  if (result.username && result.username.length > 0) {
92
95
  // Branch 1 — live token.
@@ -92,11 +92,10 @@ export function symlinkSwap(cacheDir, prepareDir) {
92
92
  catch (err) {
93
93
  const code = err.code;
94
94
  if (code === 'EBUSY' || code === 'EPERM' || code === 'EEXIST') {
95
- // Race lost — clean up our tmp and use whoever won.
96
- try {
97
- rmSync(tmpLink);
98
- }
99
- catch { }
95
+ // Race lost — clean up our tmp and use whoever won. force:true is
96
+ // the non-throwing spelling of "remove if still there"; any other
97
+ // failure on our own pid-unique link is real and should surface.
98
+ rmSync(tmpLink, { force: true });
100
99
  return realpathSync(linkPath);
101
100
  }
102
101
  throw err;
@@ -458,7 +458,7 @@ specOrigins) {
458
458
  *
459
459
  * This makes the current single-root-slot behavior honest instead of silent;
460
460
  * the real fix — a per-workspace dedup pass that gives each conflicting
461
- * requester its own nested copy — is Phase D.8 (see STATUS.md).
461
+ * requester its own nested copy — is Phase D.8 (see status/open-todos.md).
462
462
  */
463
463
  function emitTopLevelConflictWarnings(topLevelRanges, root) {
464
464
  for (const [name, ranges] of topLevelRanges) {
@@ -84,13 +84,11 @@ function lockingDisabled() {
84
84
  * we cannot prove abandoned; the mtime staleness budget still applies).
85
85
  */
86
86
  function isPidAlive(pid) {
87
- try {
88
- if (existsSync('/proc/self'))
89
- return existsSync(`/proc/${pid}`);
90
- }
91
- catch {
92
- /* fall through to the signal probe */
93
- }
87
+ // `existsSync` never throws by contract: Node returns false on any error,
88
+ // and the GJS shim is a typeof guard + Gio `query_exists` (both without a
89
+ // throw path in the GIR). Off Linux the `if` is simply false.
90
+ if (existsSync('/proc/self'))
91
+ return existsSync(`/proc/${pid}`);
94
92
  try {
95
93
  process.kill(pid, 0);
96
94
  return true;
@@ -30,6 +30,7 @@
30
30
  // same hex bytes share the same cache entry; that is the invariant pnpm
31
31
  // relies on too. Tarballs without an integrity hash (older registries)
32
32
  // fall through to a no-op cache and download every time.
33
+ import { Buffer } from 'node:buffer';
33
34
  import { existsSync, statSync } from 'node:fs';
34
35
  import { homedir } from 'node:os';
35
36
  import { join } from 'node:path';
@@ -53,14 +54,10 @@ function parseSri(integrity) {
53
54
  return null;
54
55
  const algorithm = integrity.slice(0, dashIdx);
55
56
  const b64 = integrity.slice(dashIdx + 1).replace(/=+$/, '');
56
- // Decode base64 → hex; throws on malformed input which we swallow.
57
- let hex;
58
- try {
59
- hex = Buffer.from(b64, 'base64').toString('hex');
60
- }
61
- catch {
62
- return null;
63
- }
57
+ // Base64 decoding is lenient on every runtime (Node skips invalid chars,
58
+ // @gjsify/buffer's table decode maps them to 0): malformed input yields
59
+ // short/garbage output — caught by the length check below — never a throw.
60
+ const hex = Buffer.from(b64, 'base64').toString('hex');
64
61
  if (hex.length < 4)
65
62
  return null;
66
63
  return { algorithm, hex };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The GJS ambient globals referenced as BARE identifiers in `code`, sorted.
3
+ *
4
+ * Empty for a bundle that only uses the `globalThis.imports` isomorphic-guard
5
+ * shape — that form is a runtime probe, not a dependency, and the detector
6
+ * ignores it for exactly that reason.
7
+ */
8
+ export declare function findUnshimmedGjsGlobals(code: string): string[];
9
+ /**
10
+ * Throw when a `--app node` bundle references GJS ambient globals but was built
11
+ * without the `@gjsify/node-gi/globals` shim.
12
+ *
13
+ * @param code the emitted bundle source
14
+ * @param outfile path of the bundle, for the error message
15
+ */
16
+ export declare function assertNodeBundleGlobalsShimmed(code: string, outfile: string): void;
@@ -0,0 +1,69 @@
1
+ // Build-time guard: a `--app node` bundle that USES the GJS ambient globals
2
+ // must also have been given `@gjsify/node-gi/globals`.
3
+ //
4
+ // `print`/`printerr`/`log`/`logError`/`ARGV`/`imports` exist implicitly under
5
+ // gjs and nowhere else. On the node target they are seeded by the side-effect
6
+ // shim `@gjsify/node-gi/globals`, which the CLI injects when — and only when —
7
+ // `detectNodeGiGlobals` finds one of them in an analysis bundle. Injecting it
8
+ // unconditionally is not an option: it eagerly loads the native node-gi addon,
9
+ // so a cross-platform package's node bundle would stop loading on plain Node.
10
+ //
11
+ // That makes the decision a PREDICTION about an artifact that does not exist
12
+ // yet, and a prediction can be wrong. It was: the analysis pass used to run
13
+ // before the explicit-`--globals` register stub was resolved, and that stub is
14
+ // one of the two signals `isGjsSourceBuild()` reads — so the analysis bundle
15
+ // routed `/register` subpaths to `@gjsify/empty` and emptied `@girs/*` while the
16
+ // REAL build resolved them for real. Every ambient global reachable only through
17
+ // a register module was therefore invisible to the detector. The build exited 0
18
+ // and the emitted bundle died at its first canvas op with
19
+ //
20
+ // ReferenceError: imports is not defined
21
+ // at CanvasRenderingContext2D._toDataURL
22
+ //
23
+ // on node AND bun (the excalibur-jelly-jumper showcase), while gjs — where the
24
+ // globals are ambient — ran fine. Nothing between `gjsify build --app node` and
25
+ // a user running the bundle ever looked at the artifact.
26
+ //
27
+ // So this is a POST-condition on what was actually emitted: whatever the
28
+ // detector predicted, check it against the bundle. It is deliberately NOT a
29
+ // second detector — it runs the SAME `detectGjsAmbientGlobals` over the SAME
30
+ // kind of input (bundled, tree-shaken output, parsed by acorn, bare identifiers
31
+ // only). Two passes over one oracle that must agree; when they don't, the graph
32
+ // they saw differed, which is a bug in the build pipeline, not in user code.
33
+ //
34
+ // Scope: only when the CLI decided NOT to inject (`--globals auto`, no shim).
35
+ // `--globals none` opts out of the whole mechanism explicitly and is skipped.
36
+ import { detectGjsAmbientGlobals } from '@gjsify/rolldown-plugin-gjsify/globals';
37
+ /**
38
+ * The GJS ambient globals referenced as BARE identifiers in `code`, sorted.
39
+ *
40
+ * Empty for a bundle that only uses the `globalThis.imports` isomorphic-guard
41
+ * shape — that form is a runtime probe, not a dependency, and the detector
42
+ * ignores it for exactly that reason.
43
+ */
44
+ export function findUnshimmedGjsGlobals(code) {
45
+ return [...detectGjsAmbientGlobals(code)].sort();
46
+ }
47
+ /**
48
+ * Throw when a `--app node` bundle references GJS ambient globals but was built
49
+ * without the `@gjsify/node-gi/globals` shim.
50
+ *
51
+ * @param code the emitted bundle source
52
+ * @param outfile path of the bundle, for the error message
53
+ */
54
+ export function assertNodeBundleGlobalsShimmed(code, outfile) {
55
+ const offenders = findUnshimmedGjsGlobals(code);
56
+ if (offenders.length === 0)
57
+ return;
58
+ throw new Error(`gjsify build --app node: ${outfile} references ${offenders.length} GJS ambient global(s) that ` +
59
+ 'nothing installs on node/bun/deno, so the bundle throws `ReferenceError: <name> is not defined` ' +
60
+ 'the first time that code runs:\n' +
61
+ offenders.map((s) => ` - ${s}`).join('\n') +
62
+ '\n\nThe `@gjsify/node-gi/globals` shim seeds them, but it is injected only when the pre-build ' +
63
+ 'detection pass finds them — and that pass did not. The two passes therefore disagreed about the ' +
64
+ 'module graph. Either the detection ran against a different graph than the one emitted (check that ' +
65
+ 'anything feeding `isGjsSourceBuild()` — the `--globals` register stub, `nodeGiGlobalsInject` — is ' +
66
+ 'settled BEFORE `detectNodeGiGlobals`), or the offending package should stop reaching for the ' +
67
+ 'legacy `imports` object: `gi://<Ns>` and the bare `system`/`gettext`/`cairo` built-ins resolve on ' +
68
+ 'both runtimes, `imports` does not.');
69
+ }
@@ -45,7 +45,7 @@
45
45
  // `gjsify/register-class-order` rule wired via `.oxlintrc.json` jsPlugins)
46
46
  // lives in the Node launcher, so a native oxlint bridge could only run the
47
47
  // Rust rule subset. Lint stays Node-spawned until that trade-off is decided
48
- // (tracked in STATUS.md Open TODOs).
48
+ // (tracked in status/open-todos.md).
49
49
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
50
50
  import { join, resolve } from 'node:path';
51
51
  import { createRequire } from 'node:module';
@@ -82,7 +82,12 @@ async function runRawSession(fn) {
82
82
  out.write(r.echo);
83
83
  if (r.interrupt) {
84
84
  cleanup();
85
- process.exit(130);
85
+ // `return` — a bare `process.exit()` is deferred under GJS (no
86
+ // atexit), so the loop kept consuming the rest of the chunk
87
+ // after Ctrl-C and could still resolve the pending prompt.
88
+ // Returning stops input handling here; the deferred exit then
89
+ // carries 130 on the way out.
90
+ return process.exit(130);
86
91
  }
87
92
  if (r.done) {
88
93
  const { resolve } = pending;
@@ -26,6 +26,24 @@ export declare const RUNTIMES: Record<ExampleRuntime, RuntimeSpec>;
26
26
  export declare function isExampleRuntime(value: string): value is ExampleRuntime;
27
27
  /** The `gjsify build --app <target>` a runtime consumes (`gjs`, else `node`). */
28
28
  export declare function buildAppForRuntime(runtime: ExampleRuntime): 'gjs' | 'node';
29
+ /**
30
+ * Does running an example/showcase on `runtime` require the GJS SYSTEM
31
+ * dependencies — i.e. a `gjs` binary on PATH?
32
+ *
33
+ * DERIVED from `buildApp`, deliberately not a second table: "consumes the
34
+ * `--app gjs` bundle" and "needs a gjs interpreter" are the same fact, and a
35
+ * hand-written second list drifts the first time a runtime is added.
36
+ *
37
+ * The caller must resolve the runtime BEFORE gating on it. `gjsify showcase`
38
+ * called `runMinimalChecks()` — which marks `gjs` `required` — three statements
39
+ * ABOVE the line resolving `args.runtime`, so `--runtime node` aborted with
40
+ * "Missing system dependencies: ✗ GJS" before reaching the `runtime !== 'gjs'`
41
+ * branch that never touches gjs. Every showcase was unreachable on a host
42
+ * without gjs (Windows, plain Node/bun/deno) — including the default path,
43
+ * since `defaultExampleRuntime()` falls back to the host runtime for exactly
44
+ * those hosts.
45
+ */
46
+ export declare function requiresGjsSystemDeps(runtime: ExampleRuntime): boolean;
29
47
  /** Whether `<probe> --version` succeeds (i.e. the runtime binary is on PATH). */
30
48
  export declare function isRuntimeOnPath(probe: string): boolean;
31
49
  /**
@@ -41,6 +41,26 @@ export function isExampleRuntime(value) {
41
41
  export function buildAppForRuntime(runtime) {
42
42
  return RUNTIMES[runtime].buildApp;
43
43
  }
44
+ /**
45
+ * Does running an example/showcase on `runtime` require the GJS SYSTEM
46
+ * dependencies — i.e. a `gjs` binary on PATH?
47
+ *
48
+ * DERIVED from `buildApp`, deliberately not a second table: "consumes the
49
+ * `--app gjs` bundle" and "needs a gjs interpreter" are the same fact, and a
50
+ * hand-written second list drifts the first time a runtime is added.
51
+ *
52
+ * The caller must resolve the runtime BEFORE gating on it. `gjsify showcase`
53
+ * called `runMinimalChecks()` — which marks `gjs` `required` — three statements
54
+ * ABOVE the line resolving `args.runtime`, so `--runtime node` aborted with
55
+ * "Missing system dependencies: ✗ GJS" before reaching the `runtime !== 'gjs'`
56
+ * branch that never touches gjs. Every showcase was unreachable on a host
57
+ * without gjs (Windows, plain Node/bun/deno) — including the default path,
58
+ * since `defaultExampleRuntime()` falls back to the host runtime for exactly
59
+ * those hosts.
60
+ */
61
+ export function requiresGjsSystemDeps(runtime) {
62
+ return buildAppForRuntime(runtime) === 'gjs';
63
+ }
44
64
  /** Whether `<probe> --version` succeeds (i.e. the runtime binary is on PATH). */
45
65
  export function isRuntimeOnPath(probe) {
46
66
  try {
@@ -75,14 +75,13 @@ function readStdinTextGioFile() {
75
75
  throw new Error('Gio.File.load_contents("/dev/stdin") returned false');
76
76
  return new TextDecoder().decode(contents);
77
77
  }
78
- /** The GJS `imports.gi` namespace, or `undefined` when not running on GJS. */
78
+ /**
79
+ * The GJS `imports.gi` namespace, or `undefined` when not running on GJS.
80
+ * Same optional-chained probe as `isGjs()` — off GJS `imports` is simply
81
+ * absent and the read yields `undefined`; there is no throw path.
82
+ */
79
83
  function giNamespace() {
80
- try {
81
- return globalThis.imports?.gi;
82
- }
83
- catch {
84
- return undefined;
85
- }
84
+ return globalThis.imports?.gi;
86
85
  }
87
86
  /**
88
87
  * Read the whole of stdin as UTF-8. Works on Node and under the GJS bundle.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.24.2",
3
+ "version": "0.26.0",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -130,19 +130,19 @@
130
130
  "cli"
131
131
  ],
132
132
  "dependencies": {
133
- "@gjsify/buffer": "^0.24.2",
134
- "@gjsify/create-app": "^0.24.2",
135
- "@gjsify/node-globals": "^0.24.2",
136
- "@gjsify/node-polyfills": "^0.24.2",
137
- "@gjsify/npm-registry": "^0.24.2",
138
- "@gjsify/resolve-npm": "^0.24.2",
139
- "@gjsify/rolldown-plugin-gjsify": "^0.24.2",
140
- "@gjsify/rolldown-plugin-pnp": "^0.24.2",
141
- "@gjsify/semver": "^0.24.2",
142
- "@gjsify/tar": "^0.24.2",
143
- "@gjsify/tsc": "^0.24.2",
144
- "@gjsify/web-polyfills": "^0.24.2",
145
- "@gjsify/workspace": "^0.24.2",
133
+ "@gjsify/buffer": "^0.26.0",
134
+ "@gjsify/create-app": "^0.26.0",
135
+ "@gjsify/node-globals": "^0.26.0",
136
+ "@gjsify/node-polyfills": "^0.26.0",
137
+ "@gjsify/npm-registry": "^0.26.0",
138
+ "@gjsify/resolve-npm": "^0.26.0",
139
+ "@gjsify/rolldown-plugin-gjsify": "^0.26.0",
140
+ "@gjsify/rolldown-plugin-pnp": "^0.26.0",
141
+ "@gjsify/semver": "^0.26.0",
142
+ "@gjsify/tar": "^0.26.0",
143
+ "@gjsify/tsc": "^0.26.0",
144
+ "@gjsify/web-polyfills": "^0.26.0",
145
+ "@gjsify/workspace": "^0.26.0",
146
146
  "cosmiconfig": "^9.0.2",
147
147
  "get-tsconfig": "^4.14.0",
148
148
  "pkg-types": "^2.3.1",
@@ -150,13 +150,13 @@
150
150
  "yargs": "^18.0.0"
151
151
  },
152
152
  "devDependencies": {
153
- "@gjsify/unit": "^0.24.2",
153
+ "@gjsify/unit": "^0.26.0",
154
154
  "@types/yargs": "^17.0.35",
155
155
  "typescript": "^6.0.3"
156
156
  },
157
157
  "peerDependencies": {
158
- "@gjsify/oxfmt-native": "^0.24.2",
159
- "@gjsify/rolldown-native": "^0.24.2"
158
+ "@gjsify/oxfmt-native": "^0.26.0",
159
+ "@gjsify/rolldown-native": "^0.26.0"
160
160
  },
161
161
  "peerDependenciesMeta": {
162
162
  "@gjsify/oxfmt-native": {