@gjsify/cli 0.26.1 → 0.27.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 (55) hide show
  1. package/dist/cli.gjs.mjs +184 -182
  2. package/lib/cli-app.js +3 -1
  3. package/lib/commands/affected.js +1 -1
  4. package/lib/commands/build.js +21 -14
  5. package/lib/commands/check.js +40 -4
  6. package/lib/commands/clear.d.ts +8 -0
  7. package/lib/commands/clear.js +90 -0
  8. package/lib/commands/copy.d.ts +8 -0
  9. package/lib/commands/copy.js +87 -0
  10. package/lib/commands/dlx.js +8 -1
  11. package/lib/commands/index.d.ts +2 -0
  12. package/lib/commands/index.js +2 -0
  13. package/lib/commands/install.d.ts +4 -0
  14. package/lib/commands/install.js +164 -15
  15. package/lib/commands/onboard.js +17 -14
  16. package/lib/commands/publish.js +18 -4
  17. package/lib/commands/storybook.js +5 -1
  18. package/lib/commands/test.js +29 -2
  19. package/lib/commands/uninstall.js +44 -2
  20. package/lib/commands/upgrade.js +1 -4
  21. package/lib/templates/install.mjs.tmpl +110 -19
  22. package/lib/utils/bin-shim.d.ts +73 -0
  23. package/lib/utils/bin-shim.js +97 -2
  24. package/lib/utils/build-cache.d.ts +7 -27
  25. package/lib/utils/build-cache.js +27 -2
  26. package/lib/utils/check-system-deps.d.ts +15 -1
  27. package/lib/utils/check-system-deps.js +126 -2
  28. package/lib/utils/clear-targets.d.ts +30 -0
  29. package/lib/utils/clear-targets.js +114 -0
  30. package/lib/utils/copy-targets.d.ts +41 -0
  31. package/lib/utils/copy-targets.js +145 -0
  32. package/lib/utils/detect-native-packages.d.ts +160 -5
  33. package/lib/utils/detect-native-packages.js +326 -50
  34. package/lib/utils/dir-link.d.ts +34 -6
  35. package/lib/utils/dir-link.js +23 -9
  36. package/lib/utils/install-backend-native.d.ts +109 -1
  37. package/lib/utils/install-backend-native.js +622 -59
  38. package/lib/utils/install-backend.js +17 -15
  39. package/lib/utils/install-global.js +6 -34
  40. package/lib/utils/install-lock.js +1 -1
  41. package/lib/utils/install-packument-cache.d.ts +36 -17
  42. package/lib/utils/install-packument-cache.js +59 -31
  43. package/lib/utils/oxc-resolve.d.ts +27 -1
  44. package/lib/utils/oxc-resolve.js +53 -26
  45. package/lib/utils/pkg-json.d.ts +24 -0
  46. package/lib/utils/pkg-json.js +27 -1
  47. package/lib/utils/platform-check.d.ts +215 -0
  48. package/lib/utils/platform-check.js +377 -0
  49. package/lib/utils/resolve-npm-package.js +9 -2
  50. package/lib/utils/run-gjs.d.ts +1 -1
  51. package/lib/utils/run-gjs.js +19 -3
  52. package/lib/utils/spawn.js +50 -2
  53. package/lib/utils/win32-command.d.ts +54 -0
  54. package/lib/utils/win32-command.js +140 -0
  55. package/package.json +19 -20
@@ -124,8 +124,29 @@ export const installCommand = {
124
124
  description: 'Overall install wall-clock timeout in ms (default 1800000 = 30 min). On timeout, all in-flight registry fetches are aborted and the install exits non-zero with a clear "install timed out — likely a registry slowdown" message. Per-request timeouts in @gjsify/npm-registry (default 30s) still apply within this budget. Set to 0 to disable the overall budget.',
125
125
  type: 'number',
126
126
  default: DEFAULT_INSTALL_TIMEOUT_MS,
127
+ })
128
+ .option('os', {
129
+ description: 'Resolve/install for this OS instead of the running one (npm config key `os`, e.g. darwin, win32, linux). Affects which packages pass the os/cpu/libc compatibility check; the lockfile stays platform-independent either way.',
130
+ type: 'string',
131
+ })
132
+ .option('cpu', {
133
+ description: 'Resolve/install for this CPU architecture instead of the running one (npm config key `cpu`, e.g. x64, arm64).',
134
+ type: 'string',
135
+ })
136
+ .option('libc', {
137
+ description: 'Resolve/install for this libc family instead of probing the host (npm config key `libc`: glibc or musl). Only meaningful with --os=linux. A package declaring a libc is incompatible when the target libc is unknown.',
138
+ type: 'string',
139
+ })
140
+ .option('force', {
141
+ description: 'Install a REQUIRED dependency even when its os/cpu/libc excludes the target, instead of failing with EBADPLATFORM (npm config key `force`). Incompatible OPTIONAL dependencies stay skipped — npm ignores --force for those too, and a binary that cannot load is not worth downloading.',
142
+ type: 'boolean',
143
+ default: false,
127
144
  }),
