@gjsify/cli 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,4 +6,31 @@ interface SelfUpdateOptions {
6
6
  skipDeps?: boolean;
7
7
  }
8
8
  export declare const selfUpdateCommand: Command<unknown, SelfUpdateOptions>;
9
+ /** Verdict of the post-install "did this change what runs?" check. */
10
+ export type PathVerdict = {
11
+ ok: true;
12
+ } | {
13
+ ok: false;
14
+ message: string;
15
+ };
16
+ /**
17
+ * Compare where PATH sends `binName` against the `binDir` we just linked into.
18
+ *
19
+ * Pure so the three outcomes are unit-testable off-host — the interesting two
20
+ * are exactly the ones a developer's own machine never produces, since a working
21
+ * install resolves to its own `binDir` by definition.
22
+ *
23
+ * Path comparison is case-INSENSITIVE on win32: `C:\Users\X\.local\bin` and
24
+ * `c:\users\x\.local\bin` are one directory there, and a case-sensitive compare
25
+ * would report a correct install as shadowed.
26
+ */
27
+ export declare function verifyPathResolution(opts: {
28
+ binName: string;
29
+ binDir: string;
30
+ resolvedBin: string | null;
31
+ /** `null` is what `readCurrentVersion()` returns when it cannot tell. */
32
+ runningVersion: string | null | undefined;
33
+ targetVersion: string;
34
+ platform?: NodeJS.Platform;
35
+ }): PathVerdict;
9
36
  export {};
@@ -11,12 +11,21 @@
11
11
  // `defaultGlobalLayout().prefix` (i.e. via `gjsify install -g` or via the
12
12
  // `install.mjs` bootstrap). Installs from `npm install -g @gjsify/cli` land
13
13
  // elsewhere and we don't try to chase them — we print a warning and exit.
14
+ //
15
+ // AND THE WARNING IS NOT ENOUGH ON ITS OWN (#1064). This command's whole job is
16
+ // to change which binary the user's next `gjsify` runs, so success has to be
17
+ // measured on PATH, not on the prefix it wrote. Reported from a Windows host:
18
+ // the pre-install warning fired, the install succeeded, `Updated @gjsify/cli to
19
+ // v0.31.0` printed — and the shell kept resolving a v0.26.0 npm-global install,
20
+ // so the very next command re-hit the `dlx` EPERM fixed three releases earlier.
21
+ // A silent no-op that reports success is worse than a clean failure, so the
22
+ // post-link verification below is fatal.
14
23
  import { existsSync, readFileSync } from 'node:fs';
15
- import { dirname, join, resolve } from 'node:path';
24
+ import { dirname, join, posix, resolve, win32 } from 'node:path';
16
25
  import { fileURLToPath } from 'node:url';
17
26
  import { fetchPackument } from '@gjsify/npm-registry';
18
27
  import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
19
- import { defaultGlobalLayout, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, } from '../utils/install-global.js';
28
+ import { defaultGlobalLayout, globalLayoutIsDefault, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, resolveBinOnPath, } from '../utils/install-global.js';
20
29
  const PACKAGE_NAME = '@gjsify/cli';
21
30
  export const selfUpdateCommand = {
22
31
  command: 'self-update',
@@ -157,6 +166,27 @@ export const selfUpdateCommand = {
157
166
  console.log(` • ${bin.link} → ${bin.target}`);
158
167
  }
159
168
  }
169
+ // Did the update change what `gjsify` RUNS? Everything above only proves
170
+ // we wrote the prefix. See the #1064 note at the top of this file.
171
+ //
172
+ // Only for the user's OWN layout: a caller that redirected the install
173
+ // with GJSIFY_GLOBAL_PREFIX/_BIN_DIR manages placement itself, and
174
+ // telling a harness that installed into a temp prefix "your PATH still
175
+ // points elsewhere" is true and useless.
176
+ if (globalLayoutIsDefault()) {
177
+ const verdict = verifyPathResolution({
178
+ binName: BIN_NAME,
179
+ binDir: layout.binDir,
180
+ resolvedBin: resolveBinOnPath(BIN_NAME),
181
+ runningVersion: currentVersion,
182
+ targetVersion: target,
183
+ });
184
+ if (!verdict.ok) {
185
+ console.error(`\nInstalled ${PACKAGE_NAME} v${target} at ${layout.prefix}`);
186
+ console.error(verdict.message);
187
+ return process.exit(1);
188
+ }
189
+ }
160
190
  console.log(`\nUpdated ${PACKAGE_NAME} to v${target}.`);
