@jr2/cli 0.1.1 → 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/build.ts +50 -16
- 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/build.ts
CHANGED
|
@@ -998,30 +998,47 @@ const execCommand: RunCommand = async (command, args, cwd) => {
|
|
|
998
998
|
|
|
999
999
|
/**
|
|
1000
1000
|
* Materialize the Instance into `outDir` (ADR-0043, as amended there). Two shapes, and the key is
|
|
1001
|
-
* the INSTANCE's own
|
|
1002
|
-
* {@link detectKitCheckout} — is what says whether `pnpm deploy` can run at all. A checkout CLI can
|
|
1003
|
-
* legitimately drive a standalone instance (the developer's `/tmp` folder), and keying on the CLI's
|
|
1004
|
-
* own provenance would send that instance down a path whose job — materializing workspace symlinks
|
|
1005
|
-
* — only exists in a workspace. The mirror holds too: an INSTALLED kit driving an instance nested
|
|
1006
|
-
* in the user's own pnpm monorepo takes `pnpm deploy`, because that instance carries no lockfile of
|
|
1007
|
-
* its own — the workspace root holds it.
|
|
1001
|
+
* the INSTANCE's own, asked in this order:
|
|
1008
1002
|
*
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1003
|
+
* 1. Its own lockfile. An instance that carries one is self-contained — the lockfile IS its
|
|
1004
|
+
* dependency graph, wherever the folder sits — so it is staged as a copy
|
|
1005
|
+
* ({@link BUNDLE_STAGE_EXCLUDE}) and installed frozen and production-only by the lockfile's own
|
|
1006
|
+
* package manager ({@link lockfileInstall}). This is how a standalone instance committed INSIDE
|
|
1007
|
+
* a pnpm monorepo bundles: the kit's own `docs/intro` sits under the checkout's
|
|
1008
|
+
* `pnpm-workspace.yaml` and is deliberately not one of its packages.
|
|
1009
|
+
* 2. A `pnpm-workspace.yaml` above it. A workspace member carries no lockfile of its own — the
|
|
1010
|
+
* workspace root holds it — so it is the ABSENCE of one that sends the instance to `pnpm deploy
|
|
1011
|
+
* --legacy`, unchanged. pnpm is a contributor prerequisite, like go for the operator, never a
|
|
1012
|
+
* product dependency.
|
|
1013
|
+
* 3. Neither: {@link lockfileInstall}'s named refusal.
|
|
1014
|
+
*
|
|
1015
|
+
* Neither key is {@link detectKitCheckout}: a checkout CLI legitimately drives a standalone
|
|
1016
|
+
* instance (the developer's `/tmp` folder), and an installed kit legitimately drives a member of
|
|
1017
|
+
* the user's own monorepo. Asking "is there a workspace file above me" FIRST — the previous rule —
|
|
1018
|
+
* answered a question about an ancestor, and sent shape (1) down `pnpm deploy`, which pnpm answers
|
|
1019
|
+
* for a non-member with "No projects matched the filters" and exit 0: no bundle, and the failure
|
|
1020
|
+
* surfaced later as an ENOENT from the seal. That silent success is why the deploy branch checks
|
|
1021
|
+
* that the bundle exists, and names the shape when it does not.
|
|
1014
1022
|
*/
|
|
1015
1023
|
export async function bundleInstance(
|
|
1016
1024
|
instanceDir: string,
|
|
1017
1025
|
outDir: string,
|
|
1018
1026
|
run: RunCommand = execCommand,
|
|
1019
1027
|
): Promise<void> {
|
|
1020
|
-
|
|
1028
|
+
const root = (await hasOwnLockfile(instanceDir)) ? undefined : await pnpmWorkspaceRoot(instanceDir);
|
|
1029
|
+
if (root) {
|
|
1021
1030
|
const pkg = JSON.parse(await readFile(join(instanceDir, "package.json"), "utf8")) as { name?: string };
|
|
1022
1031
|
if (!pkg.name) throw new Error(`${instanceDir}/package.json has no "name" — needed to bundle the instance`);
|
|
1023
1032
|
// --legacy: materialize (copy) workspace deps into the bundle rather than linking them.
|
|
1024
1033
|
await run("pnpm", ["--filter", pkg.name, "--prod", "deploy", "--legacy", outDir], instanceDir);
|
|
1034
|
+
// pnpm exits 0 — "No projects matched the filters" — for a `--filter` that names no member.
|
|
1035
|
+
if (!(await exists(outDir))) {
|
|
1036
|
+
throw new Error(
|
|
1037
|
+
`pnpm deploy wrote no bundle for ${instanceDir}: it sits under the pnpm workspace at ${root} but is not ` +
|
|
1038
|
+
`one of its packages, and it has no lockfile of its own — add it to the workspace's \`packages:\`, ` +
|
|
1039
|
+
`or install and commit a lockfile so it bundles as a standalone instance`,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1025
1042
|
return;
|
|
1026
1043
|
}
|
|
1027
1044
|
// Asked before the copy: an instance with no lockfile must fail on the cheap half.
|
|
@@ -1033,9 +1050,26 @@ export async function bundleInstance(
|
|
|
1033
1050
|
await run(command, args, outDir);
|
|
1034
1051
|
}
|
|
1035
1052
|
|
|
1036
|
-
/**
|
|
1037
|
-
*
|
|
1038
|
-
|
|
1053
|
+
/** Whether the instance carries a lockfile of ANY manager — yarn included, so a yarn instance under
|
|
1054
|
+
* a workspace reaches {@link lockfileInstall}'s named refusal rather than `pnpm deploy`. */
|
|
1055
|
+
async function hasOwnLockfile(instanceDir: string): Promise<boolean> {
|
|
1056
|
+
const present = new Set(await readdir(instanceDir));
|
|
1057
|
+
return [...LOCKFILE_INSTALLS.flatMap((row) => row.lockfiles), YARN_LOCKFILE].some((f) => present.has(f));
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
async function exists(path: string): Promise<boolean> {
|
|
1061
|
+
try {
|
|
1062
|
+
await stat(path);
|
|
1063
|
+
return true;
|
|
1064
|
+
} catch {
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** The nearest `pnpm-workspace.yaml` at or above `dir`. A file test, not a manifest parse:
|
|
1070
|
+
* membership proper is pnpm's to decide, and this is asked only for an instance with no lockfile of
|
|
1071
|
+
* its own ({@link bundleInstance}). A `packages:` glob that excludes the directory does NOT fail
|
|
1072
|
+
* `pnpm deploy --filter` — pnpm exits 0 having deployed nothing — which the caller checks. */
|
|
1039
1073
|
async function pnpmWorkspaceRoot(dir: string): Promise<string | undefined> {
|
|
1040
1074
|
let d = resolve(dir);
|
|
1041
1075
|
for (;;) {
|
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
|
+
}
|