@jxsuite/schema 4.0.0 → 5.0.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.
@@ -15,7 +15,7 @@ export const arrayNamespaceSchema = {
15
15
  "removals; without a key, rows are keyed by index. $map/index is not a key — an index " +
16
16
  "names a position, not a row.",
17
17
  properties: {
18
- $ref: { pattern: "^\\$map/item(/.+)?$", type: "string" },
18
+ $ref: { pattern: String.raw`^\$map/item(/.+)?$`, type: "string" },
19
19
  },
20
20
  required: ["$ref"],
21
21
  type: "object",
@@ -60,7 +60,7 @@ export const childrenValueSchema = {
60
60
  "Computed children — a ${…} template that resolves at build time to an array of child " +
61
61
  'definitions (e.g. "${state.entry.$children}" for parsed content). A bare plain-text ' +
62
62
  "string is NOT a valid children value; text children must be array items.",
63
- pattern: "\\$\\{",
63
+ pattern: String.raw`\$\{`,
64
64
  type: "string",
65
65
  },
66
66
  ],
@@ -167,7 +167,7 @@ export const formatDefSchema = {
167
167
  },
168
168
  extensions: {
169
169
  description: 'File extensions this format claims, with leading dot (e.g. [".md"]).',
170
- items: { pattern: "^\\.", type: "string" },
170
+ items: { pattern: String.raw`^\.`, type: "string" },
171
171
  minItems: 1,
172
172
  type: "array",
173
173
  },
