@gjsify/cli 0.22.0 → 0.24.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 (47) hide show
  1. package/README.md +28 -1
  2. package/dist/cli.gjs.mjs +265 -184
  3. package/lib/actions/build.d.ts +12 -0
  4. package/lib/actions/build.js +26 -0
  5. package/lib/bundler-pick.js +56 -7
  6. package/lib/commands/affected.js +84 -14
  7. package/lib/commands/check.js +38 -29
  8. package/lib/commands/flatpak/build.js +19 -18
  9. package/lib/commands/foreach.js +119 -42
  10. package/lib/commands/info.js +11 -5
  11. package/lib/commands/install.d.ts +16 -0
  12. package/lib/commands/install.js +123 -36
  13. package/lib/commands/onboard.js +6 -1
  14. package/lib/commands/pack.js +69 -4
  15. package/lib/commands/run.js +26 -14
  16. package/lib/commands/showcase.js +17 -13
  17. package/lib/commands/storybook.js +5 -2
  18. package/lib/commands/trust.js +4 -62
  19. package/lib/commands/workspace.js +49 -30
  20. package/lib/utils/bin-shim.d.ts +66 -0
  21. package/lib/utils/bin-shim.js +246 -0
  22. package/lib/utils/build-cache.d.ts +42 -6
  23. package/lib/utils/build-cache.js +114 -42
  24. package/lib/utils/detect-native-packages.d.ts +118 -7
  25. package/lib/utils/detect-native-packages.js +213 -29
  26. package/lib/utils/gjs-bundle-guard.d.ts +32 -0
  27. package/lib/utils/gjs-bundle-guard.js +103 -0
  28. package/lib/utils/install-backend-native.d.ts +24 -0
  29. package/lib/utils/install-backend-native.js +89 -16
  30. package/lib/utils/install-global.d.ts +16 -0
  31. package/lib/utils/install-global.js +58 -8
  32. package/lib/utils/oxc-resolve.js +25 -57
  33. package/lib/utils/publish-headers.d.ts +2 -3
  34. package/lib/utils/publish-headers.js +64 -3
  35. package/lib/utils/publishable-packages.d.ts +22 -0
  36. package/lib/utils/publishable-packages.js +93 -0
  37. package/lib/utils/run-gjs.d.ts +6 -7
  38. package/lib/utils/run-gjs.js +4 -3
  39. package/lib/utils/run-node.d.ts +16 -0
  40. package/lib/utils/run-node.js +28 -7
  41. package/lib/utils/runtimes.d.ts +21 -0
  42. package/lib/utils/runtimes.js +23 -0
  43. package/lib/utils/spawn.d.ts +82 -0
  44. package/lib/utils/spawn.js +139 -0
  45. package/lib/utils/stdin.d.ts +25 -0
  46. package/lib/utils/stdin.js +132 -0
  47. package/package.json +23 -18
@@ -1,6 +1,7 @@
1
1
  // Utility to find npm packages with gjsify native prebuilds.
2
- // Packages declare: "gjsify": { "prebuilds": "<dir>" } in their package.json.
3
- // The CLI uses this to auto-set LD_LIBRARY_PATH / GI_TYPELIB_PATH before running gjs.
2
+ // Packages declare: "gjsify": { "prebuilds": "<dir>" } in their package.json,
3
+ // and (since the OS-axis audit) the `<os>-<arch>` targets they promise in
4
+ // "gjsify": { "platforms": [...] }.
4
5
  //
5
6
  // One algorithm — `detectNativePackages(startDir)` — walks up from `startDir`
6
7
  // and exhaustively scans every `node_modules` it finds. Used by:
@@ -10,24 +11,138 @@
10
11
  // their full transitive prebuild set picked up automatically. The
11
12
  // transitive walk is what makes `gjsify showcase` / `gjsify dlx` work
12
13
  // for packages whose Vala typelibs live in *indirect* deps.
14
+ //
15
+ // PLATFORM RESOLUTION IS A PURE FUNCTION. Everything that depends on the host
16
+ // OS/CPU lives in `resolvePrebuildDirName()` / `buildNativeEnv()`, both of
17
+ // which take `platform` / `arch` as parameters instead of reading
18
+ // `process.platform` inline. That is what makes the darwin and win32 branches
19
+ // unit-testable from a Linux host (see `detect-native-packages.spec.ts`) —
20
+ // the host values are only read at the outermost call site, as a default.
13
21
  import { readdirSync, existsSync } from 'node:fs';
14
22
  import { join, resolve } from 'node:path';
15
23
  import { readPackageJson } from './pkg-json.js';
