@gjsify/cli 0.22.0 → 0.23.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 (37) hide show
  1. package/README.md +24 -1
  2. package/dist/cli.gjs.mjs +257 -184
  3. package/lib/bundler-pick.js +12 -4
  4. package/lib/commands/affected.js +22 -10
  5. package/lib/commands/check.js +38 -29
  6. package/lib/commands/flatpak/build.js +19 -18
  7. package/lib/commands/foreach.js +48 -41
  8. package/lib/commands/info.js +11 -5
  9. package/lib/commands/install.d.ts +16 -0
  10. package/lib/commands/install.js +103 -32
  11. package/lib/commands/onboard.js +6 -1
  12. package/lib/commands/pack.js +69 -4
  13. package/lib/commands/run.js +26 -14
  14. package/lib/commands/trust.js +4 -62
  15. package/lib/commands/workspace.js +49 -30
  16. package/lib/utils/bin-shim.d.ts +66 -0
  17. package/lib/utils/bin-shim.js +246 -0
  18. package/lib/utils/build-cache.d.ts +42 -6
  19. package/lib/utils/build-cache.js +114 -42
  20. package/lib/utils/detect-native-packages.d.ts +112 -6
  21. package/lib/utils/detect-native-packages.js +201 -29
  22. package/lib/utils/install-backend-native.d.ts +24 -0
  23. package/lib/utils/install-backend-native.js +89 -16
  24. package/lib/utils/install-global.d.ts +16 -0
  25. package/lib/utils/install-global.js +58 -8
  26. package/lib/utils/oxc-resolve.js +18 -55
  27. package/lib/utils/publish-headers.d.ts +2 -3
  28. package/lib/utils/publish-headers.js +64 -3
  29. package/lib/utils/publishable-packages.d.ts +22 -0
  30. package/lib/utils/publishable-packages.js +93 -0
  31. package/lib/utils/run-gjs.d.ts +6 -7
  32. package/lib/utils/run-gjs.js +4 -3
  33. package/lib/utils/spawn.d.ts +82 -0
  34. package/lib/utils/spawn.js +139 -0
  35. package/lib/utils/stdin.d.ts +25 -0
  36. package/lib/utils/stdin.js +132 -0
  37. 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,126 @@
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 used by the
26
+ * Vala/meson bridges, which stage into `prebuilds/linux-x86_64/` (the output
27
+ * of `uname -m` on the build host).
28
+ */
29
+ const NODE_ARCH_TO_UNAME = {
30
+ x64: 'x86_64',
31
+ arm64: 'aarch64',
32
+ arm: 'armv7',
33
+ ia32: 'i686',
34
+ };
35
+ /**
36
+ * Arch spellings that name the same target. Mirrors `ARCH_ALIASES` in
37
+ * `scripts/audit-runtimes.mjs` — that script canonicalises
38
+ * `package.json#gjsify.platforms` the same way for the platform-support
39
+ * matrix, and the two MUST agree or a package could pass the audit while the
40
+ * CLI fails to find its artifact.
41
+ */
42
+ const ARCH_ALIASES = {
43
+ x64: 'x86_64',
44
+ amd64: 'x86_64',
45
+ arm64: 'aarch64',
46
+ };
47
+ /**
48
+ * Canonical `<os>-<arch>` form so `linux-x64` and `linux-x86_64` — or
49
+ * `darwin-arm64` and `darwin-aarch64` — compare equal. Only the *arch* half is
50
+ * normalised; the OS half is always a `process.platform` token
51
+ * (`linux`/`darwin`/`win32`), which has no competing spelling.
52
+ */
53
+ export function canonicalPlatformToken(token) {
54
+ const dash = token.indexOf('-');
55
+ if (dash < 0)
56
+ return token;
57
+ const os = token.slice(0, dash);
58
+ const arch = token.slice(dash + 1);
59
+ return `${os}-${ARCH_ALIASES[arch] ?? arch}`;
60
+ }
61
+ /**
62
+ * The prebuild directory names to probe for a host, most-specific first.
63
+ *
64
+ * There are two live spellings in this repo and neither can be declared the
65
+ * winner:
66
+ *
67
+ * * the Vala/meson bridges stage `prebuilds/linux-x86_64/` — uname-style,
68
+ * because the meson jobs name the directory after `uname -m`;
69
+ * * `@gjsify/node-gi` (and `@gjsify/napi`'s prebuild staging) use
70
+ * `prebuilds/${process.platform}-${process.arch}/` — node-style
71
+ * (`linux-x64`, `darwin-arm64`, `win32-x64`).
72
+ *
73
+ * So we probe both, and — when the package declares `gjsify.platforms` — we
74
+ * probe the package's OWN spelling first. That is strictly better than
75
+ * guessing: a package that declares `darwin-arm64` tells us the exact
76
+ * directory name it stages, whatever convention it picked.
77
+ *
78
+ * Ordering of the generic fallbacks is per-OS rather than fixed, so the first
79
+ * probe is the spelling that platform's toolchain actually produces:
80
+ * uname-style on Linux (`linux-x86_64`), node-style on macOS/Windows
81
+ * (`darwin-arm64`, `win32-x64` — on Apple silicon `uname -m` also reports
82
+ * `arm64`, so there node-style *is* the native spelling).
83
+ *
84
+ * @param platform `process.platform` value.
85
+ * @param arch `process.arch` value.
86
+ * @param declaredPlatforms `package.json#gjsify.platforms`, when present.
87
+ */
88
+ export function prebuildDirCandidates(platform, arch, declaredPlatforms) {
89
+ const unameArch = NODE_ARCH_TO_UNAME[arch] ?? arch;
90
+ const preferred = platform === 'linux' ? unameArch : arch;
91
+ const alternate = platform === 'linux' ? arch : unameArch;
92
+ const generic = [`${platform}-${preferred}`, `${platform}-${alternate}`];
93
+ const hostCanonical = canonicalPlatformToken(generic[0]);
94
+ const out = [];
95
+ const push = (name) => {
96
+ if (!out.includes(name))
97
+ out.push(name);
23
98
  };
24
- return map[arch] ?? arch;
99
+ // 1. The package's own declared spelling for THIS host, if it declares one.
100
+ for (const declared of declaredPlatforms ?? []) {
101
+ if (typeof declared !== 'string')
102
+ continue;
103
+ if (canonicalPlatformToken(declared) === hostCanonical)
104
+ push(declared);
105
+ }
106
+ // 2. The two generic spellings.
107
+ for (const name of generic)
108
+ push(name);
109
+ return out;
110
+ }
111
+ /**
112
+ * Pick the prebuild directory a host should load, given the directories that
113
+ * actually exist under `<pkg>/<prebuilds>/`.
114
+ *
115
+ * Pure: no `process.*` reads, no filesystem access. This is the function the
116
+ * darwin and win32 branches are unit-tested through on a Linux host.
117
+ *
118
+ * @returns the chosen directory NAME (not a path), or `null` when the package
119
+ * ships nothing loadable on this host.
120
+ */
121
+ export function resolvePrebuildDirName(input) {
122
+ const { platform, arch, declaredPlatforms, existingDirs } = input;
123
+ for (const candidate of prebuildDirCandidates(platform, arch, declaredPlatforms)) {
124
+ if (existingDirs.includes(candidate))
125
+ return candidate;
126
+ }
127
+ return null;
25
128
  }
