@jr2/cli 0.1.2 → 0.1.4

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.2",
3
+ "version": "0.1.4",
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.2"
39
+ "ts-blank-space": "^0.9.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@jr2/orchestrator": "0.1.4"
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.4"
46
49
  },
47
50
  "files": [
48
51
  "bin",
package/src/cli.ts CHANGED
@@ -15,11 +15,14 @@ import { up } from "./commands/up.ts";
15
15
  import { down } from "./commands/down.ts";
16
16
  import { gc } from "./commands/gc.ts";
17
17
  import { kit } from "./commands/kit.ts";
18
+ import { version } from "./commands/version.ts";
18
19
 
19
20
  const USAGE = `jr2 — orchestrate agentic workflows (ADR-0009)
20
21
 
21
22
  usage: jr2 <command> [args]
22
23
 
24
+ version [--local] [--json] what runs here (this jr2, the global that handed off, the
25
+ Instance's Kit) and what the cluster runs; --local skips the cluster
23
26
  init [dir] [--name <n>] scaffold a new instance folder
24
27
  up [--yes] [--force] converge the current kube context to this instance (ADR-0019)
25
28
  down [--all] remove the instance from the cluster (--all: operator too)
@@ -30,7 +33,7 @@ usage: jr2 <command> [args]
30
33
  [--detach] ...or just print the runId and return
31
34
  runs list live runs
32
35
  status [runId] print a run's current status (read-through),
33
- or the instance's Repos, per node, when given none (ADR-0048/0051)
36
+ [--json] or the instance's Repos, per node, when given none (ADR-0048/0051)
34
37
  logs <runId> [-f] replay a run's status; -f to follow until it settles
35
38
  send <runId> --event CANCEL abandon a live run
36
39
  send <runId> --gate <gate> --event <name> [--input <json>]
@@ -40,7 +43,11 @@ run ids: any <runId> above may be abbreviated to a unique prefix (4+ chars, git-
40
43
  an ambiguous prefix lists the candidates and fails rather than guessing
41
44
 
42
45
  global (run verbs): -n/--namespace <ns>, --context <ctx> address the deployment (ADR-0019);
43
- --url <u> / JR2_URL attaches to a specific orchestrator (skips kube entirely)`;
46
+ --url <u> / JR2_URL attaches to a specific orchestrator (skips kube entirely)
47
+
48
+ output: result verbs (run, runs, status <runId>, send, logs) print one JSON result on stdout and
49
+ activity on stderr, so \`jr2 run … | jq\` yields the result; report verbs (version, bare
50
+ status) print a table on stdout, or the one object with --json`;
44
51
 
45
52
  /**
46
53
  * The catch-all half of skew reporting: a route-shaped failure gets ONE extra line naming what the
@@ -88,6 +95,9 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
88
95
  return await gc(rest, io);
89
96
  case "kit":
90
97
  return await kit(rest, io);
98
+ case "version":
99
+ case "--version":
100
+ return await version(rest, io);
91
101
  case "help":
92
102
  case "--help":
93
103
  case "-h":
@@ -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,
@@ -6,8 +6,13 @@
6
6
  // unregistered deploy key (ADR-0047), a wrong url, a git host outage — is discoverable by asking
7
7
  // the thing that knows, rather than by tailing pod logs. The Orchestrator serves either way; only
8
8
  // the Repo is degraded, and the cache agent retries it on its own.
9
+ //
10
+ // Two output classes (ADR-0009 as amended): `status <runId>` is a RESULT verb — the RunStatus as one
11
+ // JSON line on stdout, for the pipe. Bare `status` is a REPORT verb — a human table on stdout, or the
12
+ // one object with `--json`.
9
13
 
10
14
  import { parseArgs } from "node:util";
15
+ import type { RepoStatus } from "@jr2/orchestrator";
11
16
  import { JR2Client } from "../client.ts";
12
17
  import { resolveTarget, TARGET_ARGS, targetOptions } from "../instance.ts";
13
18
  import { activity, result, type Io } from "../output.ts";
@@ -18,7 +23,7 @@ export async function status(args: string[], io: Io): Promise<number> {
18
23
  args,
19
24
  allowPositionals: true,
20
25
  strict: false,
21
- options: { ...TARGET_ARGS },
26
+ options: { ...TARGET_ARGS, json: { type: "boolean" } },
22
27
  });
23
28
  // NO run named, not an EMPTY one: `jr2 status "$RUNID"` with the variable unset asked about a run
24
29
  // and got an empty string, and answering that with the instance's repos would report exit 0 on a
@@ -27,7 +32,7 @@ export async function status(args: string[], io: Io): Promise<number> {
27
32
  const target = await resolveTarget(io, targetOptions(values));
28
33
  try {
29
34
  const client = new JR2Client(target.url, io.fetch, target.token);
30
- if (given === undefined) return await instanceStatus(client, io);
35
+ if (given === undefined) return await instanceStatus(client, io, values.json === true);
31
36
  const ref = await resolveRunId(client, given);
32
37
  if (!ref.ok) {
33
38
  activity(io, ref.message);
@@ -46,11 +51,11 @@ export async function status(args: string[], io: Io): Promise<number> {
46
51
  }
47
52
 
48
53
  /**
49
- * The instance's own status: the data-plane switch and every Repo resource, on stdout as one JSON
50
- * object (extensible — the instance has more to report eventually), and every node whose last
51
- * attempt on a Repo FAILED spelled out on stderr with git's own error, since that is the line a
52
- * human acts on. An instance with no data plane says so — "no registered Machine composes a
53
- * Sandbox" is the answer, not an empty list.
54
+ * The instance's own status: the data-plane switch and every Repo resource. As a table on stdout —
55
+ * every node's state per Repo, and every node whose last attempt FAILED spelled out with git's own
56
+ * error, since that is the line a human acts on — or, with `--json`, as the one object (extensible:
57
+ * the instance has more to report eventually). An instance with no data plane says so — "no
58
+ * registered Machine composes a Sandbox" is the answer, not an empty list.
54
59
  *
55
60
  * Two failures, two consequences (ADR-0004/0051): a cache that is ABSENT on the node (a cold clone
56
61
  * failed) parks every Workspace that needs it there; a cache that is PRESENT but whose last fetch
@@ -60,31 +65,40 @@ export async function status(args: string[], io: Io): Promise<number> {
60
65
  * Exit 0 even with Repos failing: this is a report, and a degraded Repo is a state the instance
61
66
  * is serving in, not a failure of the asking.
62
67
  */
63
- async function instanceStatus(client: JR2Client, io: Io): Promise<number> {
64
- const { dataPlane, repos } = await client.repos();
65
- if (!dataPlane) activity(io, "this instance has no data plane (no registered Machine composes a Sandbox)");
68
+ async function instanceStatus(client: JR2Client, io: Io, json: boolean): Promise<number> {
69
+ const report = await client.repos();
70
+ if (json) result(io, report);
71
+ else io.stdout(renderInstanceStatus(report));
72
+ return 0;
73
+ }
74
+
75
+ /** The human table — one line per Repo, one per node under it, then the hints for what was seen. */
76
+ export function renderInstanceStatus({ dataPlane, repos }: { dataPlane: boolean; repos: RepoStatus[] }): string {
77
+ let out = dataPlane
78
+ ? "data plane: yes\n"
79
+ : "data plane: none — this instance has no data plane (no registered Machine composes a Sandbox)\n";
66
80
  let absent = 0;
67
81
  let stale = 0;
68
82
  for (const repo of repos) {
83
+ out += `repo ${repo.key} (${repo.url})${repo.bound ? " bound" : ""}\n`;
69
84
  for (const node of repo.nodes) {
70
- if (node.synced || node.lastError === undefined) continue;
71
- if (node.present) stale++;
72
- else absent++;
73
- activity(
74
- io,
75
- `repo ${repo.key} (${repo.url}) on node ${node.node}: ${node.present ? "stale" : "absent"} — ${node.lastError}`,
76
- );
85
+ if (node.synced) {
86
+ out += ` node ${node.node}: present, synced\n`;
87
+ } else if (node.lastError === undefined) {
88
+ out += ` node ${node.node}: ${node.present ? "present" : "absent"}, not yet fetched\n`;
89
+ } else {
90
+ if (node.present) stale++;
91
+ else absent++;
92
+ out += ` node ${node.node}: ${node.present ? "stale" : "absent"} — ${node.lastError}\n`;
93
+ }
77
94
  }
78
95
  }
79
96
  if (absent || stale) {
80
- activity(
81
- io,
97
+ out +=
82
98
  "the cache agent keeps retrying these — register the deploy key with your git host, or fix the url or the " +
83
- "git.credentials entry, and the next attempt lands (ADR-0047/0048/0051).",
84
- );
99
+ "git.credentials entry, and the next attempt lands (ADR-0047/0048/0051).\n";
85
100
  }
86
- if (absent) activity(io, "absent: Workspaces needing that cache on that node wait until it lands.");
87
- if (stale) activity(io, "stale: attaches proceed on what the cache holds until the next fetch lands.");
88
- result(io, { dataPlane, repos });
89
- return 0;
101
+ if (absent) out += "absent: Workspaces needing that cache on that node wait until it lands.\n";
102
+ if (stale) out += "stale: attaches proceed on what the cache holds until the next fetch lands.\n";
103
+ return out;
90
104
  }
@@ -1361,13 +1361,19 @@ async function preflightProvider(
1361
1361
  `${err instanceof Error ? err.message : err}\n` +
1362
1362
  ` - localhost never works from a pod; use a LAN address\n` +
1363
1363
  ` - the model id must be exactly what the endpoint serves (vLLM: GET /v1/models)\n` +
1364
- ` - vLLM needs --enable-auto-tool-choice and a matching --tool-call-parser`,
1364
+ ` - vLLM needs --enable-auto-tool-choice and a matching --tool-call-parser\n` +
1365
+ ` - finish_reason "length" means the model ran out of tokens before the call (a thinking model reasons first)`,
1365
1366
  );
1366
1367
  }
1367
1368
  }