128
145
  handler: async (args) => {
146
+ // Publish `--os/--cpu/--libc/--force` as npm CONFIG KEYS before anything
147
+ // resolves — see applyPlatformConfigFromFlags for why the environment is
148
+ // the right channel for them rather than a parameter.
149
+ applyPlatformConfigFromFlags(args);
129
150
  // --immutable is incompatible with explicit `<pkg>` adds and with
130
151
  // `--global` (which has no lockfile concept). Matches yarn's
131
152
  // behavior: `yarn add --immutable` is a hard error.
@@ -213,12 +234,15 @@ export const installCommand = {
213
234
  // Do NOT assert a cause here — this timer cannot tell the
214
235
  // two shapes apart, and claiming the rarer one sends the
215
236
  // next reader after a bug that isn't there. Measured on a
216
- // cold tree of this workspace: 1597 packages / ~4.8 GB
217
- // extracted, of which ~3.4 GB are foreign-platform
218
- // optional deps (`os`/`cpu` excludes the host) that npm
219
- // would never place. That install was still making steady
220
- // progress when the 30-min budget elapsed, and a re-run
221
- // finished the remainder in ~2 min. Extraction also has
237
+ // cold tree of this workspace WHEN THIS TIMER WAS WRITTEN:
238
+ // 1597 packages / ~4.8 GB extracted, of which ~3.4 GB were
239
+ // foreign-platform optional deps (`os`/`cpu` excluding the
240
+ // host) that npm would never place. Those are no longer
241
+ // downloaded — the resolver now records `os`/`cpu`/`libc`
242
+ // and marks incompatible optional nodes inert
243
+ // (`utils/platform-check.ts` + `applyPlatformFilter`), which
244
+ // leaves ~1.4 GB of that same tree to extract. The budget
245
+ // still exists for the same reason: extraction has
222
246
  // multi-second SYNCHRONOUS stretches (tar parse + file
223
247
  // writes) and 16 run concurrently, so the clean abort path
224
248
  // can easily miss the grace window with nothing wedged.
@@ -261,6 +285,43 @@ export const installCommand = {
261
285
  }
262
286
  },
263
287
  };
