@gjsify/cli 0.25.1 → 0.26.1

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 (41) hide show
  1. package/dist/cli.gjs.mjs +171 -171
  2. package/lib/cli-app.js +7 -10
  3. package/lib/commands/affected.js +160 -17
  4. package/lib/commands/check.js +25 -8
  5. package/lib/commands/create.js +3 -1
  6. package/lib/commands/debug.js +3 -1
  7. package/lib/commands/flatpak/check.js +4 -3
  8. package/lib/commands/flatpak/diff.js +3 -1
  9. package/lib/commands/flatpak/sources.js +4 -7
  10. package/lib/commands/foreach.js +25 -7
  11. package/lib/commands/generate-installer.js +2 -4
  12. package/lib/commands/install.js +15 -7
  13. package/lib/commands/login.js +3 -1
  14. package/lib/commands/onboard.js +3 -6
  15. package/lib/commands/publish.js +8 -11
  16. package/lib/commands/self-update.js +4 -8
  17. package/lib/commands/showcase.d.ts +16 -0
  18. package/lib/commands/showcase.js +44 -24
  19. package/lib/commands/storybook.js +16 -2
  20. package/lib/commands/system-check.js +1 -2
  21. package/lib/commands/test.js +6 -2
  22. package/lib/commands/trust.js +12 -7
  23. package/lib/commands/tsc.js +3 -1
  24. package/lib/commands/uninstall.js +1 -2
  25. package/lib/commands/whoami.js +6 -3
  26. package/lib/utils/build-cache.js +7 -2
  27. package/lib/utils/dir-link.d.ts +58 -0
  28. package/lib/utils/dir-link.js +126 -0
  29. package/lib/utils/dlx-cache.d.ts +30 -3
  30. package/lib/utils/dlx-cache.js +63 -10
  31. package/lib/utils/install-backend-native.js +1 -1
  32. package/lib/utils/install-lock.js +5 -7
  33. package/lib/utils/install-packument-cache.d.ts +23 -0
  34. package/lib/utils/install-packument-cache.js +45 -10
  35. package/lib/utils/install-tarball-cache.js +5 -8
  36. package/lib/utils/oxc-resolve.js +1 -1
  37. package/lib/utils/prompt.js +6 -1
  38. package/lib/utils/runtimes.d.ts +18 -0
  39. package/lib/utils/runtimes.js +20 -0
  40. package/lib/utils/stdin.js +6 -7
  41. package/package.json +17 -17
@@ -20,13 +20,40 @@ export declare function getValidCachedPkg(cacheDir: string, maxAgeMinutes?: numb
20
20
  * Atomically swap `<cacheDir>/pkg` to point at `prepareDir`.
21
21
  *
22
22
  * Strategy:
23
- * 1. Create new symlink `<cacheDir>/pkg-<rand>` → prepareDir.
24
- * 2. `rename(pkg-<rand>, pkg)` — POSIX guarantees rename-over-existing is atomic.
23
+ * 1. Create new link `<cacheDir>/pkg.tmp-<ts>-<pid>` → prepareDir.
24
+ * 2. `rename(pkg.tmp-…, pkg)` — POSIX guarantees rename-over-existing is atomic.
25
25
  *
26
26
  * Returns the realpath of the new live target. EBUSY/EEXIST indicates a race
27
27
  * — a parallel process won, return its realpath.
28
+ *
29
+ * WINDOWS, both halves of which shipped broken:
30
+ *
31
+ * • Step 1 used `symlinkSync(…, 'dir')`, and a directory SYMLINK needs
32
+ * elevation or Developer Mode there, so a plain
33
+ * `npx @gjsify/cli@latest showcase …` died with `EPERM: operation not
34
+ * permitted, symlink`. It now goes through {@link linkDirSync}, which picks
35
+ * an NTFS junction — the same choice npm and yarn make, and the one
36
+ * `commands/install.ts` already made for workspace links without sharing it.
37
+ * • Step 2's rename-over-existing is a POSIX guarantee, not a Windows one:
38
+ * `MoveFileEx` will not replace an existing DIRECTORY, and a junction is a
39
+ * directory reparse point. So on the second run — when `pkg` already exists
40
+ * — the rename failed with `EPERM`, the catch below read that as "race
41
+ * lost", and the function returned the OLD target. That is not a crash but
42
+ * it is worse than one: the cache silently never refreshed. Windows
43
+ * therefore unlinks first and accepts the non-atomic window, which the
44
+ * existing race handling already covers.
45
+ *
46
+ * @param opts.junctions force the Windows link strategy (TESTS ONLY). Defaults
47
+ * to the host's. It is a parameter for the same reason `dirLinkTarget` takes
48
+ * `linkType`: nothing in CI runs on Windows, so an injected `true` is the ONLY
49
+ * way the unlink-then-rename ordering above is ever EXECUTED, and an
50
+ * unexecutable Windows branch is exactly how the `'dir'` bug shipped. What the
51
+ * injection proves off-host is the ORDERING and that the second swap really
52
+ * refreshes; what it cannot prove is `MoveFileEx`/junction syscall behaviour.
28
53
  */
29
- export declare function symlinkSwap(cacheDir: string, prepareDir: string): string;
54
+ export declare function symlinkSwap(cacheDir: string, prepareDir: string, opts?: {
55
+ junctions?: boolean;
56
+ }): string;
30
57
  /** Clean up `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`. */
31
58
  export declare function cleanupStalePrepareDirs(cacheDir: string, _maxAgeMinutes?: number): void;
32
59
  /** Resolve absolute path to the installed package's directory inside cache. */
@@ -6,9 +6,10 @@
6
6
  // - prepare into a fresh temp dir, then atomically swap a `pkg` symlink
7
7
  // - TTL via lstat mtime + maxAgeMinutes (default 7 days)
8
8
  import { createHash } from 'node:crypto';
9
- import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync, symlinkSync } from 'node:fs';
9
+ import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync } from 'node:fs';
10
10
  import { homedir } from 'node:os';