1368
1369
  }
1369
1370
 
1370
- /** The in-cluster probe: GET /models, then one chat completion that must answer with tool_calls. */
1371
+ /**
1372
+ * The in-cluster probe: GET /models, then one chat completion that must answer with tool_calls. The budget
1373
+ * is generous because a thinking model (Qwen3, DeepSeek-R1) reasons BEFORE it calls the tool, and that
1374
+ * reasoning counts against max_tokens — at 64 the completion ended with finish_reason "length" and an
1375
+ * empty tool_calls, which read as "the endpoint cannot call tools" when it could.
1376
+ */
1371
1377
  function providerProbeScript(baseUrl: string, model: string, apiKey?: string): string {
1372
1378
  const base = JSON.stringify(baseUrl.replace(/\/+$/, ""));
1373
1379
  const headers = apiKey
@@ -1378,14 +1384,15 @@ function providerProbeScript(baseUrl: string, model: string, apiKey?: string): s
1378
1384
  `const m = await fetch(${base} + "/models", { headers: h });` +
1379
1385
  `if (!m.ok) throw new Error("GET /models: HTTP " + m.status);` +
1380
1386
  `const c = await fetch(${base} + "/chat/completions", { method: "POST", headers: h, body: JSON.stringify({` +
1381
- ` model: ${JSON.stringify(model)}, max_tokens: 64,` +
1387
+ ` model: ${JSON.stringify(model)}, max_tokens: 1024,` +
1382
1388
  ` messages: [{ role: "user", content: "Call the ping tool." }],` +
1383
1389
  ` tools: [{ type: "function", function: { name: "ping", description: "reply with a ping", parameters: { type: "object", properties: {} } } }]` +
1384
1390
  ` }) });` +
1385
1391
  `const j = await c.json();` +
1386
1392
  `if (!c.ok) throw new Error("POST /chat/completions: HTTP " + c.status + " " + JSON.stringify(j).slice(0, 300));` +
1387
- `const calls = j.choices?.[0]?.message?.tool_calls;` +
1388
- `if (!calls?.length) throw new Error("completion carried no tool_calls");` +
1393
+ `const choice = j.choices?.[0];` +
1394
+ `const calls = choice?.message?.tool_calls;` +
1395
+ `if (!calls?.length) throw new Error("completion carried no tool_calls (finish_reason " + JSON.stringify(choice?.finish_reason ?? null) + ")");` +
1389
1396
  `console.log("PROVIDER OK");`
1390
1397
  );