288
+ /**
289
+ * Hand the platform target + force bypass to the install backend.
290
+ *
291
+ * `os`, `cpu`, `libc` and `force` are npm CONFIG KEYS, and npm's own transport
292
+ * for a config key is the environment: `--libc=musl` and `npm_config_libc=musl`
293
+ * are the same input, which is why supporting the flags gives users the env
294
+ * spelling for free. That is also why this is not threaded through as a
295
+ * parameter: the config channel is already what this installer reads
296
+ * (`loadNpmrc` honours `npm_config_registry` exactly so), one write reaches
297
+ * EVERY prefix an invocation may install into (project, workspace root, `-g`,
298
+ * the `dlx` cache) instead of one call site each, and a child process inherits
299
+ * it — so a nested install can't quietly resolve for a different machine than
300
+ * the run that spawned it.
301
+ *
302
+ * Empty/whitespace values are ignored rather than written as the empty string:
303
+ * `--os=` must not mean "the nameless platform" (`readPlatformOverrides` drops
304
+ * them on the read side too, so the two ends agree).
305
+ */
306
+ function applyPlatformConfigFromFlags(args) {
307
+ const keys = [
308
+ ['os', 'npm_config_os'],
309
+ ['cpu', 'npm_config_cpu'],
310
+ ['libc', 'npm_config_libc'],
311
+ ];
312
+ for (const [flag, envKey] of keys) {
313
+ const value = args[flag];
314
+ if (typeof value !== 'string')
315
+ continue;
316
+ const trimmed = value.trim();
317
+ if (trimmed !== '')
318
+ process.env[envKey] = trimmed;
319
+ }
320
+ // Only ever SET: a `false` flag is the default, and clearing the key would
321
+ // override an `npm_config_force` the user deliberately exported.
322
+ if (args.force)
323
+ process.env.npm_config_force = 'true';
324
+ }
264
325
  /**
265
326
  * Heuristic: was this error raised because the overall-install AbortSignal
266
327
  * fired? The signal's `reason` is the sentinel `Error('install-overall-timeout')`
@@ -376,6 +437,36 @@ function isWorkspaceRoot(cwd) {
376
437
  return false;
377
438
  return pkg.workspaces !== undefined;
378
439
  }
440
+ /**
441
+ * Which dependency NAMES are optional across the given manifests.
442
+ *
443
+ * The specs handed to the install backend are flat `"<name>@<range>"` strings —
444
+ * `projectSpecsFromPackageJson` merges the three dependency blocks — so the kind
445
+ * has to travel beside them. It matters for exactly one decision: an
446
+ * incompatible `os`/`cpu`/`libc` skips an OPTIONAL dependency and fails a
447
+ * REQUIRED one (see `NativeInstallOptions.optionalSpecs`).
448
+ *
449
+ * REQUIRED WINS. In a workspace, the same package can be a plain dependency of
450
+ * one member and an optionalDependency of another; if anything is not allowed to
451
+ * be missing, the whole install is not allowed to silently miss it. Subtracting
452
+ * rather than "last block seen" also makes the answer independent of manifest
453
+ * iteration order.
454
+ */
455
+ function optionalDependencyNames(manifests) {
456
+ const optional = new Set();
457
+ const required = new Set();
458
+ for (const manifest of manifests) {
459
+ for (const name of Object.keys(manifest.optionalDependencies ?? {}))
460
+ optional.add(name);
461
+ for (const kind of ['dependencies', 'devDependencies']) {
462
+ for (const name of Object.keys(manifest[kind] ?? {}))
463
+ required.add(name);
464
+ }
465
+ }
466
+ for (const name of required)
467
+ optional.delete(name);
468
+ return optional;
469
+ }
379
470
  function depKindFromArgs(args) {
380
471
  if (args['save-dev'])
381
472
  return 'devDependencies';
@@ -469,7 +560,11 @@ async function projectInstallNative(args, signal) {
469
560
  // with ours. `installPackages` re-acquires the same lock re-entrantly.
470
561
  const lock = await acquireInstallLock(cwd, { signal });
471
562
  try {
472
- const result = await installPackages({
563
+ // Typed as NativeInstallOptions so the native-backend-only
564
+ // `optionalSpecs` is CHECKED at this call site instead of being smuggled
565
+ // past an object-literal excess-property check; `installPackages`
566
+ // forwards the object to that backend unchanged.
567
+ const nativeOpts = {
473
568
  prefix: cwd,
474
569
  specs,
475
570
  verbose: args.verbose,
@@ -480,7 +575,15 @@ async function projectInstallNative(args, signal) {
480
575
  refreshLockfile: args['refresh-lockfile'],
481
576
  signal,
482
577
  progress,
483
- });
578
+ // `gjsify install -O <pkg>` saves to optionalDependencies, but the
579
+ // manifest is only written AFTER this install — so the flag, not the
580
+ // manifest, is what makes this run treat the added specs as optional.
581
+ optionalSpecs: new Set([
582
+ ...optionalDependencyNames(pkg ? [pkg] : []),
583
+ ...(args['save-optional'] ? (args.packages ?? []).map((s) => parseSpec(s).name) : []),
584
+ ]),
585
+ };
586
+ const result = await installPackages(nativeOpts);
484
587
  // Update package.json only when the user passed explicit packages
485
588
  // (the `gjsify install <pkg>...` add-a-dep flow). The no-args refresh
486
589
  // flow doesn't mutate manifest entries.
@@ -844,7 +947,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
844
947
  const progress = makeProgressReporter({
845
948
  enabled: !args.verbose && !args.quiet && args.progress !== false,
846
949
  });
847
- await installPackages({
950
+ const rootOpts = {
848
951
  prefix: cwd,
849
952
  specs: [...externalSpecs],
850
953
  verbose: args.verbose,
@@ -860,7 +963,12 @@ async function workspaceInstallLocked(cwd, args, signal) {
860
963
  // npm and is pinned transitively in the lockfile).
861
964
  workspaceNames: new Set(byName.keys()),
862
965
  specOrigins: new Map([...specOrigins].map(([k, v]) => [k, [...v]])),
863
- });
966
+ // Aggregated over EVERY member: the specs are aggregated the same
967
+ // way, so the optional-vs-required answer has to be too (a name that
968
+ // any member declares as a plain dependency stays required).
969
+ optionalSpecs: optionalDependencyNames(workspaces.map((w) => w.manifest)),
970
+ };
971
+ await installPackages(rootOpts);
864
972
  }
865
973
  else if (args.verbose) {
866
974
  console.log('gjsify install: no external deps to fetch');
@@ -876,7 +984,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
876
984
  if (args.verbose) {
877
985
  console.log(`gjsify install: ${wsName} — installing ${specSet.size} scoped-override spec(s) into ${wsLocation}/node_modules/`);
878
986
  }
879
- await installPackages({
987
+ const scopedOpts = {
880
988
  prefix: wsLocation,
881
989
  specs: [...specSet],
882
990
  verbose: args.verbose,
@@ -886,7 +994,12 @@ async function workspaceInstallLocked(cwd, args, signal) {
886
994
  frozen: args.immutable,
887
995
  signal,
888
996
  workspaceNames: new Set(byName.keys()),
889
- });
997
+ // Same aggregated answer as the root install: these specs are a
998
+ // SUBSET of the root's, so classifying them differently here would
999
+ // make one dep optional at the root and required in a member.
1000
+ optionalSpecs: optionalDependencyNames(workspaces.map((w) => w.manifest)),
1001
+ };
1002
+ await installPackages(scopedOpts);
890
1003
  }
891
1004
  // Re-wire workspace symlinks once more now that the external + scoped
892
1005
  // installs are done. The EARLY pass above already created them so workspace
@@ -1080,11 +1193,47 @@ export function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDi
1080
1193
  // package and oversubscribes CI's few cores. Verified: the faithful
1081
1194
  // `foreach build -tp --jobs 4` over @gjsify/* with a piped stdout
1082
1195
  // completes in ~83s under GJS (was a multi-hour hang).
1083
- return (`#!/bin/sh\n` +
1084
- `if command -v gjs >/dev/null 2>&1 && [ -f "${gjsAbs}" ]; then\n` +
1196
+ // …EXCEPT on macOS and Windows, where the order is inverted — and not
1197
+ // as a preference but as a capability. GJS is a GNOME-desktop host: on
1198
+ // those two platforms it is at best a Homebrew/MSYS afterthought, while
1199
+ // Node is what a developer already has. The tooling agrees:
1200
+ //
1201
+ // - macOS: the only bundler engine under GJS is
1202
+ // `@gjsify/rolldown-native` (npm `rolldown` is a Rust napi crate
1203
+ // GJS cannot load), and it declares `linux-x64`/`linux-arm64` only.
1204
+ // So the gjs branch cannot run `gjsify build`, nor any script that
1205
+ // ends in one — it dies with "no usable bundler engine under GJS",
1206
+ // whose own closing line is "Running the build under Node also
1207
+ // works". Preferring gjs routed the user into a dead end the error
1208
+ // message tells them to leave while the shim offered no way out.
1209
+ // Measured on a cold macOS tree: root `build:infra` fails at the
1210
+ // first `gjsify build --library` (`@gjsify/utils`) with the
1211
+ // gjs-first shim and completes with this one.
1212
+ // - Windows: there is no prebuilt `libgjs` at all (see
1213
+ // `website/src/content/docs/platform-support.md`), so the gjs probe
1214
+ // can never succeed. This also makes the `sh` shim — the git-bash /
1215
+ // MSYS entry point — agree with the `.cmd`/`.ps1` companions
1216
+ // {@link buildLauncherShims} already writes as Node-only, instead
1217
+ // of leading with a branch that is dead by construction.
1218
+ //
1219
+ // The probe stays symmetric with the gjs one — `command -v node`, not a
1220
+ // bare file test — so a host carrying the bundle but no Node still
1221
+ // falls through to gjs and keeps every non-build command working. That
1222
+ // is what keeps GJS on these platforms supported-and-tested rather than
1223
+ // merely absent. Revert a platform to the shared gjs-first form once it
1224
+ // has a working engine (macOS: status/open-todos.md,
1225
+ // "@gjsify/rolldown-native's darwin-arm64 leg is PROVEN but not
1226
+ // promoted").
1227
+ const nodeFirstPlatform = platform === 'darwin' || platform === 'win32';
1228
+ const gjsFirst = `if command -v gjs >/dev/null 2>&1 && [ -f "${gjsAbs}" ]; then\n` +
1085
1229
  `${gjsPreamble}exec gjs -m "${gjsAbs}" "$@"\n` +
1086
1230
  `fi\n` +
1087
- `exec node "${nodeAbs}" "$@"\n`);
1231
+ `exec node "${nodeAbs}" "$@"\n`;
1232
+ const nodeFirst = `if command -v node >/dev/null 2>&1 && [ -f "${nodeAbs}" ]; then\n` +
1233
+ `exec node "${nodeAbs}" "$@"\n` +
1234
+ `fi\n` +
1235
+ `${gjsPreamble}exec gjs -m "${gjsAbs}" "$@"\n`;
1236
+ return `#!/bin/sh\n` + (nodeFirstPlatform ? nodeFirst : gjsFirst);
1088
1237
  }
1089
1238
  if (nodeAbs)
1090
1239
  return `#!/bin/sh\nexec node "${nodeAbs}" "$@"\n`;
@@ -24,7 +24,7 @@
24
24
  //
25
25
  // Re-running when everything is already published + trusted does nothing and
26
26
  // exits 0. `--dry-run` reports the plan and changes nothing.
27
- import { spawn, spawnSync } from 'node:child_process';
27
+ import { spawnSync } from 'node:child_process';
28
28
  import { DEFAULT_REGISTRY, whoami } from '@gjsify/npm-registry';
29
29
  import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
30
30
  import { mergePublishables } from '../utils/publishable-packages.js';
@@ -34,6 +34,7 @@ import { publishWorkspace } from './publish.js';
34
34
  import { createTrustRequester } from './trust.js';
35
35
  import { runLogin, LoginError } from './login.js';
36
36
  import { detectPackageManager } from './workspace.js';
37
+ import { spawnToCompletion } from '../utils/spawn.js';
37
38
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
38
39
  import { githubTrustBody, normalizeWorkflowFile, parseRepoFromGitRemote, validateRepository, } from '../utils/trust-registry.js';
39
40
  import { DEFAULT_PROBE_CONCURRENCY, probeAllTrustStates } from '../utils/onboard-probe.js';
@@ -379,20 +380,22 @@ async function buildIfPresent(ws, log) {
379
380
  const pm = detectPackageManager();
380
381
  const argv = ['run', 'build'];
381
382
  log(` $ ${pm} ${argv.join(' ')}`);
382
- await new Promise((resolve, reject) => {
383
- const child = spawn(pm, argv, {
384
- cwd: ws.location,
385
- stdio: 'inherit',
386
- env: { ...process.env },
387
- });
388
- child.on('close', (code) => {
389
- if (code === 0)
390
- resolve();
391
- else
392
- reject(new Error(`build exited with code ${code}`));
393
- });
394
- child.on('error', reject);
383
+ // Through `spawnToCompletion`, which resolves the package manager the way
384
+ // the rest of the CLI does. `detectPackageManager()` returns `npm` on every
385
+ // non-GJS host, and a bare `spawn('npm', …)` is ENOENT on Windows — npm is
386
+ // a `.cmd` shim that `CreateProcess` never finds (see
387
+ // utils/win32-command.ts). This is the pre-publish build, so the break
388
+ // landed between "about to publish" and the publish itself.
389
+ //
390
+ // `completion: 'return'` — this resolves to a caller that goes on to
391
+ // publish, so under GJS it must not leave a main loop armed.
392
+ const { code } = await spawnToCompletion(pm, argv, {
393
+ completion: 'return',
394
+ cwd: ws.location,
395
+ env: { ...process.env },
395
396
  });
397
+ if (code !== 0)
398
+ throw new Error(`build exited with code ${code}`);
396
399
  }
397
400
  /** Human-readable reason for a failed publish outcome. */
398
401
  function describePublishFailure(pub) {
@@ -34,6 +34,7 @@
34
34
  // and npm's @npmcli/registry-fetch internals — verified against npm's
35
35
  // in-the-wild publish payloads.
36
36
  import { readFileSync } from 'node:fs';
37
+ import { tmpdir } from 'node:os';
37
38
  import { join, resolve } from 'node:path';
38
39
  import { DEFAULT_REGISTRY, registryFor } from '@gjsify/npm-registry';
39
40
  import { buildPublishHeaders, escapePackageName } from '../utils/publish-headers.js';
@@ -477,7 +478,12 @@ async function packWorkspaceToBytes(wsDir) {
477
478
  // scripts are already run by the outer publish flow's first pack
478
479
  // call — passing `[]` here skips re-running them (idempotent for
479
480
  // most projects but a needless cost otherwise).
480
- const tmp = `/tmp/gjsify-publish-${process.pid}-${Date.now()}`;
481
+ // `tmpdir()`, not a literal `/tmp`. `pack` resolves its destination and
482
+ // creates it recursively, so on Windows the literal produced `C:\tmp\…` at
483
+ // the DRIVE ROOT — created on every publish, and only the inner directory
484
+ // was removed afterwards, so `C:\tmp` was left behind for good. On an image
485
+ // where the drive root is not writable it failed outright.
486
+ const tmp = join(tmpdir(), `gjsify-publish-${process.pid}-${Date.now()}`);
481
487
  const res = await packWorkspace(wsDir, {
482
488
  destination: tmp,
483
489
  dryRun: false,
@@ -504,9 +510,14 @@ async function loadRewrittenManifest(wsDir, pkg) {
504
510
  // Pack + re-read the tarball's package.json. Easier than duplicating the
505
511
  // rewrite logic — pack already does it correctly, including handling
506
512
  // workspace:^ patterns we'd otherwise have to reimplement here.
507
- const tmp = `/tmp/gjsify-publish-manifest-${process.pid}-${Date.now()}.tgz`;
513
+ // Only the DIRECTORY is passed to pack, so build it directly instead of
514
+ // composing a filename and slicing it back off at the last `/` — which on
515
+ // Windows found no separator in `C:\Users\…\gjsify-publish-manifest-…` and
516
+ // handed pack a truncated path. Same `/tmp`-at-the-drive-root problem as
517
+ // above on top of it.
518
+ const dest = join(tmpdir(), `gjsify-publish-manifest-${process.pid}-${Date.now()}`);
508
519
  const res = await packWorkspace(wsDir, {
509
- destination: tmp.substring(0, tmp.lastIndexOf('/')),
520
+ destination: dest,
510
521
  dryRun: false,
511
522
  });
512
523
  const { rmSync } = await import('node:fs');
@@ -514,7 +525,10 @@ async function loadRewrittenManifest(wsDir, pkg) {
514
525
  throw new Error('gjsify publish: pack did not produce a file');
515
526
  const { gunzip, parseTar } = await import('@gjsify/tar');
516
527
  const bytes = new Uint8Array(readFileSync(res.absolutePath));
517
- rmSync(res.absolutePath);
528
+ // The whole per-call directory, not just the tarball inside it — the
529
+ // destination is now unique per invocation, so leaving it behind would grow
530
+ // the temp directory once per publish.
531
+ rmSync(dest, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
518
532
  const tar = await gunzip(bytes);
519
533
  for (const entry of parseTar(tar)) {
520
534
  if (entry.name === 'package/package.json' && entry.body) {
@@ -107,7 +107,11 @@ export const storybookCommand = {
107
107
  type: 'boolean',
108
108
  default: false,
109
109
  })
110
- .option('build-only', { description: 'Build the bundle but do not launch it', type: 'boolean', default: false }),
110
+ .option('build-only', {
111
+ description: 'Build the bundle but do not launch it',
112
+ type: 'boolean',
113
+ default: false,
114
+ }),
111
115
  handler: async (args) => {
112
116
  const cwd = process.cwd();
113
117
  const pkg = readPackage(cwd);
@@ -8,6 +8,8 @@
8
8
  import { existsSync, statSync, readdirSync } from 'node:fs';
9
9
  import { join, dirname, resolve, relative } from 'node:path';
10
10
  import { spawn } from 'node:child_process';
11
+ import { isRuntimeAvailable, RUNTIMES } from '../utils/runtimes.js';
12
+ import { nodeBinary } from '../utils/run-node.js';
11
13
  import { Config } from '../config.js';
12
14
  import { BuildAction } from '../actions/build.js';
13
15
  import { runGjsBundle } from '../utils/run-gjs.js';
@@ -64,6 +66,16 @@ export const testCommand = {
64
66
  // handler would try to build the missing entry anyway.
65
67
  return process.exit(1);
66
68
  }
69
+ // EXPLICIT (`--runtime node`) vs DEFAULT (`all`, or a package's declared
70
+ // set) is the whole distinction here, and it decides what an absent
71
+ // runtime means. Asked for by name, its absence is the answer to the
72
+ // question and must fail loudly. Reached only because it is in the
73
+ // default set, its absence is a fact about the host — and failing there
74
+ // reports a PASSING suite as a failed run: on a Node-less GJS host
75
+ // (postmarketOS/aarch64, gjs 1.88, no node) `gjsify test` printed
76
+ // `✅ gjs (267ms) ❌ node (27ms) — spawn node ENOENT` for a suite whose
77
+ // every assertion passed.
78
+ const explicit = args.runtime === 'gjs' || args.runtime === 'node';
67
79
  const requested = args.runtime === 'gjs'
68
80
  ? ['gjs']
69
81
  : args.runtime === 'node'
@@ -71,8 +83,19 @@ export const testCommand = {
71
83
  : testCfg.runtimes && testCfg.runtimes.length > 0
72
84
  ? testCfg.runtimes
73
85
  : ['gjs', 'node'];
86
+ // Skipping is NOT silent — a skipped runtime is reported, so "tests
87
+ // passed" never quietly means "on fewer runtimes than you think".
88
+ const runnable = explicit ? requested : requested.filter((rt) => isRuntimeAvailable(rt));
89
+ for (const rt of requested.filter((rt) => !runnable.includes(rt))) {
90
+ console.log(`[gjsify test] skipping ${rt} — no \`${RUNTIMES[rt].probe}\` on PATH (not requested explicitly)`);
91
+ }
92
+ if (runnable.length === 0) {
93
+ console.error(`[gjsify test] none of the default runtimes (${requested.join(', ')}) is available on this host.\n` +
94
+ ` Install one, or pin the set: \`gjsify test --runtime <rt>\` / \`gjsify.test.runtimes\` in package.json.`);
95
+ return process.exit(1);
96
+ }
74
97
  const results = [];
75
- for (const runtime of requested) {
98
+ for (const runtime of runnable) {
76
99
  const outfile = join(outdir, `test.${runtime}.mjs`);
77
100
  // Build stage (skip if --no-build OR (not --rebuild AND outfile fresher than src)).
78
101
  if (args.build !== false) {
@@ -171,7 +194,11 @@ async function runTestBundle(outfile, runtime) {
171
194
  return;
172
195
  }
173
196
  await new Promise((resolvePromise, reject) => {
174
- const child = spawn('node', [outfile], { stdio: 'inherit' });
197
+ // `nodeBinary()`, never a bare `'node'`: under the committed GJS bundle
198
+ // `process.execPath` is the bundle itself, and on a host without Node a
199
+ // bare literal dies `spawn node ENOENT` — which is how a fully passing
200
+ // GJS suite was reported as a failed run.
201
+ const child = spawn(nodeBinary(), [outfile], { stdio: 'inherit' });
175
202
  child.on('error', reject);
176
203
  child.on('exit', (code) => {
177
204
  if (code === 0)
@@ -14,7 +14,7 @@
14
14
  // <pkg>` without -g) is a separate workstream — it needs to rewrite
15
15
  // package.json + refresh the lockfile, which install -g doesn't touch.
16
16
  import { existsSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs';
17
- import { join } from 'node:path';
17
+ import { isAbsolute, join, relative, sep } from 'node:path';
18
18
  import { defaultGlobalLayout, specToPackageName } from '../utils/install-global.js';
19
19
  export const uninstallCommand = {
20
20
  command: 'uninstall <packages..>',
@@ -109,7 +109,42 @@ export const uninstallCommand = {
109
109
  * string in the file, which with the preamble is the prebuild dir list) and
110
110
  * check whether it's under `pkgDir`. Non-shim files (e.g. unrelated binaries
111
111
  * the user installed via `npm install -g`) are skipped silently.
112
+ *
113
+ * WINDOWS
114
+ *
115
+ * `linkGlobalBins` writes THREE files there — the `sh` launcher above plus the
116
+ * `.cmd` and `.ps1` siblings cmd.exe and pwsh need, because Windows runs neither
117
+ * an extension-less file nor a `#!` line. Two bugs stacked on that:
118
+ *
119
+ * • the containment test was `target.startsWith(pkgDir + '/')`, while both
120
+ * `pkgDir` and the baked `targetAbs` use `\` there — so the sh launcher
121
+ * never matched either;
122
+ * • nothing looked for the `.cmd`/`.ps1` siblings at all — they do not begin
123
+ * `#!/bin/sh`, so the scan above skips them by construction.
124
+ *
125
+ * Net: `gjsify uninstall -g <pkg>` on Windows deleted the package and left all
126
+ * three launchers pointing at a deleted path. The user then got a crash rather
127
+ * than "command not found", and a later install of a different package with the
128
+ * same bin name stayed shadowed by the stale `.cmd`.
129
+ *
130
+ * The sh launcher stays the ANCHOR — it is written on every platform, so it is
131
+ * the one shape worth parsing — and its siblings are collected by name. That
132
+ * avoids teaching this function to read batch and PowerShell as well.
112
133
  */
134
+ /**
135
+ * Is `target` `dir` itself, or under it?
136
+ *
137
+ * Asked through `path.relative` rather than a string prefix, so it holds
138
+ * whatever separator either side happens to be spelled with — the string form
139
+ * was `\`-vs-`/` blind on Windows. It also stops `/opt/foo-backup` from reading
140
+ * as inside `/opt/foo`, which a bare `startsWith(dir)` would have allowed.
141
+ */
142
+ function isInside(dir, target) {
143
+ const rel = relative(dir, target);
144
+ if (rel === '')
145
+ return true;
146
+ return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
147
+ }
113
148
  function findBinShimsForPackage(binDir, pkgDir, verbose) {
114
149
  if (!existsSync(binDir))
115
150
  return [];
@@ -140,8 +175,15 @@ function findBinShimsForPackage(binDir, pkgDir, verbose) {
140
175
  if (!m)
141
176
  continue;
142
177
  const target = m[1];
143
- if (target.startsWith(pkgDir + '/') || target === pkgDir) {
178
+ if (isInside(pkgDir, target)) {
144
179
  matches.push(fullPath);
180
+ // The Windows companions, when present. `existsSync` rather
181
+ // than a platform check: what decides is whether they were
182
+ // written, and a tree can be inspected from another OS.
183
+ for (const ext of ['.cmd', '.ps1']) {
184
+ if (existsSync(fullPath + ext))
185
+ matches.push(fullPath + ext);
186
+ }
145
187
  }
146
188
  }
147
189
  catch (err) {
@@ -448,10 +448,7 @@ function printTable(candidates) {
448
448
  'kind' +
449
449
  ANSI.reset;
450
450
  console.log(head);
451
- console.log(' '.repeat(idxW + 2) +
452
- ANSI.dim +
453
- '─'.repeat(nameW + fanW + curW + newW + 16) +
454
- ANSI.reset);
451
+ console.log(' '.repeat(idxW + 2) + ANSI.dim + '─'.repeat(nameW + fanW + curW + newW + 16) + ANSI.reset);
455
452
  for (let i = 0; i < candidates.length; i++) {
456
453
  const c = candidates[i];
457
454
  const idx = `${i + 1}.`.padEnd(idxW);