@gjsify/cli 0.30.0 → 0.32.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 (51) hide show
  1. package/dist/cli.gjs.mjs +194 -193
  2. package/lib/actions/build.d.ts +14 -0
  3. package/lib/actions/build.js +78 -3
  4. package/lib/actions/index.d.ts +2 -0
  5. package/lib/actions/index.js +2 -0
  6. package/lib/affected-entry.d.ts +1 -0
  7. package/lib/affected-entry.js +49 -0
  8. package/lib/bundler-pick.js +84 -19
  9. package/lib/cli-app.js +20 -0
  10. package/lib/cli-fail.d.ts +22 -0
  11. package/lib/cli-fail.js +67 -0
  12. package/lib/commands/affected.d.ts +2 -2
  13. package/lib/commands/affected.js +16 -0
  14. package/lib/commands/install.d.ts +55 -0
  15. package/lib/commands/install.js +38 -21
  16. package/lib/commands/run.js +25 -6
  17. package/lib/commands/self-update.d.ts +27 -0
  18. package/lib/commands/self-update.js +77 -2
  19. package/lib/commands/showcase.d.ts +14 -0
  20. package/lib/commands/showcase.js +130 -10
  21. package/lib/commands/test.js +4 -4
  22. package/lib/commands/tsc.js +59 -11
  23. package/lib/commands/workspace.js +13 -0
  24. package/lib/config.js +6 -2
  25. package/lib/index.js +18 -1
  26. package/lib/templates/install.mjs.tmpl +27 -2
  27. package/lib/types/command.d.ts +18 -0
  28. package/lib/utils/check-system-deps.d.ts +66 -1
  29. package/lib/utils/check-system-deps.js +139 -2
  30. package/lib/utils/detect-native-packages.js +12 -1
  31. package/lib/utils/discover-showcases.d.ts +0 -2
  32. package/lib/utils/discover-showcases.js +0 -1
  33. package/lib/utils/dlx-cache.d.ts +22 -2
  34. package/lib/utils/dlx-cache.js +62 -6
  35. package/lib/utils/force-exit.d.ts +10 -0
  36. package/lib/utils/force-exit.js +43 -0
  37. package/lib/utils/gi-search-path.d.ts +36 -0
  38. package/lib/utils/gi-search-path.js +146 -0
  39. package/lib/utils/gjs-source-escape.d.ts +10 -0
  40. package/lib/utils/gjs-source-escape.js +49 -0
  41. package/lib/utils/gjsify-shim.d.ts +29 -2
  42. package/lib/utils/gjsify-shim.js +131 -22
  43. package/lib/utils/install-global.d.ts +79 -2
  44. package/lib/utils/install-global.js +130 -8
  45. package/lib/utils/oxc-resolve.d.ts +1 -1
  46. package/lib/utils/oxc-resolve.js +10 -6
  47. package/lib/utils/stdin.d.ts +3 -13
  48. package/lib/utils/stdin.js +21 -100
  49. package/lib/utils/system-gi.js +10 -1
  50. package/package.json +22 -17
  51. package/showcases.json +8 -16