1391
1398
  }
@@ -0,0 +1,331 @@
1
+ // `jr2 version [--local] [--json]` (ADR-0009 as amended): the report you paste into a bug. What is
2
+ // HERE — the copy that runs, the global that handed off (ADR-0056), the Instance's Kit version, the
3
+ // Instance and its mode, node — and what is DEPLOYED — the orchestrator the pod says it is, the
4
+ // operator, the Kit images pods read — side by side, so the gap between the halves is the answer
5
+ // ("you edited the pin and never ran `jr2 up`").
6
+ //
7
+ // The one Instance verb that reads the Instance without asserting it: a Kit mismatch is a LINE here,
8
+ // never the ADR-0056 refusal, because this is the verb you reach for when that refusal fires. Every
9
+ // probe is best-effort and every answer is a report — exit 0 whatever it finds. Nothing here
10
+ // computes what `jr2 up` computes: no bundle staging, no local content hash.
11
+ //
12
+ // A REPORT verb, not a result verb: the human table goes to stdout, `--json` swaps in the one object.
13
+
14
+ import { basename } from "node:path";
15
+ import { parseArgs } from "node:util";
16
+ import { IMAGES_CONFIGMAP, IMAGES_KEY, INSTANCE_SECRET, loadConfig, type ImageRefs } from "@jr2/orchestrator";
17
+ import { detectKitCheckout, KIT_IMAGE_HOME } from "../build.ts";
18
+ import type { InstanceIdentity } from "../client.ts";
19
+ import { compareVersions, LABEL_HASH, LABEL_VERSION, OPERATOR_DEPLOYMENT, OPERATOR_NAMESPACE } from "../deploy.ts";
20
+ import { handedOffFrom } from "../handoff.ts";
21
+ import { TARGET_ARGS, targetOptions, type TargetOptions } from "../instance.ts";
22
+ import { checkKitVersion, CLI_ROOT, CLI_VERSION } from "../kit-version.ts";
23
+ import { kubectlAdmin, kubectlKube, ORCHESTRATOR_PORT, ORCHESTRATOR_SERVICE, type KubeObject } from "../kube.ts";
24
+ import { activity, result, type Io } from "../output.ts";
25
+ import { findRoot } from "../root.ts";
26
+
27
+ /** A package as the report names it: the number and the real path, so the path says WHICH copy. */
28
+ export type Copy = { version: string; path: string };
29
+
30
+ /** The image map as `jr2 up` wrote it and pods read it (ADR-0038). */
31
+ type Images = Partial<ImageRefs & { operator?: string }>;
32
+
33
+ /** The comparison between the local kit line and the orchestrator line — the verdict the two
34
+ * halves exist to produce. `unknown` when either side is missing. */
35
+ export type Skew = "same" | "behind" | "ahead" | "unknown";
36
+
37
+ export type VersionReport = {
38
+ /** The copy that runs (after any handoff). */
39
+ cli: Copy;
40
+ /** The copy that handed off, when one did and said so (ADR-0056 as amended). */
41
+ global?: Copy;
42
+ node: string;
43
+ /** Absent outside an Instance; `mode` decides whether deployed Kit tags read as versions
44
+ * (installed) or content hashes (checkout). */
45
+ instance?: {
46
+ name: string;
47
+ root: string;
48
+ mode: "checkout" | "installed";
49
+ kitCheckout?: string;
50
+ /** Where an installed kit pulls its Kit images from: `kitRegistry`, else the canonical home. */
51
+ kitRegistry?: string;
52
+ configError?: string;
53
+ };
54
+ /** The Instance's Kit version — the `@jr2/orchestrator` it resolves — and the ADR-0056 check. */
55
+ kit?: {
56
+ version?: string;
57
+ path?: string;
58
+ /** What the manifest pins, when it disagrees with what resolves (or nothing resolves). */
59
+ pinned?: string;
60
+ check: "ok" | "mismatch" | "unresolved";
61
+ /** This CLI's own peer, named when the check is not `ok`. */
62
+ against?: Copy;
63
+ };
64
+ deployed?: {
65
+ url?: string;
66
+ context?: string;
67
+ namespace?: string;
68
+ orchestrator?: Orchestrator;
69
+ operator?: { version?: string; image?: string };
70
+ images?: Images;
71
+ };
72
+ skew?: Skew;
73
+ };
74
+
75
+ /** What the pod answers on `/healthz`, plus what the Deployment records — two facts, because a
76
+ * rollout that has not landed leaves them disagreeing. Or why neither could be read. */
77
+ type Orchestrator = { version?: string; hash?: string; deploymentHash?: string; image?: string } | { error: string };
78
+
79
+ export async function version(args: string[], io: Io): Promise<number> {
80
+ const { values } = parseArgs({
81
+ args,
82
+ allowPositionals: true,
83
+ strict: false,
84
+ options: { ...TARGET_ARGS, local: { type: "boolean" }, json: { type: "boolean" } },
85
+ });
86
+ const opts = targetOptions(values);
87
+ const report = await versionReport(io, opts, { local: values.local === true });
88
+ if (values.json === true) result(io, report);
89
+ else io.stdout(render(report));
90
+ return 0;
91
+ }
92
+
93
+ /** The report as data — the whole verb minus its rendering, so tests read facts and not columns. */
94
+ export async function versionReport(io: Io, opts: TargetOptions, flags: { local: boolean }): Promise<VersionReport> {
95
+ const report: VersionReport = {
96
+ cli: { version: CLI_VERSION, path: CLI_ROOT },
97
+ global: handedOffFrom(io.env),
98
+ node: process.version,
99
+ };
100
+
101
+ const root = findRoot(io.cwd);
102
+ let namespace: string | undefined;
103
+ if (root) {
104
+ const kit = checkKitVersion(root);
105
+ report.kit = {
106
+ version: kit.instance?.version,
107
+ path: kit.instance?.root,
108
+ // The pin is noise when it agrees with what resolves; it is the diagnosis when it does not.
109
+ pinned: kit.pinned !== undefined && kit.pinned !== kit.instance?.version ? kit.pinned : undefined,
110
+ check: kit.ok ? "ok" : kit.instance ? "mismatch" : "unresolved",
111
+ against: kit.ok || !kit.own ? undefined : { version: kit.own.version, path: kit.own.root },
112
+ };
113
+
114
+ // The config names the Instance (its namespace by default) and where an installed kit pulls
115
+ // from. A config that will not load is itself a fact worth a line — never a reason to stop.
116
+ let config: Awaited<ReturnType<typeof loadConfig>>;
117
+ let configError: string | undefined;
118
+ try {
119
+ config = await loadConfig(root);
120
+ } catch (e) {
121
+ configError = e instanceof Error ? e.message : String(e);
122
+ }
123
+ namespace = opts.namespace ?? config?.name ?? basename(root);
124
+ const kitCheckout = await detectKitCheckout(io.kitDir);
125
+ report.instance = {
126
+ name: config?.name ?? basename(root),
127
+ root,
128
+ mode: kitCheckout ? "checkout" : "installed",
129
+ kitCheckout,
130
+ kitRegistry: kitCheckout ? undefined : (config?.kitRegistry ?? KIT_IMAGE_HOME),
131
+ configError,
132
+ };
133
+ }
134
+
135
+ if (!flags.local) {
136
+ const url = opts.url ?? io.env.JR2_URL;
137
+ if (url) {
138
+ activity(io, `→ ${url}`);
139
+ report.deployed = { url, orchestrator: await probeHealthz(io, url) };
140
+ } else if (root && namespace) {
141
+ report.deployed = await probeCluster(io, opts, namespace);
142
+ }
143
+ // No Instance and no `--url`: there is nothing to address, and the local half already says so.
144
+ }
145
+
146
+ report.skew = skew(report);
147
+ return report;
148
+ }
149
+
150
+ /** `GET /healthz` — unauthenticated, so this needs no Instance token. The reason it could not be
151
+ * read is the answer when it fails, since an unreachable orchestrator is what a user is debugging. */
152
+ async function probeHealthz(io: Io, url: string): Promise<Orchestrator> {
153
+ const fetchImpl = io.fetch ?? globalThis.fetch;
154
+ try {
155
+ const res = await fetchImpl(`${url.replace(/\/+$/, "")}/healthz`);
156
+ if (!res.ok) return { error: `GET /healthz answered HTTP ${res.status}` };
157
+ const body = (await res.json()) as InstanceIdentity;
158
+ return { version: body.version, hash: body.hash };
159
+ } catch (e) {
160
+ return { error: `unreachable — ${e instanceof Error ? e.message : String(e)}` };
161
+ }
162
+ }
163
+
164
+ /**
165
+ * The kube path, mirroring `resolveTarget`'s two-fault split (ADR-0019) without its refusals: a
166
+ * Secret read that THROWS is a cluster that cannot be reached; an EMPTY answer is an Instance not
167
+ * deployed there. Then the Deployment's records, the pod's own account over a port-forward, the
168
+ * operator's label, and the image map pods actually read — each independently best-effort.
169
+ */
170
+ async function probeCluster(io: Io, opts: TargetOptions, namespace: string): Promise<VersionReport["deployed"]> {
171
+ const kube = io.kube ?? kubectlKube;
172
+ const admin = io.kubeAdmin ?? kubectlAdmin;
173
+ const context = opts.context ?? (await kube.currentContext());
174
+ const deployed: VersionReport["deployed"] = { context, namespace };
175
+ if (!context) {
176
+ deployed.orchestrator = { error: "no kube context — nothing to ask" };
177
+ return deployed;
178
+ }
179
+ activity(io, `→ context ${context} / namespace ${namespace}`);
180
+ const ctx = opts.context ? { context: opts.context } : {};
181
+
182
+ try {
183
+ const secret = await kube.readSecret({ namespace, name: INSTANCE_SECRET, key: "JR2_INSTANCE_TOKEN", ...ctx });
184
+ if (!secret) {
185
+ deployed.orchestrator = { error: `not deployed in ${namespace} (context ${context}) — \`jr2 up\` deploys` };
186
+ return deployed;
187
+ }
188
+ } catch (e) {
189
+ deployed.orchestrator = { error: `cannot reach the cluster — context ${context}: ${(e as Error).message}` };
190
+ return deployed;
191
+ }
192
+
193
+ const orch = await admin.getJson({ kind: "deployment", name: ORCHESTRATOR_SERVICE, namespace, ...ctx });
194
+ const recorded = { deploymentHash: orch?.metadata.labels?.[LABEL_HASH], image: firstImage(orch) };
195
+ let fwd: { url: string; close: () => void } | undefined;
196
+ try {
197
+ fwd = await kube.portForward({ namespace, service: ORCHESTRATOR_SERVICE, port: ORCHESTRATOR_PORT, ...ctx });
198
+ const live = await probeHealthz(io, fwd.url);
199
+ deployed.orchestrator = "error" in live ? live : { ...live, ...recorded };
200
+ } catch (e) {
201
+ deployed.orchestrator = { error: `port-forward failed — ${(e as Error).message}` };
202
+ } finally {
203
+ fwd?.close();
204
+ }
205
+
206
+ const operator = await admin.getJson({
207
+ kind: "deployment",
208
+ name: OPERATOR_DEPLOYMENT,
209
+ namespace: OPERATOR_NAMESPACE,
210
+ ...ctx,
211
+ });
212
+ if (operator) {
213
+ deployed.operator = { version: operator.metadata.labels?.[LABEL_VERSION], image: firstImage(operator) };
214
+ }
215
+
216
+ const map = await admin.getJson<KubeObject & { data?: Record<string, string> }>({
217
+ kind: "configmap",
218
+ name: IMAGES_CONFIGMAP,
219
+ namespace,
220
+ ...ctx,
221
+ });
222
+ const raw = map?.data?.[IMAGES_KEY];
223
+ if (raw) {
224
+ try {
225
+ deployed.images = JSON.parse(raw) as Images;
226
+ } catch {
227
+ // an unreadable map is reported by its absence; the orchestrator line already carries the hash
228
+ }
229
+ }
230
+ return deployed;
231
+ }
232
+
233
+ /** The first container's image of a Deployment, read loosely — this is a report, not a contract. */
234
+ function firstImage(obj?: KubeObject): string | undefined {
235
+ const spec = obj?.spec as { template?: { spec?: { containers?: Array<{ image?: string }> } } } | undefined;
236
+ return spec?.template?.spec?.containers?.[0]?.image;
237
+ }
238
+
239
+ function skew(report: VersionReport): Skew {
240
+ const local = report.kit?.version;
241
+ const remote = report.deployed?.orchestrator;
242
+ if (!local || !remote || "error" in remote || !remote.version) return "unknown";
243
+ const d = compareVersions(remote.version, local);
244
+ return d === 0 ? "same" : d < 0 ? "behind" : "ahead";
245
+ }
246
+
247
+ // --- rendering -----------------------------------------------------------------------------------
248
+
249
+ /** One row of the table: a label and its value. A blank label is the gap between the halves. */
250
+ type Row = [label: string, value: string];
251
+
252
+ const copy = (c: Copy): string => `${c.version.padEnd(8)} ${c.path}`;
253
+
254
+ /** The human table. Every row is one fact; the `skew` row is the only sentence. Labels are padded
255
+ * to the longest present, so a `sandbox <key>` row cannot crowd its value. */
256
+ export function render(r: VersionReport): string {
257
+ const rows: Row[] = [["cli", copy(r.cli)]];
258
+ if (r.global) rows.push(["global", copy(r.global)]);
259
+ if (r.kit) rows.push(["kit", renderKit(r.kit)]);
260
+ if (r.instance) {
261
+ const i = r.instance;
262
+ const mode = i.mode === "checkout" ? `kit checkout at ${i.kitCheckout}` : `installed kit → ${i.kitRegistry}`;
263
+ rows.push(["instance", `${i.name} ${i.root} (${mode})`]);
264
+ if (i.configError) rows.push(["config", `failed to load — ${i.configError}`]);
265
+ } else {
266
+ rows.push(["instance", "none — no jr2.config.ts walking up from cwd"]);
267
+ }
268
+ rows.push(["node", r.node]);
269
+ if (r.deployed) rows.push(["", ""], ...deployedRows(r.deployed, r));
270
+ const width = Math.max(...rows.map(([label]) => label.length)) + 3;
271
+ return rows.map(([label, value]) => (label ? `${`${label}:`.padEnd(width)}${value}` : "")).join("\n") + "\n";
272
+ }
273
+
274
+ function renderKit(k: NonNullable<VersionReport["kit"]>): string {
275
+ const pinned = k.pinned !== undefined ? `; package.json pins ${k.pinned} — reinstall` : "";
276
+ if (k.check === "unresolved") return `not installed${pinned}`;
277
+ const head = copy({ version: k.version!, path: k.path! });
278
+ if (k.check === "ok") return `${head} ok${pinned}`;
279
+ const against = k.against
280
+ ? `this jr2 runs against ${k.against.version} (${k.against.path})`
281
+ : "this jr2 has no @jr2/orchestrator beside it";
282
+ return `${head} MISMATCH — ${against}${pinned}`;
283
+ }
284
+
285
+ function deployedRows(d: NonNullable<VersionReport["deployed"]>, r: VersionReport): Row[] {
286
+ const rows: Row[] = [];
287
+ const o = d.orchestrator;
288
+ if (!o) rows.push(["orchestrator", "not probed"]);
289
+ else if ("error" in o) rows.push(["orchestrator", o.error]);
290
+ else {
291
+ const parts = [o.version ?? "(no version reported)"];
292
+ if (o.hash) parts.push(`hash ${o.hash}`);
293
+ if (o.deploymentHash && o.hash && o.deploymentHash !== o.hash) {
294
+ parts.push(`(rollout incomplete: deployment records ${o.deploymentHash})`);
295
+ }
296
+ if (o.image) parts.push(o.image);
297
+ rows.push(["orchestrator", parts.join(" ")]);
298
+ }
299
+ if (d.operator) {
300
+ rows.push(["operator", `${d.operator.version ?? "(unlabeled)"} ${d.operator.image ?? ""}`.trimEnd()]);
301
+ }
302
+ if (d.images) {
303
+ if (d.images.harness) rows.push(["harness", d.images.harness]);
304
+ if (d.images.adapter) rows.push(["adapter", d.images.adapter]);
305
+ for (const [key, ref] of Object.entries(d.images.sandbox ?? {})) rows.push([`sandbox ${key}`, ref]);
306
+ }
307
+ const verdict = skewSentence(r);
308
+ if (verdict) rows.push(["skew", verdict]);
309
+ return rows;
310
+ }
311
+
312
+ function skewSentence(r: VersionReport): string | undefined {
313
+ const local = r.kit?.version;
314
+ const o = r.deployed?.orchestrator;
315
+ const remote = o && !("error" in o) ? o.version : undefined;
316
+ switch (r.skew) {
317
+ case "behind":
318
+ return `deployed ${remote} predates this kit ${local} — \`jr2 up\` converges it`;
319
+ case "ahead":
320
+ return (
321
+ `deployed ${remote} is newer than this kit ${local} — \`jr2 up\` would roll the orchestrator back; ` +
322
+ "the operator is never downgraded"
323
+ );
324
+ case "same":
325
+ return r.instance?.mode === "checkout"
326
+ ? "same version; checkout builds are content-addressed — compare the hash against `jr2 up`'s line"
327
+ : undefined;
328
+ default:
329
+ return undefined;
330
+ }
331
+ }
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,64 @@
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
+ // Plus one env var (ADR-0056 as amended): the copy that hands off names itself in
14
+ // `JR2_HANDOFF_FROM`, because "is my global out of date" is the one fact the local copy cannot
15
+ // learn on its own, and `jr2 version` answers it from here. Nothing else reads it.
16
+ //
17
+ // Builtins only, like everything the launcher runs before it knows which `jr2` will run.
18
+
19
+ import { spawn } from "node:child_process";
20
+ import { join } from "node:path";
21
+ import { CLI_ROOT, CLI_VERSION, resolvePackage } from "./kit-version.ts";
22
+ import { findRoot } from "./root.ts";
23
+
24
+ /** `<version> <realpath>` of the copy that handed off — set on the child by {@link handoff}. */
25
+ export const HANDOFF_ENV = "JR2_HANDOFF_FROM";
26
+
27
+ /** The copy that handed off to this process, if one did and said so. A global that predates the
28
+ * variable hands off silently, which reads the same as no handoff at all — there is no third source. */
29
+ export function handedOffFrom(env: Record<string, string | undefined>): { version: string; path: string } | undefined {
30
+ const raw = env[HANDOFF_ENV];
31
+ if (!raw) return undefined;
32
+ const sp = raw.indexOf(" ");
33
+ if (sp < 0) return undefined;
34
+ return { version: raw.slice(0, sp), path: raw.slice(sp + 1) };
35
+ }
36
+
37
+ /** The local binary to hand off to, or `undefined` when this copy is the one that runs. */
38
+ export function handoffTarget(cwd: string, selfRoot: string = CLI_ROOT): string | undefined {
39
+ const root = findRoot(cwd);
40
+ if (!root) return undefined;
41
+ const local = resolvePackage("@jr2/cli", root);
42
+ if (!local || local.root === selfRoot) return undefined;
43
+ const bin = typeof local.bin === "string" ? local.bin : local.bin?.jr2;
44
+ return bin ? join(local.root, bin) : undefined;
45
+ }
46
+
47
+ /** Run `bin` with `argv` on this node, sharing stdio; resolves to its exit code. A child that dies
48
+ * by signal re-raises that signal here, so the shell sees what it would have seen. */
49
+ export function handoff(bin: string, argv: string[]): Promise<number> {
50
+ return new Promise((resolve) => {
51
+ const child = spawn(process.execPath, [bin, ...argv], {
52
+ stdio: "inherit",
53
+ env: { ...process.env, [HANDOFF_ENV]: `${CLI_VERSION} ${CLI_ROOT}` },
54
+ });
55
+ child.on("error", (err) => {
56
+ process.stderr.write(`error: cannot run ${bin}: ${err.message}\n`);
57
+ resolve(1);
58
+ });
59
+ child.on("exit", (code, signal) => {
60
+ if (signal) process.kill(process.pid, signal);
61
+ resolve(code ?? 1);
62
+ });
63
+ });
64
+ }
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, checkKitVersion, 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,133 @@
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
+ * The ADR-0056 question, answered rather than asserted: what the Instance at `root` resolves
64
+ * `@jr2/orchestrator` to, what this CLI resolves it to, and whether they are one copy. `ok` is the
65
+ * real-path identity, never a version compare. `jr2 version` prints this; every other Instance verb
66
+ * goes through {@link assertKitVersion}, which turns a not-ok answer into the refusal.
67
+ */
68
+ export type KitCheck = {
69
+ /** The Instance's own resolution — its Kit version; absent when nothing resolves from `root`. */
70
+ instance?: ResolvedPackage;
71
+ /** This CLI's peer; absent for a global with no orchestrator beside it. */
72
+ own?: ResolvedPackage;
73
+ /** What the Instance's manifest PINS for `@jr2/orchestrator`, verbatim — so "edited the line,
74
+ * never reinstalled" is visible as a pin that disagrees with what resolves. */
75
+ pinned?: string;
76
+ ok: boolean;
77
+ };
78
+
79
+ export function checkKitVersion(root: string): KitCheck {
80
+ const instance = resolvePackage(ORCHESTRATOR, root);
81
+ const own = resolvePackage(ORCHESTRATOR, CLI_ROOT);
82
+ return {
83
+ instance,
84
+ own,
85
+ pinned: pinnedVersion(root, ORCHESTRATOR),
86
+ ok: instance !== undefined && own !== undefined && instance.root === own.root,
87
+ };
88
+ }
89
+
90
+ /** The version `root/package.json` pins for `name`, from any dependency block; `undefined` when
91
+ * there is no manifest or no such line. The pin as WRITTEN — a range stays a range. */
92
+ export function pinnedVersion(root: string, name: string): string | undefined {
93
+ try {
94
+ const m = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as Record<
95
+ string,
96
+ Record<string, string> | undefined
97
+ >;
98
+ for (const block of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
99
+ const pin = m[block]?.[name];
100
+ if (pin !== undefined) return pin;
101
+ }
102
+ } catch {
103
+ // no manifest, or not JSON — the Instance has no pin to speak of
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ /**
109
+ * Refuse unless the Instance at `root` and this CLI resolve the SAME `@jr2/orchestrator`. The
110
+ * message names both versions and the lines to edit: an Instance with no `@jr2/cli` of its own is
111
+ * told to add one (the launcher then hands off to it); one that has it is told to pin both lines
112
+ * at one number.
113
+ */
114
+ export function assertKitVersion(root: string): void {
115
+ const { instance, own, ok } = checkKitVersion(root);
116
+ if (ok) return;
117
+
118
+ const manifest = join(root, "package.json");
119
+ const local = instance && resolvePackage(CLI, root);
120
+ const fix = !instance
121
+ ? `install the Instance's dependencies (${manifest} pins "${ORCHESTRATOR}" and "${CLI}" at one exact version)`
122
+ : local
123
+ ? `pin "${ORCHESTRATOR}" and "${CLI}" at ONE exact version in ${manifest} and reinstall`
124
+ : `add "${CLI}": "${instance.version}" to devDependencies in ${manifest} and reinstall — the Instance's ` +
125
+ `own jr2 then runs`;
126
+ const have = instance
127
+ ? `${ORCHESTRATOR} resolves to ${instance.version} (${instance.root})`
128
+ : `${ORCHESTRATOR} does not resolve from ${root}`;
129
+ const self = own
130
+ ? `this jr2 (${CLI_VERSION}) runs against ${own.version} (${own.root})`
131
+ : `this jr2 (${CLI_VERSION}, at ${CLI_ROOT}) has no ${ORCHESTRATOR} beside it`;
132
+ throw new Error(`Kit version mismatch — an Instance has one (ADR-0056):\n ${have}\n ${self}\n ${fix}`);
133
+ }
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
+ }