11
11
  import { join, resolve } from 'node:path';
12
+ import { dirLinksAreJunctions, linkDirSync } from './dir-link.js';
12
13
  const ONE_MINUTE_MS = 60_000;
13
14
  const DEFAULT_TTL_MIN = 60 * 24 * 7; // 7 days
14
15
  function lexCompare(a, b) {
@@ -74,30 +75,82 @@ export function getValidCachedPkg(cacheDir, maxAgeMinutes = DEFAULT_TTL_MIN) {
74
75
  * Atomically swap `<cacheDir>/pkg` to point at `prepareDir`.
75
76
  *
76
77
  * Strategy:
77
- * 1. Create new symlink `<cacheDir>/pkg-<rand>` → prepareDir.
78
- * 2. `rename(pkg-<rand>, pkg)` — POSIX guarantees rename-over-existing is atomic.
78
+ * 1. Create new link `<cacheDir>/pkg.tmp-<ts>-<pid>` → prepareDir.
79
+ * 2. `rename(pkg.tmp-…, pkg)` — POSIX guarantees rename-over-existing is atomic.
79
80
  *
80
81
  * Returns the realpath of the new live target. EBUSY/EEXIST indicates a race
81
82
  * — a parallel process won, return its realpath.
83
+ *
84
+ * WINDOWS, both halves of which shipped broken:
85
+ *
86
+ * • Step 1 used `symlinkSync(…, 'dir')`, and a directory SYMLINK needs
87
+ * elevation or Developer Mode there, so a plain
88
+ * `npx @gjsify/cli@latest showcase …` died with `EPERM: operation not
89
+ * permitted, symlink`. It now goes through {@link linkDirSync}, which picks
90
+ * an NTFS junction — the same choice npm and yarn make, and the one
91
+ * `commands/install.ts` already made for workspace links without sharing it.
92
+ * • Step 2's rename-over-existing is a POSIX guarantee, not a Windows one:
93
+ * `MoveFileEx` will not replace an existing DIRECTORY, and a junction is a
94
+ * directory reparse point. So on the second run — when `pkg` already exists
95
+ * — the rename failed with `EPERM`, the catch below read that as "race
96
+ * lost", and the function returned the OLD target. That is not a crash but
97
+ * it is worse than one: the cache silently never refreshed. Windows
98
+ * therefore unlinks first and accepts the non-atomic window, which the
99
+ * existing race handling already covers.
100
+ *
101
+ * @param opts.junctions force the Windows link strategy (TESTS ONLY). Defaults
102
+ * to the host's. It is a parameter for the same reason `dirLinkTarget` takes
103
+ * `linkType`: nothing in CI runs on Windows, so an injected `true` is the ONLY
104
+ * way the unlink-then-rename ordering above is ever EXECUTED, and an
105
+ * unexecutable Windows branch is exactly how the `'dir'` bug shipped. What the
106
+ * injection proves off-host is the ORDERING and that the second swap really
107
+ * refreshes; what it cannot prove is `MoveFileEx`/junction syscall behaviour.
82
108
  */
83
- export function symlinkSwap(cacheDir, prepareDir) {
109
+ export function symlinkSwap(cacheDir, prepareDir, opts = {}) {
110
+ const junctions = opts.junctions ?? dirLinksAreJunctions();
84
111
  const linkPath = join(cacheDir, 'pkg');
85
112
  const tmpName = `pkg.tmp-${Date.now().toString(16)}-${process.pid.toString(16)}`;
86
113
  const tmpLink = join(cacheDir, tmpName);
87
114
  // If we cannot even create the tmp link, give up (the error propagates).
88
- symlinkSync(prepareDir, tmpLink, 'dir');
115
+ linkDirSync(tmpLink, prepareDir);
89
116
  try {
117
+ if (junctions) {
118
+ // Windows: no rename-over-a-directory. Removing first opens a window
119
+ // in which `pkg` is absent; a concurrent reader treats that as a cache
120
+ // miss and prepares its own, which is correct, just wasteful. force:true
121
+ // keeps a first-ever swap (nothing to remove) from throwing.
122
+ //
123
+ // maxRetries/retryDelay for the same reason `install-backend-native.ts`
124
+ // wraps every delete in `rmWithRetry`: on Windows a delete fails with
125
+ // EBUSY/EPERM while ANY process holds a handle (an antivirus scan, a
126
+ // concurrent reader that just realpath'd this very link), and the
127
+ // deletion then stays PENDING — so without the retry a transient
128
+ // handle sends us down the "race lost" branch and the cache silently
129
+ // does not refresh. Free on POSIX, where the first attempt succeeds.
130
+ rmSync(linkPath, { force: true, recursive: false, maxRetries: 10, retryDelay: 100 });
131
+ }
90
132
  renameSync(tmpLink, linkPath);
91
133
  }
92
134
  catch (err) {
93
135
  const code = err.code;
94
- if (code === 'EBUSY' || code === 'EPERM' || code === 'EEXIST') {
95
- // Race lost — clean up our tmp and use whoever won.
136
+ if (code === 'EBUSY' || code === 'EPERM' || code === 'EEXIST' || code === 'EACCES') {
137
+ // Race lost — clean up our tmp and use whoever won. force:true is
138
+ // the non-throwing spelling of "remove if still there"; any other
139
+ // failure on our own pid-unique link is real and should surface.
140
+ rmSync(tmpLink, { force: true });
96
141
  try {
97
- rmSync(tmpLink);
142
+ return realpathSync(linkPath);
143
+ }
144
+ catch {
145
+ // Windows only, and only because the junction path unlinks before
146
+ // it renames: "race lost" can also mean the winner is INSIDE that
147
+ // non-atomic window, so `pkg` momentarily does not exist and this
148
+ // would throw ENOENT out of a function whose caller reports it to
149
+ // the user as `Could not resolve showcase "<name>"`. Our own
150
+ // prepareDir is a complete, correctly installed tree — a truthful
151
+ // answer for THIS run. The next run reads whichever link won.
152
+ return realpathSync(prepareDir);
98
153
  }
99
- catch { }
100
- return realpathSync(linkPath);
101
154
  }
102
155
  throw err;
103
156
  }
@@ -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;
@@ -3,6 +3,29 @@ export interface CachedPackument {
3
3
  etag: string;
4
4
  packument: Packument;
5
5
  }
6
+ /**
7
+ * The cache key for a (registry, name) pair — exported so the filename rule
8
+ * below is a PROPERTY a test can assert, not a claim in a comment.
9
+ *
10
+ * THE SEPARATOR IS NOT COSMETIC. It used to be `|`, producing names like
11
+ * `corgi|https%3A%2F%2Fregistry.npmjs.org%2F|%40gjsify%2Fnode-gi.json` — and `|`
12
+ * is a Win32 RESERVED filename character, so every write failed with `EINVAL`.
13
+ * Nothing reported it: `atomicWrite` is best-effort on purpose (a read-only
14
+ * cache volume must not break an install) and `readCacheFile` reads an
15
+ * unreadable path as a MISS. So on Windows this cache was never broken loudly —
16
+ * it was silently DEAD, and the `If-None-Match` → `304` revalidation that is the
17
+ * entire reason this file exists never engaged once.
18
+ *
19
+ * `,` is legal on Win32 AND escaped by `encodeURIComponent`, which is what keeps
20
+ * a flat two-part name injective: an encoded part cannot contain the separator,
21
+ * so exactly one `(registry, name)` pair maps to any given filename.
22
+ *
23
+ * The shard key and the filename are derived from the SAME encoded parts. They
24
+ * used to disagree — the key hashed the raw pair while the name used the encoded
25
+ * one — which is how a reserved character in the name survived review of a
26
+ * function whose other half looked correct.
27
+ */
28
+ export declare function packumentCacheKey(registry: string, name: string): string;
6
29
  /**
7
30
  * Read a cached packument + its ETag for `(registry, name)`. Returns `null` on
8
31
  * a miss, when the cache is disabled, or when the entry is unreadable/corrupt
@@ -13,19 +13,22 @@
13
13
  //
14
14
  // Layout:
15
15
  //
16
- // $XDG_CACHE_HOME/gjsify/metadata/v1/<shard>/<encoded-registry>|<encoded-name>.json
16
+ // $XDG_CACHE_HOME/gjsify/metadata/v2/<shard>/<encoded-registry>,<encoded-name>.json
17
17
  //
18
18
  // The cache is keyed by (registry, name) — NOT name alone — so switching the
19
19
  // registry for a scope can never serve a packument from the wrong source on a
20
20
  // coincidental ETag match. `<shard>` is a 2-hex FNV-1a digest of the key, a
21
21
  // directory-fan-out step so the leaf dir never grows unbounded (same rationale
22
- // as the tarball cache's hex sharding). `v1` is a layout version.
22
+ // as the tarball cache's hex sharding). `v2` is a layout version.
23
23
  //
24
24
  // Disabled with `GJSIFY_PACKUMENT_CACHE=0` (or `false`). Honours
25
25
  // `XDG_CACHE_HOME` like the tarball + dlx caches.
26
26
  import { join } from 'node:path';
27
27
  import { atomicWrite, gjsifyCacheRoot, readCacheFile } from './install-cache-fs.js';
28
- const CACHE_LAYOUT_VERSION = 'v1';
28
+ // v2: the separator changed from the Win32-reserved `|` to `,` (see `pathFor`),
29
+ // so every v1 entry is unreachable rather than merely stale — a new root retires
30
+ // them instead of leaving orphans under the old one.
31
+ const CACHE_LAYOUT_VERSION = 'v2';
29
32
  /** `true` unless `GJSIFY_PACKUMENT_CACHE` is `0` / `false` / empty. */
30
33
  function isEnabled() {
31
34
  const flag = process.env.GJSIFY_PACKUMENT_CACHE;
@@ -34,7 +37,7 @@ function isEnabled() {
34
37
  const trimmed = flag.trim();
35
38
  return !(trimmed === '0' || trimmed === 'false' || trimmed === '');
36
39
  }
37
- /** Root of the packument cache: `$XDG_CACHE_HOME/gjsify/metadata/v1`. */
40
+ /** Root of the packument cache: `$XDG_CACHE_HOME/gjsify/metadata/v2`. */
38
41
  function cacheRoot() {
39
42
  return gjsifyCacheRoot('metadata', CACHE_LAYOUT_VERSION);
40
43
  }
@@ -47,15 +50,47 @@ function shardFor(key) {
47
50
  }
48
51
  return ((h >>> 0) & 0xff).toString(16).padStart(2, '0');
49
52
  }
50
- /** Filesystem path for a (registry, name) pair, or `null` when disabled. */
53
+ /**
54
+ * One path part, made safe for EVERY filesystem we run on.
55
+ *
56
+ * `encodeURIComponent` alone is not enough: it deliberately leaves `*` (and
57
+ * `!'()`) unescaped, and `*` is one of Win32's reserved filename characters.
58
+ * A scoped package never contains one today, but a registry URL can, and the
59
+ * failure mode is the silent one described on {@link pathFor}.
60
+ */
61
+ function encodePart(part) {
62
+ return encodeURIComponent(part).replace(/\*/g, '%2A');
63
+ }
64
+ /**
65
+ * The cache key for a (registry, name) pair — exported so the filename rule
66
+ * below is a PROPERTY a test can assert, not a claim in a comment.
67
+ *
68
+ * THE SEPARATOR IS NOT COSMETIC. It used to be `|`, producing names like
69
+ * `corgi|https%3A%2F%2Fregistry.npmjs.org%2F|%40gjsify%2Fnode-gi.json` — and `|`
70
+ * is a Win32 RESERVED filename character, so every write failed with `EINVAL`.
71
+ * Nothing reported it: `atomicWrite` is best-effort on purpose (a read-only
72
+ * cache volume must not break an install) and `readCacheFile` reads an
73
+ * unreadable path as a MISS. So on Windows this cache was never broken loudly —
74
+ * it was silently DEAD, and the `If-None-Match` → `304` revalidation that is the
75
+ * entire reason this file exists never engaged once.
76
+ *
77
+ * `,` is legal on Win32 AND escaped by `encodeURIComponent`, which is what keeps
78
+ * a flat two-part name injective: an encoded part cannot contain the separator,
79
+ * so exactly one `(registry, name)` pair maps to any given filename.
80
+ *
81
+ * The shard key and the filename are derived from the SAME encoded parts. They
82
+ * used to disagree — the key hashed the raw pair while the name used the encoded
83
+ * one — which is how a reserved character in the name survived review of a
84
+ * function whose other half looked correct.
85
+ */
86
+ export function packumentCacheKey(registry, name) {
87
+ return `${encodePart(registry)},${encodePart(name)}`;
88
+ }
51
89
  function pathFor(registry, name) {
52
90
  if (!isEnabled())
53
91
  return null;
54
- const key = `${registry}|${name}`;
55
- // encodeURIComponent makes both halves filesystem-safe (`/` → `%2F`, etc.)
56
- // while staying a stable, reversible, single path segment.
57
- const file = `${encodeURIComponent(registry)}|${encodeURIComponent(name)}.json`;
58
- return join(cacheRoot(), shardFor(key), file);
92
+ const key = packumentCacheKey(registry, name);
93
+ return join(cacheRoot(), shardFor(key), `${key}.json`);
59
94
  }
60
95
  /**
61
96
  * Read a cached packument + its ETag for `(registry, name)`. Returns `null` on
@@ -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 };
@@ -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.25.1",
3
+ "version": "0.26.1",
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.25.1",
134
- "@gjsify/create-app": "^0.25.1",
135
- "@gjsify/node-globals": "^0.25.1",
136
- "@gjsify/node-polyfills": "^0.25.1",
137
- "@gjsify/npm-registry": "^0.25.1",
138
- "@gjsify/resolve-npm": "^0.25.1",
139
- "@gjsify/rolldown-plugin-gjsify": "^0.25.1",
140
- "@gjsify/rolldown-plugin-pnp": "^0.25.1",
141
- "@gjsify/semver": "^0.25.1",
142
- "@gjsify/tar": "^0.25.1",
143
- "@gjsify/tsc": "^0.25.1",
144
- "@gjsify/web-polyfills": "^0.25.1",
145
- "@gjsify/workspace": "^0.25.1",
133
+ "@gjsify/buffer": "^0.26.1",
134
+ "@gjsify/create-app": "^0.26.1",
135
+ "@gjsify/node-globals": "^0.26.1",
136
+ "@gjsify/node-polyfills": "^0.26.1",
137
+ "@gjsify/npm-registry": "^0.26.1",
138
+ "@gjsify/resolve-npm": "^0.26.1",
139
+ "@gjsify/rolldown-plugin-gjsify": "^0.26.1",
140
+ "@gjsify/rolldown-plugin-pnp": "^0.26.1",
141
+ "@gjsify/semver": "^0.26.1",
142
+ "@gjsify/tar": "^0.26.1",
143
+ "@gjsify/tsc": "^0.26.1",
144
+ "@gjsify/web-polyfills": "^0.26.1",
145
+ "@gjsify/workspace": "^0.26.1",
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.25.1",
153
+ "@gjsify/unit": "^0.26.1",
154
154
  "@types/yargs": "^17.0.35",
155
155
  "typescript": "^6.0.3"
156
156
  },
157
157
  "peerDependencies": {
158
- "@gjsify/oxfmt-native": "^0.25.1",
159
- "@gjsify/rolldown-native": "^0.25.1"
158
+ "@gjsify/oxfmt-native": "^0.26.1",
159
+ "@gjsify/rolldown-native": "^0.26.1"
160
160
  },
161
161
  "peerDependenciesMeta": {
162
162
  "@gjsify/oxfmt-native": {