26
129
  /**
27
130
  * Scan all packages in a node_modules directory for gjsify native prebuilds.
28
131
  * Handles scoped packages (@scope/name) as well as flat packages.
29
132
  */
30
- function scanNodeModules(nodeModulesDir, arch) {
133
+ function scanNodeModules(nodeModulesDir, target) {
31
134
  const results = [];
32
135
  if (!existsSync(nodeModulesDir))
33
136
  return results;
@@ -53,14 +156,14 @@ function scanNodeModules(nodeModulesDir, arch) {
53
156
  }
54
157
  for (const scopedPkg of scopeEntries) {
55
158
  const pkgDir = join(scopeDir, scopedPkg);
56
- const native = checkPackage(pkgDir, `${entry}/${scopedPkg}`, arch);
159
+ const native = checkPackage(pkgDir, `${entry}/${scopedPkg}`, target);
57
160
  if (native)
58
161
  results.push(native);
59
162
  }
60
163
  }
61
164
  else {
62
165
  const pkgDir = join(nodeModulesDir, entry);
63
- const native = checkPackage(pkgDir, entry, arch);
166
+ const native = checkPackage(pkgDir, entry, target);
64
167
  if (native)
65
168
  results.push(native);
66
169
  }
@@ -68,20 +171,42 @@ function scanNodeModules(nodeModulesDir, arch) {
68
171
  return results;
69
172
  }
70
173
  /** Check a single package directory for gjsify prebuilds metadata. */
71
- function checkPackage(pkgDir, name, arch) {
174
+ function checkPackage(pkgDir, name, target) {
72
175
  const pkgJson = readPackageJson(join(pkgDir, 'package.json'));
73
176
  if (!pkgJson)
74
177
  return null;
75
178
  const gjsifyMeta = pkgJson['gjsify'];
76
179
  if (!gjsifyMeta || typeof gjsifyMeta !== 'object')
77
180
  return null;
78
- const prebuildsField = gjsifyMeta['prebuilds'];
181
+ const meta = gjsifyMeta;
182
+ const prebuildsField = meta['prebuilds'];
79
183
  if (typeof prebuildsField !== 'string')
80
184
  return null;
81
- const prebuildsDir = join(pkgDir, prebuildsField, `linux-${arch}`);
82
- if (!existsSync(prebuildsDir))
185
+ const declaredRaw = meta['platforms'];
186
+ const declaredPlatforms = Array.isArray(declaredRaw)
187
+ ? declaredRaw.filter((p) => typeof p === 'string')
188
+ : undefined;
189
+ const prebuildsRoot = join(pkgDir, prebuildsField);
190
+ let existingDirs;
191
+ try {
192
+ // A prebuild dir may legitimately be a symlink (workspace/dev layouts),
193
+ // so accept both real directories and links.
194
+ existingDirs = readdirSync(prebuildsRoot, { withFileTypes: true })
195
+ .filter((e) => e.isDirectory() || e.isSymbolicLink())
196
+ .map((e) => e.name);
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ const dirName = resolvePrebuildDirName({
202
+ platform: target.platform,
203
+ arch: target.arch,
204
+ declaredPlatforms,
205
+ existingDirs,
206
+ });
207
+ if (!dirName)
83
208
  return null;
84
- return { name, prebuildsDir };
209
+ return { name, prebuildsDir: join(prebuildsRoot, dirName) };
85
210
  }
86
211
  /**
87
212
  * Walk up the directory tree from `startDir` and merge native packages found
@@ -95,16 +220,22 @@ function checkPackage(pkgDir, name, arch) {
95
220
  *
96
221
  * Deduplication: the first match for a given package name wins (closer
97
222
  * node_modules shadows outer ones), matching Node.js resolution semantics.
223
+ *
224
+ * @param target overrides the host platform/arch. Only tests pass this; the
225
+ * production call sites take the defaults.
98
226
  */
99
- export function detectNativePackages(startDir) {
100
- const arch = nodeArchToLinuxArch(process.arch);
227
+ export function detectNativePackages(startDir, target = {}) {
228
+ const resolved = {
229
+ platform: target.platform ?? process.platform,
230
+ arch: target.arch ?? process.arch,
231
+ };
101
232
  const merged = [];
102
233
  const seen = new Set();
103
234
  let dir = resolve(startDir);
104
235
  while (true) {
105
236
  const nodeModulesDir = join(dir, 'node_modules');
106
237
  if (existsSync(nodeModulesDir)) {
107
- for (const pkg of scanNodeModules(nodeModulesDir, arch)) {
238
+ for (const pkg of scanNodeModules(nodeModulesDir, resolved)) {
108
239
  if (seen.has(pkg.name))
109
240
  continue;
110
241
  seen.add(pkg.name);
@@ -119,14 +250,55 @@ export function detectNativePackages(startDir) {
119
250
  return merged;
120
251
  }
121
252
  /**
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.
253
+ * The environment-variable name the host's dynamic loader consults for an
254
+ * additional shared-library search path, plus the separator its search paths
255
+ * use.
256
+ *
257
+ * * **Linux / other ELF** — `LD_LIBRARY_PATH`, `:`-separated.
258
+ * * **macOS** — `DYLD_LIBRARY_PATH`. `dyld` does not read `LD_LIBRARY_PATH`
259
+ * at all, which is why `.github/workflows/napi.yml` sets `DYLD_LIBRARY_PATH`
260
+ * by hand on every macOS gate step.
261
+ * * **Windows** — there is no dedicated variable; `LoadLibrary` searches the
262
+ * process `PATH`, `;`-separated. This is the same mechanism
263
+ * `@gjsify/node-gi`'s `maybePrependGtkRuntimeDllPath()` uses to make its
264
+ * bundled GTK DLLs resolvable.
124
265
  */
125
- export function buildNativeEnv(packages) {
266
+ export function libraryPathVar(platform) {
267
+ if (platform === 'darwin')
268
+ return { name: 'DYLD_LIBRARY_PATH', separator: ':' };
269
+ if (platform === 'win32')
270
+ return { name: 'PATH', separator: ';' };
271
+ return { name: 'LD_LIBRARY_PATH', separator: ':' };
272
+ }
273
+ /**
274
+ * Build the typelib + shared-library search-path environment for the detected
275
+ * native packages. Prepends the new paths to any existing values.
276
+ *
277
+ * Pure w.r.t. the host: `platform` and `env` are parameters, so the darwin and
278
+ * win32 branches are exercised by unit tests running on Linux.
279
+ *
280
+ * On Windows the `PATH` key is written back under the *host's own* spelling
281
+ * (a stock Windows env block uses `Path`) — Windows env names are
282
+ * case-insensitive, but a plain JS object is not, and `{...process.env,
283
+ * PATH: …}` would hand a child process two competing entries.
284
+ */
285
+ export function buildNativeEnv(packages, opts = {}) {
286
+ const platform = opts.platform ?? process.platform;
287
+ const env = opts.env ?? process.env;
126
288
  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 };
289
+ // GLib's search-path separator is `;` on Windows (G_SEARCHPATH_SEPARATOR),
290
+ // `:` everywhere else — the same split as the loader variable below.
291
+ const giSeparator = platform === 'win32' ? ';' : ':';
292
+ const { name: libVarName, separator: libSeparator } = libraryPathVar(platform);
293
+ // Windows env names are case-insensitive; reuse whatever casing the host
294
+ // block already uses so we replace rather than shadow it.
295
+ const libVarKey = platform === 'win32'
296
+ ? (Object.keys(env).find((k) => k.toLowerCase() === libVarName.toLowerCase()) ?? libVarName)
297
+ : libVarName;
298
+ const prepend = (value, separator) => [...dirs, ...(value ? [value] : [])].join(separator);
299
+ const out = {
300
+ GI_TYPELIB_PATH: prepend(env['GI_TYPELIB_PATH'], giSeparator),
301
+ };
302
+ out[libVarKey] = prepend(env[libVarKey], libSeparator);
303
+ return out;
132
304
  }
@@ -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
  /**
@@ -23,7 +23,8 @@
23
23
  import * as fs from 'node:fs';
24
24
  import * as os from 'node:os';
25
25
  import * as path from 'node:path';
26
- import { detectNativePackages } from './detect-native-packages.js';
26
+ import { detectNativePackages, libraryPathVar } from './detect-native-packages.js';
27
+ import { buildLauncherShims, parseShebang } from './bin-shim.js';
27
28
  /**
28
29
  * Compute the canonical global install layout for the current user. Honours
29
30
  * `XDG_DATA_HOME` (per the XDG Base Directory Spec) plus
@@ -174,7 +175,9 @@ export function linkGlobalBins(packageNames, layout) {
174
175
  /* best effort */
175
176
  }
176
177
  const linkPath = path.join(layout.binDir, binName);
177
- fs.rmSync(linkPath, { force: true });
178
+ for (const stale of [linkPath, `${linkPath}.cmd`, `${linkPath}.ps1`]) {
179
+ fs.rmSync(stale, { force: true, maxRetries: 10, retryDelay: 100 });
180
+ }
178
181
  // Inline `${target}` directly — this file is rewritten on every
179
182
  // install, paths are user-owned, and POSIX `sh` quoting via
180
183
  // single-quotes plus `'\''` for embedded quotes is well-defined.
@@ -196,6 +199,36 @@ export function linkGlobalBins(packageNames, layout) {
196
199
  : `#!/bin/sh\nexec ${shQuote(targetAbs)} "$@"\n`;
197
200
  fs.writeFileSync(linkPath, launcher);
198
201
  fs.chmodSync(linkPath, 0o755);
202
+ // Windows runs neither an extension-less file nor a `#!` line, so
203
+ // the `sh` launcher above is only reachable from git-bash/MSYS/WSL.
204
+ // Write the `.cmd`/`.ps1` companions cmd.exe and pwsh need — the
205
+ // same three-file layout npm's cmd-shim produces.
206
+ if (process.platform === 'win32') {
207
+ const joined = nativePrebuildDirs.join(';');
208
+ // No dedicated DLL-search variable on Windows: LoadLibrary
209
+ // searches PATH, and GLib's search-path separator is `;` there
210
+ // too — so both variables take the same `;`-joined value.
211
+ const prependEnv = isGjsBundle && nativePrebuildDirs.length > 0 ? { GI_TYPELIB_PATH: joined, PATH: joined } : {};
212
+ // A non-bundle bin is exec'd directly by the sh launcher, which
213
+ // relies on its `#!` line; batch/pwsh have no shebang support,
214
+ // so read the interpreter out of it the way cmd-shim does
215
+ // (defaulting to `node`, what every npm bin declares).
216
+ const shebang = isGjsBundle ? null : readShebangOf(targetAbs);
217
+ const shims = isGjsBundle
218
+ ? buildLauncherShims({
219
+ interpreter: 'gjs',
220
+ interpreterArgs: ['-m'],
221
+ target: targetAbs,
222
+ prependEnv,
223
+ })
224
+ : buildLauncherShims({
225
+ interpreter: shebang?.prog || 'node',
226
+ interpreterArgs: shebang?.args.trim() ? shebang.args.trim().split(/\s+/) : [],
227
+ target: targetAbs,
228
+ });
229
+ fs.writeFileSync(`${linkPath}.cmd`, shims.cmd);
230
+ fs.writeFileSync(`${linkPath}.ps1`, shims.ps1);
231
+ }
199
232
  created.push({ name: binName, target: targetAbs, link: linkPath });
200
233
  }
201
234
  }
@@ -206,20 +239,37 @@ function shQuote(s) {
206
239
  }
207
240
  /**
208
241
  * Build the POSIX-sh `export` lines that prepend the given prebuild
209
- * directories to GI_TYPELIB_PATH and LD_LIBRARY_PATH. Any pre-existing value
210
- * inherited from the user's environment is preserved as a suffix so
211
- * user-installed typelibs/libraries still resolve.
242
+ * directories to GI_TYPELIB_PATH and the host's shared-library search path.
243
+ * Any pre-existing value inherited from the user's environment is preserved as
244
+ * a suffix so user-installed typelibs/libraries still resolve.
245
+ *
246
+ * The library variable is host-dependent — `LD_LIBRARY_PATH` on Linux,
247
+ * `DYLD_LIBRARY_PATH` on macOS (dyld never reads the ELF one), so a launcher
248
+ * written on a Mac used to export a variable nothing consults, leaving every
249
+ * `darwin-arm64` prebuild unloadable. `platform` is a parameter so both
250
+ * branches are unit-testable from either host.
212
251
  *
213
252
  * Returns the empty string when no prebuilds were found — avoids emitting an
214
253
  * inert assignment in the launcher.
215
254
  */
216
- function buildLauncherEnvPreamble(prebuildsDirs) {
255
+ export function buildLauncherEnvPreamble(prebuildsDirs, platform = process.platform) {
217
256
  if (prebuildsDirs.length === 0)
218
257
  return '';
258
+ const { name: libVar } = libraryPathVar(platform);
219
259
  const joined = shQuote(prebuildsDirs.join(':'));
220
260
  return (`GI_TYPELIB_PATH=${joined}\${GI_TYPELIB_PATH:+":$GI_TYPELIB_PATH"}\n` +
221
- `LD_LIBRARY_PATH=${joined}\${LD_LIBRARY_PATH:+":$LD_LIBRARY_PATH"}\n` +
222
- `export GI_TYPELIB_PATH LD_LIBRARY_PATH\n`);
261
+ `${libVar}=${joined}\${${libVar}:+":$${libVar}"}\n` +
262
+ `export GI_TYPELIB_PATH ${libVar}\n`);
263
+ }
264
+ /** Parse a bin file's `#!` line; `null` when unreadable or not a shebang. */
265
+ function readShebangOf(file) {
266
+ try {
267
+ const head = fs.readFileSync(file, 'utf-8');
268
+ return parseShebang(head.trimStart().split(/\r*\n/, 1)[0] ?? '');
269
+ }
270
+ catch {
271
+ return null;
272
+ }
223
273
  }
224
274
  function pickBinMap(pkgName, pkgJson) {
225
275
  const gjsifyEntry = pkgJson.gjsify;