@jr2/cli 0.1.3 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jr2/cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "The jr2 CLI: init, up, run — the interface to a jr2 Instance.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -39,13 +39,13 @@
39
39
  "ts-blank-space": "^0.9.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "@jr2/orchestrator": "0.1.3"
42
+ "@jr2/orchestrator": "0.2.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^26.0.1",
46
46
  "xstate": "^5.18.0",
47
47
  "zod": "^4.4.3",
48
- "@jr2/orchestrator": "0.1.3"
48
+ "@jr2/orchestrator": "0.2.0"
49
49
  },
50
50
  "files": [
51
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":
@@ -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/handoff.ts CHANGED
@@ -10,13 +10,30 @@
10
10
  // API the global (the copy that cannot be updated once shipped) must honor forever. The local's own
11
11
  // preamble runs: its pinned erasure hook, whatever a future bin adds.
12
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
+ //
13
17
  // Builtins only, like everything the launcher runs before it knows which `jr2` will run.
14
18
 
15
19
  import { spawn } from "node:child_process";
16
20
  import { join } from "node:path";
17
- import { CLI_ROOT, resolvePackage } from "./kit-version.ts";
21
+ import { CLI_ROOT, CLI_VERSION, resolvePackage } from "./kit-version.ts";
18
22
  import { findRoot } from "./root.ts";
19
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
+
20
37
  /** The local binary to hand off to, or `undefined` when this copy is the one that runs. */
21
38
  export function handoffTarget(cwd: string, selfRoot: string = CLI_ROOT): string | undefined {
22
39
  const root = findRoot(cwd);
@@ -31,7 +48,10 @@ export function handoffTarget(cwd: string, selfRoot: string = CLI_ROOT): string
31
48
  * by signal re-raises that signal here, so the shell sees what it would have seen. */
32
49
  export function handoff(bin: string, argv: string[]): Promise<number> {
33
50
  return new Promise((resolve) => {
34
- const child = spawn(process.execPath, [bin, ...argv], { stdio: "inherit" });
51
+ const child = spawn(process.execPath, [bin, ...argv], {
52
+ stdio: "inherit",
53
+ env: { ...process.env, [HANDOFF_ENV]: `${CLI_VERSION} ${CLI_ROOT}` },
54
+ });
35
55
  child.on("error", (err) => {
36
56
  process.stderr.write(`error: cannot run ${bin}: ${err.message}\n`);
37
57
  resolve(1);
package/src/index.ts CHANGED
@@ -6,7 +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
+ export { assertKitVersion, checkKitVersion, resolvePackage, CLI_VERSION } from "./kit-version.ts";
10
10
  export type { Target, TargetOptions } from "./instance.ts";
11
11
  export { kubectlKube } from "./kube.ts";
12
12
  export type { KubePort } from "./kube.ts";
@@ -59,6 +59,52 @@ export const CLI_VERSION = (JSON.parse(readFileSync(join(CLI_ROOT, "package.json
59
59
  const ORCHESTRATOR = "@jr2/orchestrator";
60
60
  const CLI = "@jr2/cli";
61
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
+
62
108
  /**
63
109
  * Refuse unless the Instance at `root` and this CLI resolve the SAME `@jr2/orchestrator`. The
64
110
  * message names both versions and the lines to edit: an Instance with no `@jr2/cli` of its own is
@@ -66,9 +112,8 @@ const CLI = "@jr2/cli";
66
112
  * at one number.
67
113
  */
68
114
  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;
115
+ const { instance, own, ok } = checkKitVersion(root);
116
+ if (ok) return;
72
117
 
73
118
  const manifest = join(root, "package.json");
74
119
  const local = instance && resolvePackage(CLI, root);