161
191
  if (pullDeps) {
162
192
  console.log('Runtime dependencies were resolved alongside the bundle.');
@@ -166,6 +196,51 @@ export const selfUpdateCommand = {
166
196
  }
167
197
  },
168
198
  };
199
+ /** The bin `@gjsify/cli` installs, and the name a user types. */
200
+ const BIN_NAME = 'gjsify';
201
+ /**
202
+ * Compare where PATH sends `binName` against the `binDir` we just linked into.
203
+ *
204
+ * Pure so the three outcomes are unit-testable off-host — the interesting two
205
+ * are exactly the ones a developer's own machine never produces, since a working
206
+ * install resolves to its own `binDir` by definition.
207
+ *
208
+ * Path comparison is case-INSENSITIVE on win32: `C:\Users\X\.local\bin` and
209
+ * `c:\users\x\.local\bin` are one directory there, and a case-sensitive compare
210
+ * would report a correct install as shadowed.
211
+ */
212
+ export function verifyPathResolution(opts) {
213
+ const platform = opts.platform ?? process.platform;
214
+ // The TARGET platform's path module, never the host's — see `pathFor` in
215
+ // `utils/install-global.ts` for why (`resolve('C:\\x')` on posix is wrong).
216
+ const p = platform === 'win32' ? win32 : posix;
217
+ const norm = (v) => (platform === 'win32' ? p.resolve(v).toLowerCase() : p.resolve(v));
218
+ if (opts.resolvedBin === null) {
219
+ return {
220
+ ok: false,
221
+ message: `BUT nothing named \`${opts.binName}\` is on PATH, so the update cannot take effect.\n\n` +
222
+ ` Add the bin dir to PATH:\n ${opts.binDir}`,
223
+ };
224
+ }
225
+ if (norm(p.dirname(opts.resolvedBin)) === norm(opts.binDir))
226
+ return { ok: true };
227
+ // Deliberately NOT split into "a rival install" vs "a transient runner"
228
+ // (npx/dlx, whose temp bin wins PATH for the length of the run). Both are the
229
+ // same fact — `gjsify` does not currently mean the tree we just wrote — and
230
+ // guessing which one it is would be a heuristic that can only be wrong. So
231
+ // the finding is stated and both remedies are offered.
232
+ const running = opts.runningVersion ? ` (v${opts.runningVersion})` : '';
233
+ return {
234
+ ok: false,
235
+ message: `BUT \`${opts.binName}\` on PATH resolves to\n ${opts.resolvedBin}${running}\n` +
236
+ `which is NOT the install that was just updated, so the next \`${opts.binName}\`\n` +
237
+ `you run will not be v${opts.targetVersion}.\n\n` +
238
+ ` If that is another install, remove it (e.g. \`npm uninstall -g ${PACKAGE_NAME}\`)\n` +
239
+ ` or put ${opts.binDir} ahead of it on PATH.\n` +
240
+ ` If you ran this through npx/dlx, that temporary copy owns the name only for\n` +
241
+ ` this run — open a new shell and check \`${opts.binName} --version\`.`,
242
+ };
243
+ }
169
244
  /**
170
245
  * Resolve the CLI's own `package.json#version`. Walks up from
171
246
  * `import.meta.url` (the bundle file under GJS, or `lib/index.js` under
@@ -1,4 +1,6 @@
1
1
  import type { Command } from '../types/index.js';
2
+ import { type Packument } from '@gjsify/npm-registry';
3
+ import { type ExampleRuntime } from '../utils/runtimes.js';
2
4
  /**
3
5
  * Pinned invocations for the three runners that cache an unpinned bin.
4
6
  *
@@ -22,4 +24,16 @@ interface ShowcaseOptions {
22
24
  runtime?: string;
23
25
  }
24
26
  export declare const showcaseCommand: Command<unknown, ShowcaseOptions>;
27
+ /**
28
+ * The pure half of {@link preflightDeclaredRuntimes}: pick the version whose
29
+ * declaration decides, and read it.
30
+ *
31
+ * Separate from the fetch so the fail-safe direction is unit-testable — that
32
+ * contract ("never fail a run that would have worked") is the only thing that
33
+ * makes a pre-install check safe, and it lives entirely in which branches return
34
+ * `undefined`.
35
+ *
36
+ * @param cliVersion the CLI's own version; `showcase` pins the showcase to it.
37
+ */
38
+ export declare function declaredRuntimesFromPackument(packument: Packument, cliVersion: string | undefined): ExampleRuntime[] | null | undefined;
25
39
  export {};