@@ -0,0 +1,36 @@
1
+ import { type NativePackage } from './detect-native-packages.js';
2
+ /**
3
+ * Put every detected prebuild directory on girepository's typelib AND library
4
+ * search paths. Idempotent — the first call decides, later ones return it.
5
+ *
6
+ * This is THE LAUNCHER'S ENVIRONMENT, APPLIED TO THE PROCESS THAT IS ALREADY
7
+ * RUNNING: it feeds on the same `detectNativePackages()` whose output the
8
+ * `gjsify` bin turns into `GI_TYPELIB_PATH` + the host library-path variable
9
+ * (`buildNativeEnv`), so the two mechanisms cannot resolve different sets. That
10
+ * symmetry is also why this is not per-bridge: there is no list of covered
11
+ * engines to maintain, and a bridge added later is covered without touching
12
+ * this file. Today it covers all three the CLI loads in-process —
13
+ * `rolldown-native`, `lightningcss-native` (via the `css-as-string` plugin) and
14
+ * `oxfmt-native`.
15
+ *
16
+ * TWO ANCHORS, merged first-wins exactly as `computeNativeEnvForBundle()` does
17
+ * it: the cwd, and the directory this module itself lives in. The second is not
18
+ * redundant — with a GLOBAL CLI the engine sits next to the bundle while the cwd
19
+ * is an unrelated project, which is the same case `tryLoadNative()` documents
20
+ * needing its `bundleUrl` anchor for. After bundling, every CLI module collapses
21
+ * into `dist/cli.gjs.mjs`, so `import.meta.url` is the bundle's own URL, which
22
+ * is precisely the "where does the CLI live" question being asked.
23
+ *
24
+ * Called from the native-load paths, which are themselves memoized, so the
25
+ * node_modules sweep (~65 ms per anchor on this 960-workspace tree, far less in
26
+ * a consumer) is paid at most once per process and only by a command that
27
+ * actually wants an engine. Deliberately NOT skipped when the launcher already
28
+ * exported the same directories: that check can be wrong, prepending a
29
+ * directory that already resolves changes nothing, and a guard whose job is
30
+ * watching another mechanism is the shape `docs/governance.md` § simplicity
31
+ * warns about.
32
+ *
33
+ * @returns the packages activated — empty when there is no repository to write
34
+ * to (Node, or GLib < 2.86), which every caller reads as "carry on".
35
+ */
36
+ export declare function activateNativePrebuilds(): readonly NativePackage[];
@@ -0,0 +1,146 @@
1
+ // Make the native prebuilds this process can see resolvable to the RUNNING GJS
2
+ // process, with nothing exported into the environment beforehand. ADR 0021.
3
+ //
4
+ // THE MEASUREMENT (gjs 1.88.1 / GLib 2.88.3, importing `gi://GjsifyRolldown`
5
+ // from a bare `gjs -m`) — there are TWO lookups, not one, and they fail
6
+ // differently:
7
+ //
8
+ // neither var "Typelib file for namespace 'GjsifyRolldown' (any
9
+ // version) not found"
10
+ // GI_TYPELIB_PATH "Failed to load shared library 'libgjsifyrolldown.so'
11
+ // only referenced by the typelib" — reaching JS as the nameless
12
+ // "Unsupported type void, deriving from fundamental void"
13
+ // both loads
14
+ //
15
+ // The received answer was "the typelib lookup happens inside the GJS runtime,
16
+ // so those must be set BEFORE the process starts — the CLI cannot repair it
17
+ // from the inside". Half of that is permanently true: `LD_LIBRARY_PATH` cannot
18
+ // be changed in-process because ld.so freezes its search path at startup, which
19
+ // is an OS property and is why the `gjsify` launcher still exists for user
20
+ // bundles.
21
+ //
22
+ // It is the wrong frame, because girepository delegates NEITHER lookup to the
23
+ // loader's environment. It keeps a typelib search path AND a library search
24
+ // path of its own, consults both before falling back to the system loader, and
25
+ // both are writable at runtime. `prepend_library_path()` is the half nobody
26
+ // went looking for: `LD_LIBRARY_PATH` was assumed to BE the mechanism when it
27
+ // is only girepository's fallback — which is exactly what the middle row above
28
+ // proves, since girepository's own library path starts empty and no environment
29
+ // variable seeds it.
30
+ //
31
+ // NOT an rpath problem, and the rpath is not the fix. `libgjsifyrolldown.so`
32
+ // already carries `RUNPATH=$ORIGIN` (every bridge's `meson.build` sets it so
33
+ // the Vala library finds the Rust cdylib shipped beside it — on macOS nothing
34
+ // sets `DYLD_LIBRARY_PATH`, so it is the only thing that does). It structurally
35
+ // cannot help gjs FIND `libgjsifyrolldown.so` itself: the typelib names it by
36
+ // bare SONAME, and `dlopen()` of a bare name searches the RPATH/RUNPATH of the
37
+ // CALLING object — libgirepository, whose `$ORIGIN` is /usr/lib64. An rpath on
38
+ // the callee is never consulted to find the callee, so no artifact changes.
39
+ import { dirname } from 'node:path';
40
+ import { fileURLToPath } from 'node:url';
41
+ import { detectNativePackages } from './detect-native-packages.js';
42
+ /**
43
+ * girepository's process-global default repository — the one GJS's own `gi://`
44
+ * importer resolves against — or `null` when there is none to write to.
45
+ *
46
+ * `globalThis.imports?.gi` is both the GJS probe (`.oxlintrc.json` sanctions
47
+ * exactly this spelling for a runtime probe) and the correct CONDITION, which
48
+ * is why there is no `isGjs()` beside it: the question is "is there a
49
+ * GIRepository to prepend to", and a Node host answers no all by itself. That
50
+ * is the required no-op — on Node the CLI uses the npm `rolldown` crate and
51
+ * never wants a prebuild.
52
+ *
53
+ * `dup_default()` is GLib >= 2.86. A capability probe rather than a try/catch
54
+ * because this is a KNOWN host property with a defined answer, not an
55
+ * exceptional one: an older GLib simply keeps today's behaviour (the launcher's
56
+ * environment, or an unchanged diagnosis), so the change cannot make any host
57
+ * worse than it is.
58
+ */
59
+ function defaultRepository() {
60
+ const gi = globalThis.imports?.gi;
61
+ const repository = gi?.GIRepository?.Repository;
62
+ if (typeof repository?.dup_default !== 'function')
63
+ return null;
64
+ return repository.dup_default();
65
+ }
66
+ let activated = null;
67
+ /**
68
+ * Put every detected prebuild directory on girepository's typelib AND library
69
+ * search paths. Idempotent — the first call decides, later ones return it.
70
+ *
71
+ * This is THE LAUNCHER'S ENVIRONMENT, APPLIED TO THE PROCESS THAT IS ALREADY
72
+ * RUNNING: it feeds on the same `detectNativePackages()` whose output the
73
+ * `gjsify` bin turns into `GI_TYPELIB_PATH` + the host library-path variable
74
+ * (`buildNativeEnv`), so the two mechanisms cannot resolve different sets. That
75
+ * symmetry is also why this is not per-bridge: there is no list of covered
76
+ * engines to maintain, and a bridge added later is covered without touching
77
+ * this file. Today it covers all three the CLI loads in-process —
78
+ * `rolldown-native`, `lightningcss-native` (via the `css-as-string` plugin) and
79
+ * `oxfmt-native`.
80
+ *
81
+ * TWO ANCHORS, merged first-wins exactly as `computeNativeEnvForBundle()` does
82
+ * it: the cwd, and the directory this module itself lives in. The second is not
83
+ * redundant — with a GLOBAL CLI the engine sits next to the bundle while the cwd
84
+ * is an unrelated project, which is the same case `tryLoadNative()` documents
85
+ * needing its `bundleUrl` anchor for. After bundling, every CLI module collapses
86
+ * into `dist/cli.gjs.mjs`, so `import.meta.url` is the bundle's own URL, which
87
+ * is precisely the "where does the CLI live" question being asked.
88
+ *
89
+ * Called from the native-load paths, which are themselves memoized, so the
90
+ * node_modules sweep (~65 ms per anchor on this 960-workspace tree, far less in
91
+ * a consumer) is paid at most once per process and only by a command that
92
+ * actually wants an engine. Deliberately NOT skipped when the launcher already
93
+ * exported the same directories: that check can be wrong, prepending a
94
+ * directory that already resolves changes nothing, and a guard whose job is
95
+ * watching another mechanism is the shape `docs/governance.md` § simplicity
96
+ * warns about.
97
+ *
98
+ * @returns the packages activated — empty when there is no repository to write
99
+ * to (Node, or GLib < 2.86), which every caller reads as "carry on".
100
+ */
101
+ export function activateNativePrebuilds() {
102
+ if (activated)
103
+ return activated;
104
+ // Memoized BEFORE the work, so every outcome — including a throw — is
105
+ // decided once and every later caller gets an answer instead of a repeat
106
+ // attempt. `diagnoseNativeEngine()` depends on exactly that: it documents
107
+ // that nothing it calls may throw while explaining a failure.
108
+ activated = [];
109
+ try {
110
+ const repository = defaultRepository();
111
+ if (!repository)
112
+ return activated;
113
+ const cwdPackages = detectNativePackages(process.cwd());
114
+ const seen = new Set(cwdPackages.map((p) => p.name));
115
+ const selfPackages = detectNativePackages(dirname(fileURLToPath(import.meta.url)));
116
+ const packages = [...cwdPackages, ...selfPackages.filter((p) => !seen.has(p.name))];
117
+ for (const pkg of packages) {
118
+ repository.prepend_search_path(pkg.prebuildsDir);
119
+ repository.prepend_library_path(pkg.prebuildsDir);
120
+ }
121
+ activated = packages;
122
+ }
123
+ catch {
124
+ // TWO REAL THROW PATHS, named because a catch without one is the
125
+ // anti-pattern this repo measures:
126
+ //
127
+ // 1. `imports.gi.GIRepository` LOADS the namespace. GJS raises
128
+ // "Requiring GIRepository, version none: Typelib file for namespace
129
+ // 'GIRepository' (any version) not found" when that typelib is
130
+ // absent — verified by probe. It is a SEPARATE FILE from the
131
+ // girepository library GJS links against, and distributions split
132
+ // them (Debian ships `gir1.2-girepository-3.0` apart from
133
+ // `libgirepository-2.0-0`), so a lean host can have the second
134
+ // without the first.
135
+ // 2. The two `detectNativePackages()` walks are filesystem I/O and can
136
+ // raise (EACCES on an unreadable node_modules, a dir removed
137
+ // mid-walk).
138
+ //
139
+ // Both mean the same thing — no activation — and swallowing is the
140
+ // CORRECT handling rather than a hidden failure: the caller ends up
141
+ // exactly where it was before this function existed (the launcher's
142
+ // environment, and the unchanged diagnosis if that is absent too), so
143
+ // no host can be made worse by it.
144
+ }
145
+ return activated;
146
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Replace every raw U+0000 in `code` with the `\x00` escape.
3
+ *
4
+ * Returns the rewritten code and how many were replaced, so the caller can skip
5
+ * writing the file (and reporting) in the overwhelmingly common zero case.
6
+ */
7
+ export declare function escapeRawNulForGjs(code: string): {
8
+ code: string;
9
+ replaced: number;
10
+ };
@@ -0,0 +1,49 @@
1
+ // Build-time rewrite: a `--app gjs` bundle must not contain a raw U+0000 byte.
2
+ //
3
+ // GJS hands module source to SpiderMonkey as a NUL-TERMINATED C string, so a raw
4
+ // U+0000 in the emitted file truncates it at that point. Everything after is
5
+ // silently discarded and the error names whatever construct happened to be open
6
+ // when the text ran out:
7
+ //
8
+ // SyntaxError: `` literal not terminated before end of script
9
+ //
10
+ // which points at the wrong place entirely and never mentions a NUL.
11
+ //
12
+ // This is easy to hit and hard to diagnose. `'\u0000'` in TypeScript source is an
13
+ // ESCAPE — six harmless ASCII characters — but the minifier is free to inline the
14
+ // constant and emit the CHARACTER:
15
+ //
16
+ // const P = '\u0000'; … return `${P}${i}${P}` // source: escaped, loads fine
17
+ // … return`<NUL>${e}<NUL>` // minified: raw byte, will not load
18
+ //
19
+ // so the same code builds and runs unminified and breaks under `--minify`, which is
20
+ // the default. A real consumer hit exactly this with an IMAP literal marker — NUL
21
+ // being the one byte that cannot occur in IMAP response text is precisely why it
22
+ // was chosen as the marker.
23
+ //
24
+ // Node and browsers load a raw NUL in source without complaint (U+0000 is a legal
25
+ // source character per the spec), so no other target notices and nothing upstream
26
+ // is going to change. Escaping it on the way out is gjsify's job.
27
+ //
28
+ // The rewrite is safe as a plain text substitution: in valid JS a raw NUL can only
29
+ // appear inside a string, a template literal, a regex or a comment, and `\x00`
30
+ // denotes exactly U+0000 in all four — so the meaning is preserved wherever it sits.
31
+ //
32
+ // `\x00` and NOT `\0`: `\0` followed by a digit is a legacy octal escape, which is a
33
+ // SyntaxError in a template literal and under strict mode. A digit right after the
34
+ // marker is exactly the shape that triggered this (`\0${index}\0`), so `\0` would
35
+ // trade one unloadable bundle for another.
36
+ /**
37
+ * Replace every raw U+0000 in `code` with the `\x00` escape.
38
+ *
39
+ * Returns the rewritten code and how many were replaced, so the caller can skip
40
+ * writing the file (and reporting) in the overwhelmingly common zero case.
41
+ */
42
+ export function escapeRawNulForGjs(code) {
43
+ // split/join rather than a regex: a NUL inside a regex is exactly what oxlint's
44
+ // `no-control-regex` exists to flag, and the rule is right in general even though this use
45
+ // is deliberate. Building the needle with fromCharCode also keeps THIS file free of the raw
46
+ // byte it exists to remove.
47
+ const parts = code.split(String.fromCharCode(0));
48
+ return { code: parts.join('\\x00'), replaced: parts.length - 1 };
49
+ }
@@ -1,6 +1,33 @@
1
1
  /**
2
- * Ensure a GJS-runnable `gjsify` is on `process.env.PATH` for child processes.
2
+ * True when nested `gjsify` calls in this process tree are being routed through
3
+ * a self-shim rather than through the workspace's `node_modules/.bin/gjsify`.
4
+ *
5
+ * `detectPackageManager()` reads this: a bootstrap CLI must run package scripts
6
+ * ITSELF, because npm re-prepends `node_modules/.bin` for every lifecycle
7
+ * script it starts — which puts the tree's dead shim back ahead of the shim dir
8
+ * this module placed on PATH, and undoes the whole repair one level down.
9
+ */
10
+ export declare function usingSelfShim(): boolean;
11
+ /**
12
+ * Does this invocation need its own `gjsify` on PATH, rather than the
13
+ * workspace's npm bin?
14
+ *
15
+ * Pure, and takes the host facts as PARAMETERS rather than reading them
16
+ * ambiently — the same shape `platform-check.ts` documents, and what lets the
17
+ * Windows-relevant branch be tested from a Linux host.
18
+ *
19
+ * @param gjs running under GJS (the npm bin is the Node entry there)
20
+ * @param selfEntry absolute path of the CLI entry currently executing
21
+ * @param workspaceRoot absolute path of the workspace the command will act on
22
+ */
23
+ export declare function needsSelfShim(opts: {
24
+ gjs: boolean;
25
+ selfEntry: string;
26
+ workspaceRoot: string;
27
+ }): boolean;
28
+ /**
29
+ * Ensure a runnable `gjsify` is on `process.env.PATH` for child processes.
3
30
  * Idempotent per process; children inherit the shim via `GJSIFY_SHIM_DIR` and
4
- * reuse it instead of creating their own. No-op when not running under GJS.
31
+ * reuse it instead of creating their own.
5
32
  */
6
33
  export declare function ensureGjsifyShimOnPath(): void;
@@ -1,52 +1,161 @@
1
- // Node-free orchestration: a GJS-runnable `gjsify` on PATH.
1
+ // A runnable `gjsify` on PATH, for the two trees where the npm bin is not one.
2
2
  //
3
- // The CLI's npm `bin` (`node_modules/.bin/gjsify`) is the NODE entry — useless
4
- // in a node-free environment (the Flatpak sandbox has no Node). But multi-
5
- // package orchestration and compound package scripts resolve `gjsify` from
6
- // PATH and would hit that Node bin:
3
+ // The CLI's npm bin (`node_modules/.bin/gjsify`) is a DISPATCHER written at
4
+ // install time: it prefers `packages/infra/cli/dist/cli.gjs.mjs` on a host that
5
+ // has `gjs`, and otherwise execs the Node entry `packages/infra/cli/lib/index.js`.
6
+ // Multi-package orchestration and compound package scripts resolve `gjsify`
7
+ // from PATH and go through it:
7
8
  // - `gjsify workspace`/`foreach` spawn `<pm> run <script>` per package; under
8
9
  // GJS the package manager is `gjsify` itself, and `spawn('gjsify', …)`
9
10
  // searches PATH.
10
11
  // - a compound script like `gjsify run a && gjsify run b` is executed via
11
- // `/bin/sh -c …`, and the shell resolves each `gjsify` from PATH.
12
- // Both fail without Node (`spawn npm ENOENT`, `gjsify: not found`).
12
+ // `/bin/sh -c …` (or `%COMSPEC% /c …`), and the shell resolves each
13
+ // `gjsify` from PATH.
13
14
  //
14
- // Fix: under GJS, write a tiny wrapper that re-invokes the running GJS bundle
15
- // under `gjs -m`, and prepend its directory to `process.env.PATH` so every
16
- // child process (and grandchild, via the inherited `GJSIFY_SHIM_DIR` marker)
17
- // resolves `gjsify` to a GJS-runnable executable. No-op on Node.
15
+ // Fix: write a tiny wrapper that re-invokes the RUNNING CLI, and prepend its
16
+ // directory to `process.env.PATH` so every child process (and grandchild, via
17
+ // the inherited `GJSIFY_SHIM_DIR` marker) resolves `gjsify` to a CLI that
18
+ // actually exists.
19
+ //
20
+ // TWO HOSTS NEED IT, FOR TWO DIFFERENT REASONS
21
+ //
22
+ // GJS — always. The npm bin is the NODE entry and is useless in a node-free
23
+ // environment (the Flatpak sandbox has no Node): without the shim the failures
24
+ // are `spawn npm ENOENT` and `gjsify: not found`.
25
+ //
26
+ // Node — only when the running CLI came from OUTSIDE the workspace, which is
27
+ // exactly the cold-tree bootstrap (`npx @gjsify/cli@latest …`, or a global
28
+ // install). ADR 0002 untracked BOTH bundles, so on a tree that is installed but
29
+ // not yet built, `node_modules/.bin/gjsify` exists and everything it dispatches
30
+ // to is missing. The bin shim is then a live landmine: it resolves, and then
31
+ // dies with `Cannot find module …/packages/infra/cli/lib/index.js` on the FIRST
32
+ // nested `gjsify` — which for `gjsify run build:infra` is its first clause,
33
+ // `gjsify workspace @gjsify/vite-plugin-blueprint build`.
34
+ //
35
+ // THE INCIDENT, because a rule without it gets simplified back into the bug.
36
+ //
37
+ // `windows-suites.yml` (added #1021) and `macos-suites.yml` (#1022) both landed
38
+ // during the 2026-08-06 GitHub Actions degradation with NO checks reported on
39
+ // their PRs, and neither leg had ever been green. Both ordered the cold-tree
40
+ // bootstrap as `run build:infra` BEFORE `install`, which cannot work either:
41
+ // `@gjsify/vite-plugin-blueprint` declares `"types": ["node"]`, so with no
42
+ // `node_modules` its `gjsify tsc` fails `TS2688: Cannot find type definition
43
+ // file for 'node'`. Correcting the order to install-then-build then exposed
44
+ // THIS gap, one step later.
45
+ //
46
+ // Reproduced on Linux at de7f5525 in a cold worktree — nothing about it is
47
+ // Windows-specific:
48
+ //
49
+ // npx --yes @gjsify/cli@latest install --immutable # ok
50
+ // npx --yes @gjsify/cli@latest run build:infra # Cannot find module …
51
+ //
52
+ // Linux CI never saw it because its bootstrap runs `gjs -m <published bundle>
53
+ // run build:infra` (`.github/actions/gjsify-setup`), which takes the GJS branch
54
+ // above. Windows is simply the only leg with no `gjs` to fall back on, so it is
55
+ // where the gap became load-bearing.
56
+ //
57
+ // Scoped to an outside-the-workspace CLI ON PURPOSE: when the invocation
58
+ // already came through the workspace's own `node_modules/.bin/gjsify`, that
59
+ // shim IS the CLI the tree wants, and today's dispatch (GJS bundle first on a
60
+ // host with `gjs`) stays byte-for-byte unchanged. Widening this to every Node
61
+ // invocation would silently move in-repo nested builds off the GJS bundle.
18
62
  import { mkdtempSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
19
63
  import { tmpdir } from 'node:os';
20
- import { join, delimiter } from 'node:path';
64
+ import { join, delimiter, relative, isAbsolute } from 'node:path';
21
65
  import { fileURLToPath } from 'node:url';
22
66
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
67
+ import { buildLauncherShims } from './bin-shim.js';
68
+ import { findWorkspaceRoot } from './workspace-root.js';
23
69
  let _ensured = false;
70
+ let _selfShimActive = false;
24
71
  /**
25
- * Ensure a GJS-runnable `gjsify` is on `process.env.PATH` for child processes.
72
+ * True when nested `gjsify` calls in this process tree are being routed through
73
+ * a self-shim rather than through the workspace's `node_modules/.bin/gjsify`.
74
+ *
75
+ * `detectPackageManager()` reads this: a bootstrap CLI must run package scripts
76
+ * ITSELF, because npm re-prepends `node_modules/.bin` for every lifecycle
77
+ * script it starts — which puts the tree's dead shim back ahead of the shim dir
78
+ * this module placed on PATH, and undoes the whole repair one level down.
79
+ */
80
+ export function usingSelfShim() {
81
+ return _selfShimActive;
82
+ }
83
+ /**
84
+ * Does this invocation need its own `gjsify` on PATH, rather than the
85
+ * workspace's npm bin?
86
+ *
87
+ * Pure, and takes the host facts as PARAMETERS rather than reading them
88
+ * ambiently — the same shape `platform-check.ts` documents, and what lets the
89
+ * Windows-relevant branch be tested from a Linux host.
90
+ *
91
+ * @param gjs running under GJS (the npm bin is the Node entry there)
92
+ * @param selfEntry absolute path of the CLI entry currently executing
93
+ * @param workspaceRoot absolute path of the workspace the command will act on
94
+ */
95
+ export function needsSelfShim(opts) {
96
+ if (opts.gjs)
97
+ return true;
98
+ // Outside the workspace ⇒ a bootstrap CLI (npx / global install), so the
99
+ // tree's own `.bin/gjsify` may well point at build outputs that do not
100
+ // exist yet. Inside ⇒ the tree's CLI is the one running; leave PATH alone.
101
+ const rel = relative(opts.workspaceRoot, opts.selfEntry);
102
+ return rel === '' || rel.startsWith('..') || isAbsolute(rel);
103
+ }
104
+ /**
105
+ * Ensure a runnable `gjsify` is on `process.env.PATH` for child processes.
26
106
  * Idempotent per process; children inherit the shim via `GJSIFY_SHIM_DIR` and
27
- * reuse it instead of creating their own. No-op when not running under GJS.
107
+ * reuse it instead of creating their own.
28
108
  */
29
109
  export function ensureGjsifyShimOnPath() {
30
- if (_ensured || !isGjs())
110
+ if (_ensured)
31
111
  return;
32
112
  _ensured = true;
33
- // A parent gjsify (also under GJS) already created the shim — reuse it so
34
- // nested orchestration doesn't pile up temp dirs / PATH entries.
113
+ // A parent gjsify already created the shim — reuse it so nested
114
+ // orchestration doesn't pile up temp dirs / PATH entries. The
115
+ // extension-less member is written on every host, so it stays the probe.
35
116
  const inherited = process.env.GJSIFY_SHIM_DIR;
36
117
  if (inherited && existsSync(join(inherited, 'gjsify'))) {
118
+ _selfShimActive = true;
37
119
  if (!(process.env.PATH ?? '').split(delimiter).includes(inherited)) {
38
120
  process.env.PATH = inherited + delimiter + (process.env.PATH ?? '');
39
121
  }
40
122
  return;
41
123
  }
42
- // `import.meta.url` resolves to the running GJS bundle (cli.gjs.mjs) — the
43
- // whole CLI collapses into that single file, so this module's URL is it.
44
- const selfBundle = fileURLToPath(import.meta.url);
45
- const gjs = process.env.GJS_CONSOLE || 'gjs';
124
+ const gjs = isGjs();
125
+ // Under GJS `import.meta.url` IS the entry — the whole CLI collapses into
126
+ // the single `cli.gjs.mjs` bundle, so this module's URL is it. Under Node
127
+ // this module is `lib/utils/gjsify-shim.js` and the npm bin is
128
+ // `lib/index.js` (package.json `bin`), one hop up.
129
+ const selfEntry = gjs ? fileURLToPath(import.meta.url) : fileURLToPath(new URL('../index.js', import.meta.url));
130
+ const workspaceRoot = findWorkspaceRoot(process.cwd()) ?? process.cwd();
131
+ if (!needsSelfShim({ gjs, selfEntry, workspaceRoot }))
132
+ return;
46
133
  const dir = mkdtempSync(join(tmpdir(), 'gjsify-shim-'));
47
134
  const shim = join(dir, 'gjsify');
48
- writeFileSync(shim, `#!/bin/sh\nexec "${gjs}" -m "${selfBundle}" "$@"\n`, { mode: 0o755 });
135
+ // The interpreter is named, not pathed, on every member — matching
136
+ // `buildShLauncher`'s `exec gjs` / `exec node` and `buildLauncherShims`'
137
+ // documented contract, so a missing interpreter fails the same way on every
138
+ // OS. `process.execPath` is deliberately NOT used: the batch member cannot
139
+ // quote an interpreter argument (`<interpreter> "<target>" %*`), so a path
140
+ // like `C:\Program Files\nodejs\node.exe` would split, and a trio whose
141
+ // members disagree about which interpreter they re-enter is worse than one
142
+ // name resolved consistently.
143
+ const interpreter = gjs ? process.env.GJS_CONSOLE || 'gjs' : 'node';
144
+ const interpreterArgs = gjs ? ['-m'] : [];
145
+ const argv = interpreterArgs.length > 0 ? `${interpreterArgs.join(' ')} ` : '';
146
+ writeFileSync(shim, `#!/bin/sh\nexec "${interpreter}" ${argv}"${selfEntry}" "$@"\n`, { mode: 0o755 });
49
147
  chmodSync(shim, 0o755);
148
+ // cmd.exe and pwsh cannot run the extension-less member: it is not on
149
+ // PATHEXT and Windows has no shebang handling. npm's three-sibling answer
150
+ // is the de-facto standard, and `buildLauncherShims` is this repo's
151
+ // reviewed port of it — including the IF/ELSE ERRORLEVEL handling that
152
+ // `&&`/`||` chaining gets wrong.
153
+ if (!gjs && process.platform === 'win32') {
154
+ const { cmd, ps1 } = buildLauncherShims({ interpreter, interpreterArgs, target: selfEntry });
155
+ writeFileSync(`${shim}.cmd`, cmd);
156
+ writeFileSync(`${shim}.ps1`, ps1);
157
+ }
158
+ _selfShimActive = true;
50
159
  process.env.GJSIFY_SHIM_DIR = dir;
51
160
  process.env.PATH = dir + delimiter + (process.env.PATH ?? '');
52
161
  }
@@ -29,8 +29,36 @@ export declare function defaultGlobalLayout(): GlobalLayout;
29
29
  * story already degrades on Node.
30
30
  */
31
31
  export declare const GJS_ENGINE_PACKAGES: readonly ["@gjsify/rolldown-native", "@gjsify/lightningcss-native", "@gjsify/oxfmt-native"];
32
- /** True when `<prefix>/node_modules/@gjsify/rolldown-native` is laid down. */
33
- export declare function hasBundlerEngineInstalled(prefix: string): boolean;
32
+ /**
33
+ * The one engine whose absence is fatal rather than degrading: `gjsify build`
34
+ * has no fallback under GJS. Named separately because two different questions
35
+ * ("what do we install") and ("is a build possible") want different sets.
36
+ */
37
+ export declare const BUNDLER_ENGINE_PACKAGE = "@gjsify/rolldown-native";
38
+ /**
39
+ * True when a bundler engine `gjsify build` can actually LOAD is reachable from
40
+ * `dir`.
41
+ *
42
+ * This used to `existsSync` the bridge's own `package.json` and stop there. That
43
+ * answer was correct before ADR 0017 and is wrong after it: the bridge is now
44
+ * pure TypeScript (`files: ["lib", "meson.build", "src/rust", "src/vala"]`) and
45
+ * the binary lives in a per-target child (`@gjsify/rolldown-native-<os>-<arch>`).
46
+ * So "bridge present, child missing" reported `true` — which is why
47
+ * `self-update`'s repair gate could say "Already up to date" about a prefix whose
48
+ * engine cannot load. A live defect, not a hypothetical one.
49
+ *
50
+ * Asks `detectNativePackages` instead, which is the same walk-up
51
+ * `findRolldownNativeDir` performs at BUILD time. That matters more than the
52
+ * shortcut it replaces: an installer that disagrees with the builder about
53
+ * whether the engine is present is worse than one that is merely slow. It also
54
+ * makes the gjsify monorepo a no-op — the workspace symlink resolves — which is
55
+ * what keeps ADR 0001's non-destructive invariant safe from a registry copy
56
+ * landing on top of `packages/infra/rolldown-native`.
57
+ *
58
+ * `dir` is a prefix for a global install and a project root for a project one;
59
+ * the walk handles both.
60
+ */
61
+ export declare function hasBundlerEngineInstalled(dir: string): boolean;
34
62
  /**
35
63
  * Best-effort install of the GJS-native engine packages into a global prefix.
36
64
  *
@@ -86,8 +114,57 @@ export interface LinkedBin {
86
114
  * simplicity and so we don't need to discriminate by runtime here.
87
115
  */
88
116
  export declare function linkGlobalBins(packageNames: string[], layout: GlobalLayout): LinkedBin[];
117
+ /**
118
+ * Is the global layout the user's own, or has a caller redirected it?
119
+ *
120
+ * `GJSIFY_GLOBAL_PREFIX` / `GJSIFY_GLOBAL_BIN_DIR` are the escape hatches
121
+ * {@link defaultGlobalLayout} documents for tests, and a caller that sets them
122
+ * has taken over placement — so "which `gjsify` will PATH resolve next" stops
123
+ * being a question about THEIR intent. `self-update` gates its fatal PATH check
124
+ * on this: without it, every harness installing into a temp prefix it never put
125
+ * on PATH is told its update did not take effect, which is true and useless.
126
+ * Caught by `tests/e2e/global-install-engine`.
127
+ *
128
+ * @param env injectable so both answers are testable without mutating the real
129
+ * environment mid-suite.
130
+ */
131
+ export declare function globalLayoutIsDefault(env?: NodeJS.ProcessEnv): boolean;
89
132
  /** Returns `true` if `binDir` is on the user's PATH. */
90
133
  export declare function binDirOnPath(binDir: string): boolean;
134
+ /** Injection points for {@link resolveBinOnPath} — see its `platform` note. */
135
+ export interface ResolveBinOnPathOptions {
136
+ /** PATH to search. Defaults to `process.env.PATH`. */
137
+ pathValue?: string;
138
+ /** PATHEXT to honour on win32. Defaults to `process.env.PATHEXT` or cmd.exe's set. */
139
+ pathExt?: string;
140
+ /** Host platform. Defaults to `process.platform`. */
141
+ platform?: NodeJS.Platform;
142
+ /** File-existence probe. Defaults to a real `fs.existsSync`. */
143
+ exists?: (file: string) => boolean;
144
+ }
145
+ /**
146
+ * Resolve `binName` the way the user's shell will: the FIRST hit in PATH order.
147
+ *
148
+ * WHY THIS IS NOT `binDirOnPath`. That answers "is our dir on PATH", which is
149
+ * necessary but not sufficient for anything that CHANGES which binary runs:
150
+ * `~/.local/bin` can be on PATH and still lose to an npm-global `gjsify` earlier
151
+ * in it. `self-update` reported "Updated to v0.31.0" on exactly that host while
152
+ * the shell kept resolving a v0.26.0 install five releases behind — and the user
153
+ * then re-hit a bug fixed three releases earlier and reported it as new (#1064).
154
+ * "Which one wins" is the question, so this returns the winning PATH.
155
+ *
156
+ * WINDOWS. An extension-less name is not executable there; cmd.exe and pwsh
157
+ * append each `PATHEXT` entry, which is how the `.cmd` shim `bin-shim.ts` writes
158
+ * gets found. The bare name is probed LAST so a git-bash/MSYS `sh` shim is still
159
+ * reported rather than missed. `platform`/`pathExt`/`exists` are injectable for
160
+ * the same reason `dirLinkTarget` takes `linkType`: nothing in CI resolves a bin
161
+ * on a Windows host, so an injected `'win32'` is the ONLY way that branch is ever
162
+ * EXECUTED — and an unexecutable Windows branch is exactly how the `'dir'` bug in
163
+ * `dir-link.ts` shipped.
164
+ *
165
+ * @returns the absolute path of the winning candidate, or `null` when PATH holds none.
166
+ */
167
+ export declare function resolveBinOnPath(binName: string, opts?: ResolveBinOnPathOptions): string | null;
91
168
  /**
92
169
  * Extracts the package name from a spec like `name@1.2`, `@scope/name`,
93
170
  * or `@scope/name@latest`. Returns the unchanged string for plain names.