16
- /** Map Node.js process.arch values to the convention used in prebuilds/ directories. */
17
- function nodeArchToLinuxArch(arch) {
18
- const map = {
19
- x64: 'x86_64',
20
- arm64: 'aarch64',
21
- arm: 'armv7',
22
- ia32: 'i686',
24
+ /**
25
+ * Map a Node `process.arch` value to the uname-style spelling the Vala/meson
26
+ * bridges used to stage into (`prebuilds/linux-x86_64/`, the output of
27
+ * `uname -m` on the build host).
28
+ *
29
+ * LEGACY. Every `@gjsify/*` package now declares and stages the node spelling
30
+ * (`linux-x64`); this table exists only so the CLI can still load a prebuild
31
+ * out of a tarball published before the rename. See
32
+ * {@link prebuildDirCandidates}.
33
+ */
34
+ const NODE_ARCH_TO_LEGACY_UNAME = {
35
+ x64: 'x86_64',
36
+ arm64: 'aarch64',
37
+ arm: 'armv7',
38
+ ia32: 'i686',
39
+ };
40
+ /**
41
+ * Arch spellings that name the same target, folded onto the NODE spelling.
42
+ * Mirrors `ARCH_ALIASES` in `scripts/audit-runtimes.mjs` — that script
43
+ * canonicalises `package.json#gjsify.platforms` the same way for the
44
+ * platform-support matrix, and the two MUST agree or a package could pass the
45
+ * audit while the CLI fails to find its artifact.
46
+ *
47
+ * The direction matters: `${process.platform}-${process.arch}` is what a
48
+ * running process can compute about itself, so folding *onto* it means the
49
+ * host identity never has to be translated in the hot path.
50
+ */
51
+ const ARCH_ALIASES = {
52
+ x86_64: 'x64',
53
+ amd64: 'x64',
54
+ aarch64: 'arm64',
55
+ };
56
+ /**
57
+ * Canonical `<os>-<arch>` form — the node spelling — so `linux-x86_64` and
58
+ * `linux-x64` compare equal. Only the *arch* half is normalised; the OS half
59
+ * is always a `process.platform` token (`linux`/`darwin`/`win32`), which has
60
+ * no competing spelling.
61
+ */
62
+ export function canonicalPlatformToken(token) {
63
+ const dash = token.indexOf('-');
64
+ if (dash < 0)
65
+ return token;
66
+ const os = token.slice(0, dash);
67
+ const arch = token.slice(dash + 1);
68
+ return `${os}-${ARCH_ALIASES[arch] ?? arch}`;
69
+ }
70
+ /**
71
+ * The prebuild directory names to probe for a host, most-specific first.
72
+ *
73
+ * The canonical name is `${process.platform}-${process.arch}` — `linux-x64`,
74
+ * `linux-arm64`, `darwin-arm64`, `win32-x64`. Every package in the release
75
+ * train declares it (`gjsify.platforms`), `scripts/stage-prebuild.mjs` is the
76
+ * only thing that creates it, and `scripts/audit-runtimes.mjs --check` rejects
77
+ * any other spelling, so the three can no longer drift.
78
+ *
79
+ * Two probes remain BEHIND that canonical name, and both are backward
80
+ * compatibility with tarballs already on npm — not a second convention:
81
+ *
82
+ * 1. **The package's own declared spelling.** A tarball published before the
83
+ * rename declares (and ships) `linux-x86_64`; probing its declaration
84
+ * first loads it without the CLI having to guess. This is also what makes
85
+ * the rename work in the OTHER direction — an older CLI resolving a newer
86
+ * package — because `canonicalPlatformToken` folds both spellings onto
87
+ * one form at both ends.
88
+ * 2. **The legacy uname spelling.** Only reachable for a package that ships
89
+ * a prebuild dir and declares NO `gjsify.platforms` at all — i.e. a
90
+ * tarball predating the OS-axis audit, or a third-party package using
91
+ * `gjsify.prebuilds` without the declaration. Inside this repo the audit
92
+ * makes that state impossible.
93
+ *
94
+ * Both are pure array entries matched against an already-read directory
95
+ * listing — no extra I/O — so tolerance on the READ side costs nothing, while
96
+ * the single spelling is enforced everywhere a name is WRITTEN.
97
+ *
98
+ * @param platform `process.platform` value.
99
+ * @param arch `process.arch` value.
100
+ * @param declaredPlatforms `package.json#gjsify.platforms`, when present.
101
+ */
102
+ export function prebuildDirCandidates(platform, arch, declaredPlatforms) {
103
+ const canonical = `${platform}-${arch}`;
104
+ const legacyArch = NODE_ARCH_TO_LEGACY_UNAME[arch] ?? arch;
105
+ const out = [];
106
+ const push = (name) => {
107
+ if (!out.includes(name))
108
+ out.push(name);
23
109
  };
24
- return map[arch] ?? arch;
110
+ // 1. The package's own declared spelling for THIS host, if it declares one.
111
+ for (const declared of declaredPlatforms ?? []) {
112
+ if (typeof declared !== 'string')
113
+ continue;
114
+ if (canonicalPlatformToken(declared) === canonical)
115
+ push(declared);
116
+ }
117
+ // 2. The canonical node spelling — what everything in the train stages.
118
+ push(canonical);
119
+ // 3. The legacy uname spelling, for undeclared pre-rename tarballs.
120
+ push(`${platform}-${legacyArch}`);
121
+ return out;
122
+ }
123
+ /**
124
+ * Pick the prebuild directory a host should load, given the directories that
125
+ * actually exist under `<pkg>/<prebuilds>/`.
126
+ *
127
+ * Pure: no `process.*` reads, no filesystem access. This is the function the
128
+ * darwin and win32 branches are unit-tested through on a Linux host.
129
+ *
130
+ * @returns the chosen directory NAME (not a path), or `null` when the package
131
+ * ships nothing loadable on this host.
132
+ */
133
+ export function resolvePrebuildDirName(input) {
134
+ const { platform, arch, declaredPlatforms, existingDirs } = input;
135
+ for (const candidate of prebuildDirCandidates(platform, arch, declaredPlatforms)) {
136
+ if (existingDirs.includes(candidate))
137
+ return candidate;
138
+ }
139
+ return null;
25
140
  }
26
141
  /**
27
142
  * Scan all packages in a node_modules directory for gjsify native prebuilds.
28
143
  * Handles scoped packages (@scope/name) as well as flat packages.
29
144
  */
30
- function scanNodeModules(nodeModulesDir, arch) {
145
+ function scanNodeModules(nodeModulesDir, target) {
31
146
  const results = [];
32
147
  if (!existsSync(nodeModulesDir))
33
148
  return results;
@@ -53,14 +168,14 @@ function scanNodeModules(nodeModulesDir, arch) {
53
168
  }
54
169
  for (const scopedPkg of scopeEntries) {
55
170
  const pkgDir = join(scopeDir, scopedPkg);
56
- const native = checkPackage(pkgDir, `${entry}/${scopedPkg}`, arch);
171
+ const native = checkPackage(pkgDir, `${entry}/${scopedPkg}`, target);
57
172
  if (native)
58
173
  results.push(native);
59
174
  }
60
175
  }
61
176
  else {
62
177
  const pkgDir = join(nodeModulesDir, entry);
63
- const native = checkPackage(pkgDir, entry, arch);
178
+ const native = checkPackage(pkgDir, entry, target);
64
179
  if (native)
65
180
  results.push(native);
66
181
  }
@@ -68,20 +183,42 @@ function scanNodeModules(nodeModulesDir, arch) {
68
183
  return results;
69
184
  }
70
185
  /** Check a single package directory for gjsify prebuilds metadata. */
71
- function checkPackage(pkgDir, name, arch) {
186
+ function checkPackage(pkgDir, name, target) {
72
187
  const pkgJson = readPackageJson(join(pkgDir, 'package.json'));
73
188
  if (!pkgJson)
74
189
  return null;
75
190
  const gjsifyMeta = pkgJson['gjsify'];
76
191
  if (!gjsifyMeta || typeof gjsifyMeta !== 'object')
77
192
  return null;
78
- const prebuildsField = gjsifyMeta['prebuilds'];
193
+ const meta = gjsifyMeta;
194
+ const prebuildsField = meta['prebuilds'];
79
195
  if (typeof prebuildsField !== 'string')
80
196
  return null;
81
- const prebuildsDir = join(pkgDir, prebuildsField, `linux-${arch}`);
82
- if (!existsSync(prebuildsDir))
197
+ const declaredRaw = meta['platforms'];
198
+ const declaredPlatforms = Array.isArray(declaredRaw)
199
+ ? declaredRaw.filter((p) => typeof p === 'string')
200
+ : undefined;
201
+ const prebuildsRoot = join(pkgDir, prebuildsField);
202
+ let existingDirs;
203
+ try {
204
+ // A prebuild dir may legitimately be a symlink (workspace/dev layouts),
205
+ // so accept both real directories and links.
206
+ existingDirs = readdirSync(prebuildsRoot, { withFileTypes: true })
207
+ .filter((e) => e.isDirectory() || e.isSymbolicLink())
208
+ .map((e) => e.name);
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ const dirName = resolvePrebuildDirName({
214
+ platform: target.platform,
215
+ arch: target.arch,
216
+ declaredPlatforms,
217
+ existingDirs,
218
+ });
219
+ if (!dirName)
83
220
  return null;
84
- return { name, prebuildsDir };
221
+ return { name, prebuildsDir: join(prebuildsRoot, dirName) };
85
222
  }
86
223
  /**
87
224
  * Walk up the directory tree from `startDir` and merge native packages found
@@ -95,16 +232,22 @@ function checkPackage(pkgDir, name, arch) {
95
232
  *
96
233
  * Deduplication: the first match for a given package name wins (closer
97
234
  * node_modules shadows outer ones), matching Node.js resolution semantics.
235
+ *
236
+ * @param target overrides the host platform/arch. Only tests pass this; the
237
+ * production call sites take the defaults.
98
238
  */
99
- export function detectNativePackages(startDir) {
100
- const arch = nodeArchToLinuxArch(process.arch);
239
+ export function detectNativePackages(startDir, target = {}) {
240
+ const resolved = {
241
+ platform: target.platform ?? process.platform,
242
+ arch: target.arch ?? process.arch,
243
+ };
101
244
  const merged = [];
102
245
  const seen = new Set();
103
246
  let dir = resolve(startDir);
104
247
  while (true) {
105
248
  const nodeModulesDir = join(dir, 'node_modules');
106
249
  if (existsSync(nodeModulesDir)) {
107
- for (const pkg of scanNodeModules(nodeModulesDir, arch)) {
250
+ for (const pkg of scanNodeModules(nodeModulesDir, resolved)) {
108
251
  if (seen.has(pkg.name))
109
252
  continue;
110
253
  seen.add(pkg.name);
@@ -119,14 +262,55 @@ export function detectNativePackages(startDir) {
119
262
  return merged;
120
263
  }
121
264
  /**
122
- * Build the LD_LIBRARY_PATH and GI_TYPELIB_PATH env var values for the detected native packages.
123
- * Prepends the new paths to any existing values from the environment.
265
+ * The environment-variable name the host's dynamic loader consults for an
266
+ * additional shared-library search path, plus the separator its search paths
267
+ * use.
268
+ *
269
+ * * **Linux / other ELF** — `LD_LIBRARY_PATH`, `:`-separated.
270
+ * * **macOS** — `DYLD_LIBRARY_PATH`. `dyld` does not read `LD_LIBRARY_PATH`
271
+ * at all, which is why `.github/workflows/napi.yml` sets `DYLD_LIBRARY_PATH`
272
+ * by hand on every macOS gate step.
273
+ * * **Windows** — there is no dedicated variable; `LoadLibrary` searches the
274
+ * process `PATH`, `;`-separated. This is the same mechanism
275
+ * `@gjsify/node-gi`'s `maybePrependGtkRuntimeDllPath()` uses to make its
276
+ * bundled GTK DLLs resolvable.
124
277
  */
125
- export function buildNativeEnv(packages) {
278
+ export function libraryPathVar(platform) {
279
+ if (platform === 'darwin')
280
+ return { name: 'DYLD_LIBRARY_PATH', separator: ':' };
281
+ if (platform === 'win32')
282
+ return { name: 'PATH', separator: ';' };
283
+ return { name: 'LD_LIBRARY_PATH', separator: ':' };
284
+ }
285
+ /**
286
+ * Build the typelib + shared-library search-path environment for the detected
287
+ * native packages. Prepends the new paths to any existing values.
288
+ *
289
+ * Pure w.r.t. the host: `platform` and `env` are parameters, so the darwin and
290
+ * win32 branches are exercised by unit tests running on Linux.
291
+ *
292
+ * On Windows the `PATH` key is written back under the *host's own* spelling
293
+ * (a stock Windows env block uses `Path`) — Windows env names are
294
+ * case-insensitive, but a plain JS object is not, and `{...process.env,
295
+ * PATH: …}` would hand a child process two competing entries.
296
+ */
297
+ export function buildNativeEnv(packages, opts = {}) {
298
+ const platform = opts.platform ?? process.platform;
299
+ const env = opts.env ?? process.env;
126
300
  const dirs = packages.map((p) => p.prebuildsDir);
127
- const existing_ld = process.env['LD_LIBRARY_PATH'] ?? '';
128
- const existing_gi = process.env['GI_TYPELIB_PATH'] ?? '';
129
- const LD_LIBRARY_PATH = [...dirs, ...(existing_ld ? [existing_ld] : [])].join(':');
130
- const GI_TYPELIB_PATH = [...dirs, ...(existing_gi ? [existing_gi] : [])].join(':');
131
- return { LD_LIBRARY_PATH, GI_TYPELIB_PATH };
301
+ // GLib's search-path separator is `;` on Windows (G_SEARCHPATH_SEPARATOR),
302
+ // `:` everywhere else — the same split as the loader variable below.
303
+ const giSeparator = platform === 'win32' ? ';' : ':';
304
+ const { name: libVarName, separator: libSeparator } = libraryPathVar(platform);
305
+ // Windows env names are case-insensitive; reuse whatever casing the host
306
+ // block already uses so we replace rather than shadow it.
307
+ const libVarKey = platform === 'win32'
308
+ ? (Object.keys(env).find((k) => k.toLowerCase() === libVarName.toLowerCase()) ?? libVarName)
309
+ : libVarName;
310
+ const prepend = (value, separator) => [...dirs, ...(value ? [value] : [])].join(separator);
311
+ const out = {
312
+ GI_TYPELIB_PATH: prepend(env['GI_TYPELIB_PATH'], giSeparator),
313
+ };
314
+ out[libVarKey] = prepend(env[libVarKey], libSeparator);
315
+ return out;
132
316
  }
@@ -0,0 +1,32 @@
1
+ /** The one bundler-output field this guard needs, from either engine. */
2
+ export interface GuardedChunk {
3
+ /** Emitted chunk name, for the error message. */
4
+ fileName?: string;
5
+ /** External specifiers the chunk STATICALLY imports (rollup/rolldown contract). */
6
+ imports?: readonly string[];
7
+ }
8
+ /**
9
+ * Bare `node:` specifiers a `--app gjs` bundle may keep as static imports.
10
+ *
11
+ * Deliberately EMPTY. Every Node builtin has an `@gjsify/*` polyfill that
12
+ * `--app gjs` aliases to, so there is no specifier that both survives into the
13
+ * module graph and loads under GJS. Reading the graph instead of the text is
14
+ * what keeps this list empty — the entries an earlier text-scanning version
15
+ * needed (`node:module`, `node:process`) were quoted lookalikes, never imports.
16
+ *
17
+ * Add an entry ONLY with a comment naming why GJS can resolve it. Each one is a
18
+ * specifier this guard can no longer catch for real.
19
+ */
20
+ export declare const GJS_ALLOWED_NODE_IMPORTS: readonly string[];
21
+ /**
22
+ * The distinct bare `node:` specifiers `chunks` statically import, minus the
23
+ * allowlist. Sorted, so the error message and any test are stable.
24
+ */
25
+ export declare function findDisallowedNodeImports(chunks: readonly GuardedChunk[], allowed?: readonly string[]): string[];
26
+ /**
27
+ * Throw when a built `--app gjs` bundle statically imports Node builtins.
28
+ *
29
+ * @param chunks the emitted chunks, as reported by the bundler
30
+ * @param outfile path of the bundle, for the error message
31
+ */
32
+ export declare function assertGjsBundleLoadable(chunks: readonly GuardedChunk[], outfile: string): void;
@@ -0,0 +1,103 @@
1
+ // Build-time guard: a `--app gjs` bundle must not keep bare `node:` imports.
2
+ //
3
+ // Stock GJS has NO `node:` module loader. A single surviving
4
+ // `import … from "node:fs"` makes it abort the ENTIRE module graph before one
5
+ // line executes:
6
+ //
7
+ // Gjs-WARNING **: JS ERROR: ImportError: Unsupported URI scheme for importing: node
8
+ // Gjs-CRITICAL **: Failed to resolve imports for module: '…/dist/cli.gjs.mjs'
9
+ //
10
+ // `--app gjs` maps every Node builtin to its `@gjsify/*` polyfill, so a healthy
11
+ // bundle has ZERO of them. But when an alias TARGET cannot be resolved — the
12
+ // classic case being a workspace checkout whose `@gjsify/*` packages have no
13
+ // built `lib/`, so `exports["."]` points at a file that does not exist — the
14
+ // bundler falls back to treating the import as external and re-emits the
15
+ // ORIGINAL `node:*` specifier. That is a WARNING, not an error: the build
16
+ // "succeeds" and writes a bundle that cannot load anywhere. It cost three red
17
+ // CI jobs to notice, because nothing between `gjsify build` and CI ever
18
+ // executes the emitted GJS bundle (tsc, lint, format and the unit/e2e suites
19
+ // all run the Node entry).
20
+ //
21
+ // The pre-commit hook makes it worse rather than better: it rebuilds and
22
+ // AUTO-STAGES `dist/cli.gjs.mjs`, so a developer whose checkout cannot resolve
23
+ // the polyfills commits a broken artifact without ever seeing it.
24
+ //
25
+ // So the refusal belongs at build time, in `gjsify build --app gjs` itself —
26
+ // not in a git hook and not in one package's `build:gjs-bundle` script. The
27
+ // invariant is a property of the ARTIFACT, and every showcase, every downstream
28
+ // app (buchhaltung, bauplaner, Learn6502) and CI all produce it through this
29
+ // one code path. A hook would protect only commits in this repo; a per-package
30
+ // script would protect one package.
31
+ //
32
+ // ## The oracle is the module graph, NOT the emitted text
33
+ //
34
+ // The first cut of this guard grepped the output for `from "node:…"`. That is
35
+ // wrong, and CI caught it: `@gjsify/process`'s own test bundle names a case
36
+ //
37
+ // await it(`named-import hrtime (from "node:process") preserves .bigint`, …)
38
+ //
39
+ // so the phrase appears verbatim inside a STRING, in a bundle that loads and
40
+ // runs under GJS perfectly well. The same trap sits in `@gjsify/cli`'s own
41
+ // bundle, which embeds `giNodeShimSource()`'s `--app node` codegen template
42
+ // (`import { createRequire } from 'node:module';`). Telling a statement from a
43
+ // quoted lookalike needs a JS lexer — but the bundler already did that work:
44
+ // every chunk carries the list of external specifiers it actually imports. That
45
+ // list is exact, engine-independent (npm `rolldown` returns it directly,
46
+ // `@gjsify/rolldown-native` forwards it through `synthRolldownOutput`), and
47
+ // free.
48
+ //
49
+ // Scope is STATIC imports, which is precisely the failure mode above: they are
50
+ // resolved when GJS links the module, so one bad specifier kills the whole
51
+ // bundle. A dynamic `import('node:x')` throws lazily at its call site instead —
52
+ // a real bug, but not "the artifact cannot load", and not what this gate is
53
+ // for.
54
+ /**
55
+ * Bare `node:` specifiers a `--app gjs` bundle may keep as static imports.
56
+ *
57
+ * Deliberately EMPTY. Every Node builtin has an `@gjsify/*` polyfill that
58
+ * `--app gjs` aliases to, so there is no specifier that both survives into the
59
+ * module graph and loads under GJS. Reading the graph instead of the text is
60
+ * what keeps this list empty — the entries an earlier text-scanning version
61
+ * needed (`node:module`, `node:process`) were quoted lookalikes, never imports.
62
+ *
63
+ * Add an entry ONLY with a comment naming why GJS can resolve it. Each one is a
64
+ * specifier this guard can no longer catch for real.
65
+ */
66
+ export const GJS_ALLOWED_NODE_IMPORTS = [];
67
+ /**
68
+ * The distinct bare `node:` specifiers `chunks` statically import, minus the
69
+ * allowlist. Sorted, so the error message and any test are stable.
70
+ */
71
+ export function findDisallowedNodeImports(chunks, allowed = GJS_ALLOWED_NODE_IMPORTS) {
72
+ const found = new Set();
73
+ for (const chunk of chunks) {
74
+ for (const specifier of chunk.imports ?? []) {
75
+ if (!specifier.startsWith('node:'))
76
+ continue;
77
+ if (allowed.includes(specifier))
78
+ continue;
79
+ found.add(specifier);
80
+ }
81
+ }
82
+ return [...found].sort();
83
+ }
84
+ /**
85
+ * Throw when a built `--app gjs` bundle statically imports Node builtins.
86
+ *
87
+ * @param chunks the emitted chunks, as reported by the bundler
88
+ * @param outfile path of the bundle, for the error message
89
+ */
90
+ export function assertGjsBundleLoadable(chunks, outfile) {
91
+ const offenders = findDisallowedNodeImports(chunks);
92
+ if (offenders.length === 0)
93
+ return;
94
+ throw new Error(`gjsify build --app gjs: ${outfile} still imports ${offenders.length} bare \`node:\` module(s), ` +
95
+ 'which stock GJS cannot resolve — it would fail at load with ' +
96
+ '"ImportError: Unsupported URI scheme for importing: node".\n' +
97
+ offenders.map((s) => ` - ${s}`).join('\n') +
98
+ '\n\nThese should have been aliased to their `@gjsify/*` polyfills. The usual cause is that the ' +
99
+ 'alias TARGET could not be resolved, so the bundler externalised the import instead (look for ' +
100
+ 'UNRESOLVED_IMPORT warnings above): e.g. a workspace checkout whose `@gjsify/*` packages have no ' +
101
+ 'built `lib/`. Build the workspace first (`gjsify run build:infra`), or fix the resolution, then ' +
102
+ 'rebuild.');
103
+ }
@@ -41,5 +41,29 @@ export declare function installPackagesNative(opts: InstallOptions): Promise<Ins
41
41
  export declare function warnMissingNativeBuilds(nodes: ResolvedNode[], prefix: string, log: Logger): void;
42
42
  export declare function parseSpec(raw: string): ParsedSpec;
43
43
  export declare function pickVersion(packument: Packument, range: string, preferred?: string): string | null;
44
+ /**
45
+ * Materialise ONE `node_modules/.bin/<name>` entry for `targetAbs`.
46
+ *
47
+ * Exported (and platform-injectable) so the Windows branch is unit-tested from
48
+ * a Linux host — the file layout and shim contents are asserted there; only the
49
+ * `symlinkSync`/`CreateSymbolicLink` syscall behaviour itself is OS-bound.
50
+ *
51
+ * POSIX: a relative symlink, with a copy as the fallback (some filesystems and
52
+ * container overlays reject symlinks).
53
+ *
54
+ * Windows: `.bin/<name>` is not on `PATHEXT` and Windows has no shebang
55
+ * handling, so a symlink OR a copy of a `#!/usr/bin/env node` script is
56
+ * unrunnable — and a *file* symlink additionally needs elevation or Developer
57
+ * Mode, so the previous code reliably fell into its copy fallback and produced
58
+ * a dead entry. npm solves this with three sibling files (`<name>` for
59
+ * git-bash/MSYS/WSL, `<name>.cmd` for cmd.exe, `<name>.ps1` for pwsh); we write
60
+ * the same set from the same templates. See `./bin-shim.ts`.
61
+ */
62
+ export declare function writeBinEntry(opts: {
63
+ binDir: string;
64
+ binName: string;
65
+ targetAbs: string;
66
+ platform?: string;
67
+ }): void;
44
68
  type Logger = (fmt: string, ...args: unknown[]) => void;
45
69
  export {};
@@ -24,6 +24,7 @@ import { acquireInstallLock } from './install-lock.js';
24
24
  import { getCachedTarball, getForeignCachedTarball, putCachedTarball, } from './install-tarball-cache.js';
25
25
  import { getCachedPackument, putCachedPackument } from './install-packument-cache.js';
26
26
  import { assertNativeBackendNodeVersion } from './node-version.js';
27
+ import { buildCmdShim, parseShebang } from './bin-shim.js';
27
28
  // 16-wide download pool. Matched to the shared Soup.Session's lifted
28
29
  // `max-conns-per-host` (see @gjsify/fetch `getSharedSession`) — a higher pool
29
30
  // here only translates into real concurrency because that cap was raised from
@@ -183,9 +184,7 @@ export function warnMissingNativeBuilds(nodes, prefix, log) {
183
184
  if (!hasBinary) {
184
185
  try {
185
186
  const releaseDir = path.join(pkgDir, 'build', 'Release');
186
- hasBinary = fs
187
- .readdirSync(releaseDir)
188
- .some((f) => f.endsWith('.node') || f.endsWith('.so'));
187
+ hasBinary = fs.readdirSync(releaseDir).some((f) => f.endsWith('.node') || f.endsWith('.so'));
189
188
  }
190
189
  catch {
191
190
  /* no build/Release */
@@ -984,7 +983,11 @@ async function extractOne(node, prefix, npmrc, log, signal) {
984
983
  // so a read-only HOME / out-of-disk cache volume doesn't break the install.
985
984
  putCachedTarball(node.integrity, bytes);
986
985
  }
987
- fs.rmSync(dest, { recursive: true, force: true });
986
+ // `rmWithRetry` (not a bare rmSync): on Windows the delete of a freshly
987
+ // extracted tree fails with EBUSY/EPERM while any handle is still open, and
988
+ // stays pending afterwards — so the mkdirSync on the next line would hit
989
+ // EPERM/ENOTEMPTY. No-op cost on POSIX. This runs for EVERY package.
990
+ rmWithRetry(dest);
988
991
  fs.mkdirSync(dest, { recursive: true });
989
992
  await extractWithStallGuard(bytes, dest, node, signal);
990
993
  return false;
@@ -1116,6 +1119,73 @@ function depth(installPath) {
1116
1119
  // `node_modules/foo` = 1, `node_modules/foo/node_modules/bar` = 2, etc.
1117
1120
  return installPath.split('/node_modules/').length;
1118
1121
  }
1122
+ /**
1123
+ * Read a bin file's `#!` line, if it has one. Returns `null` for an
1124
+ * unreadable/empty file or a first line that is not a shebang — the same
1125
+ * fallbacks npm's cmd-shim applies (it then invokes the target directly).
1126
+ */
1127
+ function readShebang(file) {
1128
+ let head;
1129
+ try {
1130
+ head = fs.readFileSync(file, 'utf-8');
1131
+ }
1132
+ catch {
1133
+ return null;
1134
+ }
1135
+ const firstLine = head.trimStart().split(/\r*\n/, 1)[0] ?? '';
1136
+ return parseShebang(firstLine);
1137
+ }
1138
+ /**
1139
+ * Materialise ONE `node_modules/.bin/<name>` entry for `targetAbs`.
1140
+ *
1141
+ * Exported (and platform-injectable) so the Windows branch is unit-tested from
1142
+ * a Linux host — the file layout and shim contents are asserted there; only the
1143
+ * `symlinkSync`/`CreateSymbolicLink` syscall behaviour itself is OS-bound.
1144
+ *
1145
+ * POSIX: a relative symlink, with a copy as the fallback (some filesystems and
1146
+ * container overlays reject symlinks).
1147
+ *
1148
+ * Windows: `.bin/<name>` is not on `PATHEXT` and Windows has no shebang
1149
+ * handling, so a symlink OR a copy of a `#!/usr/bin/env node` script is
1150
+ * unrunnable — and a *file* symlink additionally needs elevation or Developer
1151
+ * Mode, so the previous code reliably fell into its copy fallback and produced
1152
+ * a dead entry. npm solves this with three sibling files (`<name>` for
1153
+ * git-bash/MSYS/WSL, `<name>.cmd` for cmd.exe, `<name>.ps1` for pwsh); we write
1154
+ * the same set from the same templates. See `./bin-shim.ts`.
1155
+ */
1156
+ export function writeBinEntry(opts) {
1157
+ const { binDir, binName, targetAbs } = opts;
1158
+ const platform = opts.platform ?? process.platform;
1159
+ const linkPath = path.join(binDir, binName);
1160
+ if (platform === 'win32') {
1161
+ const relPosix = path.relative(binDir, targetAbs).split('\\').join('/');
1162
+ const { sh, cmd, ps1 } = buildCmdShim(relPosix, readShebang(targetAbs));
1163
+ for (const [file, contents] of [
1164
+ [linkPath, sh],
1165
+ [`${linkPath}.cmd`, cmd],
1166
+ [`${linkPath}.ps1`, ps1],
1167
+ ]) {
1168
+ rmWithRetry(file);
1169
+ fs.writeFileSync(file, contents);
1170
+ try {
1171
+ fs.chmodSync(file, 0o755);
1172
+ }
1173
+ catch {
1174
+ /* inert on Windows; harmless under WSL/MSYS */
1175
+ }
1176
+ }
1177
+ return;
1178
+ }
1179
+ rmWithRetry(linkPath);
1180
+ const rel = path.relative(binDir, targetAbs);
1181
+ try {
1182
+ fs.symlinkSync(rel, linkPath);
1183
+ }
1184
+ catch {
1185
+ fs.copyFileSync(targetAbs, linkPath);
1186
+ fs.chmodSync(linkPath, 0o755);
1187
+ }
1188
+ }
1119
1189
  async function linkBins(nodes, prefix, log) {
1120
1190
  // Only root-level packages publish bins into the top-level
1121
1191
  // `node_modules/.bin/`. Nested-package bins are addressable by their
@@ -1143,23 +1213,26 @@ async function linkBins(nodes, prefix, log) {
1143
1213
  catch {
1144
1214
  /* best effort */
1145
1215
  }
1146
- const linkPath = path.join(binDir, binName);
1147
- fs.rmSync(linkPath, { force: true });
1148
- const rel = path.relative(binDir, targetAbs);
1149
- try {
1150
- fs.symlinkSync(rel, linkPath);
1151
- created++;
1152
- }
1153
- catch {
1154
- fs.copyFileSync(targetAbs, linkPath);
1155
- fs.chmodSync(linkPath, 0o755);
1156
- created++;
1157
- }
1216
+ writeBinEntry({ binDir, binName, targetAbs });
1217
+ created++;
1158
1218
  }
1159
1219
  }
1160
1220
  if (created > 0)
1161
1221
  log('bin: linked %d entry(ies) under .bin/', created);
1162
1222
  }
1223
+ /**
1224
+ * `fs.rmSync(..., { force: true })` with the Windows retry npm/rimraf apply.
1225
+ *
1226
+ * On Windows a delete fails with `EBUSY`/`EPERM` while any process holds a
1227
+ * handle (antivirus scan, an editor, a still-terminating child), and the
1228
+ * deletion stays *pending* until the last handle closes — so an immediately
1229
+ * following create can hit `EPERM`/`ENOTEMPTY`. Node implements the retry loop
1230
+ * for us behind `maxRetries`; it is a no-op on POSIX, where the first attempt
1231
+ * always succeeds.
1232
+ */
1233
+ function rmWithRetry(target) {
1234
+ fs.rmSync(target, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
1235
+ }
1163
1236
  function normalizeBin(pkgName, bin) {
1164
1237
  const out = new Map();
1165
1238
  if (typeof bin === 'string') {
@@ -86,6 +86,22 @@ export interface LinkedBin {
86
86
  * simplicity and so we don't need to discriminate by runtime here.
87
87
  */
88
88
  export declare function linkGlobalBins(packageNames: string[], layout: GlobalLayout): LinkedBin[];
89
+ /**
90
+ * Build the POSIX-sh `export` lines that prepend the given prebuild
91
+ * directories to GI_TYPELIB_PATH and the host's shared-library search path.
92
+ * Any pre-existing value inherited from the user's environment is preserved as
93
+ * a suffix so user-installed typelibs/libraries still resolve.
94
+ *
95
+ * The library variable is host-dependent — `LD_LIBRARY_PATH` on Linux,
96
+ * `DYLD_LIBRARY_PATH` on macOS (dyld never reads the ELF one), so a launcher
97
+ * written on a Mac used to export a variable nothing consults, leaving every
98
+ * `darwin-arm64` prebuild unloadable. `platform` is a parameter so both
99
+ * branches are unit-testable from either host.
100
+ *
101
+ * Returns the empty string when no prebuilds were found — avoids emitting an
102
+ * inert assignment in the launcher.
103
+ */
104
+ export declare function buildLauncherEnvPreamble(prebuildsDirs: string[], platform?: string): string;
89
105
  /** Returns `true` if `binDir` is on the user's PATH. */
90
106
  export declare function binDirOnPath(binDir: string): boolean;
91
107
  /**