@@ -4,6 +4,8 @@ import { createRequire } from 'node:module';
4
4
  import { dirname } from 'node:path';
5
5
  import { existsSync, readFileSync } from 'node:fs';
6
6
  import { ensurePkgDir } from './dlx.js';
7
+ import { fetchPackument } from '@gjsify/npm-registry';
8
+ import { loadNpmrc } from '../utils/load-npmrc.js';
7
9
  import { parseSpec } from '../utils/parse-spec.js';
8
10
  import { resolveNodeEntry } from '../utils/resolve-gjs-entry.js';
9
11
  import { runRuntimeBundle } from '../utils/run-node.js';
@@ -212,15 +214,9 @@ export const showcaseCommand = {
212
214
  */
213
215
  async function resolveShowcaseDir(showcase, cliVersion, extraSpecs = []) {
214
216
  // Local-first: a workspace showcase resolves via its own package name.
215
- try {
216
- const req = createRequire(import.meta.url);
217
- const localPkgJson = req.resolve(`${showcase.packageName}/package.json`);
218
- if (existsSync(localPkgJson))
219
- return dirname(localPkgJson);
220
- }
221
- catch {
222
- // Not resolvable locally — fall through to dlx install.
223
- }
217
+ const local = resolveLocalShowcaseDir(showcase);
218
+ if (local)
219
+ return local;
224
220
  const spec = cliVersion ? `${showcase.packageName}@${cliVersion}` : showcase.packageName;
225
221
  const { pkgDir } = await ensurePkgDir(parseSpec(spec), {
226
222
  verbose: false,
@@ -230,6 +226,117 @@ async function resolveShowcaseDir(showcase, cliVersion, extraSpecs = []) {
230
226
  });
231
227
  return pkgDir;
232
228
  }
229
+ /**
230
+ * The LOCAL (workspace / node_modules) directory of a showcase, or `null`.
231
+ *
232
+ * Factored out because two callers need the same question answered: the resolver
233
+ * below, and the pre-flight, which must know whether an install is even going to
234
+ * happen before deciding to spend a registry request avoiding it.
235
+ */
236
+ function resolveLocalShowcaseDir(showcase) {
237
+ try {
238
+ const req = createRequire(import.meta.url);
239
+ const localPkgJson = req.resolve(`${showcase.packageName}/package.json`);
240
+ if (existsSync(localPkgJson))
241
+ return dirname(localPkgJson);
242
+ }
243
+ catch {
244
+ // Not resolvable locally — the caller falls back to a dlx install.
245
+ }
246
+ return null;
247
+ }
248
+ /**
249
+ * The batteries-included GTK runtime to add to a showcase's dlx tree, if any.
250
+ *
251
+ * WHY THE LAUNCHER AND NOT THE PACKAGE. `@gjsify/node-gi` deliberately declares
252
+ * no bundle: making one its dependency is #910, where a from-source addon met a
253
+ * foreign GTK and produced wrong method entries and a 29-minute timeout. The rule
254
+ * that replaced it is "whoever SHIPS an app declares the bundle" — and for a
255
+ * showcase, the shipper is this command, which assembles the dlx tree. Same
256
+ * reasoning, same place, as the `@gjsify/node-gi` spec beside it.
257
+ *
258
+ * Only where a system GTK is not a given, which is exactly where a bundle is
259
+ * built: Windows has none at all (#1063 — every `gi://` showcase died in
260
+ * `ERR_DLOPEN_FAILED` on a clean host), and macOS has Homebrew's, which is
261
+ * measurably not findable. On Linux the distribution's GTK is the tested
262
+ * combination and node-gi's own policy prefers it, so pulling ~80 MB nobody would
263
+ * use would be waste.
264
+ *
265
+ * The tree only ever holds the PUBLISHED PREBUILD, never a from-source build, so
266
+ * the mismatch #910 hit cannot arise here by construction.
267
+ */
268
+ function gtkRuntimeSpecs(cliVersion) {
269
+ const target = `${process.platform}-${process.arch}`;
270
+ const shipped = ['win32-x64', 'darwin-arm64', 'darwin-x64'];
271
+ if (!shipped.includes(target))
272
+ return [];
273
+ const name = `@gjsify/gtk-runtime-${target}`;
274
+ return [cliVersion ? `${name}@${cliVersion}` : name];
275
+ }
276
+ /**
277
+ * Read a showcase's declared runtimes from the REGISTRY, with no install (#1069).
278
+ *
279
+ * WHY. `--runtime node` on a gjs-only showcase used to resolve and download the
280
+ * whole dlx tree — plus the pinned native `@gjsify/node-gi` bridge, added
281
+ * BECAUSE `--runtime node` was requested — and only then read the declaration
282
+ * that rejects the run. Measured for `adwaita-storybook`: a 310 KB showcase with
283
+ * no runtime deps, and 3.77 MB of `node-gi` fetched to support a runtime the
284
+ * package was about to refuse. The full packument carries
285
+ * `gjsify.example.runtimes` verbatim, so one metadata GET answers it.
286
+ *
287
+ * NOT solved by putting `runtimes` in `showcases.json`: that is a second copy of
288
+ * a declaration the package already owns, maintained by hand. See the
289
+ * `needsWebgl` post-mortem in `utils/discover-showcases.ts` — "a field nobody
290
+ * reads is a field nobody maintains", proven by a showcase that shipped
291
+ * `"needsWebgl": false` while its engine was WebGL2-only.
292
+ *
293
+ * FAIL-SAFE DIRECTION, and it is the whole contract: this may only ever turn a
294
+ * run that WOULD have failed into a faster failure. It must never fail a run
295
+ * that would have worked, so every uncertain branch — offline, unpublished
296
+ * version, missing field, malformed document — returns `undefined` and lets the
297
+ * install proceed to the authoritative on-disk check.
298
+ *
299
+ * @returns the declared list, `null` for a package that declares none
300
+ * (unconstrained), or `undefined` when the question could not be answered.
301
+ */
302
+ async function preflightDeclaredRuntimes(showcase, cliVersion) {
303
+ try {
304
+ // `fullMetadata` is REQUIRED: the abbreviated (corgi) document omits
305
+ // custom fields, so `gjsify.example` is simply absent there — the same
306
+ // shape as the `libc` note in `@gjsify/npm-registry`'s PackumentVersion.
307
+ const packument = await fetchPackument(showcase.packageName, {
308
+ npmrc: await loadNpmrc(process.cwd()),
309
+ fullMetadata: true,
310
+ });
311
+ return declaredRuntimesFromPackument(packument, cliVersion);
312
+ }
313
+ catch {
314
+ // Offline, private registry, auth failure, 404 — all "cannot say".
315
+ return undefined;
316
+ }
317
+ }
318
+ /**
319
+ * The pure half of {@link preflightDeclaredRuntimes}: pick the version whose
320
+ * declaration decides, and read it.
321
+ *
322
+ * Separate from the fetch so the fail-safe direction is unit-testable — that
323
+ * contract ("never fail a run that would have worked") is the only thing that
324
+ * makes a pre-install check safe, and it lives entirely in which branches return
325
+ * `undefined`.
326
+ *
327
+ * @param cliVersion the CLI's own version; `showcase` pins the showcase to it.
328
+ */
329
+ export function declaredRuntimesFromPackument(packument, cliVersion) {
330
+ // A pinned version that is NOT published is "cannot say", never "ask latest":
331
+ // a different version's declaration is a different answer, and a wrong
332
+ // "unsupported" is precisely the failure this must not introduce. (A dev or
333
+ // pre-release CLI is the normal way to reach this.)
334
+ const version = cliVersion ?? packument['dist-tags']?.latest;
335
+ const entry = version ? packument.versions?.[version] : undefined;
336
+ if (!entry)
337
+ return undefined;
338
+ return readDeclaredRuntimes(entry);
339
+ }
233
340
  /**
234
341
  * Run a showcase on a non-gjs runtime (node/bun/deno). Resolves the package,
235
342
  * validates it against the showcase's `gjsify.example.runtimes` declaration
@@ -237,6 +344,19 @@ async function resolveShowcaseDir(showcase, cliVersion, extraSpecs = []) {
237
344
  * node), resolves its `--app node` bundle and launches it via the shared runner.
238
345
  */
239
346
  async function runShowcaseOnRuntime(showcase, runtime, cliVersion) {
347
+ // PRE-FLIGHT, before anything is downloaded. Only for the dlx path: a local
348
+ // showcase resolves with no network and no install, so there is nothing to
349
+ // pre-empt there and the on-disk check below is already free.
350
+ if (resolveLocalShowcaseDir(showcase) === null) {
351
+ const preflight = await preflightDeclaredRuntimes(showcase, cliVersion);
352
+ if (preflight !== undefined) {
353
+ const support = checkRuntimeSupported(runtime, preflight, showcase.name);
354
+ if (!support.ok) {
355
+ console.error(support.message);
356
+ return process.exit(1);
357
+ }
358
+ }
359
+ }
240
360
  let pkgDir;
241
361
  try {
242
362
  // `@gjsify/node-gi` is the `gi://` bridge the `--app node` bundle
@@ -248,7 +368,7 @@ async function runShowcaseOnRuntime(showcase, runtime, cliVersion) {
248
368
  // it supplies the bridge. Pinned to the CLI's version like the showcase
249
369
  // itself, so the bridge and the bundle move together.
250
370
  const nodeGiSpec = cliVersion ? `@gjsify/node-gi@${cliVersion}` : '@gjsify/node-gi';
251
- pkgDir = await resolveShowcaseDir(showcase, cliVersion, [nodeGiSpec]);
371
+ pkgDir = await resolveShowcaseDir(showcase, cliVersion, [nodeGiSpec, ...gtkRuntimeSpecs(cliVersion)]);
252
372
  }
253
373
  catch (err) {
254
374
  console.error(`Could not resolve showcase "${showcase.name}": ${err.message}`);
@@ -114,8 +114,57 @@ export interface LinkedBin {
114
114
  * simplicity and so we don't need to discriminate by runtime here.
115
115
  */
116
116
  export declare function linkGlobalBins(packageNames: string[], layout: GlobalLayout): LinkedBin[];
117
+ /**
118
+ * Is the global layout the user's own, or has a caller redirected it?
119
+ *
120
+ * `GJSIFY_GLOBAL_PREFIX` / `GJSIFY_GLOBAL_BIN_DIR` are the escape hatches
121
+ * {@link defaultGlobalLayout} documents for tests, and a caller that sets them
122
+ * has taken over placement — so "which `gjsify` will PATH resolve next" stops
123
+ * being a question about THEIR intent. `self-update` gates its fatal PATH check
124
+ * on this: without it, every harness installing into a temp prefix it never put
125
+ * on PATH is told its update did not take effect, which is true and useless.
126
+ * Caught by `tests/e2e/global-install-engine`.
127
+ *
128
+ * @param env injectable so both answers are testable without mutating the real
129
+ * environment mid-suite.
130
+ */
131
+ export declare function globalLayoutIsDefault(env?: NodeJS.ProcessEnv): boolean;
117
132
  /** Returns `true` if `binDir` is on the user's PATH. */
118
133
  export declare function binDirOnPath(binDir: string): boolean;
134
+ /** Injection points for {@link resolveBinOnPath} — see its `platform` note. */
135
+ export interface ResolveBinOnPathOptions {
136
+ /** PATH to search. Defaults to `process.env.PATH`. */
137
+ pathValue?: string;
138
+ /** PATHEXT to honour on win32. Defaults to `process.env.PATHEXT` or cmd.exe's set. */
139
+ pathExt?: string;
140
+ /** Host platform. Defaults to `process.platform`. */
141
+ platform?: NodeJS.Platform;
142
+ /** File-existence probe. Defaults to a real `fs.existsSync`. */
143
+ exists?: (file: string) => boolean;
144
+ }
145
+ /**
146
+ * Resolve `binName` the way the user's shell will: the FIRST hit in PATH order.
147
+ *
148
+ * WHY THIS IS NOT `binDirOnPath`. That answers "is our dir on PATH", which is
149
+ * necessary but not sufficient for anything that CHANGES which binary runs:
150
+ * `~/.local/bin` can be on PATH and still lose to an npm-global `gjsify` earlier
151
+ * in it. `self-update` reported "Updated to v0.31.0" on exactly that host while
152
+ * the shell kept resolving a v0.26.0 install five releases behind — and the user
153
+ * then re-hit a bug fixed three releases earlier and reported it as new (#1064).
154
+ * "Which one wins" is the question, so this returns the winning PATH.
155
+ *
156
+ * WINDOWS. An extension-less name is not executable there; cmd.exe and pwsh
157
+ * append each `PATHEXT` entry, which is how the `.cmd` shim `bin-shim.ts` writes
158
+ * gets found. The bare name is probed LAST so a git-bash/MSYS `sh` shim is still
159
+ * reported rather than missed. `platform`/`pathExt`/`exists` are injectable for
160
+ * the same reason `dirLinkTarget` takes `linkType`: nothing in CI resolves a bin
161
+ * on a Windows host, so an injected `'win32'` is the ONLY way that branch is ever
162
+ * EXECUTED — and an unexecutable Windows branch is exactly how the `'dir'` bug in
163
+ * `dir-link.ts` shipped.
164
+ *
165
+ * @returns the absolute path of the winning candidate, or `null` when PATH holds none.
166
+ */
167
+ export declare function resolveBinOnPath(binName: string, opts?: ResolveBinOnPathOptions): string | null;
119
168
  /**
120
169
  * Extracts the package name from a spec like `name@1.2`, `@scope/name`,
121
170
  * or `@scope/name@latest`. Returns the unchanged string for plain names.
@@ -297,12 +297,100 @@ function readShebangOf(file) {
297
297
  function readJson(file) {
298
298
  return JSON.parse(fs.readFileSync(file, 'utf-8'));
299
299
  }
300
+ /**
301
+ * Is the global layout the user's own, or has a caller redirected it?
302
+ *
303
+ * `GJSIFY_GLOBAL_PREFIX` / `GJSIFY_GLOBAL_BIN_DIR` are the escape hatches
304
+ * {@link defaultGlobalLayout} documents for tests, and a caller that sets them
305
+ * has taken over placement — so "which `gjsify` will PATH resolve next" stops
306
+ * being a question about THEIR intent. `self-update` gates its fatal PATH check
307
+ * on this: without it, every harness installing into a temp prefix it never put
308
+ * on PATH is told its update did not take effect, which is true and useless.
309
+ * Caught by `tests/e2e/global-install-engine`.
310
+ *
311
+ * @param env injectable so both answers are testable without mutating the real
312
+ * environment mid-suite.
313
+ */
314
+ export function globalLayoutIsDefault(env = process.env) {
315
+ return !env.GJSIFY_GLOBAL_PREFIX && !env.GJSIFY_GLOBAL_BIN_DIR;
316
+ }
317
+ /**
318
+ * The path module for a TARGET platform, not for the host.
319
+ *
320
+ * Load-bearing for every function below that takes a `platform`. `path.resolve`
321
+ * is host-bound: on Linux `path.resolve('C:\\tools')` returns
322
+ * `<cwd>/C:\tools`, because posix has no notion of a drive letter. So a function
323
+ * that accepts `platform` and then calls the host's `path` is only pretending to
324
+ * be portable — it silently mangles Windows input everywhere except Windows,
325
+ * which is exactly the class of bug an injected platform is supposed to expose.
326
+ * Caught by `resolve-bin-on-path.spec.ts`, which asserts real `C:\…` semantics
327
+ * from Linux.
328
+ */
329
+ function pathFor(platform) {
330
+ return platform === 'win32' ? path.win32 : path.posix;
331
+ }
332
+ /** PATH split into resolved directory entries, in search order. */
333
+ function searchPathEntries(pathValue, platform) {
334
+ const p = pathFor(platform);
335
+ const sep = platform === 'win32' ? ';' : ':';
336
+ return pathValue
337
+ .split(sep)
338
+ .filter(Boolean)
339
+ .map((entry) => p.resolve(entry));
340
+ }
300
341
  /** Returns `true` if `binDir` is on the user's PATH. */
301
342
  export function binDirOnPath(binDir) {
302
- const PATH = process.env.PATH ?? '';
303
- const sep = process.platform === 'win32' ? ';' : ':';
304
- const want = path.resolve(binDir);
305
- return PATH.split(sep).some((entry) => entry && path.resolve(entry) === want);
343
+ const want = pathFor(process.platform).resolve(binDir);
344
+ return searchPathEntries(process.env.PATH ?? '', process.platform).includes(want);
345
+ }
346
+ /** cmd.exe's default when PATHEXT is unset. `.CMD` is the one npm-style shims need. */
347
+ const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC';
348
+ /**
349
+ * Resolve `binName` the way the user's shell will: the FIRST hit in PATH order.
350
+ *
351
+ * WHY THIS IS NOT `binDirOnPath`. That answers "is our dir on PATH", which is
352
+ * necessary but not sufficient for anything that CHANGES which binary runs:
353
+ * `~/.local/bin` can be on PATH and still lose to an npm-global `gjsify` earlier
354
+ * in it. `self-update` reported "Updated to v0.31.0" on exactly that host while
355
+ * the shell kept resolving a v0.26.0 install five releases behind — and the user
356
+ * then re-hit a bug fixed three releases earlier and reported it as new (#1064).
357
+ * "Which one wins" is the question, so this returns the winning PATH.
358
+ *
359
+ * WINDOWS. An extension-less name is not executable there; cmd.exe and pwsh
360
+ * append each `PATHEXT` entry, which is how the `.cmd` shim `bin-shim.ts` writes
361
+ * gets found. The bare name is probed LAST so a git-bash/MSYS `sh` shim is still
362
+ * reported rather than missed. `platform`/`pathExt`/`exists` are injectable for
363
+ * the same reason `dirLinkTarget` takes `linkType`: nothing in CI resolves a bin
364
+ * on a Windows host, so an injected `'win32'` is the ONLY way that branch is ever
365
+ * EXECUTED — and an unexecutable Windows branch is exactly how the `'dir'` bug in
366
+ * `dir-link.ts` shipped.
367
+ *
368
+ * @returns the absolute path of the winning candidate, or `null` when PATH holds none.
369
+ */
370
+ export function resolveBinOnPath(binName, opts = {}) {
371
+ const platform = opts.platform ?? process.platform;
372
+ const pathValue = opts.pathValue ?? process.env.PATH ?? '';
373
+ const exists = opts.exists ?? fs.existsSync;
374
+ // On win32 the extensions come FIRST (what cmd.exe/pwsh actually launch) and
375
+ // the bare name last (git-bash). Elsewhere there is only the bare name.
376
+ const suffixes = platform === 'win32'
377
+ ? [
378
+ ...(opts.pathExt ?? process.env.PATHEXT ?? DEFAULT_PATHEXT)
379
+ .split(';')
380
+ .filter(Boolean)
381
+ .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`)),
382
+ '',
383
+ ]
384
+ : [''];
385
+ const p = pathFor(platform);
386
+ for (const dir of searchPathEntries(pathValue, platform)) {
387
+ for (const suffix of suffixes) {
388
+ const candidate = p.join(dir, `${binName}${suffix}`);
389
+ if (exists(candidate))
390
+ return candidate;
391
+ }
392
+ }
393
+ return null;
306
394
  }
307
395
  /**
308
396
  * Extracts the package name from a spec like `name@1.2`, `@scope/name`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -134,19 +134,19 @@
134
134
  "cli"
135
135
  ],
136
136
  "dependencies": {
137
- "@gjsify/buffer": "^0.31.0",
138
- "@gjsify/create-app": "^0.31.0",
139
- "@gjsify/node-globals": "^0.31.0",
140
- "@gjsify/node-polyfills": "^0.31.0",
141
- "@gjsify/npm-registry": "^0.31.0",
142
- "@gjsify/resolve-npm": "^0.31.0",
143
- "@gjsify/rolldown-plugin-gjsify": "^0.31.0",
144
- "@gjsify/rolldown-plugin-pnp": "^0.31.0",
145
- "@gjsify/semver": "^0.31.0",
146
- "@gjsify/tar": "^0.31.0",
147
- "@gjsify/tsc": "^0.31.0",
148
- "@gjsify/web-polyfills": "^0.31.0",
149
- "@gjsify/workspace": "^0.31.0",
137
+ "@gjsify/buffer": "^0.32.0",
138
+ "@gjsify/create-app": "^0.32.0",
139
+ "@gjsify/node-globals": "^0.32.0",
140
+ "@gjsify/node-polyfills": "^0.32.0",
141
+ "@gjsify/npm-registry": "^0.32.0",
142
+ "@gjsify/resolve-npm": "^0.32.0",
143
+ "@gjsify/rolldown-plugin-gjsify": "^0.32.0",
144
+ "@gjsify/rolldown-plugin-pnp": "^0.32.0",
145
+ "@gjsify/semver": "^0.32.0",
146
+ "@gjsify/tar": "^0.32.0",
147
+ "@gjsify/tsc": "^0.32.0",
148
+ "@gjsify/web-polyfills": "^0.32.0",
149
+ "@gjsify/workspace": "^0.32.0",
150
150
  "cosmiconfig": "^9.0.2",
151
151
  "get-tsconfig": "^4.14.0",
152
152
  "pkg-types": "^2.3.1",
@@ -154,13 +154,13 @@
154
154
  "yargs": "^18.0.0"
155
155
  },
156
156
  "devDependencies": {
157
- "@gjsify/unit": "^0.31.0",
157
+ "@gjsify/unit": "^0.32.0",
158
158
  "@types/yargs": "^17.0.35",
159
159
  "typescript": "^6.0.3"
160
160
  },
161
161
  "peerDependencies": {
162
- "@gjsify/oxfmt-native": "^0.31.0",
163
- "@gjsify/rolldown-native": "^0.31.0"
162
+ "@gjsify/oxfmt-native": "^0.32.0",
163
+ "@gjsify/rolldown-native": "^0.32.0"
164
164
  },
165
165
  "peerDependenciesMeta": {
166
166
  "@gjsify/oxfmt-native": {