@@ -6,7 +6,7 @@ export const expressionPointerSchema = {
6
6
  "A JSON Pointer $ref operand within an expression node. $args/<name> resolves a named formula's parameter (callable entries only).",
7
7
  properties: {
8
8
  $ref: {
9
- pattern: "^(\\$map/|\\$reduce/|\\$args/|event#/|#/|parent#/|window#/|document#/)",
9
+ pattern: String.raw`^(\$map/|\$reduce/|\$args/|event#/|#/|parent#/|window#/|document#/)`,
10
10
  type: "string",
11
11
  },
12
12
  },
@@ -16,7 +16,7 @@ export const expressionPointerSchema = {
16
16
 
17
17
  export const expressionLiteralSchema = {
18
18
  anyOf: [
19
- { not: { pattern: "\\$\\{" }, type: "string" },
19
+ { not: { pattern: String.raw`\$\{` }, type: "string" },
20
20
  { type: "number" },
21
21
  { type: "boolean" },
22
22
  { type: "null" },
@@ -1,7 +1,7 @@
1
1
  export const internalRefSchema = {
2
2
  description: "Reference to a $defs type definition in the current component.",
3
3
  examples: ["#/$defs/Count", "#/$defs/TodoItem", "#/$defs/Status"],
4
- pattern: "^#/\\$defs/",
4
+ pattern: String.raw`^#/\$defs/`,
5
5
  type: "string",
6
6
  } as const;
7
7
 
@@ -15,7 +15,7 @@ export const stateRefSchema = {
15
15
  export const externalRefSchema = {
16
16
  description: "Reference to an external Jx component file.",
17
17
  examples: ["./card.json", "https://cdn.example.com/button.json"],
18
- pattern: "^(\\./|\\.\\./).*\\.json$|^https?://",
18
+ pattern: String.raw`^(\./|\.\./).*\.json$|^https?://`,
19
19
  type: "string",
20
20
  } as const;
21
21
 
@@ -36,7 +36,7 @@ export const parentRefSchema = {
36
36
  export const mapRefSchema = {
37
37
  description: "Reference to the current Array map iteration context.",
38
38
  examples: ["$map/item", "$map/index", "$map/item/text", "$map/item/done"],
39
- pattern: "^\\$map/(item|index)(/.*)?$",
39
+ pattern: String.raw`^\$map/(item|index)(/.*)?$`,
40
40
  type: "string",
41
41
  } as const;
42
42
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "dependencies": {
3
- "@webref/css": "^8.7.7",
3
+ "@webref/css": "^8.7.8",
4
4
  "@webref/elements": "^2.9.2",
5
- "@webref/idl": "^3.85.2",
5
+ "@webref/idl": "^3.86.0",
6
6
  "ajv": "^8.20.0",
7
7
  "ajv-formats": "^3.0.1"
8
8
  },
@@ -20,6 +20,7 @@
20
20
  "./locale": "./src/locale.ts",
21
21
  "./intl": "./src/intl.ts",
22
22
  "./media-type": "./src/media-type.ts",
23
+ "./which": "./src/which.ts",
23
24
  "./overlays": "./src/overlays.ts",
24
25
  "./dialogs": "./src/dialogs.ts",
25
26
  "./a11y": "./src/a11y.ts",
@@ -60,7 +61,7 @@
60
61
  "upgrade": "bun update --latest && bun install"
61
62
  },
62
63
  "type": "module",
63
- "version": "4.0.0",
64
+ "version": "5.0.0",
64
65
  "devDependencies": {
65
66
  "json-schema-to-ts": "^3.1.1"
66
67
  }
@@ -8,8 +8,8 @@
8
8
  *
9
9
  * It lives here because THREE backends need the same answer and two of them had already written it
10
10
  * twice: `packages/server/src/studio-api.ts` (the dev server's `/__studio/components`) and
11
- * `packages/desktop/src/project-session.ts`. A third copy in the cloud adapter would have made
12
- * "which entries in `state` count as props" a question with three answers.
11
+ * `packages/desktop/src/backend/project-session.ts`. A third copy in the cloud adapter would have
12
+ * made "which entries in `state` count as props" a question with three answers.
13
13
  *
14
14
  * @license MIT
15
15
  */
package/src/doc-ops.ts CHANGED
@@ -15,8 +15,8 @@
15
15
  * @module @jxsuite/schema/doc-ops
16
16
  */
17
17
 
18
- import { isNonNullObject, isNumber } from "./guards";
19
- import type { JxDocumentValue, JxMutableNode, JxPath } from "../types.ts";
18
+ import { isNonNullObject, isNumber, isString } from "./guards";
19
+ import type { JsonValue, JxDocumentValue, JxMutableNode, JxPath } from "../types.ts";
20
20
 
21
21
  /** Value-carrying document mutation, replayable in either direction. */
22
22
  export type JxDocOp =
@@ -161,8 +161,15 @@ function childArrayAt(doc: JxMutableNode, parentPath: JxPath): (JxMutableNode |
161
161
  * insertion (delta +1, inclusive) shifts them up. Returns `path` unchanged when the splice does not
162
162
  * affect it — including when `path` IS `parentPath`, since a splice moves a node's children, never
163
163
  * the node.
164
+ *
165
+ * @param {JxPath} parentPath - The node whose children were spliced
166
+ * @param {JxPath} path - The path to translate
167
+ * @param {number} spliceIndex - Where the splice happened
168
+ * @param {number} delta - -1 for a removal, +1 for an insertion
169
+ * @param {boolean} inclusive - Whether a path AT the index shifts (an insertion's rule)
170
+ * @returns {JxPath}
164
171
  */
165
- function shiftThroughSplice(
172
+ export function shiftPrefixedIndex(
166
173
  parentPath: JxPath,
167
174
  path: JxPath,
168
175
  spliceIndex: number,
@@ -201,7 +208,7 @@ function assertNotIntoOwnSubtree(op: Extract<JxDocOp, { op: "move-child" }>): vo
201
208
  * counts from the end and an index past the end appends. The applier inserts with `splice`, so an
202
209
  * inverse must name the slot the node really landed in, not the one the op asked for.
203
210
  */
204
- function insertionSlot(index: number, length: number): number {
211
+ export function insertionSlot(index: number, length: number): number {
205
212
  return index < 0 ? Math.max(length + index, 0) : Math.min(index, length);
206
213
  }
207
214
 
@@ -236,12 +243,18 @@ function insertionSlot(index: number, length: number): number {
236
243
  export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
237
244
  // Read before the switch: an op from outside may carry a kind the union does not name.
238
245
  const { op: kind } = op;
246
+ assertIntegerIndexes(op);
239
247
  switch (op.op) {
240
248
  case "set-key": {
241
249
  const node = getNodeAtPath(doc, op.path);
242
- if (!node) {
250
+ if (node === undefined) {
243
251
  throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
244
252
  }
253
+ /* A key set on a text child or a children array lands nowhere a renderer reads, so the edit
254
+ would succeed without effect; on `null` it would throw mid-transaction. */
255
+ if (!isNonNullObject(node) || Array.isArray(node)) {
256
+ throw new TypeError(`doc-op-not-a-node:${op.path.join("/")}`);
257
+ }
245
258
  const before = cloneValue(node[op.key]);
246
259
  return before === undefined
247
260
  ? { key: op.key, op: "set-key", path: op.path }
@@ -273,7 +286,7 @@ export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
273
286
  return {
274
287
  fromIndex: toIndex,
275
288
  // The removal shifted paths under the source parent; the insertion, under the target.
276
- fromParentPath: shiftThroughSplice(
289
+ fromParentPath: shiftPrefixedIndex(
277
290
  op.fromParentPath,
278
291
  op.toParentPath,
279
292
  op.fromIndex,
@@ -282,7 +295,7 @@ export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
282
295
  ),
283
296
  op: "move-child",
284
297
  toIndex: op.fromIndex,
285
- toParentPath: shiftThroughSplice(op.toParentPath, op.fromParentPath, toIndex, 1, true),
298
+ toParentPath: shiftPrefixedIndex(op.toParentPath, op.fromParentPath, toIndex, 1, true),
286
299
  };
287
300
  }
288
301
  default: {
@@ -292,6 +305,26 @@ export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
292
305
  throw new Error(`unknown-doc-op:${kind}`);
293
306
  }
294
307
 
308
+ /**
309
+ * Throw `doc-op-index:<path>` unless every index the op carries is an integer. A numeric string, a
310
+ * fraction or `NaN` would reach `splice`, which coerces or truncates it, so the op would land in a
311
+ * slot its recorded inverse does not name. Producers coerce canonical numeric strings before they
312
+ * build an op; this is the defence behind them.
313
+ */
314
+ function assertIntegerIndexes(op: JxDocOp): void {
315
+ const indexes: [JxPath, unknown][] = [];
316
+ if (op.op === "move-child") {
317
+ indexes.push([op.fromParentPath, op.fromIndex], [op.toParentPath, op.toIndex]);
318
+ } else if (op.op === "insert-child" || op.op === "remove-child" || op.op === "set-child") {
319
+ indexes.push([op.parentPath, op.index]);
320
+ }
321
+ for (const [parentPath, index] of indexes) {
322
+ if (!Number.isInteger(index)) {
323
+ throw new TypeError(`doc-op-index:${[...parentPath, "children", String(index)].join("/")}`);
324
+ }
325
+ }
326
+ }
327
+
295
328
  /** The node's existing children array, or null when it has none (or is not a node). */
296
329
  function childrenAt(doc: JxMutableNode, parentPath: JxPath): (JxMutableNode | string)[] | null {
297
330
  const parent = getNodeAtPath(doc, parentPath);
@@ -370,3 +403,197 @@ export function applyDocOpsWithInverse(doc: JxMutableNode, ops: readonly JxDocOp
370
403
  }
371
404
  return pairs;
372
405
  }
406
+
407
+ // ─── Paths ───────────────────────────────────────────────────────────────────
408
+
409
+ /**
410
+ * Whether `path` is an ancestor of (or equal to) `descendant`.
411
+ *
412
+ * @param {JxPath} path
413
+ * @param {JxPath} descendant
414
+ * @returns {boolean}
415
+ */
416
+ export function isAncestor(path: JxPath, descendant: JxPath): boolean {
417
+ if (path.length > descendant.length) {
418
+ return false;
419
+ }
420
+ return path.every((v, i) => v === descendant[i]);
421
+ }
422
+
423
+ /**
424
+ * Order two paths the way the document reads: parents before children, siblings by index.
425
+ *
426
+ * Segments are compared pairwise; numbers numerically (so `children/10` follows `children/9`), and
427
+ * anything else by string order. A prefix sorts before what extends it.
428
+ *
429
+ * @param {JxPath} a
430
+ * @param {JxPath} b
431
+ * @returns {number}
432
+ */
433
+ export function comparePaths(a: JxPath, b: JxPath): number {
434
+ const len = Math.min(a.length, b.length);
435
+ for (let i = 0; i < len; i++) {
436
+ const x = a[i]!;
437
+ const y = b[i]!;
438
+ if (x === y) {
439
+ continue;
440
+ }
441
+ if (isNumber(x) && isNumber(y)) {
442
+ return x - y;
443
+ }
444
+ return String(x) < String(y) ? -1 : 1;
445
+ }
446
+ return a.length - b.length;
447
+ }
448
+
449
+ /**
450
+ * Drop any path whose ancestor is also in the list.
451
+ *
452
+ * Deleting a `<section>` and one of its own paragraphs is one deletion, not two: the paragraph goes
453
+ * with its parent, and a second splice into coordinates that no longer exist is how a batch
454
+ * corrupts a document.
455
+ *
456
+ * @param {readonly JxPath[]} paths
457
+ * @returns {JxPath[]}
458
+ */
459
+ export function topLevelSelection(paths: readonly JxPath[]): JxPath[] {
460
+ const top: JxPath[] = [];
461
+ for (const path of paths) {
462
+ if (!paths.some((q) => !samePath(q, path) && isAncestor(q, path))) {
463
+ top.push([...path]);
464
+ }
465
+ }
466
+ return top;
467
+ }
468
+
469
+ function samePath(a: JxPath, b: JxPath): boolean {
470
+ return a.length === b.length && isAncestor(a, b);
471
+ }
472
+
473
+ /** Where a structural verb splices: the parent element's path and the index in its children. */
474
+ export interface SpliceCoordinate {
475
+ parentPath: JxPath;
476
+ index: number;
477
+ }
478
+
479
+ /**
480
+ * The splice coordinate `path` names, or `null` for no path and for every path
481
+ * {@link isSpliceablePath} refuses.
482
+ *
483
+ * @param {JxPath | null | undefined} path
484
+ * @returns {SpliceCoordinate | null}
485
+ */
486
+ export function spliceCoordinate(path: JxPath | null | undefined): SpliceCoordinate | null {
487
+ const index = path?.at(-1);
488
+ return path && path.at(-2) === "children" && isNumber(index)
489
+ ? { index, parentPath: path.slice(0, -2) }
490
+ : null;
491
+ }
492
+
493
+ /**
494
+ * Whether `path` names a position a structural edit can splice: a tail of `"children"` + index.
495
+ *
496
+ * Every structural edit reaches its parent by dropping the last two segments and its position by
497
+ * taking the last. That produces a splice coordinate for exactly one path shape, and a document
498
+ * holds three others a caller can name: the document element (`[]`), a repeater's map template
499
+ * (`[…, "children", "map"]`) and a `$switch` case (`[…, "cases", "warn"]`). For the template, the
500
+ * parent resolves to a children ARRAY, so the splice throws mid-edit; for a case it splices at
501
+ * `NaN` and takes the wrong child.
502
+ *
503
+ * @param {JxPath} path
504
+ * @returns {boolean}
505
+ */
506
+ export function isSpliceablePath(path: JxPath): boolean {
507
+ return spliceCoordinate(path) !== null;
508
+ }
509
+
510
+ /**
511
+ * The order a structural batch must run in: contained paths dropped, unspliceable paths dropped,
512
+ * then LAST node first.
513
+ *
514
+ * Descending document order is what makes a loop of splices safe. Removing or duplicating at index
515
+ * `n` renumbers every sibling after `n`, so a batch that starts at the end never touches a
516
+ * coordinate a later step still needs.
517
+ *
518
+ * Containment is decided BEFORE spliceability, on the paths as given: a repeater template still
519
+ * shadows its own descendants, so naming the template and something inside it yields nothing rather
520
+ * than quietly deleting the child nobody pointed at.
521
+ *
522
+ * @param {readonly JxPath[]} paths
523
+ * @returns {JxPath[]} Deepest/last first, every entry a splice coordinate.
524
+ */
525
+ export function structuralBatch(paths: readonly JxPath[]): JxPath[] {
526
+ return topLevelSelection(paths)
527
+ .filter((path) => isSpliceablePath(path))
528
+ .toSorted((a, b) => comparePaths(b, a));
529
+ }
530
+
531
+ /**
532
+ * An index written as JSON a model or a form produced, as the integer it names: an integer passes,
533
+ * a canonical decimal string (`"0"`, `"12"`, never `"01"`, `"1.0"` or `" 2"`) becomes its number,
534
+ * and anything else is `null`. Negative integers pass; whether one is meaningful is the caller's.
535
+ *
536
+ * @param {JsonValue | undefined} value - A tool argument or form field, as parsed JSON
537
+ * @returns {number | null}
538
+ */
539
+ export function coerceIndex(value: JsonValue | undefined): number | null {
540
+ if (isNumber(value) && Number.isInteger(value)) {
541
+ return value;
542
+ }
543
+ return isString(value) && /^(?:0|[1-9]\d*)$/.test(value) ? Number(value) : null;
544
+ }
545
+
546
+ /** A move's forward and inverse ops, both `move-child`. */
547
+ export interface PlannedMove {
548
+ forward: Extract<JxDocOp, { op: "move-child" }>;
549
+ inverse: Extract<JxDocOp, { op: "move-child" }>;
550
+ }
551
+
552
+ /**
553
+ * The ops that move the node at `fromPath` to `toIndex` of `toParentPath`'s children, without
554
+ * moving anything; null when the move cannot happen (the source is no child in a static children
555
+ * array, or the target is no node).
556
+ *
557
+ * `toIndex` is read as the slot in the target's children BEFORE the move, the way a drop indicator
558
+ * names it, so a move later in the same parent lands one earlier once the source is gone. A slot
559
+ * past the end is clamped to the last one, so the forward names where the node really lands. Throws
560
+ * `doc-op-move-into-self` when the target is inside the moved node.
561
+ *
562
+ * @param {JxMutableNode} doc - The document the move applies to
563
+ * @param {JxPath} fromPath
564
+ * @param {JxPath} toParentPath
565
+ * @param {number} toIndex
566
+ * @returns {PlannedMove | null}
567
+ */
568
+ export function planMove(
569
+ doc: JxMutableNode,
570
+ fromPath: JxPath,
571
+ toParentPath: JxPath,
572
+ toIndex: number,
573
+ ): PlannedMove | null {
574
+ const at = spliceCoordinate(fromPath);
575
+ const fromParent = at && getNodeAtPath(doc, at.parentPath);
576
+ const toParent = getNodeAtPath(doc, toParentPath);
577
+ if (!at || !fromParent || !Array.isArray(fromParent.children) || !toParent) {
578
+ return null;
579
+ }
580
+ if (fromParent.children[at.index] === undefined) {
581
+ return null;
582
+ }
583
+ const sameParent = fromParent === toParent;
584
+ const adjusted = sameParent && at.index < toIndex ? toIndex - 1 : toIndex;
585
+ const targetLength = Array.isArray(toParent.children) ? toParent.children.length : 0;
586
+ const forward: PlannedMove["forward"] = {
587
+ fromIndex: at.index,
588
+ fromParentPath: at.parentPath,
589
+ op: "move-child",
590
+ toIndex: insertionSlot(adjusted, targetLength - (sameParent ? 1 : 0)),
591
+ toParentPath,
592
+ };
593
+ /* Computed against the document the move applies to. The inverse runs against the post-move
594
+ document, so `inverseOf` writes both of its parent paths in post-move coordinates: the removal
595
+ shifted paths under the source parent, the insertion paths under the target. */
596
+ const inverse = inverseOf(doc, forward);
597
+ /* SAFETY: `inverseOf` answers a `move-child` for a `move-child`. */
598
+ return { forward, inverse: inverse as PlannedMove["inverse"] };
599
+ }
@@ -111,9 +111,11 @@ export async function validateProjectFile(projectRoot: string): Promise<ProjectV
111
111
  }
112
112
 
113
113
  /**
114
- * Restricted schema loader: only `file:` URLs inside the project root are served. Missing files
115
- * under `<root>/node_modules/` fall back to host resolution of the corresponding bare specifier
116
- * (in-repo starters have no local node_modules; the fragments still ship with the host).
114
+ * Restricted schema loader: only `file:` URLs inside the project root are served. A missing file
115
+ * under `<root>/node_modules/` resolves its bare specifier through the project's own resolution
116
+ * (in-repo starters have no local node_modules and walk up to the workspace's). Only this package's
117
+ * own core schemas fall back to the host: an extension's fragments, first-party ones included, are
118
+ * the project's to supply (specs/extensions.md §5.4).
117
119
  *
118
120
  * @param {string} uri - The `$ref` target ajv could not resolve from already-loaded resources
119
121
  * @param {string} root - Absolute project root
@@ -137,8 +139,8 @@ async function loadProjectSchemaRef(uri: string, root: string): Promise<JsonValu
137
139
  return readJson(filePath);
138
140
  }
139
141
 
140
- // Host fallback: <root>/node_modules/<specifier> is absent — resolve the specifier through the
141
- // Project's own resolution first, then the host's (workspace symlinks resolve transparently).
142
+ // <root>/node_modules/<specifier> is absent — resolve the specifier through the project's own
143
+ // Resolution (workspace symlinks resolve transparently), and through the host only for the core.
142
144
  const posixRel = rel.split("\\").join("/");
143
145
  const marker = "node_modules/";
144
146
  const markerIndex = posixRel.lastIndexOf(marker);
@@ -147,32 +149,37 @@ async function loadProjectSchemaRef(uri: string, root: string): Promise<JsonValu
147
149
  }
148
150
  const specifier = posixRel.slice(markerIndex + marker.length);
149
151
 
152
+ const ownPrefix = "@jxsuite/schema/";
150
153
  let resolved: string;
151
154
  try {
152
155
  const projRequire = createRequire(resolve(root, "package.json"));
153
156
  resolved = projRequire.resolve(specifier);
154
- } catch {
157
+ } catch (error) {
158
+ if (!specifier.startsWith(ownPrefix)) {
159
+ throw new Error(
160
+ `Schema ref "${uri}" does not exist and "${specifier}" is not resolvable from the ` +
161
+ `project — is the package installed?`,
162
+ { cause: error },
163
+ );
164
+ }
155
165
  try {
156
166
  const selfRequire = createRequire(import.meta.url);
157
167
  resolved = selfRequire.resolve(specifier);
158
- } catch (error) {
168
+ } catch (hostError) {
159
169
  // Exports-map subpath resolution is brittle against version-skewed shadowing installs (a
160
170
  // Stale local copy terminates Node's walk-up with ERR_PACKAGE_PATH_NOT_EXPORTED). Our own
161
171
  // Schema files are pure data and the running validator's copy is guaranteed to match, so
162
172
  // Read them straight off this package's disk as the last resort.
163
- const ownPrefix = "@jxsuite/schema/";
164
- if (specifier.startsWith(ownPrefix)) {
165
- const ownFile = fileURLToPath(
166
- new URL(`../${specifier.slice(ownPrefix.length)}`, import.meta.url),
167
- );
168
- if (existsSync(ownFile)) {
169
- return readJson(ownFile);
170
- }
173
+ const ownFile = fileURLToPath(
174
+ new URL(`../${specifier.slice(ownPrefix.length)}`, import.meta.url),
175
+ );
176
+ if (existsSync(ownFile)) {
177
+ return readJson(ownFile);
171
178
  }
172
179
  throw new Error(
173
180
  `Schema ref "${uri}" does not exist and "${specifier}" is not resolvable from the ` +
174
- `project or the host`,
175
- { cause: error },
181
+ `project or this host's core`,
182
+ { cause: hostError },
176
183
  );
177
184
  }
178
185
  }
package/src/which.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Find an executable the way `Bun.which` does, on `node:fs` alone.
3
+ *
4
+ * The desktop backend runs on Electron's Node (specs/desktop.md §7.1), so the code it reaches
5
+ * cannot call `Bun.which`. The answer is held to Bun's by `tests/which.test.ts`: a name containing
6
+ * a path separator is resolved against the working directory and returned when it is an executable
7
+ * regular file; any other name is looked up in each non-empty `PATH` entry in order. On Windows,
8
+ * where every file passes an `X_OK` check, only a launchable name counts: one that already ends in
9
+ * `.exe`, `.cmd`, `.bat` or `.com` is tried as written, and any other gets each of those appended
10
+ * in that order. Like Bun, it ignores `PATHEXT`, whose `.js` and `.vbs` entries name files a
11
+ * process cannot spawn. The path returned is the one that was tried, so an appended extension is
12
+ * lower case whatever the file's own case, and never its realpath.
13
+ *
14
+ * Node-only: it reads the filesystem, unlike the rest of this package's browser-safe modules.
15
+ */
16
+ import { accessSync, constants, statSync } from "node:fs";
17
+ import { delimiter, join, resolve } from "node:path";
18
+
19
+ /** True when `path` is a regular file this process may execute. */
20
+ function isExecutable(path: string): boolean {
21
+ try {
22
+ if (!statSync(path).isFile()) {
23
+ return false;
24
+ }
25
+ accessSync(path, constants.X_OK);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /** The extensions Bun's `which` tries on Windows, in its order. */
33
+ const WINDOWS_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
34
+
35
+ /**
36
+ * The names to try for `name`: itself elsewhere; on Windows, only names with a launchable
37
+ * extension.
38
+ */
39
+ function candidates(name: string, platform: NodeJS.Platform): string[] {
40
+ if (platform !== "win32") {
41
+ return [name];
42
+ }
43
+ const lower = name.toLowerCase();
44
+ if (WINDOWS_EXTENSIONS.some((extension) => lower.endsWith(extension))) {
45
+ return [name];
46
+ }
47
+ return WINDOWS_EXTENSIONS.map((extension) => `${name}${extension}`);
48
+ }
49
+
50
+ /**
51
+ * The absolute path of the executable `name`, or null when there is none.
52
+ *
53
+ * @param name A command name, or a path (absolute, or relative to the working directory)
54
+ * @param options `PATH` overrides the environment's search path
55
+ */
56
+ export function which(
57
+ name: string,
58
+ options: { PATH?: string; platform?: NodeJS.Platform } = {},
59
+ ): string | null {
60
+ if (name === "") {
61
+ return null;
62
+ }
63
+ const platform = options.platform ?? process.platform;
64
+ if (name.includes("/") || (platform === "win32" && name.includes("\\"))) {
65
+ return candidates(resolve(name), platform).find((path) => isExecutable(path)) ?? null;
66
+ }
67
+ const dirs = (options.PATH ?? process.env.PATH ?? "").split(delimiter).filter(Boolean);
68
+ for (const dir of dirs) {
69
+ const found = candidates(join(dir, name), platform).find((path) => isExecutable(path));
70
+ if (found) {
71
+ return found;
72
+ }
73
+ }
74
+ return null;
75
+ }