@jr2/cli 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/bin/jr2.js +15 -7
- package/package.json +7 -4
- package/src/commands/init.ts +9 -6
- package/src/env.ts +1 -1
- package/src/handoff.ts +44 -0
- package/src/index.ts +1 -0
- package/src/instance.ts +10 -14
- package/src/kit-version.ts +88 -0
- package/src/root.ts +17 -0
package/README.md
CHANGED
|
@@ -15,4 +15,9 @@ jr2 run <workflow>
|
|
|
15
15
|
Orchestrator, Sandboxes, secrets), and every other verb (`run`, `runs`, `status`, `logs`, `send`, `down`, `gc`,
|
|
16
16
|
`kit push`) talks to what `up` deployed. Node 24, a kube context, and docker for the image `up` builds.
|
|
17
17
|
|
|
18
|
+
Inside an Instance, the global `jr2` hands off to the Instance's own `@jr2/cli` (the gulp model), so the version that
|
|
19
|
+
runs is the one the Instance pins and the global's stops mattering. An Instance has one kit version: `@jr2/cli` and
|
|
20
|
+
`@jr2/orchestrator` at the same exact number — every verb refuses a mismatch by name. Upgrade by editing both lines and
|
|
21
|
+
reinstalling.
|
|
22
|
+
|
|
18
23
|
Docs, glossary, and architecture decisions: [github.com/snapwich/jr2](https://github.com/snapwich/jr2).
|
package/bin/jr2.js
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
// bundle solves the same problem with `tsx` (see INSTANCE_DOCKERFILE) because there the entry is
|
|
10
10
|
// the orchestrator's, not this one.
|
|
11
11
|
//
|
|
12
|
-
// After the hook
|
|
13
|
-
//
|
|
12
|
+
// After the hook, the launcher's one decision (ADR-0056): inside an Instance that resolves its own
|
|
13
|
+
// `@jr2/cli` to a different copy, hand off — run THAT copy's binary with the same argv and stdio
|
|
14
|
+
// and exit with its code — so the `jr2` that runs is the one the Instance pins, whatever this one's
|
|
15
|
+
// version is. Otherwise hand argv to `main` and surface the exit code. `main` is pure-ish (takes an
|
|
16
|
+
// injectable IO) so the dispatch + commands stay unit-testable without a process. The handoff
|
|
17
|
+
// modules import no kit code: a global with no orchestrator beside it can still hand off.
|
|
14
18
|
|
|
15
19
|
import { readFileSync } from "node:fs";
|
|
16
20
|
import { registerHooks } from "node:module";
|
|
@@ -31,8 +35,12 @@ registerHooks({
|
|
|
31
35
|
},
|
|
32
36
|
});
|
|
33
37
|
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
const argv = process.argv.slice(2);
|
|
39
|
+
const { handoffTarget, handoff } = await import("../src/handoff.ts");
|
|
40
|
+
const local = handoffTarget(process.cwd());
|
|
41
|
+
if (local) {
|
|
42
|
+
process.exitCode = await handoff(local, argv);
|
|
43
|
+
} else {
|
|
44
|
+
const { main } = await import("../src/cli.ts");
|
|
45
|
+
process.exitCode = await main(argv);
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jr2/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "The jr2 CLI: init, up, run — the interface to a jr2 Instance.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -36,13 +36,16 @@
|
|
|
36
36
|
},
|
|
37
37
|
"types": "./src/index.ts",
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"ts-blank-space": "^0.9.0"
|
|
40
|
-
|
|
39
|
+
"ts-blank-space": "^0.9.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@jr2/orchestrator": "0.1.3"
|
|
41
43
|
},
|
|
42
44
|
"devDependencies": {
|
|
43
45
|
"@types/node": "^26.0.1",
|
|
44
46
|
"xstate": "^5.18.0",
|
|
45
|
-
"zod": "^4.4.3"
|
|
47
|
+
"zod": "^4.4.3",
|
|
48
|
+
"@jr2/orchestrator": "0.1.3"
|
|
46
49
|
},
|
|
47
50
|
"files": [
|
|
48
51
|
"bin",
|
package/src/commands/init.ts
CHANGED
|
@@ -13,19 +13,22 @@
|
|
|
13
13
|
// Templates mirror `templates/default/` verbatim (that folder is the model instance, ADR-0054) —
|
|
14
14
|
// byte-for-byte except package.json's `name`/`description`, which are per-instance.
|
|
15
15
|
// `test/init.test.ts` enforces that; without it the two drift silently, and since the manifest
|
|
16
|
-
// carries
|
|
16
|
+
// carries the kit version that same test is the version-bump tripwire (bump the kit, re-render the
|
|
17
17
|
// template). Existing files are left untouched (init is additive); created paths are reported on
|
|
18
18
|
// stderr.
|
|
19
19
|
//
|
|
20
20
|
// ONE template serves both checkout and installed mode (ADR-0043) — a branch there would mean the
|
|
21
21
|
// tested output and the shipped output diverge. So the scaffold names no package manager, and pins
|
|
22
|
-
// @jr2/* at the exact running
|
|
23
|
-
// to its own packages via `linkWorkspacePackages: true`.
|
|
22
|
+
// @jr2/* at the exact running version: 0.x minors break, and the checkout resolves that literal
|
|
23
|
+
// to its own packages via `linkWorkspacePackages: true`. The number is the CLI's OWN (ADR-0056),
|
|
24
|
+
// not the orchestrator's `KIT_VERSION`: a global `jr2` with no Instance around it must scaffold
|
|
25
|
+
// without a peer resolved, and the two numbers are equal by construction (one release train,
|
|
26
|
+
// lockstep — ADR-0055).
|
|
24
27
|
|
|
25
28
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
26
29
|
import { basename, dirname, join, resolve } from "node:path";
|
|
27
30
|
import { parseArgs } from "node:util";
|
|
28
|
-
import {
|
|
31
|
+
import { CLI_VERSION } from "../kit-version.ts";
|
|
29
32
|
import { activity, type Io } from "../output.ts";
|
|
30
33
|
|
|
31
34
|
export async function init(args: string[], io: Io): Promise<number> {
|
|
@@ -90,8 +93,8 @@ function packageJson(name: string): string {
|
|
|
90
93
|
private: true,
|
|
91
94
|
type: "module",
|
|
92
95
|
scripts: { typecheck: "tsc --noEmit" },
|
|
93
|
-
dependencies: { "@jr2/orchestrator":
|
|
94
|
-
devDependencies: { "@jr2/cli":
|
|
96
|
+
dependencies: { "@jr2/orchestrator": CLI_VERSION, xstate: "^5.18.0" },
|
|
97
|
+
devDependencies: { "@jr2/cli": CLI_VERSION, "@types/node": "^26.0.1", typescript: "^5.6.0" },
|
|
95
98
|
},
|
|
96
99
|
null,
|
|
97
100
|
2,
|
package/src/env.ts
CHANGED
package/src/handoff.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The Handoff (ADR-0056, the gulp/grunt model): a global `jr2` run inside an Instance that resolves
|
|
2
|
+
// its own `@jr2/cli` to a DIFFERENT copy runs that copy's binary with the same arguments and stdio,
|
|
3
|
+
// returns its exit code, and does nothing else. So the copy that runs is the one the Instance pins,
|
|
4
|
+
// and the global's version stops mattering inside an Instance. No Instance, or the Instance resolves
|
|
5
|
+
// the running copy itself (a workspace member in the checkout, `npx jr2`): no handoff.
|
|
6
|
+
//
|
|
7
|
+
// Spawn, not import. gulp loads local gulp in-process because local gulp is a LIBRARY; here the
|
|
8
|
+
// local is a BINARY, and spawning couples the global to one contract — the package's `bin` field,
|
|
9
|
+
// npm's own — where importing `src/cli.ts` would make the local's internal layout a cross-version
|
|
10
|
+
// API the global (the copy that cannot be updated once shipped) must honor forever. The local's own
|
|
11
|
+
// preamble runs: its pinned erasure hook, whatever a future bin adds.
|
|
12
|
+
//
|
|
13
|
+
// Builtins only, like everything the launcher runs before it knows which `jr2` will run.
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { CLI_ROOT, resolvePackage } from "./kit-version.ts";
|
|
18
|
+
import { findRoot } from "./root.ts";
|
|
19
|
+
|
|
20
|
+
/** The local binary to hand off to, or `undefined` when this copy is the one that runs. */
|
|
21
|
+
export function handoffTarget(cwd: string, selfRoot: string = CLI_ROOT): string | undefined {
|
|
22
|
+
const root = findRoot(cwd);
|
|
23
|
+
if (!root) return undefined;
|
|
24
|
+
const local = resolvePackage("@jr2/cli", root);
|
|
25
|
+
if (!local || local.root === selfRoot) return undefined;
|
|
26
|
+
const bin = typeof local.bin === "string" ? local.bin : local.bin?.jr2;
|
|
27
|
+
return bin ? join(local.root, bin) : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Run `bin` with `argv` on this node, sharing stdio; resolves to its exit code. A child that dies
|
|
31
|
+
* by signal re-raises that signal here, so the shell sees what it would have seen. */
|
|
32
|
+
export function handoff(bin: string, argv: string[]): Promise<number> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const child = spawn(process.execPath, [bin, ...argv], { stdio: "inherit" });
|
|
35
|
+
child.on("error", (err) => {
|
|
36
|
+
process.stderr.write(`error: cannot run ${bin}: ${err.message}\n`);
|
|
37
|
+
resolve(1);
|
|
38
|
+
});
|
|
39
|
+
child.on("exit", (code, signal) => {
|
|
40
|
+
if (signal) process.kill(process.pid, signal);
|
|
41
|
+
resolve(code ?? 1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { JR2Client } from "./client.ts";
|
|
|
6
6
|
export type { RunStatus, RunFeedEvent, RunEvent, FetchLike } from "./client.ts";
|
|
7
7
|
export type { Io } from "./output.ts";
|
|
8
8
|
export { resolveRoot, resolveTarget } from "./instance.ts";
|
|
9
|
+
export { assertKitVersion, resolvePackage, CLI_VERSION } from "./kit-version.ts";
|
|
9
10
|
export type { Target, TargetOptions } from "./instance.ts";
|
|
10
11
|
export { kubectlKube } from "./kube.ts";
|
|
11
12
|
export type { KubePort } from "./kube.ts";
|
package/src/instance.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// folder we are in, and WHERE its deployed orchestrator is.
|
|
3
3
|
//
|
|
4
4
|
// - `resolveRoot` walks up from cwd to the dir holding `jr2.config.ts` — the root marker (mirrors
|
|
5
|
-
// flue's `flue.config.ts`)
|
|
5
|
+
// flue's `flue.config.ts`) — and refuses unless that Instance and this CLI resolve the same
|
|
6
|
+
// `@jr2/orchestrator` (ADR-0056: an Instance has one Kit version). Every Instance verb goes
|
|
7
|
+
// through it; `--url` verbs skip the walk and so skip the check.
|
|
6
8
|
// - `resolveTarget` finds the orchestrator. `--url` / `JR2_URL` (+ `JR2_TOKEN`) short-circuits
|
|
7
9
|
// everything — the ingress-exposed/remote-caller escape hatch, no folder walk. Otherwise the
|
|
8
10
|
// DEPLOYMENT is addressed by the current kube context + the instance's namespace (`-n` >
|
|
@@ -11,27 +13,21 @@
|
|
|
11
13
|
// kube RBAC is the real gate, and no local state file can go stale (ADR-0019).
|
|
12
14
|
// - Every resolution prints its target on stderr, so ambient-context drift stays visible.
|
|
13
15
|
|
|
14
|
-
import {
|
|
15
|
-
import { basename, dirname, join } from "node:path";
|
|
16
|
+
import { basename } from "node:path";
|
|
16
17
|
import { loadConfig } from "@jr2/orchestrator";
|
|
17
18
|
import { INSTANCE_SECRET, kubectlKube, ORCHESTRATOR_PORT, ORCHESTRATOR_SERVICE } from "./kube.ts";
|
|
19
|
+
import { assertKitVersion } from "./kit-version.ts";
|
|
18
20
|
import { activity, type Io } from "./output.ts";
|
|
21
|
+
import { findRoot } from "./root.ts";
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
export function findRoot(cwd: string): string | undefined {
|
|
22
|
-
let dir = cwd;
|
|
23
|
-
for (;;) {
|
|
24
|
-
if (existsSync(join(dir, "jr2.config.ts"))) return dir;
|
|
25
|
-
const parent = dirname(dir);
|
|
26
|
-
if (parent === dir) return undefined;
|
|
27
|
-
dir = parent;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
23
|
+
export { findRoot } from "./root.ts";
|
|
30
24
|
|
|
31
|
-
/** `findRoot
|
|
25
|
+
/** `findRoot` for the callers that cannot proceed without one — and the Kit version check, which
|
|
26
|
+
* every Instance verb therefore makes before it reads a byte of the Instance. */
|
|
32
27
|
export function resolveRoot(cwd: string): string {
|
|
33
28
|
const root = findRoot(cwd);
|
|
34
29
|
if (!root) throw new Error("not inside a jr2 instance — no jr2.config.ts found walking up from cwd");
|
|
30
|
+
assertKitVersion(root);
|
|
35
31
|
return root;
|
|
36
32
|
}
|
|
37
33
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// An Instance has ONE Kit version (ADR-0056): the `@jr2/orchestrator` it resolves, because that is
|
|
2
|
+
// what its image bakes. Published `@jr2/cli@X` used to DEPEND on `@jr2/orchestrator@X` exact, so an
|
|
3
|
+
// Instance pinning any other orchestrator quietly held two copies — the CLI's `KIT_VERSION` named
|
|
4
|
+
// one, the bundle baked the other, and the CLI's own code handled config objects built by a
|
|
5
|
+
// different copy of the same classes. Now the orchestrator is the CLI's PEER (one copy can exist),
|
|
6
|
+
// and every Instance verb asks this module whether the copy the CLI resolves IS the copy the
|
|
7
|
+
// Instance resolves: same REAL path, never a version compare. Same file means same version by
|
|
8
|
+
// construction under npm hoisting, pnpm dedup, and checkout symlinks alike — and identity is
|
|
9
|
+
// exactly the property the class-identity bug needs.
|
|
10
|
+
//
|
|
11
|
+
// Builtins only: the launcher (`bin/jr2.js`) resolves through here before any kit import.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
export type ResolvedPackage = {
|
|
19
|
+
/** The package's REAL root directory (symlinks resolved). */
|
|
20
|
+
root: string;
|
|
21
|
+
version: string;
|
|
22
|
+
bin?: string | Record<string, string>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve `name` the way code in `fromDir` would — Node resolution from that folder — and answer
|
|
27
|
+
* the package's real root. `undefined` when nothing resolves (no `node_modules` yet, or a global
|
|
28
|
+
* with no peer beside it). Walks up from the resolved ENTRY to the nearest manifest carrying the
|
|
29
|
+
* name, rather than resolving `<name>/package.json`, because an `exports` map hides the manifest
|
|
30
|
+
* and the kits already published export none.
|
|
31
|
+
*/
|
|
32
|
+
export function resolvePackage(name: string, fromDir: string): ResolvedPackage | undefined {
|
|
33
|
+
const require = createRequire(join(fromDir, "package.json"));
|
|
34
|
+
let entry: string;
|
|
35
|
+
try {
|
|
36
|
+
entry = realpathSync(require.resolve(name));
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
let dir = dirname(entry);
|
|
41
|
+
for (;;) {
|
|
42
|
+
try {
|
|
43
|
+
const m = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as ResolvedPackage & { name?: string };
|
|
44
|
+
if (m.name === name) return { root: dir, version: m.version, bin: m.bin };
|
|
45
|
+
} catch {
|
|
46
|
+
// not a manifest, or not this package's — keep walking
|
|
47
|
+
}
|
|
48
|
+
const parent = dirname(dir);
|
|
49
|
+
if (parent === dir) return undefined;
|
|
50
|
+
dir = parent;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** This CLI's own real root and version — what `jr2 init` pins (ADR-0056), what the refusal names. */
|
|
55
|
+
export const CLI_ROOT = realpathSync(fileURLToPath(new URL("..", import.meta.url)));
|
|
56
|
+
export const CLI_VERSION = (JSON.parse(readFileSync(join(CLI_ROOT, "package.json"), "utf8")) as { version: string })
|
|
57
|
+
.version;
|
|
58
|
+
|
|
59
|
+
const ORCHESTRATOR = "@jr2/orchestrator";
|
|
60
|
+
const CLI = "@jr2/cli";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Refuse unless the Instance at `root` and this CLI resolve the SAME `@jr2/orchestrator`. The
|
|
64
|
+
* message names both versions and the lines to edit: an Instance with no `@jr2/cli` of its own is
|
|
65
|
+
* told to add one (the launcher then hands off to it); one that has it is told to pin both lines
|
|
66
|
+
* at one number.
|
|
67
|
+
*/
|
|
68
|
+
export function assertKitVersion(root: string): void {
|
|
69
|
+
const instance = resolvePackage(ORCHESTRATOR, root);
|
|
70
|
+
const own = resolvePackage(ORCHESTRATOR, CLI_ROOT);
|
|
71
|
+
if (instance && own && instance.root === own.root) return;
|
|
72
|
+
|
|
73
|
+
const manifest = join(root, "package.json");
|
|
74
|
+
const local = instance && resolvePackage(CLI, root);
|
|
75
|
+
const fix = !instance
|
|
76
|
+
? `install the Instance's dependencies (${manifest} pins "${ORCHESTRATOR}" and "${CLI}" at one exact version)`
|
|
77
|
+
: local
|
|
78
|
+
? `pin "${ORCHESTRATOR}" and "${CLI}" at ONE exact version in ${manifest} and reinstall`
|
|
79
|
+
: `add "${CLI}": "${instance.version}" to devDependencies in ${manifest} and reinstall — the Instance's ` +
|
|
80
|
+
`own jr2 then runs`;
|
|
81
|
+
const have = instance
|
|
82
|
+
? `${ORCHESTRATOR} resolves to ${instance.version} (${instance.root})`
|
|
83
|
+
: `${ORCHESTRATOR} does not resolve from ${root}`;
|
|
84
|
+
const self = own
|
|
85
|
+
? `this jr2 (${CLI_VERSION}) runs against ${own.version} (${own.root})`
|
|
86
|
+
: `this jr2 (${CLI_VERSION}, at ${CLI_ROOT}) has no ${ORCHESTRATOR} beside it`;
|
|
87
|
+
throw new Error(`Kit version mismatch — an Instance has one (ADR-0056):\n ${have}\n ${self}\n ${fix}`);
|
|
88
|
+
}
|
package/src/root.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The Instance root walk (ADR-0009): up from `cwd` to the directory holding `jr2.config.ts`, the
|
|
2
|
+
// root marker. Its own module — builtins only — because the launcher runs it BEFORE it knows which
|
|
3
|
+
// `jr2` will run (ADR-0056), so nothing here may import the kit.
|
|
4
|
+
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
/** Walk up from `cwd` to the directory containing `jr2.config.ts`; `undefined` if there is none. */
|
|
9
|
+
export function findRoot(cwd: string): string | undefined {
|
|
10
|
+
let dir = cwd;
|
|
11
|
+
for (;;) {
|
|
12
|
+
if (existsSync(join(dir, "jr2.config.ts"))) return dir;
|
|
13
|
+
const parent = dirname(dir);
|
|
14
|
+
if (parent === dir) return undefined;
|
|
15
|
+
dir = parent;
|
|
16
|
+
}
|
|
17
|
+
}
|