@jr2/cli 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -15,4 +15,9 @@ jr2 run <workflow>
15
15
  Orchestrator, Sandboxes, secrets), and every other verb (`run`, `runs`, `status`, `logs`, `send`, `down`, `gc`,
16
16
  `kit push`) talks to what `up` deployed. Node 24, a kube context, and docker for the image `up` builds.
17
17
 
18
+ Inside an Instance, the global `jr2` hands off to the Instance's own `@jr2/cli` (the gulp model), so the version that
19
+ runs is the one the Instance pins and the global's stops mattering. An Instance has one kit version: `@jr2/cli` and
20
+ `@jr2/orchestrator` at the same exact number — every verb refuses a mismatch by name. Upgrade by editing both lines and
21
+ reinstalling.
22
+
18
23
  Docs, glossary, and architecture decisions: [github.com/snapwich/jr2](https://github.com/snapwich/jr2).
package/bin/jr2.js CHANGED
@@ -9,8 +9,12 @@
9
9
  // bundle solves the same problem with `tsx` (see INSTANCE_DOCKERFILE) because there the entry is
10
10
  // the orchestrator's, not this one.
11
11
  //
12
- // After the hook it does nothing but hand argv to `main` and surface the exit code. `main` is
13
- // pure-ish (takes an injectable IO) so the dispatch + commands stay unit-testable without a process.
12
+ // After the hook, the launcher's one decision (ADR-0056): inside an Instance that resolves its own
13
+ // `@jr2/cli` to a different copy, hand off run THAT copy's binary with the same argv and stdio
14
+ // and exit with its code — so the `jr2` that runs is the one the Instance pins, whatever this one's
15
+ // version is. Otherwise hand argv to `main` and surface the exit code. `main` is pure-ish (takes an
16
+ // injectable IO) so the dispatch + commands stay unit-testable without a process. The handoff
17
+ // modules import no kit code: a global with no orchestrator beside it can still hand off.
14
18
 
15
19
  import { readFileSync } from "node:fs";
16
20
  import { registerHooks } from "node:module";
@@ -31,8 +35,12 @@ registerHooks({
31
35
  },
32
36
  });
33
37
 
34
- const { main } = await import("../src/cli.ts");
35
-
36
- main(process.argv.slice(2)).then((code) => {
37
- process.exitCode = code;
38
- });
38
+ const argv = process.argv.slice(2);
39
+ const { handoffTarget, handoff } = await import("../src/handoff.ts");
40
+ const local = handoffTarget(process.cwd());
41
+ if (local) {
42
+ process.exitCode = await handoff(local, argv);
43
+ } else {
44
+ const { main } = await import("../src/cli.ts");
45
+ process.exitCode = await main(argv);
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jr2/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "The jr2 CLI: init, up, run — the interface to a jr2 Instance.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -36,13 +36,16 @@
36
36
  },
37
37
  "types": "./src/index.ts",
38
38
  "dependencies": {
39
- "ts-blank-space": "^0.9.0",
40
- "@jr2/orchestrator": "0.1.1"
39
+ "ts-blank-space": "^0.9.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@jr2/orchestrator": "0.1.3"
41
43
  },
42
44
  "devDependencies": {
43
45
  "@types/node": "^26.0.1",
44
46
  "xstate": "^5.18.0",
45
- "zod": "^4.4.3"
47
+ "zod": "^4.4.3",
48
+ "@jr2/orchestrator": "0.1.3"
46
49
  },
