@jr2/cli 0.3.0 → 0.5.1

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.
@@ -0,0 +1,133 @@
1
+ // What a push owes the author's Effector image (ADR-0068). The Sandbox Effector runs in the
2
+ // author's image — the Sandbox Image by default — and runs jr2's own programs from one fixed PATH
3
+ // (`/usr/local/bin:/usr/bin:/bin`, never the image's own). So, per push form the Workspace holds,
4
+ // `jr2 up` reads the image it built — or, for the stock Harness image, which the kit builds, knows
5
+ // what it holds — and refuses, with the fix, an image that lacks what that form needs:
6
+ //
7
+ // - ssh transport (`ssh://`, `git@`): `ssh`, and a passwd entry for the uid the Effector runs as —
8
+ // OpenSSH's client calls `getpwuid(getuid())` before any option, and exits "No user exists for
9
+ // uid N" without one;
10
+ // - ssh signing (`sign: { sshKey }`): `ssh-keygen` (`-Y sign` does no passwd lookup);
11
+ // - GPG signing (`sign: { gpgKey }`): `gpg` and `gpg-agent`;
12
+ // - a token over https: nothing beyond `git`, the Sandbox Image floor (the helper is jr2's binary).
13
+ //
14
+ // The uid is the image's: its `USER` line by name or number, else 1000 (ADR-0037's fallback).
15
+ // `http` (a Worktree file body included) and the credentialed command owe nothing: the first is
16
+ // Go, the last is the author's argv.
17
+
18
+ /** Where the Effector looks for jr2's own programs: effector/internal/gitclean's Path. */
19
+ export const EFFECTOR_TOOL_DIRS = ["/usr/local/bin", "/usr/bin", "/bin"] as const;
20
+
21
+ /** One form a push of a Workspace takes, by what it owes its image. */
22
+ export type PushForm = "ssh-transport" | "ssh-sign" | "gpg-sign";
23
+
24
+ /** The programs each form needs, by name. */
25
+ const TOOLS: Record<PushForm, readonly string[]> = {
26
+ "ssh-transport": ["ssh"],
27
+ "ssh-sign": ["ssh-keygen"],
28
+ "gpg-sign": ["gpg", "gpg-agent"],
29
+ };
30
+
31
+ /** What to install, by program: the package name on Debian and on Alpine, where they differ. */
32
+ const PACKAGES: Record<string, string> = {
33
+ ssh: "openssh-client",
34
+ "ssh-keygen": "openssh-client (Alpine: openssh-keygen)",
35
+ gpg: "gnupg",
36
+ "gpg-agent": "gnupg",
37
+ };
38
+
39
+ /**
40
+ * What the stock Harness image holds of the forms' programs, and its /etc/passwd: the fallback
41
+ * Sandbox Image, which the Sandbox Effector runs in when the Workspace names no image and the
42
+ * instance has no `images/default`. The kit builds it (deploy/harness/Dockerfile, its last stage:
43
+ * `node:24-slim` with `git` and `ca-certificates`, `USER 1000`, node's uid), so `jr2 up` knows it in both worlds
44
+ * without reading it — a published one is never pulled to the host for this. A test holds the
45
+ * Dockerfile to it.
46
+ */
47
+ export const STOCK_HARNESS_IMAGE = {
48
+ present: ["/usr/bin/git"],
49
+ passwd: "root:x:0:0:root:/root:/bin/bash\nnode:x:1000:1000::/home/node:/bin/bash\n",
50
+ user: "1000",
51
+ } as const;
52
+
53
+ /** Every path the forms' programs may be at: each program in each of the Effector's tool dirs. */
54
+ export function effectorImagePaths(forms: readonly PushForm[]): string[] {
55
+ const out: string[] = [];
56
+ for (const form of new Set(forms)) {
57
+ for (const tool of TOOLS[form]) for (const dir of EFFECTOR_TOOL_DIRS) out.push(`${dir}/${tool}`);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /** The uid the Effector runs as, from the image's recorded `USER`: a name, a number, or — none
63
+ * declared — ADR-0037's 1000. A `user:group` line decides by its user half. */
64
+ export function effectorSeat(user: string): { name: string } | { uid: number } {
65
+ const head = user.split(":")[0]!.trim();
66
+ if (head === "") return { uid: 1000 };
67
+ if (/^\d+$/.test(head)) return { uid: Number(head) };
68
+ return { name: head };
69
+ }
70
+
71
+ /** Whether /etc/passwd's text holds an entry for the seat. */
72
+ export function passwdHolds(passwd: string, seat: { name: string } | { uid: number }): boolean {
73
+ for (const line of passwd.split("\n")) {
74
+ const fields = line.split(":");
75
+ if (fields.length < 3) continue;
76
+ if ("name" in seat ? fields[0] === seat.name : fields[2] === String(seat.uid)) return true;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ /**
82
+ * Why the image cannot run the forms, with the fix, or undefined. `present` is which of
83
+ * {@link effectorImagePaths} the image holds (links followed); `passwd` its `/etc/passwd`, "" when
84
+ * it has none; `user` its recorded `USER`.
85
+ */
86
+ export function effectorImageRefusal(input: {
87
+ /** For the message: `Workspace of workflow "w"`. */
88
+ label: string;
89
+ ref: string;
90
+ user: string;
91
+ /** Each form, and the push it is for: `the push of Repo Slot "app"`. */
92
+ forms: ReadonlyArray<{ form: PushForm; push: string }>;
93
+ present: ReadonlySet<string>;
94
+ passwd: string;
95
+ /** The image is the stock Harness image: the fix is an image of the author's, not an install. */
96
+ stock?: boolean;
97
+ }): string | undefined {
98
+ const { label, ref } = input;
99
+ for (const { form, push } of input.forms) {
100
+ for (const tool of TOOLS[form]) {
101
+ if (EFFECTOR_TOOL_DIRS.some((dir) => input.present.has(`${dir}/${tool}`))) continue;
102
+ const why =
103
+ form === "ssh-transport" ? "over ssh" : form === "ssh-sign" ? "signed with an ssh key" : "signed with GPG";
104
+ if (input.stock) {
105
+ return (
106
+ `${label}: ${push} ${why} needs ${tool}, and its Sandbox Effector runs in the stock Harness image ${ref}, ` +
107
+ `which holds git alone — the Workspace names no image and the instance has no images/default; give it an ` +
108
+ `image that holds ${tool} (${PACKAGES[tool]}): an images/default Sandbox Image, the workspace()'s image, ` +
109
+ `or its effector: { image } (ADR-0068)`
110
+ );
111
+ }
112
+ return (
113
+ `${label}: ${push} ${why} needs ${tool}, and its Sandbox Effector's image ${ref} holds none in ` +
114
+ `${EFFECTOR_TOOL_DIRS.join(", ")} — the Effector runs jr2's programs from there alone; install it in the ` +
115
+ `image (${PACKAGES[tool]}) (ADR-0068)`
116
+ );
117
+ }
118
+ if (form !== "ssh-transport") continue;
119
+ const seat = effectorSeat(input.user);
120
+ if (passwdHolds(input.passwd, seat)) continue;
121
+ const who = "name" in seat ? `user ${seat.name}` : `uid ${seat.uid}`;
122
+ const fix =
123
+ "name" in seat
124
+ ? `a USER line naming a user the image's /etc/passwd holds`
125
+ : `RUN adduser -D -u ${seat.uid} <name> in the image` +
126
+ (input.user === "" ? " (the image declares no USER, so the Effector runs as uid 1000)" : "");
127
+ return (
128
+ `${label}: ${push} over ssh needs a passwd entry for the ${who} the Sandbox Effector runs as, and its image ` +
129
+ `${ref} has none — ssh exits "No user exists for uid" before it connects; add ${fix} (ADR-0068)`
130
+ );
131
+ }
132
+ return undefined;
133
+ }
package/src/index.ts CHANGED
@@ -14,3 +14,29 @@ export type { KubePort } from "./kube.ts";
14
14
  // test can issue its own fixtures the same way (the @kind tier's HTTPS model provider).
15
15
  export { issueCa, issueLeaf } from "./held-pki.ts";
16
16
  export type { Pem } from "./held-pki.ts";
17
+ // The command table (ADR-0072): every verb as data, and the renderers `--help` prints with — what the
18
+ // docs' CLI Reference is generated from, so the help and the site cannot disagree.
19
+ export {
20
+ COMMANDS,
21
+ GLOBAL,
22
+ GLOBAL_OPTIONS,
23
+ HELP_FLAG,
24
+ SECTIONS,
25
+ EXIT_CODES,
26
+ nameOf,
27
+ synopsis,
28
+ renderHelp,
29
+ renderCommand,
30
+ } from "./command-table.ts";
31
+ export type {
32
+ Command,
33
+ CommandName,
34
+ Flag,
35
+ Arg,
36
+ EnvVar,
37
+ Form,
38
+ Output,
39
+ OutputKind,
40
+ GlobalOption,
41
+ Section,
42
+ } from "./command-table.ts";
@@ -0,0 +1,309 @@
1
+ // The Instance check (ADR-0050): the typecheck and the Machine walk, which `jr2 up` and `jr2 check`
2
+ // both run through this one function, so the two commands cannot give different answers. It reads
3
+ // the Instance folder alone: no kube context, no cluster, no docker, no install. Each refusal is an
4
+ // `activity` line that starts with `refusing:`, and the function then returns `undefined`. A module
5
+ // that throws when it loads (a refusal of `createMachine`, `defineEvent` or `workspace()`) throws
6
+ // out of `loadWorkflows`, and the CLI's dispatch prints it as one `error:` line, for each command.
7
+ //
8
+ // The order is the order of `jr2 up`: the compiler first, because a wrong name is a type error since
9
+ // ADR-0049; then the walk, which holds the refusals that the compiler cannot make (an Open part, a
10
+ // Chart file, a dispatch, an Effect secret, a Size). The Assembly is not staged here: staging installs
11
+ // the Instance's production dependencies from its lockfile, and its refusals are about that install,
12
+ // so they stay with `jr2 up`.
13
+
14
+ import { readdir } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import {
17
+ chartFileRefusals,
18
+ CUSTOM_PROVIDER_APIS,
19
+ dispatchRefusals,
20
+ doorRefusals,
21
+ loadCharts,
22
+ loadWorkflows,
23
+ machineBodies,
24
+ openPartRefusals,
25
+ partsOf,
26
+ placementWalkOf,
27
+ refuseEffectSecrets,
28
+ resolveSize,
29
+ seatLine,
30
+ serializeMachine,
31
+ splitSize,
32
+ targetText,
33
+ type JR2Config,
34
+ type PlacementWalk,
35
+ type ResolvedSize,
36
+ } from "@jr2/orchestrator/internal";
37
+ import { activity, type Io } from "./output.ts";
38
+ import { tscTypecheck } from "./typecheck.ts";
39
+
40
+ /** What the walk found, for the layers of `jr2 up` that come after it. */
41
+ export type CheckedInstance = {
42
+ /** The registered Workflows, loaded as the Orchestrator loads them. */
43
+ workflows: Awaited<ReturnType<typeof loadWorkflows>>;
44
+ /** The parts that the registered Machines carry. */
45
+ carried: ReturnType<typeof partsOf>;
46
+ /** The placement walk: what names each secret, and where each Harness pod goes. */
47
+ placementWalk: PlacementWalk;
48
+ /** The Size of each `workspace()`, resolved and split as the provision does it. */
49
+ sizes: Array<{ workflow: string; size: ResolvedSize }>;
50
+ /** The Chart files under `workflows/`, as `<wrapper>/<name>`. */
51
+ chartFiles: string[];
52
+ };
53
+
54
+ /**
55
+ * Run the typecheck and the Machine walk of the Instance at `root`. Returns what the walk found, or
56
+ * `undefined` after a `refusing:` line. A module that fails to load throws.
57
+ */
58
+ export async function checkInstance(io: Io, root: string, config: JR2Config): Promise<CheckedInstance | undefined> {
59
+ // --- typecheck (ADR-0050): the compiler agrees the folder is coherent, before anything is spent -
60
+ // A REFUSAL, not a warning. `jr2 up` runs it before the namespace apply and before the builds:
61
+ // the names a Machine carries (an Agent slot, a composed Machine, a Repo Slot, a `customize()` of
62
+ // any of them) are typed since ADR-0049/0051, so the answer here is the same answer the author's
63
+ // editor gives — and a converge that shipped a Machine the compiler rejects would surface it as
64
+ // an invoke-time failure mid-run, minutes and three image builds later.
65
+ activity(io, "typecheck: tsc --noEmit (the instance's own compiler)");
66
+ const checked = await (io.typecheck ?? tscTypecheck)(root);
67
+ if (!checked.ok) {
68
+ activity(io, "refusing: this instance does not typecheck — nothing was built and nothing was applied");
69
+ for (const line of checked.output.split("\n")) activity(io, ` ${line}`);
70
+ return undefined;
71
+ }
72
+ // A custom provider's wire protocol, from the Harness's own list (`CUSTOM_PROVIDER_APIS`): the
73
+ // Harness refuses any other at boot, and a check pod would only crash on it (ADR-0018).
74
+ const api = config.harness?.provider?.api;
75
+ if (api !== undefined && !CUSTOM_PROVIDER_APIS.includes(api)) {
76
+ activity(
77
+ io,
78
+ `refusing: harness.provider.api is "${api}" — the Harness speaks: ${CUSTOM_PROVIDER_APIS.join(", ")} (ADR-0018)`,
79
+ );
80
+ return undefined;
81
+ }
82
+
83
+ // --- the Machine walk (ADR-0049, ADR-0051) -----------------------------------------------------
84
+ // A Machine carries its parts, so the check LOADS the instance's registered Workflows — the
85
+ // same discovery and module contract the Orchestrator boots with — and walks them. The walk
86
+ // returns what `jr2 up` uses after the check: the Agent definitions whose models are preflighted
87
+ // (ADR-0018), any `workspace: "none"`, for which `jr2 up` converges the Instance Harness
88
+ // (ADR-0031), every `file:` docker context a `workspace()` names, which `jr2 up` builds
89
+ // (ADR-0037), every bound Repo, whose ssh key `jr2 up` asks for and whose resource the
90
+ // Orchestrator creates at boot (ADR-0051), and whether any Machine composes a Sandbox at all —
91
+ // the data-plane switch. The walk comes right after the gate, and `jr2 up` runs the check before
92
+ // ownership, because the walk holds REFUSALS the compiler cannot make: a part left Open on a
93
+ // registered Machine — a Repo Slot with no url, an Agent with no model (ADR-0054) — and a zod
94
+ // `input` on a Machine that `jr2Setup` did not make, which no door reads (ADR-0033). Nothing
95
+ // about Agents is published, because the definition rides each Turn.
96
+ const workflows = await loadWorkflows(root);
97
+ const carried = partsOf(workflows.map((w) => w.machine));
98
+ const { agents } = carried;
99
+ // The Machine is named by where it sits under the registered root — the slot chain a
100
+ // `customize()` walks — and the fix line nests the same way, so it pastes into
101
+ // `workflows/<name>.ts` with the imported Machine and the url filled in.
102
+ // Every Open part is refused in ONE listing, in walk order within each kind: a composer fixing a
103
+ // packaged Machine fixes all of them in the same edit, and a converge that refused the Repo Slot,
104
+ // then the Agent one run later, would cost them two rounds to learn one thing. The listing is the
105
+ // door's own (ADR-0069): the Orchestrator refuses a publish with the same lines, so the host walk
106
+ // and the door's walk cannot drift.
107
+ // A zod `input` that no door reads (ADR-0033) is in the same listing, first: the door refuses it
108
+ // with the same line.
109
+ const open = [...doorRefusals(workflows), ...openPartRefusals(workflows)];
110
+ if (open.length) {
111
+ for (const line of open) activity(io, `refusing: ${line}`);
112
+ return undefined;
113
+ }
114
+ // Each Chart file is admitted HERE by the door's own check (ADR-0070), so a Chart file the
115
+ // Orchestrator would refuse is refused at the same command as `tsc`'s errors, before any
116
+ // cluster work, with the lines the publish would answer.
117
+ const charts = await loadCharts(root, workflows, (line) => activity(io, `warning: ${line}`));
118
+ const chartRefusals = chartFileRefusals(charts, workflows);
119
+ if (chartRefusals.length) {
120
+ for (const line of chartRefusals) activity(io, `refusing: ${line}`);
121
+ return undefined;
122
+ }
123
+ // What each same-Instance dispatch names resolves in this Assembly, and its slot schema matches
124
+ // the callee's door (ADR-0071): the door's own check, so the publish would refuse the same lines.
125
+ // The host cannot see the Orchestrator's volume, so a named Chart that is no Chart file here is a
126
+ // warning, which the door decides.
127
+ const dispatched = dispatchRefusals(workflows, charts);
128
+ for (const line of dispatched.warnings) activity(io, `warning: ${line}`);
129
+ if (dispatched.errors.length) {
130
+ for (const line of dispatched.errors) activity(io, `refusing: ${line}`);
131
+ return undefined;
132
+ }
133
+ // What each registered Machine runs outside itself (ADR-0067, ADR-0068): every Effect slot, with
134
+ // the plain data it carries — a command's argv, its timeout, the Repo Slot it runs in; an http
135
+ // Effect's method, its fixed target and the secrets it names — read, never run.
136
+ for (const w of workflows) {
137
+ const effects = partsOf([w.machine]).effects;
138
+ if (!effects.length) continue;
139
+ activity(io, `effects: workflow "${w.name}" — what it runs outside itself (ADR-0067)`);
140
+ for (const e of effects) {
141
+ const where = e.path?.length ? ` (on ${e.path.map((k) => `"${k}"`).join(" → ")})` : "";
142
+ const slot = "slot" in e && e.slot !== undefined ? `, in Repo Slot "${e.slot}"` : "";
143
+ if (e.kind === "http") {
144
+ const names = e.secrets.length ? `, secrets ${e.secrets.map((n) => `"${n}"`).join(", ")}` : "";
145
+ // Its body decides where it runs (ADR-0067): a Worktree file needs its Workspace's Sandbox Effector.
146
+ const where2 = e.file
147
+ ? `in the Sandbox Effector, a Worktree file as its body${slot}`
148
+ : "in the Instance Effector";
149
+ activity(
150
+ io,
151
+ ` ${e.name}${where}: http ${e.method} ${e.target ? targetText({ target: e.target }) : "<open>"} — ${e.timeoutMs / 1000}s, ` +
152
+ `${where2}${names}${e.idempotent ? ", idempotent" : ""}`,
153
+ );
154
+ continue;
155
+ }
156
+ if (e.kind === "dispatch") {
157
+ // Where it runs is read from the target (ADR-0071): none is this Orchestrator, in-process.
158
+ const to = e.target ? targetText({ target: e.target }) : e.open ? "<open>" : "this Orchestrator";
159
+ const names = e.secrets.length ? `, token ${e.secrets.map((n) => `"${n}"`).join(", ")}` : "";
160
+ const where2 = e.placement === "instance" ? "in the Instance Effector" : "in the Orchestrator's own process";
161
+ activity(
162
+ io,
163
+ ` ${e.name}${where}: dispatch ${e.workflow}${e.wait ? " wait" : ""} → ${to} — ${e.timeoutMs / 1000}s, ` +
164
+ `${where2}${names}`,
165
+ );
166
+ continue;
167
+ }
168
+ if (e.kind === "push") {
169
+ const opts = (["pin", "sign", "squash", "force"] as const).filter((k) => e[k]);
170
+ activity(
171
+ io,
172
+ ` ${e.name}${where}: push${opts.length ? ` (${opts.join(", ")})` : ""} — ${e.timeoutMs / 1000}s, ` +
173
+ `in the Sandbox Effector${slot}`,
174
+ );
175
+ continue;
176
+ }
177
+ if (!("argv" in e)) continue;
178
+ const names = e.secrets?.length ? `, secrets ${e.secrets.map((n) => `"${n}"`).join(", ")}` : "";
179
+ const seat = e.secrets?.length ? `the Sandbox Effector${e.pin ? ", pinned" : ""}` : "the Harness";
180
+ activity(
181
+ io,
182
+ ` ${e.name}${where}: ${e.kind} ${JSON.stringify(e.argv)} — ${e.timeoutMs / 1000}s, in ${seat}${slot}${names}`,
183
+ );
184
+ }
185
+ }
186
+ // Each state that can fail the run (ADR-0073): every state that invokes a `fail()` slot, in the
187
+ // registered Machine and in each Machine it composes, read, never run — so an author sees which
188
+ // ends of a Workflow are `failed` and not `done` before a run.
189
+ for (const w of workflows) {
190
+ const failures = partsOf([w.machine]).failures;
191
+ if (!failures.length) continue;
192
+ activity(io, `failures: workflow "${w.name}" — the states that fail the run (ADR-0073)`);
193
+ for (const f of failures) {
194
+ const where = f.path?.length ? ` (on ${f.path.map((k) => `"${k}"`).join(" → ")})` : "";
195
+ activity(io, ` ${f.state}${where}: invokes ${f.slot}`);
196
+ }
197
+ }
198
+ // An Effect that names a secret the `secrets` list does not hold, and a literal Effect secret too
199
+ // short to scrub (ADR-0068), are refused HERE, off the walk and the config alone — before anything is
200
+ // built or applied (ADR-0050, ADR-0068). `jr2 up` places a secret read from the user's Secret
201
+ // after the check, once it reaches the cluster.
202
+ // What names each secret, read off the walk (ADR-0059, ADR-0068): every Effect, every Workspace's
203
+ // pod — its `workspace()`'s `secrets` and the models of the Agents whose Turns run there — and the
204
+ // models of the `"none"` Agents the Instance Harness hosts.
205
+ // An Open body that allows "none" needs the Instance Harness, holding a key for each model it
206
+ // allows (ADR-0070), as a "none" Agent does. The door's walk says both, so `jr2 up` and the
207
+ // publish door place the same set.
208
+ const placementWalk = placementWalkOf(workflows);
209
+ refuseEffectSecrets(config, placementWalk);
210
+ // Who may send what, per state (ADR-0015): the audience rule's answer for each registered
211
+ // Machine and every Machine it composes, as its doc carries it — so an author reads who holds a
212
+ // pick before a run finds out. Then what the build found legal and suspect: a warning, never a
213
+ // refusal (the refusals threw when the Machine loaded).
214
+ for (const w of workflows) {
215
+ const bodies = machineBodies(serializeMachine(w.name, w.machine));
216
+ const seats = bodies.flatMap((body) => body.seats.map(seatLine));
217
+ if (seats.length) {
218
+ activity(io, `seats: workflow "${w.name}" — who may send what, per state (ADR-0015)`);
219
+ for (const line of seats) activity(io, ` ${line}`);
220
+ }
221
+ for (const body of bodies) {
222
+ for (const warning of body.warnings) activity(io, `warning: workflow "${w.name}": ${warning} (ADR-0015)`);
223
+ }
224
+ }
225
+ // The Size (ADR-0060): every `workspace()` a registered Machine composes, resolved down its chain
226
+ // (the Machine's, then `sandbox.resources`, then the kit's) and split inside the pod, exactly as
227
+ // the provision will. A split that leaves the Harness below its floor is refused HERE, naming the
228
+ // Machine and the number to change, rather than at the first provision of a run. The check
229
+ // returns the resolved Sizes, and `jr2 up` uses them for its Sandbox-node warning.
230
+ const sizes: Array<{ workflow: string; size: ResolvedSize }> = [];
231
+ for (const w of workflows) {
232
+ for (const { path, resources, user } of partsOf([w.machine]).sizes) {
233
+ try {
234
+ const size = resolveSize(resources, config.sandbox?.resources);
235
+ splitSize(size, user);
236
+ sizes.push({ workflow: w.name, size });
237
+ } catch (e) {
238
+ const where =
239
+ path !== undefined && path.length
240
+ ? ` (on the Machine composed as ${path.map((k) => `"${k}"`).join(" → ")})`
241
+ : "";
242
+ const fix =
243
+ path === undefined
244
+ ? ""
245
+ : `; a composer retunes it where the Machine is registered: export const machine = ${sizeLine(path)}`;
246
+ activity(io, `refusing: workflow "${w.name}"${where}: ${(e as Error).message}${fix}`);
247
+ return undefined;
248
+ }
249
+ }
250
+ }
251
+ const agentNames = [...new Set(agents.map((a) => a.name))].join(", ") || "(none)";
252
+ activity(io, `agents: ${agentNames} (carried by ${workflows.length} workflow machine(s))`);
253
+ // Each Chart wrapper (ADR-0070): what its Open body lends a Chart, how many models a Chart's own
254
+ // Agents may name (`jr2 up` places a key for each), and the Chart files the Assembly carries for
255
+ // it. The walk reads the Open body's declaration, so the keys and the Instance Harness a Chart
256
+ // could need are placed before any run.
257
+ const chartFiles = await chartFilesIn(root);
258
+ for (const w of workflows) {
259
+ const bodies = partsOf([w.machine]).openBodies.filter((b) => b.path !== undefined && b.path.length === 0);
260
+ if (!bodies.length) continue;
261
+ const lends = [
262
+ ...new Set(
263
+ bodies.flatMap(({ declaration: { lends } }) => [
264
+ ...(lends.agents ?? []),
265
+ ...(lends.effects ?? []),
266
+ ...(lends.machines ?? []),
267
+ ...(lends.guards ?? []),
268
+ ...(lends.actions ?? []),
269
+ ...(lends.delays ?? []),
270
+ ]),
271
+ ),
272
+ ];
273
+ const models = new Set(bodies.flatMap((b) => b.declaration.allows.models));
274
+ const files = chartFiles.filter((n) => n.startsWith(`${w.name}/`)).length;
275
+ activity(
276
+ io,
277
+ `chart wrapper "${w.name}": lends ${lends.join(", ") || "nothing"}; allows ${models.size} model(s); ${files} Chart file(s)`,
278
+ );
279
+ }
280
+ return { workflows, carried, placementWalk, sizes, chartFiles };
281
+ }
282
+
283
+ /**
284
+ * The `customize` line that retunes a composed Machine's Size (ADR-0060), nested through `actors`
285
+ * the way `customize()` accepts — the Size twin of `customizeLine`, so the refusal pastes.
286
+ */
287
+ function sizeLine(path: readonly string[]): string {
288
+ const binds = '{ resources: { limits: { memory: "<memory>" } } }';
289
+ return `customize(<import>, ${path.reduceRight((parts, key) => `{ actors: { ${key}: ${parts} } }`, binds)})`;
290
+ }
291
+
292
+ /**
293
+ * The Chart files under an Assembly root (ADR-0070), as Workflow names `<wrapper>/<name>`: each
294
+ * `workflows/<wrapper>/<name>.chart.json`, one level below the flat `workflows/*.ts`, in a directory
295
+ * named after its wrapper. The layout the Orchestrator's discovery reads (charts.ts); a directory
296
+ * whose name starts with `_` or `.` is not a wrapper's.
297
+ */
298
+ export async function chartFilesIn(root: string): Promise<string[]> {
299
+ const out: string[] = [];
300
+ const dir = join(root, "workflows");
301
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
302
+ for (const entry of entries) {
303
+ if (!entry.isDirectory() || entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
304
+ const files = await readdir(join(dir, entry.name)).catch(() => [] as string[]);
305
+ for (const f of files)
306
+ if (f.endsWith(".chart.json")) out.push(`${entry.name}/${f.slice(0, -".chart.json".length)}`);
307
+ }
308
+ return out.sort();
309
+ }
package/src/instance.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // - Every resolution prints its target on stderr, so ambient-context drift stays visible.
15
15
 
16
16
  import { basename } from "node:path";
17
- import { loadConfig } from "@jr2/orchestrator";
17
+ import { loadConfig } from "@jr2/orchestrator/internal";
18
18
  import { INSTANCE_SECRET, kubectlKube, ORCHESTRATOR_PORT, ORCHESTRATOR_SERVICE } from "./kube.ts";
19
19
  import { assertKitVersion } from "./kit-version.ts";
20
20
  import { activity, type Io } from "./output.ts";
@@ -43,14 +43,8 @@ export type TargetOptions = {
43
43
  context?: string;
44
44
  };
45
45
 
46
- /** The parseArgs options every run-verb shares — how a target is addressed (ADR-0019). */
47
- export const TARGET_ARGS = {
48
- url: { type: "string" },
49
- namespace: { type: "string", short: "n" },
50
- context: { type: "string" },
51
- } as const;
52
-
53
- /** Pull the target-addressing flags out of a strict:false parseArgs `values` bag. */
46
+ /** Pull the target-addressing flags (the command table's shared `-n`, `--context`, `--url`) out of
47
+ * a strict:false parseArgs `values` bag. */
54
48
  export function targetOptions(values: Record<string, unknown>): TargetOptions {
55
49
  return {
56
50
  url: values.url as string | undefined,
@@ -4,9 +4,13 @@
4
4
  // one, the bundle baked the other, and the CLI's own code handled config objects built by a
5
5
  // different copy of the same classes. Now the orchestrator is the CLI's PEER (one copy can exist),
6
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.
7
+ // Instance resolves: same REAL path. Same file means same version by construction under npm
8
+ // hoisting, pnpm dedup, and checkout symlinks alike — and identity is exactly the property the
9
+ // class-identity bug needs. One copy is not yet one Kit version: an Instance that pins only
10
+ // `@jr2/orchestrator` at a new number gets a warning from npm (`ERESOLVE overriding peer
11
+ // dependency`), not an error, because the root's own pin wins over a peer, and the one hoisted
12
+ // copy is then the CLI's peer too. So the check also asks that copy's version to be the CLI's own:
13
+ // one release train (ADR-0055) carries one number.
10
14
  //
11
15
  // Builtins only: the launcher (`bin/jr2.js`) resolves through here before any kit import.
12
16
 
@@ -59,11 +63,17 @@ export const CLI_VERSION = (JSON.parse(readFileSync(join(CLI_ROOT, "package.json
59
63
  const ORCHESTRATOR = "@jr2/orchestrator";
60
64
  const CLI = "@jr2/cli";
61
65
 
66
+ /** The CLI a check is for: this one, unless a test names another copy. */
67
+ export type CliCopy = { root: string; version: string };
68
+
69
+ const THIS_CLI: CliCopy = { root: CLI_ROOT, version: CLI_VERSION };
70
+
62
71
  /**
63
72
  * 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.
73
+ * `@jr2/orchestrator` to, what this CLI resolves it to, and whether they are one copy at the CLI's
74
+ * own version. `ok` is the real-path identity AND that version. `jr2 version` prints this; every
75
+ * other Instance verb goes through {@link assertKitVersion}, which turns a not-ok answer into the
76
+ * refusal.
67
77
  */
68
78
  export type KitCheck = {
69
79
  /** The Instance's own resolution — its Kit version; absent when nothing resolves from `root`. */
@@ -76,14 +86,14 @@ export type KitCheck = {
76
86
  ok: boolean;
77
87
  };
78
88
 
79
- export function checkKitVersion(root: string): KitCheck {
89
+ export function checkKitVersion(root: string, cli: CliCopy = THIS_CLI): KitCheck {
80
90
  const instance = resolvePackage(ORCHESTRATOR, root);
81
- const own = resolvePackage(ORCHESTRATOR, CLI_ROOT);
91
+ const own = resolvePackage(ORCHESTRATOR, cli.root);
82
92
  return {
83
93
  instance,
84
94
  own,
85
95
  pinned: pinnedVersion(root, ORCHESTRATOR),
86
- ok: instance !== undefined && own !== undefined && instance.root === own.root,
96
+ ok: instance !== undefined && own !== undefined && instance.root === own.root && own.version === cli.version,
87
97
  };
88
98
  }
89
99
 
@@ -106,13 +116,13 @@ export function pinnedVersion(root: string, name: string): string | undefined {
106
116
  }
107
117
 
108
118
  /**
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.
119
+ * Refuse unless the Instance at `root` and this CLI resolve the SAME `@jr2/orchestrator`, at the
120
+ * CLI's own version. The message names the versions and the lines to edit: an Instance with no
121
+ * `@jr2/cli` of its own is told to add one (the launcher then hands off to it); one that has it is
122
+ * told to pin both lines at one number.
113
123
  */
114
- export function assertKitVersion(root: string): void {
115
- const { instance, own, ok } = checkKitVersion(root);
124
+ export function assertKitVersion(root: string, cli: CliCopy = THIS_CLI): void {
125
+ const { instance, own, ok } = checkKitVersion(root, cli);
116
126
  if (ok) return;
117
127
 
118
128
  const manifest = join(root, "package.json");
@@ -127,7 +137,7 @@ export function assertKitVersion(root: string): void {
127
137
  ? `${ORCHESTRATOR} resolves to ${instance.version} (${instance.root})`
128
138
  : `${ORCHESTRATOR} does not resolve from ${root}`;
129
139
  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`;
140
+ ? `this jr2 (${cli.version}) runs against ${own.version} (${own.root})`
141
+ : `this jr2 (${cli.version}, at ${cli.root}) has no ${ORCHESTRATOR} beside it`;
132
142
  throw new Error(`Kit version mismatch — an Instance has one (ADR-0056):\n ${have}\n ${self}\n ${fix}`);
133
143
  }