@jr2/cli 0.1.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/src/nodes.ts ADDED
@@ -0,0 +1,74 @@
1
+ // Which nodes are an Instance's Sandbox nodes (ADR-0052), read off the cluster's nodes with the
2
+ // scheduler's own rules: not cordoned, every label of `sandbox.nodeSelector` present, and every
3
+ // NoSchedule/NoExecute taint tolerated by `sandbox.tolerations`. No jr2 label exists — taints and
4
+ // cordons already say "not for ordinary work" for every workload, and jr2 reads them instead of
5
+ // asking for a second, jr2-only opt-in. The predicate is pure and mirrors what the Sandbox pod and
6
+ // the cache agent's DaemonSet carry, so the set `jr2 up` reports is the set the scheduler will use —
7
+ // at the moment of the read. The set moves (a pool autoscales, a node is cordoned), which is why an
8
+ // empty set is reported and never refused.
9
+
10
+ import type { SandboxPlacement, Toleration } from "@jr2/orchestrator";
11
+
12
+ /** A cluster node, read for the facts that decide placement and platform (ADR-0045/0052). */
13
+ export type NodeObject = {
14
+ metadata: { name: string; labels?: Record<string, string> };
15
+ spec?: { unschedulable?: boolean; taints?: readonly Taint[] };
16
+ status?: { nodeInfo?: { architecture?: string } };
17
+ };
18
+
19
+ export type Taint = { key: string; value?: string; effect: string };
20
+
21
+ /** One node's exclusion, in the words a human acts on: the taint or label that kept it out. */
22
+ export type Excluded = { name: string; reason: string };
23
+
24
+ export type SandboxNodes = { nodes: NodeObject[]; excluded: Excluded[] };
25
+
26
+ /** The Sandbox nodes among `nodes`, and why each other one is not. */
27
+ export function sandboxNodes(nodes: readonly NodeObject[], placement: SandboxPlacement | undefined): SandboxNodes {
28
+ const out: SandboxNodes = { nodes: [], excluded: [] };
29
+ for (const node of nodes) {
30
+ const reason = excludedBecause(node, placement);
31
+ if (reason === undefined) out.nodes.push(node);
32
+ else out.excluded.push({ name: node.metadata.name, reason });
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /**
38
+ * The nodes the build set is derived from (ADR-0045 as rewritten by ADR-0052): the union of the
39
+ * nodes an ordinary pod lands on — the Orchestrator Deployment's own placement, no selector, no
40
+ * tolerations — and the Sandbox nodes. A tainted pool no Sandbox reaches is never built for.
41
+ */
42
+ export function buildNodes(nodes: readonly NodeObject[], placement: SandboxPlacement | undefined): NodeObject[] {
43
+ return nodes.filter(
44
+ (n) => excludedBecause(n, undefined) === undefined || excludedBecause(n, placement) === undefined,
45
+ );
46
+ }
47
+
48
+ /** Why `node` is not a Sandbox node under `placement`, or undefined when it is. */
49
+ export function excludedBecause(node: NodeObject, placement: SandboxPlacement | undefined): string | undefined {
50
+ if (node.spec?.unschedulable === true) return "cordoned (spec.unschedulable)";
51
+ for (const [key, value] of Object.entries(placement?.nodeSelector ?? {})) {
52
+ const has = node.metadata.labels?.[key];
53
+ if (has !== value)
54
+ return has === undefined
55
+ ? `lacks the label ${key} that sandbox.nodeSelector requires`
56
+ : `label ${key}=${has} is not the ${key}=${value} that sandbox.nodeSelector requires`;
57
+ }
58
+ for (const taint of node.spec?.taints ?? []) {
59
+ if (taint.effect === "PreferNoSchedule") continue;
60
+ if (!(placement?.tolerations ?? []).some((t) => tolerates(t, taint)))
61
+ return `taint ${taint.key}${taint.value !== undefined && taint.value !== "" ? `=${taint.value}` : ""}:${taint.effect} is not tolerated by sandbox.tolerations`;
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ /** Kubernetes' own rule: an empty key with `Exists` matches every taint; an absent effect matches
67
+ * every effect; `Equal` (the default) also compares the value. */
68
+ export function tolerates(t: Toleration, taint: Taint): boolean {
69
+ if (t.effect !== undefined && t.effect !== taint.effect) return false;
70
+ if (t.key === undefined || t.key === "") return t.operator === "Exists";
71
+ if (t.key !== taint.key) return false;
72
+ if (t.operator === "Exists") return true;
73
+ return (t.value ?? "") === (taint.value ?? "");
74
+ }
package/src/output.ts ADDED
@@ -0,0 +1,211 @@
1
+ // IO seam + output discipline (ADR-0009). The CLI keeps two streams strictly separated:
2
+ // - STDOUT carries the one machine-readable RESULT (terminal RunStatus, a run list, a runId) as JSON,
3
+ // so `jr2 run ping | jq` and friends get clean data;
4
+ // - STDERR carries human ACTIVITY (status deltas, author emits, notices, errors).
5
+ // Every command takes an `Io` rather than touching `process` directly, so dispatch + commands are
6
+ // unit-testable: tests pass buffers for out/err, a fixed cwd/env, and (optionally) a `fetch` bound to a
7
+ // hono app so the whole client path runs without a socket.
8
+
9
+ import type { BuildPort } from "./build.ts";
10
+ import type { FetchLike } from "./client.ts";
11
+ import type { KubeAdmin, KubePort } from "./kube.ts";
12
+ import type { TypecheckPort } from "./typecheck.ts";
13
+
14
+ export type Io = {
15
+ stdout: (s: string) => void;
16
+ stderr: (s: string) => void;
17
+ env: Record<string, string | undefined>;
18
+ cwd: string;
19
+ /** Override the HTTP transport (tests bind it to `app.request`); default → global `fetch`. */
20
+ fetch?: FetchLike;
21
+ /** Override the kube transport (tests inject a fake); default → `kubectl` subprocesses. */
22
+ kube?: KubePort;
23
+ /** Override the kube admin surface `jr2 up`/`down` converge through; default → `kubectl`. */
24
+ kubeAdmin?: KubeAdmin;
25
+ /** Override the image build port; default → pnpm + docker + kind subprocesses. */
26
+ build?: BuildPort;
27
+ /** Override `jr2 up`'s Instance typecheck (ADR-0050); default → the Instance's own `tsc`. */
28
+ typecheck?: TypecheckPort;
29
+ /** Where kit-checkout detection starts walking up from (ADR-0038); default → the CLI's own
30
+ * module directory, which is the whole signal: a checkout resolves the kit sources, an npm
31
+ * install does not. Exists so tests can drive both worlds instead of detecting the real repo
32
+ * they happen to run inside. NOT user-facing — no flag and no env reads it. */
33
+ kitDir?: string;
34
+ /** Answer a yes/no confirmation; default → interactive TTY prompt (non-TTY answers no). */
35
+ confirm?: (question: string) => Promise<boolean>;
36
+ /** Pick one of several offered options (`jr2 up`'s git-ssh key source, ADR-0047); default →
37
+ * an interactive TTY menu. Returns the chosen index; `undefined` is "none of these", which
38
+ * every caller must treat as a decline — a non-TTY always answers that. */
39
+ choose?: (question: string, options: string[]) => Promise<number | undefined>;
40
+ /** Read one VISIBLE line — a path, or the bare enter that ends a pause; default → TTY readline
41
+ * (non-TTY reads nothing, never hangs). */
42
+ prompt?: (question: string) => Promise<string>;
43
+ /** Read key material with echo OFF; default → TTY readline with its own echo suppressed. */
44
+ readSecret?: (question: string) => Promise<string>;
45
+ /** Override deploy-keypair generation (`jr2 up`'s ssh offer); default → `ssh-keygen`. */
46
+ sshKeygen?: () => Promise<{ privateKey: string; publicKey: string }>;
47
+ /** Derive `key.pub` from a private key the USER supplied, refusing a passphrase-protected one
48
+ * by name (ADR-0047); default → `ssh-keygen -y -P ""`. */
49
+ sshPublicKey?: (privateKey: string) => Promise<string>;
50
+ };
51
+
52
+ /** The real-process IO the `jr2` bin runs with. */
53
+ export const defaultIo: Io = {
54
+ stdout: (s) => void process.stdout.write(s),
55
+ stderr: (s) => void process.stderr.write(s),
56
+ env: process.env,
57
+ cwd: process.cwd(),
58
+ };
59
+
60
+ /** Emit the command's machine-readable result as a JSON line on stdout. */
61
+ export function result(io: Io, value: unknown): void {
62
+ io.stdout(`${JSON.stringify(value)}\n`);
63
+ }
64
+
65
+ /** Emit a human-facing activity/notice line on stderr. */
66
+ export function activity(io: Io, line: string): void {
67
+ io.stderr(`${line}\n`);
68
+ }
69
+
70
+ /**
71
+ * Ask a yes/no question on the terminal (ADR-0019: `up` prompts exactly when meeting a cluster
72
+ * that isn't yet home; `down` always). Injectable via `io.confirm`; the default reads one line
73
+ * from the tty — and a NON-tty (CI without `--yes`) answers no, never hangs.
74
+ */
75
+ export async function confirmOrBail(io: Io, question: string): Promise<boolean> {
76
+ if (io.confirm) return io.confirm(question);
77
+ if (!process.stdin.isTTY) {
78
+ activity(io, `${question} — not a tty; pass --yes to proceed non-interactively`);
79
+ return false;
80
+ }
81
+ const { createInterface } = await import("node:readline/promises");
82
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
83
+ try {
84
+ // readline OWNS the line: pre-writing the prompt to stderr gets erased by its first
85
+ // line-refresh on a tty, leaving a question-less cursor that reads as a hang.
86
+ const answer = await rl.question(`${question} [y/N] `);
87
+ return /^y(es)?$/i.test(answer.trim());
88
+ } finally {
89
+ rl.close();
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Ask which of several offered options to take (ADR-0047: the git-ssh key source). The same
95
+ * discipline as `confirmOrBail` — injectable, and a non-tty answers "none" rather than hanging.
96
+ * Interactively it is an arrow-key menu: the highlight moves, ENTER commits it, and everything
97
+ * else that resolves (esc, q, ctrl-c) is "none". No keystroke but enter can pick, because this
98
+ * menu's entries are not interchangeable — the dangerous ones sit beside the recommended one, so
99
+ * a stray key must never resolve into a pick, and the caller's decline path (bail with the manual
100
+ * instructions) is the safe answer to an unreadable one.
101
+ */
102
+ export async function chooseOrBail(io: Io, question: string, options: string[]): Promise<number | undefined> {
103
+ if (io.choose) return io.choose(question, options);
104
+ if (!process.stdin.isTTY) {
105
+ activity(io, `${question} — not a tty; pass --yes to take the recommended option non-interactively`);
106
+ return undefined;
107
+ }
108
+ return menu(question, options);
109
+ }
110
+
111
+ /** The menu's own drawing and key handling. It writes to stderr directly rather than through
112
+ * `activity`, because cursor moves and highlights are not activity lines — the block is redrawn
113
+ * in place and then collapsed to the one line worth keeping in the scrollback. */
114
+ async function menu(question: string, options: string[]): Promise<number | undefined> {
115
+ const input = process.stdin;
116
+ const out = process.stderr;
117
+ const color = !process.env.NO_COLOR;
118
+ const { emitKeypressEvents } = await import("node:readline");
119
+ emitKeypressEvents(input);
120
+ const wasRaw = input.isRaw === true;
121
+ input.setRawMode(true);
122
+ input.resume();
123
+ out.write(`${question}\n\u001b[?25l`); // question stays; cursor hidden while the block moves
124
+
125
+ let cursor = 0;
126
+ let drawn = false;
127
+ const hint = " (↑/↓ move · enter selects · esc cancels)";
128
+ const draw = (): void => {
129
+ if (drawn) out.write(`\u001b[${options.length + 1}A`); // back to the block's first line
130
+ for (const [i, option] of options.entries()) {
131
+ const line = clip(i === cursor ? `❯ ${option}` : ` ${option}`, out.columns);
132
+ out.write(`\u001b[2K${color && i === cursor ? `\u001b[36m${line}\u001b[0m` : line}\n`);
133
+ }
134
+ out.write(`\u001b[2K${clip(hint, out.columns)}\n`);
135
+ drawn = true;
136
+ };
137
+
138
+ return new Promise<number | undefined>((resolve) => {
139
+ const finish = (pick: number | undefined): void => {
140
+ input.off("keypress", onKey);
141
+ if (!wasRaw) input.setRawMode(false);
142
+ input.pause();
143
+ // Collapse the block to its outcome: a menu that scrolled past should still say what was
144
+ // chosen, and a redrawn one must not leave N stale lines behind.
145
+ out.write(`\u001b[${options.length + 1}A\u001b[0J`);
146
+ out.write(`${pick === undefined ? " (cancelled)" : clip(` ❯ ${options[pick]}`, out.columns)}\n\u001b[?25h`);
147
+ resolve(pick);
148
+ };
149
+ const onKey = (str: string | undefined, key: { name?: string; ctrl?: boolean } | undefined): void => {
150
+ if (!key) return;
151
+ if (key.ctrl && (key.name === "c" || key.name === "d")) return finish(undefined);
152
+ if (key.name === "up" || key.name === "k") cursor = (cursor + options.length - 1) % options.length;
153
+ else if (key.name === "down" || key.name === "j") cursor = (cursor + 1) % options.length;
154
+ else if (str !== undefined && /^[1-9]$/.test(str) && Number(str) <= options.length) cursor = Number(str) - 1;
155
+ else if (key.name === "return" || key.name === "enter") return finish(cursor);
156
+ else if (key.name === "escape" || key.name === "q") return finish(undefined);
157
+ else return; // an unmapped key changes nothing — no redraw, no pick
158
+ draw();
159
+ };
160
+ input.on("keypress", onKey);
161
+ draw();
162
+ });
163
+ }
164
+
165
+ /** One rendered menu line, kept to one terminal row: a wrapped line would break the cursor
166
+ * arithmetic the redraw depends on. A width of 0 is a terminal that never reported one (a pty
167
+ * opened without a size), not a zero-wide screen — assume 80 rather than clip every line away. */
168
+ function clip(line: string, columns: number | undefined): string {
169
+ const width = (columns && columns > 0 ? columns : 80) - 1;
170
+ return line.length <= width ? line : `${line.slice(0, Math.max(1, width - 1))}…`;
171
+ }
172
+
173
+ /** Read one visible line — a path to type, or the bare enter that ends a pause. A non-tty reads
174
+ * nothing and returns "", so a scripted run never blocks on a human. */
175
+ export async function promptLine(io: Io, question: string): Promise<string> {
176
+ if (io.prompt) return io.prompt(question);
177
+ if (!process.stdin.isTTY) return "";
178
+ const { createInterface } = await import("node:readline/promises");
179
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
180
+ try {
181
+ return (await rl.question(question)).trim();
182
+ } finally {
183
+ rl.close();
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Read pasted key material with the terminal's echo OFF (ADR-0047) — a private key must not land
189
+ * in the scrollback or the shell's history of whoever is watching the screen. Multi-line by
190
+ * nature: lines are collected until the key's own `-----END …-----` trailer (or EOF, ctrl-D).
191
+ * Echo suppression is readline's own writer, silenced — which is also why the prompt may be
192
+ * written before the interface exists: with nothing echoed, nothing refreshes over it.
193
+ */
194
+ export async function readSecretInput(io: Io, question: string): Promise<string> {
195
+ if (io.readSecret) return io.readSecret(question);
196
+ if (!process.stdin.isTTY) return "";
197
+ activity(io, question);
198
+ const { createInterface } = await import("node:readline");
199
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
200
+ (rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = () => {};
201
+ const lines: string[] = [];
202
+ try {
203
+ for await (const line of rl) {
204
+ lines.push(line);
205
+ if (/-----END [A-Z0-9 ]*PRIVATE KEY-----/.test(line)) break;
206
+ }
207
+ } finally {
208
+ rl.close();
209
+ }
210
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
211
+ }
@@ -0,0 +1,134 @@
1
+ // The Repo half of `jr2 gc` (ADR-0051): eviction is reachability plus age. A `Repo` resource is kept
2
+ // while a registered Machine binds it — the Orchestrator labels those at every boot and unlabels
3
+ // what its walk no longer names — and, unbound, while a run has attached it within the TTL; the
4
+ // Orchestrator moves that clock on every attach. What is left is a Repo nothing binds that no run
5
+ // has asked for lately, and deleting the resource is what lets the cache agent evict the node
6
+ // copies once nothing there mounts them. The sweep only ever deletes RESOURCES: the bytes on each
7
+ // node are the agent's to reclaim, on its own schedule.
8
+ //
9
+ // Cluster-wide like the image sweep (sweep.ts): every namespace some instance owns is read, so
10
+ // "disk is full now" reaches every instance's caches from anywhere. A cluster with no Repo CRD holds
11
+ // no Repos — the one read that may answer "none" — while any other failure throws and the caller
12
+ // sweeps nothing.
13
+
14
+ import { ANNOTATION_REPO_LAST_ATTACHED, LABEL_REPO_BOUND } from "@jr2/orchestrator";
15
+ import { LABEL_INSTANCE } from "./deploy.ts";
16
+ import { isMissingResourceType, type KubeAdmin } from "./kube.ts";
17
+ import { activity, type Io } from "./output.ts";
18
+
19
+ /** The CRD, fully qualified so the read cannot collide with another `repos` resource. */
20
+ export const REPO_KIND = "repos.core.jr2.dev";
21
+
22
+ /** The eviction TTL `jr2 gc` applies when none is given. */
23
+ export const DEFAULT_REPO_TTL = "7d";
24
+
25
+ const UNIT_MS: Record<string, number> = { d: 86_400_000, h: 3_600_000, m: 60_000 };
26
+
27
+ /** `<n>d|h|m` → milliseconds; `0` is "now" (evict everything unbound). Anything else is refused by
28
+ * name, so a typo cannot read as a zero TTL. */
29
+ export function parseRepoTtl(text: string): number {
30
+ if (text === "0") return 0;
31
+ const m = /^(\d+)([dhm])$/.exec(text);
32
+ if (!m) throw new Error(`--repo-ttl ${JSON.stringify(text)} is not a TTL — use <n>d, <n>h, or <n>m (or 0 for now)`);
33
+ return Number(m[1]) * UNIT_MS[m[2]!]!;
34
+ }
35
+
36
+ type RepoObject = {
37
+ metadata: {
38
+ name: string;
39
+ namespace?: string;
40
+ creationTimestamp?: string;
41
+ labels?: Record<string, string>;
42
+ annotations?: Record<string, string>;
43
+ };
44
+ spec?: { url?: string };
45
+ };
46
+
47
+ /** One Repo the sweep decided about: where it is, what it clones, and when it was last wanted. */
48
+ export type SweptRepo = { namespace: string; key: string; url: string; lastAttached: string };
49
+
50
+ /**
51
+ * Whether one Repo is garbage at `now`, under `ttlMs`: unbound, and last attached (or, never
52
+ * attached, created) before the TTL's edge. An unreadable clock keeps the Repo — a resource this
53
+ * cannot date is not one it may take.
54
+ */
55
+ export function repoIsEvictable(repo: RepoObject, now: Date, ttlMs: number): boolean {
56
+ if (repo.metadata.labels?.[LABEL_REPO_BOUND] === "true") return false;
57
+ const stamp = repo.metadata.annotations?.[ANNOTATION_REPO_LAST_ATTACHED] ?? repo.metadata.creationTimestamp;
58
+ if (stamp === undefined) return false;
59
+ const at = Date.parse(stamp);
60
+ if (Number.isNaN(at)) return false;
61
+ return now.getTime() - at >= ttlMs;
62
+ }
63
+
64
+ /**
65
+ * One sweep: every instance namespace's Repos, those the rule above evicts deleted (or, dry, named).
66
+ * Returns what went, for the caller's narration. Throws on any read the fail-closed rule does not
67
+ * excuse — though here a partial read can only UNDER-collect, the caller still owes the user a true
68
+ * "swept nothing" rather than a silent short list.
69
+ */
70
+ export async function sweepRepos(opts: {
71
+ io: Io;
72
+ kube: KubeAdmin;
73
+ ctx: { context?: string };
74
+ ttl: string;
75
+ dryRun?: boolean;
76
+ now?: () => Date;
77
+ }): Promise<SweptRepo[]> {
78
+ const { io, kube, ctx, ttl, dryRun } = opts;
79
+ const ttlMs = parseRepoTtl(ttl);
80
+ const now = (opts.now ?? (() => new Date()))();
81
+ const namespaces = (await kube.listJson({ kind: "namespace", selector: LABEL_INSTANCE, ...ctx })).map(
82
+ (n) => n.metadata.name,
83
+ );
84
+ const swept: SweptRepo[] = [];
85
+ for (const namespace of namespaces) {
86
+ for (const repo of await listRepos(kube, namespace, ctx)) {
87
+ if (!repoIsEvictable(repo, now, ttlMs)) continue;
88
+ const entry: SweptRepo = {
89
+ namespace,
90
+ key: repo.metadata.name,
91
+ url: repo.spec?.url ?? "",
92
+ lastAttached:
93
+ repo.metadata.annotations?.[ANNOTATION_REPO_LAST_ATTACHED] ?? repo.metadata.creationTimestamp ?? "",
94
+ };
95
+ if (!dryRun) await kube.deleteObject({ kind: REPO_KIND, name: entry.key, namespace, ...ctx });
96
+ swept.push(entry);
97
+ }
98
+ }
99
+ narrateRepoSweep(io, swept, { ttl, dryRun });
100
+ return swept;
101
+ }
102
+
103
+ /** The Repos of one namespace, with the one degradation the fail-closed rule allows: a cluster with
104
+ * no `repos.core.jr2.dev` resource type (the operator never reached it, or `jr2 down --all` took the
105
+ * CRD) holds no Repos, so "none" is the complete answer. */
106
+ async function listRepos(kube: KubeAdmin, namespace: string, ctx: { context?: string }): Promise<RepoObject[]> {
107
+ try {
108
+ return await kube.listJson<RepoObject>({ kind: REPO_KIND, namespace, ...ctx });
109
+ } catch (err) {
110
+ if (isMissingResourceType(err)) return [];
111
+ throw err;
112
+ }
113
+ }
114
+
115
+ /** `repos: swept 2 Repo resource(s) no Machine binds and no run attached within 7d` — the count and
116
+ * the rule, since the rule is the whole justification; the resources themselves are listed only for
117
+ * a dry run, where the plan IS the output. */
118
+ function narrateRepoSweep(io: Io, swept: SweptRepo[], opts: { ttl: string; dryRun?: boolean }): void {
119
+ const verb = opts.dryRun ? "would sweep" : "swept";
120
+ if (swept.length === 0) {
121
+ activity(
122
+ io,
123
+ `repos: ${verb} nothing — every Repo resource is bound by a Machine or was attached within ${opts.ttl}`,
124
+ );
125
+ return;
126
+ }
127
+ activity(
128
+ io,
129
+ `repos: ${verb} ${swept.length} Repo resource(s) no Machine binds and no run attached within ${opts.ttl}`,
130
+ );
131
+ if (opts.dryRun) {
132
+ for (const r of swept) activity(io, ` ${r.namespace}/${r.key} (${r.url}) — last attached ${r.lastAttached}`);
133
+ }
134
+ }
package/src/run-id.ts ADDED
@@ -0,0 +1,85 @@
1
+ // Abbreviated run ids (ADR-0009): the CLI's answer to "which run", the way `instance.ts` answers
2
+ // "which instance". A run id is a bare uuid, so pasting all 36 characters is the common case worth
3
+ // removing — git's short hashes, with git's rule: prefix only, and an ambiguous prefix FAILS rather
4
+ // than guessing.
5
+ //
6
+ // Resolution lives here and not on the orchestrator's addressed routes. A prefix is not an identity
7
+ // — one that resolves today goes ambiguous tomorrow when an unrelated run starts — and `jr2 send
8
+ // <prefix> --event CANCEL` is a write. Resolving first means every write on the wire carries a full
9
+ // id, and the HTTP surface stays the machine-to-machine one it claims to be.
10
+ //
11
+ // The floor is about NOISE, not safety: safety comes from the ambiguity error, since a short prefix
12
+ // never resolves to the wrong run, only to a list. Four characters just keeps `jr2 status a` from
13
+ // printing the whole table.
14
+
15
+ import { JR2HttpError, RESOLVE_PATH, type InstanceIdentity, type JR2Client } from "./client.ts";
16
+
17
+ /** Below this, a prefix is a table scan rather than a question. Mirrors the orchestrator's guard. */
18
+ const MIN_PREFIX = 4;
19
+
20
+ /** A full run id: matched on SHAPE, not `length === 36`, so a 36-character non-uuid takes the error
21
+ * path here instead of 404-ing off an addressed route. */
22
+ const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
23
+
24
+ /**
25
+ * A resolved argument, or the message + exit code to report. A result rather than a throw: throwing
26
+ * would route through `cli.ts`'s catch and flatten everything to exit 1, losing the split between a
27
+ * malformed argument (2, usage) and server state (1, runtime).
28
+ */
29
+ export type ResolvedRunId = { ok: true; runId: string } | { ok: false; code: 1 | 2; message: string };
30
+
31
+ /**
32
+ * What to say when the deployed instance has no prefix-resolution route. Names the workaround
33
+ * (a full id short-circuits before any resolve traffic) AND the fix, in that order: the user is
34
+ * mid-task, and `jr2 up` is a rollout they may not want this second.
35
+ */
36
+ function skewMessage(given: string, instance?: InstanceIdentity): string {
37
+ const deployed = instance?.version
38
+ ? `deployed instance: ${instance.version}${instance.hash ? ` (${instance.hash})` : ""}`
39
+ : "the deployed instance predates it";
40
+ return [
41
+ `error: this instance does not support abbreviated run ids ("${given}")`,
42
+ ` ${deployed}`,
43
+ " use the full run id, or run `jr2 up` to converge the cluster to this kit",
44
+ ].join("\n");
45
+ }
46
+
47
+ /**
48
+ * Turn a user-typed run id — full or abbreviated — into a full one. A full id short-circuits, so
49
+ * scripted pipelines (`jr2 run --detach | jq -r .runId` → `jr2 status $id`) issue exactly the traffic
50
+ * they issue today.
51
+ */
52
+ export async function resolveRunId(client: JR2Client, given: string): Promise<ResolvedRunId> {
53
+ if (RUN_ID.test(given)) return { ok: true, runId: given };
54
+
55
+ if (given.length < MIN_PREFIX) {
56
+ return { ok: false, code: 2, message: `jr2: run id "${given}" is too short (need ${MIN_PREFIX}+ characters)` };
57
+ }
58
+
59
+ let runIds: string[];
60
+ let truncated: boolean;
61
+ try {
62
+ ({ runIds, truncated } = await client.candidates(given));
63
+ } catch (err) {
64
+ // A 404 on `/runs/resolve` is unreachable on an instance that HAS the route — the route is
65
+ // static and answers `{ runIds: [] }` when nothing matches. So a 404 here means the request fell
66
+ // through to `/runs/:runId`, which captured the literal string "resolve" and 404'd naming it:
67
+ // this instance predates abbreviated run ids. Left uncaught, that reads as `no run "resolve"` —
68
+ // an error about a run the user never asked for.
69
+ if (err instanceof JR2HttpError && err.status === 404 && err.path === RESOLVE_PATH) {
70
+ return { ok: false, code: 1, message: skewMessage(given, err.instance) };
71
+ }
72
+ throw err;
73
+ }
74
+
75
+ if (runIds.length === 0) return { ok: false, code: 1, message: `no run "${given}"` };
76
+ if (runIds.length === 1) return { ok: true, runId: runIds[0] as string };
77
+
78
+ const listed = runIds.map((id) => ` ${id}`).join("\n");
79
+ const more = truncated ? "\n …" : "";
80
+ return {
81
+ ok: false,
82
+ code: 1,
83
+ message: `error: run id "${given}" is ambiguous\n candidates:\n${listed}${more}`,
84
+ };
85
+ }
package/src/sse.ts ADDED
@@ -0,0 +1,41 @@
1
+ // A minimal Server-Sent-Events frame parser over a `ReadableStream<Uint8Array>` (the body the
2
+ // orchestrator's `streamSSE` produces). Just enough of the SSE grammar for this surface: frames are
3
+ // separated by a blank line; within a frame `event:` names the channel (default "message") and one or
4
+ // more `data:` lines form the payload. We don't need ids, retry, or comments.
5
+ //
6
+ // Cancelling the reader in `finally` means a consumer that `break`s out of `for await` closes the
7
+ // underlying HTTP connection — that is how `jr2 run` / `jr2 logs` detach without killing the run.
8
+
9
+ export type SSEFrame = { event: string; data: string };
10
+
11
+ export async function* parseSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEFrame> {
12
+ const reader = body.getReader();
13
+ const decoder = new TextDecoder();
14
+ let buf = "";
15
+ try {
16
+ for (;;) {
17
+ const { value, done } = await reader.read();
18
+ if (done) break;
19
+ buf += decoder.decode(value, { stream: true });
20
+ let sep: number;
21
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
22
+ const frame = parseFrame(buf.slice(0, sep));
23
+ buf = buf.slice(sep + 2);
24
+ if (frame) yield frame;
25
+ }
26
+ }
27
+ } finally {
28
+ await reader.cancel().catch(() => {});
29
+ }
30
+ }
31
+
32
+ /** Parse one frame's lines into `{ event, data }`; returns undefined for a frame with no data. */
33
+ function parseFrame(block: string): SSEFrame | undefined {
34
+ let event = "message";
35
+ const data: string[] = [];
36
+ for (const line of block.split("\n")) {
37
+ if (line.startsWith("event:")) event = line.slice(6).trim();
38
+ else if (line.startsWith("data:")) data.push(line.slice(5).trim());
39
+ }
40
+ return data.length ? { event, data: data.join("\n") } : undefined;
41
+ }