47
50
  "files": [
48
51
  "bin",
package/src/build.ts CHANGED
@@ -998,30 +998,47 @@ const execCommand: RunCommand = async (command, args, cwd) => {
998
998
 
999
999
  /**
1000
1000
  * Materialize the Instance into `outDir` (ADR-0043, as amended there). Two shapes, and the key is
1001
- * the INSTANCE's own: walk up for a `pnpm-workspace.yaml`, because that — not
1002
- * {@link detectKitCheckout} — is what says whether `pnpm deploy` can run at all. A checkout CLI can
1003
- * legitimately drive a standalone instance (the developer's `/tmp` folder), and keying on the CLI's
1004
- * own provenance would send that instance down a path whose job — materializing workspace symlinks
1005
- * — only exists in a workspace. The mirror holds too: an INSTALLED kit driving an instance nested
1006
- * in the user's own pnpm monorepo takes `pnpm deploy`, because that instance carries no lockfile of
1007
- * its own — the workspace root holds it.
1001
+ * the INSTANCE's own, asked in this order:
1008
1002
  *
1009
- * - Workspace member (the kit checkout's `templates/*` and `features/kind-instance`): `pnpm deploy
1010
- * --legacy`, unchanged. pnpm is a contributor prerequisite, like go for the operator, never a
1011
- * product dependency.
1012
- * - Standalone: stage a copy ({@link BUNDLE_STAGE_EXCLUDE}) and run a frozen production install
1013
- * from the committed lockfile ({@link lockfileInstall}) inside it.
1003
+ * 1. Its own lockfile. An instance that carries one is self-contained the lockfile IS its
1004
+ * dependency graph, wherever the folder sits so it is staged as a copy
1005
+ * ({@link BUNDLE_STAGE_EXCLUDE}) and installed frozen and production-only by the lockfile's own
1006
+ * package manager ({@link lockfileInstall}). This is how a standalone instance committed INSIDE
1007
+ * a pnpm monorepo bundles: the kit's own `docs/intro` sits under the checkout's
1008
+ * `pnpm-workspace.yaml` and is deliberately not one of its packages.
1009
+ * 2. A `pnpm-workspace.yaml` above it. A workspace member carries no lockfile of its own — the
1010
+ * workspace root holds it — so it is the ABSENCE of one that sends the instance to `pnpm deploy
1011
+ * --legacy`, unchanged. pnpm is a contributor prerequisite, like go for the operator, never a
1012
+ * product dependency.
1013
+ * 3. Neither: {@link lockfileInstall}'s named refusal.
1014
+ *
1015
+ * Neither key is {@link detectKitCheckout}: a checkout CLI legitimately drives a standalone
1016
+ * instance (the developer's `/tmp` folder), and an installed kit legitimately drives a member of
1017
+ * the user's own monorepo. Asking "is there a workspace file above me" FIRST — the previous rule —
1018
+ * answered a question about an ancestor, and sent shape (1) down `pnpm deploy`, which pnpm answers
1019
+ * for a non-member with "No projects matched the filters" and exit 0: no bundle, and the failure
1020
+ * surfaced later as an ENOENT from the seal. That silent success is why the deploy branch checks
1021
+ * that the bundle exists, and names the shape when it does not.
1014
1022
  */
1015
1023
  export async function bundleInstance(
1016
1024
  instanceDir: string,
1017
1025
  outDir: string,
1018
1026
  run: RunCommand = execCommand,
1019
1027
  ): Promise<void> {
1020
- if (await pnpmWorkspaceRoot(instanceDir)) {
1028
+ const root = (await hasOwnLockfile(instanceDir)) ? undefined : await pnpmWorkspaceRoot(instanceDir);
1029
+ if (root) {
1021
1030
  const pkg = JSON.parse(await readFile(join(instanceDir, "package.json"), "utf8")) as { name?: string };
1022
1031
  if (!pkg.name) throw new Error(`${instanceDir}/package.json has no "name" — needed to bundle the instance`);
1023
1032
  // --legacy: materialize (copy) workspace deps into the bundle rather than linking them.
1024
1033
  await run("pnpm", ["--filter", pkg.name, "--prod", "deploy", "--legacy", outDir], instanceDir);
1034
+ // pnpm exits 0 — "No projects matched the filters" — for a `--filter` that names no member.
1035
+ if (!(await exists(outDir))) {
1036
+ throw new Error(
1037
+ `pnpm deploy wrote no bundle for ${instanceDir}: it sits under the pnpm workspace at ${root} but is not ` +
1038
+ `one of its packages, and it has no lockfile of its own — add it to the workspace's \`packages:\`, ` +
1039
+ `or install and commit a lockfile so it bundles as a standalone instance`,
1040
+ );
1041
+ }
1025
1042
  return;
1026
1043
  }
1027
1044
  // Asked before the copy: an instance with no lockfile must fail on the cheap half.
@@ -1033,9 +1050,26 @@ export async function bundleInstance(
1033
1050
  await run(command, args, outDir);
1034
1051
  }
1035
1052
 
1036
- /** The nearest `pnpm-workspace.yaml` at or above `dir`, i.e. "is this instance a workspace member".
1037
- * A file test, not a manifest parse: pnpm's own membership rule starts here, and a `packages:` glob
1038
- * that excluded this directory would leave `pnpm deploy --filter` failing loudly by name. */
1053
+ /** Whether the instance carries a lockfile of ANY manager yarn included, so a yarn instance under
1054
+ * a workspace reaches {@link lockfileInstall}'s named refusal rather than `pnpm deploy`. */
1055
+ async function hasOwnLockfile(instanceDir: string): Promise<boolean> {
1056
+ const present = new Set(await readdir(instanceDir));
1057
+ return [...LOCKFILE_INSTALLS.flatMap((row) => row.lockfiles), YARN_LOCKFILE].some((f) => present.has(f));
1058
+ }
1059
+
1060
+ async function exists(path: string): Promise<boolean> {
1061
+ try {
1062
+ await stat(path);
1063
+ return true;
1064
+ } catch {
1065
+ return false;
1066
+ }
1067
+ }
1068
+
1069
+ /** The nearest `pnpm-workspace.yaml` at or above `dir`. A file test, not a manifest parse:
1070
+ * membership proper is pnpm's to decide, and this is asked only for an instance with no lockfile of
1071
+ * its own ({@link bundleInstance}). A `packages:` glob that excludes the directory does NOT fail
1072
+ * `pnpm deploy --filter` — pnpm exits 0 having deployed nothing — which the caller checks. */
1039
1073
  async function pnpmWorkspaceRoot(dir: string): Promise<string | undefined> {
1040
1074
  let d = resolve(dir);
1041
1075
  for (;;) {
@@ -13,19 +13,22 @@
13
13
  // Templates mirror `templates/default/` verbatim (that folder is the model instance, ADR-0054) —
14
14
  // byte-for-byte except package.json's `name`/`description`, which are per-instance.
15
15
  // `test/init.test.ts` enforces that; without it the two drift silently, and since the manifest
16
- // carries KIT_VERSION that same test is the version-bump tripwire (bump the kit, re-render the
16
+ // carries the kit version that same test is the version-bump tripwire (bump the kit, re-render the
17
17
  // template). Existing files are left untouched (init is additive); created paths are reported on
18
18
  // stderr.
19
19
  //
20
20
  // ONE template serves both checkout and installed mode (ADR-0043) — a branch there would mean the
21
21
  // tested output and the shipped output diverge. So the scaffold names no package manager, and pins
22
- // @jr2/* at the exact running KIT_VERSION: 0.x minors break, and the checkout resolves that literal
23
- // to its own packages via `linkWorkspacePackages: true`.
22
+ // @jr2/* at the exact running version: 0.x minors break, and the checkout resolves that literal
23
+ // to its own packages via `linkWorkspacePackages: true`. The number is the CLI's OWN (ADR-0056),
24
+ // not the orchestrator's `KIT_VERSION`: a global `jr2` with no Instance around it must scaffold
25
+ // without a peer resolved, and the two numbers are equal by construction (one release train,
26
+ // lockstep — ADR-0055).
24
27
 
25
28
  import { mkdir, readFile, writeFile } from "node:fs/promises";
26
29
  import { basename, dirname, join, resolve } from "node:path";
27
30
  import { parseArgs } from "node:util";
28
- import { KIT_VERSION } from "@jr2/orchestrator";
31
+ import { CLI_VERSION } from "../kit-version.ts";
29
32
  import { activity, type Io } from "../output.ts";
30
33
 
31
34
  export async function init(args: string[], io: Io): Promise<number> {
@@ -90,8 +93,8 @@ function packageJson(name: string): string {
90
93
  private: true,
91
94
  type: "module",
92
95
  scripts: { typecheck: "tsc --noEmit" },
93
- dependencies: { "@jr2/orchestrator": KIT_VERSION, xstate: "^5.18.0" },
94
- devDependencies: { "@jr2/cli": KIT_VERSION, "@types/node": "^26.0.1", typescript: "^5.6.0" },
96
+ dependencies: { "@jr2/orchestrator": CLI_VERSION, xstate: "^5.18.0" },
97
+ devDependencies: { "@jr2/cli": CLI_VERSION, "@types/node": "^26.0.1", typescript: "^5.6.0" },
95
98
  },
96
99
  null,
97
100
  2,
package/src/env.ts CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
- import { findRoot } from "./instance.ts";
19
+ import { findRoot } from "./root.ts";
20
20
  import { activity, type Io } from "./output.ts";
21
21
 
22
22
  /**
package/src/handoff.ts ADDED
@@ -0,0 +1,44 @@
1
+ // The Handoff (ADR-0056, the gulp/grunt model): a global `jr2` run inside an Instance that resolves
2
+ // its own `@jr2/cli` to a DIFFERENT copy runs that copy's binary with the same arguments and stdio,
3
+ // returns its exit code, and does nothing else. So the copy that runs is the one the Instance pins,
4
+ // and the global's version stops mattering inside an Instance. No Instance, or the Instance resolves
5
+ // the running copy itself (a workspace member in the checkout, `npx jr2`): no handoff.
6
+ //
7
+ // Spawn, not import. gulp loads local gulp in-process because local gulp is a LIBRARY; here the
8
+ // local is a BINARY, and spawning couples the global to one contract — the package's `bin` field,
9
+ // npm's own — where importing `src/cli.ts` would make the local's internal layout a cross-version
10
+ // API the global (the copy that cannot be updated once shipped) must honor forever. The local's own
11
+ // preamble runs: its pinned erasure hook, whatever a future bin adds.
12
+ //
13
+ // Builtins only, like everything the launcher runs before it knows which `jr2` will run.
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { join } from "node:path";
17
+ import { CLI_ROOT, resolvePackage } from "./kit-version.ts";
18
+ import { findRoot } from "./root.ts";
19
+
20
+ /** The local binary to hand off to, or `undefined` when this copy is the one that runs. */
21
+ export function handoffTarget(cwd: string, selfRoot: string = CLI_ROOT): string | undefined {
22
+ const root = findRoot(cwd);
23
+ if (!root) return undefined;
24
+ const local = resolvePackage("@jr2/cli", root);
25
+ if (!local || local.root === selfRoot) return undefined;
26
+ const bin = typeof local.bin === "string" ? local.bin : local.bin?.jr2;
27
+ return bin ? join(local.root, bin) : undefined;
28
+ }
29
+
30
+ /** Run `bin` with `argv` on this node, sharing stdio; resolves to its exit code. A child that dies
31
+ * by signal re-raises that signal here, so the shell sees what it would have seen. */
32
+ export function handoff(bin: string, argv: string[]): Promise<number> {
33
+ return new Promise((resolve) => {
34
+ const child = spawn(process.execPath, [bin, ...argv], { stdio: "inherit" });
35
+ child.on("error", (err) => {
36
+ process.stderr.write(`error: cannot run ${bin}: ${err.message}\n`);
37
+ resolve(1);
38
+ });
39
+ child.on("exit", (code, signal) => {
40
+ if (signal) process.kill(process.pid, signal);
41
+ resolve(code ?? 1);
42
+ });
43
+ });
44
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export { JR2Client } from "./client.ts";
6
6
  export type { RunStatus, RunFeedEvent, RunEvent, FetchLike } from "./client.ts";
7
7
  export type { Io } from "./output.ts";
8
8
  export { resolveRoot, resolveTarget } from "./instance.ts";
9
+ export { assertKitVersion, resolvePackage, CLI_VERSION } from "./kit-version.ts";
9
10
  export type { Target, TargetOptions } from "./instance.ts";
10
11
  export { kubectlKube } from "./kube.ts";
11
12
  export type { KubePort } from "./kube.ts";
package/src/instance.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  // folder we are in, and WHERE its deployed orchestrator is.
3
3
  //
4
4
  // - `resolveRoot` walks up from cwd to the dir holding `jr2.config.ts` — the root marker (mirrors
5
- // flue's `flue.config.ts`).
5
+ // flue's `flue.config.ts`) — and refuses unless that Instance and this CLI resolve the same
6
+ // `@jr2/orchestrator` (ADR-0056: an Instance has one Kit version). Every Instance verb goes
7
+ // through it; `--url` verbs skip the walk and so skip the check.
6
8
  // - `resolveTarget` finds the orchestrator. `--url` / `JR2_URL` (+ `JR2_TOKEN`) short-circuits
7
9
  // everything — the ingress-exposed/remote-caller escape hatch, no folder walk. Otherwise the
8
10
  // DEPLOYMENT is addressed by the current kube context + the instance's namespace (`-n` >
@@ -11,27 +13,21 @@
11
13
  // kube RBAC is the real gate, and no local state file can go stale (ADR-0019).
12
14
  // - Every resolution prints its target on stderr, so ambient-context drift stays visible.
13
15
 
14
- import { existsSync } from "node:fs";
15
- import { basename, dirname, join } from "node:path";
16
+ import { basename } from "node:path";
16
17
  import { loadConfig } from "@jr2/orchestrator";
17
18
  import { INSTANCE_SECRET, kubectlKube, ORCHESTRATOR_PORT, ORCHESTRATOR_SERVICE } from "./kube.ts";
19
+ import { assertKitVersion } from "./kit-version.ts";
18
20
  import { activity, type Io } from "./output.ts";
21
+ import { findRoot } from "./root.ts";
19
22
 
20
- /** Walk up from `cwd` to the directory containing `jr2.config.ts`; `undefined` if there is none. */
21
- export function findRoot(cwd: string): string | undefined {
22
- let dir = cwd;
23
- for (;;) {
24
- if (existsSync(join(dir, "jr2.config.ts"))) return dir;
25
- const parent = dirname(dir);
26
- if (parent === dir) return undefined;
27
- dir = parent;
28
- }
29
- }
23
+ export { findRoot } from "./root.ts";
30
24
 
31
- /** `findRoot`, for the callers that cannot proceed without one. */
25
+ /** `findRoot` for the callers that cannot proceed without one — and the Kit version check, which
26
+ * every Instance verb therefore makes before it reads a byte of the Instance. */
32
27
  export function resolveRoot(cwd: string): string {
33
28
  const root = findRoot(cwd);
34
29
  if (!root) throw new Error("not inside a jr2 instance — no jr2.config.ts found walking up from cwd");
30
+ assertKitVersion(root);
35
31
  return root;
36
32
  }
37
33
 
@@ -0,0 +1,88 @@
1
+ // An Instance has ONE Kit version (ADR-0056): the `@jr2/orchestrator` it resolves, because that is
2
+ // what its image bakes. Published `@jr2/cli@X` used to DEPEND on `@jr2/orchestrator@X` exact, so an
3
+ // Instance pinning any other orchestrator quietly held two copies — the CLI's `KIT_VERSION` named
4
+ // one, the bundle baked the other, and the CLI's own code handled config objects built by a
5
+ // different copy of the same classes. Now the orchestrator is the CLI's PEER (one copy can exist),
6
+ // and every Instance verb asks this module whether the copy the CLI resolves IS the copy the
7
+ // Instance resolves: same REAL path, never a version compare. Same file means same version by
8
+ // construction under npm hoisting, pnpm dedup, and checkout symlinks alike — and identity is
9
+ // exactly the property the class-identity bug needs.
10
+ //
11
+ // Builtins only: the launcher (`bin/jr2.js`) resolves through here before any kit import.
12
+
13
+ import { readFileSync, realpathSync } from "node:fs";
14
+ import { createRequire } from "node:module";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ export type ResolvedPackage = {
19
+ /** The package's REAL root directory (symlinks resolved). */
20
+ root: string;
21
+ version: string;
22
+ bin?: string | Record<string, string>;
23
+ };
24
+
25
+ /**
26
+ * Resolve `name` the way code in `fromDir` would — Node resolution from that folder — and answer
27
+ * the package's real root. `undefined` when nothing resolves (no `node_modules` yet, or a global
28
+ * with no peer beside it). Walks up from the resolved ENTRY to the nearest manifest carrying the
29
+ * name, rather than resolving `<name>/package.json`, because an `exports` map hides the manifest
30
+ * and the kits already published export none.
31
+ */
32
+ export function resolvePackage(name: string, fromDir: string): ResolvedPackage | undefined {
33
+ const require = createRequire(join(fromDir, "package.json"));
34
+ let entry: string;
35
+ try {
36
+ entry = realpathSync(require.resolve(name));
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ let dir = dirname(entry);
41
+ for (;;) {
42
+ try {
43
+ const m = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as ResolvedPackage & { name?: string };
44
+ if (m.name === name) return { root: dir, version: m.version, bin: m.bin };
45
+ } catch {
46
+ // not a manifest, or not this package's — keep walking
47
+ }
48
+ const parent = dirname(dir);
49
+ if (parent === dir) return undefined;
50
+ dir = parent;
51
+ }
52
+ }
53
+
54
+ /** This CLI's own real root and version — what `jr2 init` pins (ADR-0056), what the refusal names. */
55
+ export const CLI_ROOT = realpathSync(fileURLToPath(new URL("..", import.meta.url)));
56
+ export const CLI_VERSION = (JSON.parse(readFileSync(join(CLI_ROOT, "package.json"), "utf8")) as { version: string })
57
+ .version;
58
+
59
+ const ORCHESTRATOR = "@jr2/orchestrator";
60
+ const CLI = "@jr2/cli";
61
+
62
+ /**
63
+ * Refuse unless the Instance at `root` and this CLI resolve the SAME `@jr2/orchestrator`. The
64
+ * message names both versions and the lines to edit: an Instance with no `@jr2/cli` of its own is
65
+ * told to add one (the launcher then hands off to it); one that has it is told to pin both lines
66
+ * at one number.
67
+ */
68
+ export function assertKitVersion(root: string): void {
69
+ const instance = resolvePackage(ORCHESTRATOR, root);
70
+ const own = resolvePackage(ORCHESTRATOR, CLI_ROOT);
71
+ if (instance && own && instance.root === own.root) return;
72
+
73
+ const manifest = join(root, "package.json");
74
+ const local = instance && resolvePackage(CLI, root);
75
+ const fix = !instance
76
+ ? `install the Instance's dependencies (${manifest} pins "${ORCHESTRATOR}" and "${CLI}" at one exact version)`
77
+ : local
78
+ ? `pin "${ORCHESTRATOR}" and "${CLI}" at ONE exact version in ${manifest} and reinstall`
79
+ : `add "${CLI}": "${instance.version}" to devDependencies in ${manifest} and reinstall — the Instance's ` +
80
+ `own jr2 then runs`;
81
+ const have = instance
82
+ ? `${ORCHESTRATOR} resolves to ${instance.version} (${instance.root})`
83
+ : `${ORCHESTRATOR} does not resolve from ${root}`;
84
+ const self = own
85
+ ? `this jr2 (${CLI_VERSION}) runs against ${own.version} (${own.root})`
86
+ : `this jr2 (${CLI_VERSION}, at ${CLI_ROOT}) has no ${ORCHESTRATOR} beside it`;
87
+ throw new Error(`Kit version mismatch — an Instance has one (ADR-0056):\n ${have}\n ${self}\n ${fix}`);
88
+ }
package/src/root.ts ADDED
@@ -0,0 +1,17 @@
1
+ // The Instance root walk (ADR-0009): up from `cwd` to the directory holding `jr2.config.ts`, the
2
+ // root marker. Its own module — builtins only — because the launcher runs it BEFORE it knows which
3
+ // `jr2` will run (ADR-0056), so nothing here may import the kit.
4
+
5
+ import { existsSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+
8
+ /** Walk up from `cwd` to the directory containing `jr2.config.ts`; `undefined` if there is none. */
9
+ export function findRoot(cwd: string): string | undefined {
10
+ let dir = cwd;
11
+ for (;;) {
12
+ if (existsSync(join(dir, "jr2.config.ts"))) return dir;
13
+ const parent = dirname(dir);
14
+ if (parent === dir) return undefined;
15
+ dir = parent;
16
+ }
17
+ }