@catalyst-cloud/cli 0.16.19 → 0.16.20

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.20
4
+
5
+ Setup on Linux now prints every command whole, on its own line, so you can copy it from any terminal width. When this computer still needs the session egress service, setup prints one `sudo` command that checks the installer's fingerprint and installs and starts the service. Run it, then run the same setup command again. When nothing may take work for your workspace yet, setup now names the command that enrolls this computer as a self-hosted machine: `catalyst onboard --runner --team <your team>`.
6
+
7
+ New: `catalyst env shell` opens your repository's declared environment on your own computer in Docker, with the declared toolchain versions on `PATH`.
8
+
3
9
  ## 0.16.19
4
10
 
5
11
  A self-hosted Mac whose `catalyst` command was installed with Homebrew's Node now keeps its images current and finishes setup's private thoughts storage step. Before, Docker Desktop could not read a file the check needs from the Homebrew folder, so every update and that setup step stopped. Install this version; the next 15-minute check updates the machine.
package/README.md CHANGED
@@ -241,6 +241,8 @@ Your personal key reads everything the skills need — tickets, pull requests, t
241
241
  - Read pull-request labels or the reviewer's reaction. The mirror does not carry them; GitHub's own page does.
242
242
  - Compute a flow number — cycle time, throughput, or how long pull requests have been open. Nothing serves those yet, so say they are not computed rather than counting something else and calling it that.
243
243
 
244
+ `catalyst env shell` opens this repository's environment on your computer: the image Catalyst's agents work in, the toolchain your `.catalyst/catalyst.toml` declares, and the variables it names, with values taken from your terminal or an `--env-file`. It needs Docker (Docker Desktop, OrbStack or Docker Engine) and no sign-in. The first run downloads the image, about 2.3 GB, with no credential. `catalyst env shell --check` prepares the same environment and reports each toolchain and variable without opening a shell.
245
+
244
246
  ## Versions and origins
245
247
 
246
248
  The package pins the contract range `1.x || 2.x`, recorded in `package.json` under `catalystCloud.tenantContractRange`, and reports it in `--version`, `status` and `login`. An account whose contract version falls outside that range is refused with one line naming both versions; update the bundle. Every skill carries a `vendored-from:` line naming this package as its origin, and that line now also carries the version it was vendored at; all of them are written in this repository for customer accounts.
package/dist/args.js CHANGED
@@ -342,7 +342,7 @@ export const FLAG_TABLES = {
342
342
  env: {
343
343
  root: {
344
344
  value: true,
345
- help: "draft: repository directory (default: current directory)",
345
+ help: "draft, shell: repository directory (default: the current repository's top level)",
346
346
  },
347
347
  write: {
348
348
  value: false,
@@ -352,6 +352,19 @@ export const FLAG_TABLES = {
352
352
  value: false,
353
353
  help: "draft: show the generated TOML as an additions-only diff",
354
354
  },
355
+ "env-file": {
356
+ value: true,
357
+ repeat: true,
358
+ help: "shell: read declared variables' values from this dotenv file (repeatable; your terminal's own values win)",
359
+ },
360
+ image: {
361
+ value: true,
362
+ help: "shell: open this digest-pinned image instead of Catalyst's (it must carry bash, and mise when toolchains are declared)",
363
+ },
364
+ check: {
365
+ value: false,
366
+ help: "shell: prepare the environment and report it, without opening a shell",
367
+ },
355
368
  },
356
369
  var: {
357
370
  repo: {
@@ -529,7 +542,7 @@ export const VERB_USAGE = {
529
542
  seat: "seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE] [--key-env NAME]",
530
543
  mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>> [--json]",
531
544
  team: "team <list|check <KEY>|check --all|map <KEY> [--stage role=StateName]... [--yes --plan-hash H]|adopt <KEY> [--undo] [--yes --plan-hash H]|migrate <KEY> [--choice sourceId=destinationId]... [--retire] [--yes --plan-hash H]|checklist <KEY>> [--json]",
532
- env: "env <inventory [path] | check <file> | migrate [catalyst.env.json] | draft [--root DIR] [--diff] [--write]> [--json] (THIS repository, offline — no login, no network)",
545
+ env: "env <inventory [path] | check <file> | migrate [catalyst.env.json] | draft [--root DIR] [--diff] [--write] | shell [--root DIR] [--env-file FILE]... [--image REF] [--check]> [--json] (THIS repository; no login — shell needs Docker and downloads Catalyst's image once)",
533
546
  environment: "environment [read] [--json] | environment propose --file <path>|--stdin [--expect-revision N] [--approve] [--json] | environment approve [--revision N --hash H] [--json] (your ACCOUNT's declaration; needs login)",
534
547
  secret: "secret set <NAME> --repo <owner/name> [--command '<cmd>'] [--json] (value from --command, stdin, or a hidden prompt) | secret import <file> --repo <owner/name> [--names NAME[,NAME...]] [--rotate NAME]... [--json]",
535
548
  var: "var <set NAME|import <file>> [--repo <owner/name>] [--names NAME[,NAME...]] [--json] (plain environment variables; set reads stdin or a hidden prompt)",
@@ -30,6 +30,7 @@ export const CAPABILITIES = [
30
30
  { verb: "project wip-limit set", does: "set a project's new-start WIP limit (0 to 9999, or back to the workspace default)", needs: "admin", routes: [agent("POST", "team-wip-limit")], since: "0.13.1" },
31
31
  { verb: "repo agents-block", does: "add or refresh the one Catalyst block in a checkout's AGENTS.md, in the working tree, for the person to commit and open as a pull request", needs: "member", routes: [], since: "0.13.1" },
32
32
  { verb: "repo agent-setup", does: "read a checkout's agent setup (AGENTS.md, CLAUDE.md, .agents and .claude skills and rules) and, on request, make it portable in the working tree", needs: "member", routes: [], since: "0.13.1" },
33
+ { verb: "env shell", does: "open this repository's environment on this computer: Catalyst's image, the declared toolchain and variables (needs Docker; no login for Catalyst's image)", needs: "member", routes: [], since: "0.16.20" },
33
34
  { verb: "legacy", does: "find leftovers of the old local Catalyst runtime on this machine and, on a yes, remove them through their own tools", needs: "member", routes: [], since: "0.13.1" },
34
35
  { verb: "environment read", does: "read the workspace-wide environment declaration", needs: "member", routes: [agent("GET", "account-environment")], since: "0.8.0" },
35
36
  { verb: "environment propose", does: "propose the workspace-wide environment declaration (names only)", needs: "admin", routes: [agent("POST", "account-environment/propose")], since: "0.8.0" },
package/dist/cli.js CHANGED
@@ -98,7 +98,7 @@ export function usageText() {
98
98
  " catalyst ask <raise|accept|list> ...",
99
99
  " catalyst release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] | release --class <c> --team <K> ...",
100
100
  " catalyst environment [read] | environment propose --file <path>|--stdin [--approve] | environment approve (your account; needs login)",
101
- " catalyst env <inventory [path] | check <file> | migrate [catalyst.env.json] | draft [--root DIR] [--diff] [--write]> [--json] (this repository, offline — no login, no network)",
101
+ " catalyst env <inventory [path] | check <file> | migrate [catalyst.env.json] | draft [--root DIR] [--diff] [--write] | shell [--root DIR] [--env-file FILE]... [--image REF] [--check]> [--json] (this repository; no login — shell needs Docker and downloads Catalyst's image once)",
102
102
  " catalyst secret set <NAME> --repo <owner/name> [--command '<cmd>'] | secret import <file> --repo <owner/name> [--names NAME[,NAME...]]",
103
103
  " catalyst var set <NAME> | var import <file> [--repo <owner/name>] [--names NAME[,NAME...]] (plain environment variables)",
104
104
  " catalyst team <list|check|map|adopt|migrate|checklist> ...",
@@ -0,0 +1,147 @@
1
+ // shell-declaration.ts — what `catalyst env shell` reads from `.catalyst/catalyst.toml` (the declared
2
+ // toolchains and variables) and how it resolves the variables' values from this terminal and any
3
+ // `--env-file`. Pure: no Docker, no network. A value lives only in `ResolvedVariables.values`; no
4
+ // error, list or message here ever carries one.
5
+ import { parse } from "smol-toml";
6
+ import { validateSettingsToml } from "./settings-toml.js";
7
+ // Mirrors vendor/settings DECLARATION_NAME_RE / DECLARATION_VERSION_RE (index.js:607-608).
8
+ const NAME_RE = /^[a-z][a-z0-9+._-]{0,63}$/;
9
+ const VERSION_RE = /^[A-Za-z0-9^~*<>=][A-Za-z0-9.+_\-^~*<>=| ]{0,63}$/;
10
+ /** What mise takes as one exact or prefix version (measured: 22 → 22.23.3, 1.3.14, 3.12, lts). */
11
+ const PREPARABLE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$/;
12
+ const MAX_TOOLCHAINS = 32;
13
+ /** The toolchains and variables the shell prepares. Variables are validated by `validateSettingsToml`
14
+ * first, so `env check` and `env shell` refuse the same file the same way. */
15
+ export function readShellDeclaration(text) {
16
+ const checked = validateSettingsToml(text);
17
+ if (checked.state === "invalid")
18
+ return { state: "invalid", errors: checked.errors };
19
+ if (checked.state === "no-source")
20
+ return { state: "valid", toolchains: [], variables: [] };
21
+ // validateSettingsToml parsed the same text, so this cannot throw.
22
+ const environment = record(record(parse(text))?.environment) ?? {};
23
+ const toolchains = readToolchains(environment.toolchains);
24
+ if (toolchains.state !== "valid")
25
+ return toolchains;
26
+ const variables = Array.isArray(environment.variables)
27
+ ? environment.variables.map((raw) => {
28
+ const entry = record(raw) ?? {};
29
+ return {
30
+ name: String(entry.name),
31
+ required: entry.required === true,
32
+ secret: entry.secret === true,
33
+ };
34
+ })
35
+ : [];
36
+ return { state: "valid", toolchains: toolchains.toolchains, variables };
37
+ }
38
+ function readToolchains(raw) {
39
+ if (raw === undefined)
40
+ return { state: "valid", toolchains: [] };
41
+ if (!Array.isArray(raw))
42
+ return { state: "invalid", errors: ["environment.toolchains must be an array of tables"] };
43
+ const errors = [];
44
+ if (raw.length > MAX_TOOLCHAINS)
45
+ errors.push(`environment.toolchains has more than ${MAX_TOOLCHAINS} entries`);
46
+ const toolchains = [];
47
+ const names = new Set();
48
+ for (const [index, item] of raw.entries()) {
49
+ const prefix = `environment.toolchains[${index}]`;
50
+ const entry = record(item);
51
+ if (!entry) {
52
+ errors.push(`${prefix} must be a table`);
53
+ continue;
54
+ }
55
+ const unknown = Object.keys(entry).filter((key) => key !== "name" && key !== "version");
56
+ if (unknown.length > 0)
57
+ errors.push(`${prefix} has an unknown key: ${unknown.sort().join(", ")}`);
58
+ const { name, version } = entry;
59
+ const nameOk = typeof name === "string" && NAME_RE.test(name);
60
+ const versionOk = typeof version === "string" && VERSION_RE.test(version);
61
+ if (!nameOk)
62
+ errors.push(`${prefix}.name is not a valid toolchain name`);
63
+ else if (names.has(name))
64
+ errors.push(`${prefix}.name duplicates ${name}`);
65
+ if (!versionOk)
66
+ errors.push(`${prefix}.version is not a valid toolchain version`);
67
+ if (nameOk)
68
+ names.add(name);
69
+ if (nameOk && versionOk)
70
+ toolchains.push({ name, version });
71
+ }
72
+ if (errors.length > 0)
73
+ return { state: "invalid", errors };
74
+ const range = toolchains.find((toolchain) => !PREPARABLE_RE.test(toolchain.version));
75
+ if (range)
76
+ return { state: "unsupported", toolchain: range };
77
+ return { state: "valid", toolchains };
78
+ }
79
+ /** Names the shell sets itself; a declared one is skipped with a note rather than overriding them. */
80
+ export const RESERVED_VARIABLE_NAMES = new Set([
81
+ "PATH",
82
+ "HOME",
83
+ "USER",
84
+ "SHELL",
85
+ "PWD",
86
+ "TERM",
87
+ "CLAUDE_CODE_ENABLE_TELEMETRY",
88
+ ]);
89
+ // DOCKER_: a value rides the docker client's own environment to reach the container, so a declared
90
+ // DOCKER_HOST or DOCKER_CONTEXT from an --env-file would move `docker run` to another engine.
91
+ const RESERVED_PREFIXES = ["MISE_", "OTEL_", "GIT_CONFIG_", "DOCKER_"];
92
+ // On Windows, environment names are case-insensitive, so a declared docker_host reaches the docker
93
+ // client as DOCKER_HOST; compare upper-cased there. Elsewhere user and USER are distinct (CTC-6152).
94
+ export function isReservedVariableName(name, platform = process.platform) {
95
+ const key = platform === "win32" ? name.toUpperCase() : name;
96
+ return RESERVED_VARIABLE_NAMES.has(key) || RESERVED_PREFIXES.some((prefix) => key.startsWith(prefix));
97
+ }
98
+ export const TERMINAL_SOURCE = "this terminal";
99
+ /** Resolve declared names: env files in order (later wins), then this terminal's own environment,
100
+ * which wins over every file. Undeclared names are never read into `values`. */
101
+ export function resolveShellVariables(declared, input) {
102
+ const values = {};
103
+ const set = [];
104
+ const missingRequired = [];
105
+ const unsetOptional = [];
106
+ const skippedReserved = [];
107
+ const used = new Set();
108
+ for (const variable of declared) {
109
+ if (isReservedVariableName(variable.name, input.platform)) {
110
+ skippedReserved.push(variable.name);
111
+ continue;
112
+ }
113
+ let value;
114
+ let source;
115
+ const fromTerminal = input.env[variable.name];
116
+ if (typeof fromTerminal === "string") {
117
+ value = fromTerminal;
118
+ source = TERMINAL_SOURCE;
119
+ }
120
+ else {
121
+ for (const file of input.envFiles) {
122
+ const fromFile = Object.hasOwn(file.values, variable.name) ? file.values[variable.name] : undefined;
123
+ if (typeof fromFile === "string") {
124
+ value = fromFile;
125
+ source = file.path;
126
+ }
127
+ }
128
+ }
129
+ if (value === undefined || source === undefined) {
130
+ (variable.required ? missingRequired : unsetOptional).push(variable.name);
131
+ continue;
132
+ }
133
+ values[variable.name] = value;
134
+ set.push(variable.name);
135
+ used.add(source);
136
+ }
137
+ const sources = [
138
+ ...(used.has(TERMINAL_SOURCE) ? [TERMINAL_SOURCE] : []),
139
+ ...input.envFiles.map((file) => file.path).filter((path, index, all) => used.has(path) && all.indexOf(path) === index),
140
+ ];
141
+ return { values, set, missingRequired: missingRequired.sort(), unsetOptional, skippedReserved, sources };
142
+ }
143
+ function record(value) {
144
+ return typeof value === "object" && value !== null && !Array.isArray(value)
145
+ ? value
146
+ : null;
147
+ }
@@ -0,0 +1,137 @@
1
+ // shell-docker.ts — the docker CLI as `catalyst env shell` uses it: is an engine running, where is
2
+ // it, is the image here, one download through a throwaway config, and the container itself. Modelled
3
+ // on onboard-runner.ts's spawnDocker, endpoint rule and anonymous pull.
4
+ import { spawn } from "node:child_process";
5
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { constants, homedir, tmpdir as osTmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ const OUTPUT_CAP = 1_000_000;
9
+ /** Spawn docker and wait for it. SIGINT reaches the child through the terminal's process group, so
10
+ * this process only has to survive it; SIGTERM and SIGHUP are forwarded. Handlers go in `finally`. */
11
+ function spawnWait(args, options) {
12
+ return new Promise((resolve) => {
13
+ let child;
14
+ try {
15
+ child = spawn("docker", args, { env: options.env, stdio: options.stdio, timeout: options.timeoutMs });
16
+ }
17
+ catch {
18
+ resolve({ code: 127, stdout: "", stderr: "", missing: true });
19
+ return;
20
+ }
21
+ let stdout = "";
22
+ let stderr = "";
23
+ child.stdout?.setEncoding("utf8");
24
+ child.stdout?.on("data", (chunk) => {
25
+ if (stdout.length < OUTPUT_CAP)
26
+ stdout += chunk;
27
+ });
28
+ child.stderr?.setEncoding("utf8");
29
+ child.stderr?.on("data", (chunk) => {
30
+ if (stderr.length < OUTPUT_CAP)
31
+ stderr += chunk;
32
+ });
33
+ const onInterrupt = () => { };
34
+ const forward = (signal) => () => {
35
+ child.kill(signal);
36
+ };
37
+ const onTerm = forward("SIGTERM");
38
+ const onHup = forward("SIGHUP");
39
+ process.on("SIGINT", onInterrupt);
40
+ process.on("SIGTERM", onTerm);
41
+ process.on("SIGHUP", onHup);
42
+ const done = (result) => {
43
+ process.off("SIGINT", onInterrupt);
44
+ process.off("SIGTERM", onTerm);
45
+ process.off("SIGHUP", onHup);
46
+ resolve(result);
47
+ };
48
+ child.on("error", (error) => done({ code: error.code === "ENOENT" ? 127 : 1, stdout, stderr, missing: error.code === "ENOENT" }));
49
+ child.on("close", (status, signal) => done({ code: exitCodeOf(status, signal), stdout, stderr, missing: false }));
50
+ });
51
+ }
52
+ export function exitCodeOf(status, signal) {
53
+ if (status !== null)
54
+ return status;
55
+ return signal ? 128 + (constants.signals[signal] ?? 0) : 1;
56
+ }
57
+ /** Docker's last non-empty error line, printable characters only. */
58
+ export function lastLine(text) {
59
+ const lines = text
60
+ .split(/\r?\n/)
61
+ .map((line) => line.replace(/[^\x20-\x7e]/g, "").trim())
62
+ .filter((line) => line !== "");
63
+ return (lines.at(-1) ?? "").slice(0, 400);
64
+ }
65
+ export function spawnEnvShellDocker(deps) {
66
+ const env = deps.env;
67
+ const quiet = (args, timeoutMs = 30_000) => spawnWait(args, { env, stdio: ["ignore", "pipe", "pipe"], timeoutMs });
68
+ return {
69
+ async server() {
70
+ const read = await quiet(["version", "--format", "{{.Server.Os}}"]);
71
+ if (read.missing)
72
+ return { state: "missing" };
73
+ if (read.code !== 0)
74
+ return { state: "not_running" };
75
+ const os = read.stdout.trim();
76
+ return os === "linux" ? { state: "ok" } : { state: "not_linux", os: os.replace(/[^\x20-\x7e]/g, "").slice(0, 40) || "unknown" };
77
+ },
78
+ async endpoint() {
79
+ // Reject conflicting selectors rather than infer precedence across Docker versions.
80
+ if (env.DOCKER_CONTEXT && env.DOCKER_HOST)
81
+ return { ok: false, reason: "conflict" };
82
+ if (env.DOCKER_HOST)
83
+ return { ok: true, host: env.DOCKER_HOST };
84
+ const read = await quiet([
85
+ "context",
86
+ "inspect",
87
+ ...(env.DOCKER_CONTEXT ? [env.DOCKER_CONTEXT] : []),
88
+ "--format",
89
+ '{{(index .Endpoints "docker").Host}}',
90
+ ]);
91
+ const host = read.stdout.trim();
92
+ return read.code === 0 && host !== "" ? { ok: true, host } : { ok: false, reason: "unreadable" };
93
+ },
94
+ async imagePresent(ref) {
95
+ return (await quiet(["image", "inspect", "--format", "{{.Id}}", ref])).code === 0;
96
+ },
97
+ async pull(ref, host, config) {
98
+ let dir;
99
+ try {
100
+ // mkdtemp creates the directory 0700; the file is 0600 and holds nothing else.
101
+ dir = mkdtempSync(join((deps.tmpdir ?? osTmpdir)(), "catalyst-env-pull-"));
102
+ writeFileSync(join(dir, "config.json"), JSON.stringify(config), { mode: 0o600 });
103
+ const pullEnv = {
104
+ ...env,
105
+ DOCKER_CONFIG: dir,
106
+ // A TLS endpoint keeps the developer's own certificates although the config moved.
107
+ DOCKER_CERT_PATH: env.DOCKER_CERT_PATH || env.DOCKER_CONFIG || join(env.HOME || homedir(), ".docker"),
108
+ };
109
+ delete pullEnv.DOCKER_CONTEXT;
110
+ delete pullEnv.DOCKER_AUTH_CONFIG;
111
+ // Progress goes to our stderr, so `--json` stdout stays one line; the error stream is kept.
112
+ const result = await spawnWait(["--host", host, "--config", dir, "pull", ref], {
113
+ env: pullEnv,
114
+ stdio: ["ignore", 2, "pipe"],
115
+ timeoutMs: 3_600_000,
116
+ });
117
+ if (result.missing)
118
+ return { ok: false, detail: "docker is not installed" };
119
+ return { ok: result.code === 0, detail: lastLine(result.stderr) };
120
+ }
121
+ catch (error) {
122
+ return { ok: false, detail: error instanceof Error ? lastLine(error.message) : "the download could not start" };
123
+ }
124
+ finally {
125
+ if (dir)
126
+ rmSync(dir, { recursive: true, force: true });
127
+ }
128
+ },
129
+ async run(argv, runEnv, mode) {
130
+ const result = await spawnWait(argv, {
131
+ env: runEnv,
132
+ stdio: mode === "interactive" ? "inherit" : ["ignore", "pipe", "inherit"],
133
+ });
134
+ return { code: result.code, stdout: result.stdout };
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,120 @@
1
+ // shell-plan.ts — the container `catalyst env shell` starts: the one `docker run` argv, the in-image
2
+ // start script, the home volume's name and the reader for `--check`'s report. Pure: nothing here
3
+ // spawns, reads a file or sees a variable's value. Values reach the container only as bare `-e NAME`
4
+ // words, which Docker fills from the client process's environment.
5
+ import { createHash } from "node:crypto";
6
+ /** The start script's exit code when `mise install` could not prepare a declared toolchain. */
7
+ export const START_EXIT_TOOLCHAIN = 97;
8
+ /** The image's own user, as phases and `catalyst runner` run it. */
9
+ export const ENV_SHELL_USER = "10001:10001";
10
+ export const ENV_SHELL_WORKSPACE = "/workspace";
11
+ const HOME = "/home/catalyst";
12
+ /** Runs as `sh -c <this> catalyst-env-shell <shell|check> <name@version>...` inside the image.
13
+ * Prototyped and run against mise 2026.8.14 in a Catalyst image in the plan phase (see the plan). */
14
+ export const ENV_SHELL_START_SCRIPT = [
15
+ "set -u",
16
+ 'mode="$1"; shift',
17
+ `if [ "$#" -gt 0 ]; then mise install "$@" >&2 || exit ${START_EXIT_TOOLCHAIN}; fi`,
18
+ 'if [ "$mode" = check ]; then',
19
+ ' for spec in "$@"; do printf \'toolchain\\t%s\\t%s\\n\' "$spec" "$(basename "$(mise where "$spec")")"; done',
20
+ " for name in ${CATALYST_ENV_NAMES:-}; do",
21
+ ' if printenv "$name" >/dev/null 2>&1; then printf \'variable\\t%s\\tset\\n\' "$name"; else printf \'variable\\t%s\\tunset\\n\' "$name"; fi',
22
+ " done",
23
+ " exit 0",
24
+ "fi",
25
+ // Not a login shell: that re-reads /etc/profile, which replaces the PATH mise just set, so the
26
+ // declared toolchain would be lost (CTC-6153). Not forced interactive either: bash decides from its
27
+ // stdin, so a terminal gets ~/.bashrc and piped input keeps non-interactive semantics.
28
+ 'if [ "$#" -gt 0 ]; then exec mise exec "$@" -- bash; fi',
29
+ "exec bash -l",
30
+ ].join("\n");
31
+ /** One home volume per image digest: downloaded toolchains and history persist, a new image starts fresh. */
32
+ export function homeVolumeFor(image) {
33
+ const digest = /@sha256:([0-9a-f]{12})/.exec(image)?.[1];
34
+ return `catalyst-env-home-${digest ?? createHash("sha256").update(image).digest("hex").slice(0, 12)}`;
35
+ }
36
+ export function toolchainSpecs(toolchains) {
37
+ return toolchains.map((toolchain) => `${toolchain.name}@${toolchain.version}`);
38
+ }
39
+ /** THE argv builder: `--check` and the shell differ only by mode, -i/-t and CATALYST_ENV_NAMES. */
40
+ export function shellRunArgs(input) {
41
+ const shell = input.mode === "shell";
42
+ return [
43
+ "run",
44
+ "--rm",
45
+ "--pull",
46
+ "never",
47
+ ...(shell ? ["-i"] : []),
48
+ ...(shell && input.tty ? ["-t"] : []),
49
+ "--user",
50
+ ENV_SHELL_USER,
51
+ "--mount",
52
+ `type=bind,src=${input.root},dst=${ENV_SHELL_WORKSPACE}`,
53
+ ...(input.gitCommonDir ? ["--mount", `type=bind,src=${input.gitCommonDir},dst=${input.gitCommonDir}`] : []),
54
+ "--mount",
55
+ `type=volume,src=${homeVolumeFor(input.image)},dst=${HOME}`,
56
+ "-w",
57
+ ENV_SHELL_WORKSPACE,
58
+ "-e",
59
+ `HOME=${HOME}`,
60
+ "-e",
61
+ "MISE_SYSTEM_DATA_DIR=/opt/catalyst/toolchain",
62
+ "-e",
63
+ "MISE_SHARED_INSTALL_DIRS=/opt/catalyst/toolchain/installs",
64
+ "-e",
65
+ "CLAUDE_CODE_ENABLE_TELEMETRY=0",
66
+ "-e",
67
+ "OTEL_METRICS_EXPORTER=none",
68
+ "-e",
69
+ "OTEL_LOGS_EXPORTER=none",
70
+ "-e",
71
+ "OTEL_TRACES_EXPORTER=none",
72
+ "-e",
73
+ "GIT_CONFIG_COUNT=1",
74
+ "-e",
75
+ "GIT_CONFIG_KEY_0=safe.directory",
76
+ "-e",
77
+ "GIT_CONFIG_VALUE_0=*",
78
+ ...(shell && input.tty ? ["-e", "TERM"] : []),
79
+ ...input.passNames.flatMap((name) => ["-e", name]),
80
+ ...(shell ? [] : ["-e", `CATALYST_ENV_NAMES=${input.checkNames.join(" ")}`]),
81
+ "--entrypoint",
82
+ "/bin/sh",
83
+ input.image,
84
+ "-c",
85
+ ENV_SHELL_START_SCRIPT,
86
+ "catalyst-env-shell",
87
+ input.mode,
88
+ ...toolchainSpecs(input.toolchains),
89
+ ];
90
+ }
91
+ /** Map the start script's check lines back to the declared specs and names. Null when any declared
92
+ * spec or name has no line (or an empty version), so a partial report is never shown as ready. */
93
+ export function parseCheckOutput(stdout, specs, names) {
94
+ const resolved = new Map();
95
+ const set = new Map();
96
+ for (const line of stdout.split(/\r?\n/)) {
97
+ const [kind, key, value, ...extra] = line.split("\t");
98
+ if (extra.length > 0 || key === undefined || value === undefined)
99
+ continue;
100
+ if (kind === "toolchain" && specs.includes(key) && value.trim() !== "")
101
+ resolved.set(key, value.trim());
102
+ if (kind === "variable" && names.includes(key) && (value === "set" || value === "unset"))
103
+ set.set(key, value === "set");
104
+ }
105
+ const toolchains = [];
106
+ for (const spec of specs) {
107
+ const version = resolved.get(spec);
108
+ if (version === undefined)
109
+ return null;
110
+ toolchains.push({ spec, resolved: version });
111
+ }
112
+ const variables = [];
113
+ for (const name of names) {
114
+ const value = set.get(name);
115
+ if (value === undefined)
116
+ return null;
117
+ variables.push({ name, set: value });
118
+ }
119
+ return { toolchains, variables };
120
+ }
@@ -0,0 +1,123 @@
1
+ import { loadConfig } from "../config.js";
2
+ import { loadContract } from "../contract.js";
3
+ import { RUNNER_HOST_IMAGES } from "../onboard-runner.js";
4
+ import { readLocalRepositoryName } from "../onboard-settings-context.js";
5
+ import { apiClient } from "../transport.js";
6
+ /** A non-empty `auths`, so the Docker CLI does not fall back to the platform's credential store. */
7
+ export const CREDENTIAL_FREE_CONFIG = { auths: { "https://index.docker.io/v1/": {} } };
8
+ export const PULL_USERNAME = "x-catalyst-pull";
9
+ /** Docker's rule: the first path component is a registry when it has a "." or ":" or is "localhost". */
10
+ export function registryOf(ref) {
11
+ const name = ref.split("@", 1)[0] ?? ref;
12
+ const slash = name.indexOf("/");
13
+ if (slash < 0)
14
+ return "docker.io";
15
+ const first = name.slice(0, slash);
16
+ return first.includes(".") || first.includes(":") || first === "localhost" ? first : "docker.io";
17
+ }
18
+ /** The contract's own path for the credential route: POST, ending in `/registry/pull-credential`. */
19
+ export function pullCredentialRoute(doc) {
20
+ return doc.routes.find((r) => r.method === "POST" && r.path.endsWith("/registry/pull-credential"))?.path ?? null;
21
+ }
22
+ const REGISTRY_HOST_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+(?::[0-9]{1,5})?$/;
23
+ const REPOSITORY_RE = /^[a-z0-9]+(?:(?:[._-]|__)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._-]|__)[a-z0-9]+)*)*$/;
24
+ const PASSWORD_RE = /^[\x21-\x7e]{8,4096}$/;
25
+ /** Strict: anything but the CTC-2565 answer shapes is `invalid`, never a partial credential. */
26
+ export function parsePullCredential(body, nowMs) {
27
+ if (typeof body !== "object" || body === null || Array.isArray(body))
28
+ return { kind: "invalid" };
29
+ const answer = body;
30
+ if (answer.issued === false)
31
+ return typeof answer.refusal === "string" && /^[a-z_]{1,64}$/.test(answer.refusal)
32
+ ? { kind: "refused", refusal: answer.refusal }
33
+ : { kind: "invalid" };
34
+ if (answer.issued !== true)
35
+ return { kind: "invalid" };
36
+ const { registry, repository, username, password, expiresAtMs } = answer;
37
+ if (typeof registry !== "string" ||
38
+ !REGISTRY_HOST_RE.test(registry) ||
39
+ typeof repository !== "string" ||
40
+ repository.length > 255 ||
41
+ !REPOSITORY_RE.test(repository) ||
42
+ username !== PULL_USERNAME ||
43
+ typeof password !== "string" ||
44
+ !PASSWORD_RE.test(password) ||
45
+ typeof expiresAtMs !== "number" ||
46
+ !Number.isFinite(expiresAtMs) ||
47
+ expiresAtMs <= nowMs)
48
+ return { kind: "invalid" };
49
+ return { kind: "issued", credential: { registry, repository, username, password, expiresAtMs } };
50
+ }
51
+ /** Covers only `<registry>/<repository>[:tag]@sha256:…`, never a sibling that shares a prefix. */
52
+ export function credentialCovers(ref, credential) {
53
+ const prefix = `${credential.registry}/${credential.repository}`;
54
+ if (!ref.startsWith(prefix))
55
+ return false;
56
+ return /^(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127})?@sha256:[0-9a-f]{64}$/.test(ref.slice(prefix.length));
57
+ }
58
+ const REFUSAL_REASONS = {
59
+ disabled: "download credentials are not turned on for your workspace",
60
+ not_configured: "download credentials are not turned on for your workspace",
61
+ };
62
+ function withoutStop(text) {
63
+ return text.replace(/[.\s]+$/, "");
64
+ }
65
+ export async function downloadImage(input) {
66
+ const { ref, host, ctx, docker, notify } = input;
67
+ notify(ref === RUNNER_HOST_IMAGES.runner
68
+ ? " Downloading it with no credential (about 2.3 GB, once per image)…"
69
+ : " Downloading it with no credential…");
70
+ const anonymous = await docker.pull(ref, host, CREDENTIAL_FREE_CONFIG);
71
+ if (anonymous.ok) {
72
+ notify(" Downloaded.");
73
+ return { ok: true, credential: "none" };
74
+ }
75
+ const refused = `Could not download ${ref} with no credential: ${withoutStop(anonymous.detail) || "docker pull failed"}.`;
76
+ const refuse = (code, message) => ({ ok: false, refusal: { code, message } });
77
+ const cfg = loadConfig(ctx.home);
78
+ if (!cfg)
79
+ return refuse("image_download_refused", `${refused} If this image needs your workspace's permission, sign in with catalyst login and run this again.`);
80
+ const credentialRefused = (reason) => refuse("credential_refused", `Your workspace did not issue a download credential for ${ref}: ${reason}.`);
81
+ let route;
82
+ try {
83
+ route = pullCredentialRoute((await loadContract(ctx, cfg)).doc);
84
+ }
85
+ catch (error) {
86
+ return credentialRefused(withoutStop(error instanceof Error ? error.message : String(error)));
87
+ }
88
+ if (!route)
89
+ return refuse("image_download_refused", `${refused} Your Catalyst Cloud does not issue download credentials.`);
90
+ const repositoryName = await (input.readRepositoryName ?? readLocalRepositoryName)(input.root);
91
+ const [owner, name, ...extra] = repositoryName?.split("/") ?? [];
92
+ if (!owner || !name || extra.length > 0)
93
+ return refuse("repository_unknown", "This checkout's origin is not a GitHub repository, so your workspace cannot issue it a download credential.");
94
+ notify(" The download needs permission. Asking your workspace for a short-lived download credential…");
95
+ let body;
96
+ try {
97
+ body = (await apiClient(cfg, ctx).postJson(route, { owner, name }, { accept: [200] })).body;
98
+ }
99
+ catch {
100
+ return credentialRefused("the request was refused");
101
+ }
102
+ const nowMs = ctx.now().getTime();
103
+ const answer = parsePullCredential(body, nowMs);
104
+ if (answer.kind === "invalid")
105
+ return credentialRefused("the answer was not understood");
106
+ if (answer.kind === "refused")
107
+ return credentialRefused(answer.refusal === "unknown_repository"
108
+ ? `${owner}/${name} is not a repository in your workspace`
109
+ : (REFUSAL_REASONS[answer.refusal] ?? "the request was refused"));
110
+ const credential = answer.credential;
111
+ if (!credentialCovers(ref, credential))
112
+ return refuse("credential_out_of_scope", `Your workspace issued a download credential for ${credential.registry}/${credential.repository}, which does not cover ${ref}. Nothing was sent to ${registryOf(ref)}.`);
113
+ const minutes = Math.max(1, Math.floor((credential.expiresAtMs - nowMs) / 60_000));
114
+ notify(` Downloading it with a short-lived credential your workspace issued (valid for ${minutes} minutes)…`);
115
+ const auth = Buffer.from(`${credential.username}:${credential.password}`).toString("base64");
116
+ const issued = await docker.pull(ref, host, { auths: { [credential.registry]: { auth } } });
117
+ if (issued.ok) {
118
+ notify(" Downloaded.");
119
+ return { ok: true, credential: "issued" };
120
+ }
121
+ const detail = withoutStop(issued.detail.split(credential.password).join("[redacted]").split(auth).join("[redacted]"));
122
+ return refuse("image_download_refused", `Could not download ${ref} with the credential your workspace issued: ${detail || "docker pull failed"}.`);
123
+ }
@@ -0,0 +1,288 @@
1
+ // shell.ts — `catalyst env shell [--check]`: open this repository's environment on this computer.
2
+ // Every refusal a person can fix in the file or the terminal fires before Docker is touched; the
3
+ // cloud is asked only when a credential-free download is refused (shell-pull.ts).
4
+ import { execFile } from "node:child_process";
5
+ import { readFileSync, realpathSync } from "node:fs";
6
+ import { basename, isAbsolute, relative, resolve } from "node:path";
7
+ import { parseEnv, promisify } from "node:util";
8
+ import { flagBool, flagList, flagString, positionals } from "../args.js";
9
+ import { UsageError } from "../errors.js";
10
+ import { IMAGE_REF, RUNNER_HOST_IMAGES } from "../onboard-runner.js";
11
+ import { readLocalRepositoryName } from "../onboard-settings-context.js";
12
+ import { readLocalSettings } from "./settings-authority.js";
13
+ import { readShellDeclaration, resolveShellVariables } from "./shell-declaration.js";
14
+ import { spawnEnvShellDocker } from "./shell-docker.js";
15
+ import { parseCheckOutput, shellRunArgs, START_EXIT_TOOLCHAIN, toolchainSpecs } from "./shell-plan.js";
16
+ import { downloadImage } from "./shell-pull.js";
17
+ const DECLARATION = ".catalyst/catalyst.toml";
18
+ const IMAGE_UID = 10001;
19
+ const run = promisify(execFile);
20
+ /** Git with no global or system config, no prompt, bounded: a path, or null. */
21
+ async function gitPath(cwd, args) {
22
+ try {
23
+ const { stdout } = await run("git", args, {
24
+ cwd,
25
+ timeout: 3_000,
26
+ maxBuffer: 16_384,
27
+ env: {
28
+ PATH: process.env.PATH,
29
+ GIT_CONFIG_GLOBAL: "/dev/null",
30
+ GIT_CONFIG_NOSYSTEM: "1",
31
+ GIT_TERMINAL_PROMPT: "0",
32
+ },
33
+ });
34
+ const path = stdout.trim();
35
+ return path !== "" && isAbsolute(path) ? path : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ const defaultGitTopLevel = (cwd) => gitPath(cwd, ["rev-parse", "--show-toplevel"]);
42
+ const defaultGitCommonDir = (root) => gitPath(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
43
+ /** The digest shortened for a person: `<name>@sha256:<12 hex>…`. */
44
+ function shortImage(image) {
45
+ return image.replace(/@sha256:([0-9a-f]{12})[0-9a-f]{52}$/, "@sha256:$1…");
46
+ }
47
+ function inside(child, parent) {
48
+ const rel = relative(parent, child);
49
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
50
+ }
51
+ function unmountable(path) {
52
+ if (path.includes(","))
53
+ return `env shell cannot mount ${path}: the path contains a comma.`;
54
+ if (/[\r\n]/.test(path))
55
+ return `env shell cannot mount ${JSON.stringify(path)}: the path contains a line break.`;
56
+ return null;
57
+ }
58
+ function toolchainLine(toolchains) {
59
+ return toolchains.length === 0
60
+ ? " Toolchain: none declared"
61
+ : ` Toolchain: ${toolchains.map((t) => `${t.name} ${t.version}`).join(", ")} (the first start downloads any version the image lacks)`;
62
+ }
63
+ function joinSources(sources) {
64
+ return sources.length <= 1 ? (sources[0] ?? "") : `${sources.slice(0, -1).join(", ")} and ${sources.at(-1)}`;
65
+ }
66
+ export async function runEnvShell(args, ctx, deps = {}) {
67
+ // 1. Flags.
68
+ const [, ...rest] = positionals(args);
69
+ if (rest.length > 0)
70
+ throw new UsageError(`env shell takes no positional arguments (got "${rest[0]}"); use --root DIR`);
71
+ const check = flagBool(args, "check");
72
+ const json = args.json;
73
+ if (json && !check)
74
+ throw new UsageError("env shell --json needs --check: the shell itself is interactive");
75
+ const imageFlag = flagString(args, "image");
76
+ if (imageFlag !== undefined && !IMAGE_REF.test(imageFlag))
77
+ throw new UsageError("env shell --image needs a digest-pinned reference, such as ghcr.io/owner/image@sha256:<64 hex>");
78
+ const image = imageFlag ?? RUNNER_HOST_IMAGES.runner;
79
+ const say = (line) => {
80
+ if (!json)
81
+ ctx.stdout(line);
82
+ };
83
+ const notice = (line) => (json ? ctx.stderr(line) : ctx.stdout(line));
84
+ const refuse = (refusal) => {
85
+ if (json)
86
+ ctx.stdout(JSON.stringify({ ok: false, refusal }));
87
+ else
88
+ for (const line of refusal.message.split("\n"))
89
+ ctx.stderr(line);
90
+ return 1;
91
+ };
92
+ // 2. Root.
93
+ const rootFlag = flagString(args, "root");
94
+ const start = resolve(rootFlag ?? ".");
95
+ const requested = rootFlag !== undefined ? start : ((await (deps.gitTopLevel ?? defaultGitTopLevel)(start)) ?? start);
96
+ let root;
97
+ try {
98
+ root = realpathSync(requested);
99
+ }
100
+ catch {
101
+ return refuse({
102
+ code: "declaration_missing",
103
+ message: `No ${DECLARATION} in ${requested}. Draft one with: catalyst env draft --write`,
104
+ });
105
+ }
106
+ const rootProblem = unmountable(root);
107
+ if (rootProblem)
108
+ return refuse({ code: "repository_path_unsupported", message: rootProblem });
109
+ // 3. Declaration.
110
+ const text = await readLocalSettings(root);
111
+ if (text === null)
112
+ return refuse({
113
+ code: "declaration_missing",
114
+ message: `No ${DECLARATION} in ${root}. Draft one with: catalyst env draft --write`,
115
+ });
116
+ const declaration = readShellDeclaration(text);
117
+ if (declaration.state === "invalid")
118
+ return refuse({
119
+ code: "declaration_invalid",
120
+ message: [...declaration.errors.map((error) => `${DECLARATION}: ${error}`), "Fix the file, then run catalyst env check."].join("\n"),
121
+ });
122
+ if (declaration.state === "unsupported") {
123
+ const { name, version } = declaration.toolchain;
124
+ return refuse({
125
+ code: "toolchain_version_unsupported",
126
+ message: `${name} ${JSON.stringify(version)} is a version range. env shell prepares an exact or prefix version, such as 22 or 22.23.3. Change it in ${DECLARATION}.`,
127
+ });
128
+ }
129
+ // 4. Variables.
130
+ const envFiles = [];
131
+ for (const path of flagList(args, "env-file")) {
132
+ try {
133
+ envFiles.push({ path, values: parseEnv(readFileSync(resolve(path), "utf8")) });
134
+ }
135
+ catch {
136
+ return refuse({ code: "env_file_unreadable", message: `could not read --env-file ${path}` });
137
+ }
138
+ }
139
+ const variables = resolveShellVariables(declaration.variables, { env: ctx.env, envFiles, platform: deps.platform });
140
+ if (variables.missingRequired.length > 0)
141
+ return refuse({
142
+ code: "variables_missing",
143
+ message: `These required variables are not set in this terminal or an --env-file: ${variables.missingRequired.join(", ")}. Set them, then run catalyst env shell again.`,
144
+ names: variables.missingRequired,
145
+ });
146
+ // 5. Engine.
147
+ const docker = deps.docker ?? spawnEnvShellDocker({ env: ctx.env });
148
+ const server = await docker.server();
149
+ if (server.state === "missing")
150
+ return refuse({
151
+ code: "docker_missing",
152
+ message: "Docker is not installed on this computer. Install Docker Desktop, OrbStack or Docker Engine, then run catalyst env shell again.",
153
+ });
154
+ if (server.state === "not_running")
155
+ return refuse({
156
+ code: "docker_not_running",
157
+ message: "Docker is installed but not running. Start Docker Desktop, OrbStack or Docker Engine, then run catalyst env shell again.",
158
+ });
159
+ if (server.state === "not_linux")
160
+ return refuse({
161
+ code: "docker_not_linux",
162
+ message: `Docker is running ${server.os} containers. Switch it to Linux containers, then run catalyst env shell again.`,
163
+ });
164
+ // 6. Image.
165
+ const readRepositoryName = deps.readRepositoryName ?? readLocalRepositoryName;
166
+ const repository = (await readRepositoryName(root)) ?? basename(root);
167
+ say(`Environment for ${repository}, from ${DECLARATION}`);
168
+ const present = await docker.imagePresent(image);
169
+ say(` Image: ${shortImage(image)} (${present ? "already on this computer" : "not on this computer yet"})`);
170
+ let credential = "none";
171
+ if (!present) {
172
+ const endpoint = await docker.endpoint();
173
+ if (!endpoint.ok)
174
+ return refuse(endpoint.reason === "conflict"
175
+ ? {
176
+ code: "docker_endpoint_conflict",
177
+ message: "DOCKER_HOST and DOCKER_CONTEXT are both set. Unset one, then run catalyst env shell again.",
178
+ }
179
+ : {
180
+ code: "docker_endpoint_unreadable",
181
+ message: "Docker's current context could not be read. Check it with docker context ls, then run catalyst env shell again.",
182
+ });
183
+ const downloaded = await downloadImage({ ref: image, host: endpoint.host, root, ctx, docker, notify: notice, readRepositoryName });
184
+ if (!downloaded.ok)
185
+ return refuse(downloaded.refusal);
186
+ credential = downloaded.credential;
187
+ }
188
+ // 7. Git common directory: a worktree's shared .git outside the root is mounted at its own path.
189
+ const commonDir = await (deps.gitCommonDir ?? defaultGitCommonDir)(root);
190
+ let gitCommonDir = null;
191
+ if (commonDir !== null) {
192
+ let real = commonDir;
193
+ try {
194
+ real = realpathSync(commonDir);
195
+ }
196
+ catch {
197
+ // Not there any more: git inside the shell reports it; nothing is mounted.
198
+ }
199
+ if (!inside(real, root)) {
200
+ const problem = unmountable(real);
201
+ if (problem)
202
+ return refuse({ code: "repository_path_unsupported", message: problem });
203
+ gitCommonDir = real;
204
+ }
205
+ }
206
+ // 8. Run.
207
+ const tty = !check && (deps.isTty?.() ?? (process.stdin.isTTY === true && process.stdout.isTTY === true));
208
+ const checkNames = declaration.variables.map((v) => v.name).filter((name) => !variables.skippedReserved.includes(name));
209
+ const argv = shellRunArgs({
210
+ mode: check ? "check" : "shell",
211
+ image,
212
+ root,
213
+ gitCommonDir,
214
+ toolchains: declaration.toolchains,
215
+ passNames: variables.set,
216
+ tty,
217
+ checkNames,
218
+ });
219
+ const runEnv = { ...ctx.env, ...variables.values };
220
+ if (tty)
221
+ runEnv.TERM = ctx.env.TERM || "xterm-256color";
222
+ if (variables.skippedReserved.length > 0)
223
+ notice(` Note: env shell sets ${variables.skippedReserved.join(", ")} itself, so the declared value is not used.`);
224
+ const uid = deps.uid ?? process.getuid?.();
225
+ if ((deps.platform ?? process.platform) === "linux" && uid !== undefined && uid !== IMAGE_UID)
226
+ notice(`Note: the shell runs as uid ${IMAGE_UID}, as Catalyst's agents do. Files here belong to uid ${uid}, so on Docker Engine for Linux the shell may not be able to write them.`);
227
+ const toolchainNotPrepared = {
228
+ code: "toolchain_not_prepared",
229
+ message: `The shell did not start: a declared toolchain could not be prepared (mise's message above says which). Check the name and version in ${DECLARATION}, then run catalyst env shell --check.`,
230
+ };
231
+ const secret = new Set(declaration.variables.filter((v) => v.secret).map((v) => v.name));
232
+ if (!check) {
233
+ say(toolchainLine(declaration.toolchains));
234
+ const set = variables.set.map((name) => (secret.has(name) ? `${name} (secret)` : name));
235
+ const parts = [
236
+ ...(set.length > 0 ? [`${set.join(", ")} set from ${joinSources(variables.sources)}`] : []),
237
+ ...(variables.unsetOptional.length > 0 ? [`not set: ${variables.unsetOptional.map((name) => `${name} (optional)`).join(", ")}`] : []),
238
+ ];
239
+ say(` Variables: ${parts.length > 0 ? parts.join("; ") : "none declared"}`);
240
+ say(` Your repository is at /workspace. Type exit to leave.`);
241
+ const result = await docker.run(argv, runEnv, "interactive");
242
+ if (result.code === START_EXIT_TOOLCHAIN)
243
+ return refuse(toolchainNotPrepared);
244
+ return result.code;
245
+ }
246
+ const result = await docker.run(argv, runEnv, "capture");
247
+ if (result.code === START_EXIT_TOOLCHAIN)
248
+ return refuse(toolchainNotPrepared);
249
+ const specs = toolchainSpecs(declaration.toolchains);
250
+ const report = result.code === 0 ? parseCheckOutput(result.stdout, specs, checkNames) : null;
251
+ if (report === null)
252
+ return refuse({
253
+ code: "check_incomplete",
254
+ message: `The environment check did not finish (exit ${result.code}). Run catalyst env shell --check again; if it repeats, open the shell and run mise doctor.`,
255
+ });
256
+ const byName = new Map(declaration.variables.map((v) => [v.name, v]));
257
+ const resolvedBySpec = new Map(report.toolchains.map((t) => [t.spec, t.resolved]));
258
+ const resolvedOf = (t) => resolvedBySpec.get(`${t.name}@${t.version}`) ?? "";
259
+ if (json) {
260
+ ctx.stdout(JSON.stringify({
261
+ ok: true,
262
+ repository,
263
+ declaration: DECLARATION,
264
+ image: { reference: image, source: present ? "present" : "downloaded", credential },
265
+ toolchains: declaration.toolchains.map((t) => ({ name: t.name, version: t.version, resolved: resolvedOf(t) })),
266
+ variables: report.variables.map((v) => ({
267
+ name: v.name,
268
+ required: byName.get(v.name)?.required === true,
269
+ secret: byName.get(v.name)?.secret === true,
270
+ set: v.set,
271
+ })),
272
+ }));
273
+ return 0;
274
+ }
275
+ say(declaration.toolchains.length === 0
276
+ ? " Toolchain: none declared"
277
+ : ` Toolchain: ${declaration.toolchains.map((t) => `${t.name} ${t.version} → ${resolvedOf(t)}`).join(", ")}`);
278
+ say(` Variables: ${report.variables.length === 0
279
+ ? "none declared"
280
+ : report.variables
281
+ .map((v) => {
282
+ const notes = [...(secret.has(v.name) ? ["secret"] : []), ...(!v.set && byName.get(v.name)?.required !== true ? ["optional"] : [])];
283
+ return `${v.name} ${v.set ? "set" : "not set"}${notes.length > 0 ? ` (${notes.join(", ")})` : ""}`;
284
+ })
285
+ .join(", ")}`);
286
+ say("Ready: catalyst env shell opens this environment.");
287
+ return 0;
288
+ }
package/dist/env.js CHANGED
@@ -1,7 +1,9 @@
1
- // env.ts — `catalyst env inventory` / `env check`: a LOCAL, OFFLINE, repo-scoped reporter and
2
- // validator. Unlike every other verb in this file's siblings, `cmdEnv` never calls `requireConfig` or
3
- // `apiClient` — it needs neither a login nor a network call, which is the whole point of the feature:
4
- // a person reviews what a repository declares without connecting anything first.
1
+ // env.ts — `catalyst env inventory` / `check` / `migrate` / `draft`: a LOCAL, OFFLINE, repo-scoped
2
+ // reporter and validator. Unlike every other verb in this file's siblings, those four never call
3
+ // `requireConfig` or `apiClient` — they need neither a login nor a network call, which is the whole
4
+ // point of the feature: a person reviews what a repository declares without connecting anything first.
5
+ // `env shell` (env/shell.ts) talks to Docker, and asks the cloud only when a credential-free download
6
+ // of the image is refused; Catalyst's own image needs no login.
5
7
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
8
  import { dirname, join, resolve } from "node:path";
7
9
  import { flagBool, flagString, positionals } from "./args.js";
@@ -10,11 +12,14 @@ import { convertLegacyEnvironmentJson } from "./env/legacy.js";
10
12
  import { draftEnvironment } from "./env/draft.js";
11
13
  import { inventoryRepo } from "./env/inventory.js";
12
14
  import { inventoryToJson, renderInventory } from "./env/render.js";
15
+ import { runEnvShell } from "./env/shell.js";
13
16
  import { UsageError } from "./errors.js";
14
17
  export async function cmdEnv(args, ctx, deps = {}) {
15
18
  const [sub, ...rest] = positionals(args);
16
19
  if (sub === undefined)
17
- throw new UsageError("env needs a subcommand: inventory | check | migrate | draft");
20
+ throw new UsageError("env needs a subcommand: inventory | check | migrate | draft | shell");
21
+ if (sub === "shell")
22
+ return await runEnvShell(args, ctx, deps.shell ?? {});
18
23
  if (sub === "draft") {
19
24
  if (rest.length > 0)
20
25
  throw new UsageError("env draft takes no positional arguments; use --root DIR");
@@ -172,5 +177,5 @@ export async function cmdEnv(args, ctx, deps = {}) {
172
177
  ctx.stderr(`${file}: ${error}`);
173
178
  return result.state === "invalid" ? 1 : 0;
174
179
  }
175
- throw new UsageError(`unknown env subcommand "${sub}": inventory | check | migrate | draft`);
180
+ throw new UsageError(`unknown env subcommand "${sub}": inventory | check | migrate | draft | shell`);
176
181
  }
@@ -109,7 +109,6 @@ const REASONS = {
109
109
  workflow_unavailable: "Setup could not read the team's workflow. Run catalyst onboard again to retry.",
110
110
  capacity_context_unverified: "Setup could not confirm this project's registered repositories and its default repository for capacity. Run catalyst ready to see the project checks, then run catalyst setup again. Starting a ticket checks its own repository's admission.",
111
111
  capacity_identity_unverified: "Setup could not confirm your workspace and administrator role for the capacity check. Run catalyst login, then run catalyst setup again.",
112
- capacity_admission_unverified: "No runner is allowed to take work for this workspace yet. Enroll a self-hosted runner host, or ask Catalyst to enable cloud runners for it. Then run catalyst onboard.",
113
112
  capacity_currently_full: "Every runner slot for this repository is in use. Run catalyst onboard again after current work finishes.",
114
113
  capacity_unavailable: "Setup could not read runner capacity. Run catalyst onboard again to retry.",
115
114
  runner_not_selected: "This setup does not start a runner on this machine. To start one, run catalyst onboard --runner.",
@@ -424,9 +423,16 @@ export function onboardReasonText(step, context = {}) {
424
423
  ? `This setup step is not available on this server yet. Continue in the web app at ${page}, then run catalyst onboard.`
425
424
  : "This setup step is not available on this server yet. Run catalyst onboard after the server update.";
426
425
  }
426
+ // CTC-6180: the command that enrolls this computer, named with its team.
427
+ // The command is its own line (never wrapped), and runnable as printed: with no known team key,
428
+ // setup asks for the team.
429
+ if (reason === "capacity_admission_unverified") {
430
+ const key = onboardTeamKey(context.journal);
431
+ return `No runner is allowed to take work for this workspace yet. To use Catalyst's cloud runners, ask Catalyst support to turn on cloud runners for your workspace, then run catalyst onboard. To run work on this computer instead, enroll it as a self-hosted runner${key ? "" : " (setup asks for your Linear team)"} with:\ncatalyst onboard --runner${key ? ` --team ${key}` : ""}`;
432
+ }
427
433
  if (reason === "runner_native_egress_setup_required") {
428
- const commands = nativeEgressInstallCommands();
429
- return "Native Linux needs a root-managed session egress producer before the runner can take work. Its elevated installation creates the isolated session network and private-range and host-isolation nftables rules. Review and authorize this separately; setup has not run it. It requires systemd, python3, Docker and nftables. " + (commands ? "Install the verified bundled artifacts, then run the same setup command:\n" + commands : "Setup could not verify its bundled artifacts. Reinstall the CLI before authorizing installation.");
434
+ const command = nativeEgressInstallCommands();
435
+ return "Native Linux needs a root-managed session egress service before this computer can take work. It creates the isolated session network and the nftables rules that keep sessions off private addresses and this machine. It needs systemd, python3, Docker and nftables, and setup has not run it. " + (command ? "Review it, run this one command, then run the same setup command:\n" + command : "Setup could not verify its bundled files. Reinstall the CLI, then run the same setup command.");
430
436
  }
431
437
  if (reason === "runner_images_unavailable" || reason === "runner_supervisor_image_unavailable") {
432
438
  const image = step.evidence?.image;
@@ -51,10 +51,20 @@ export async function nativeEgressReady(now, signal, io = nodeReads) {
51
51
  return false;
52
52
  }
53
53
  }
54
- /** Exact bundled bytes are checked before showing the separate, elevated install commands. */
54
+ export const EGRESS_INSTALLER = { name: 'install-session-egress.sh', digest: 'b187180a9e5965ebe5e356a5c133ff2ec715fb294197cd636f8ff1301dc49ff0' };
55
+ const quote = (s) => "'" + s.replaceAll("'", "'\\''") + "'";
56
+ /** CTC-6180: root copies the installer into its own temp file, checks the pinned digest there and
57
+ * runs only those bytes, so an edit to the user-writable CLI install cannot run as root. */
58
+ export function egressInstallCommand(vendor, digest = EGRESS_INSTALLER.digest, sudo = 'sudo ') {
59
+ const script = `t=$(mktemp) && cp -- ${quote(join(vendor, EGRESS_INSTALLER.name))} "$t" && echo ${quote(digest + ' ')}"$t" | sha256sum -c --quiet - && sh "$t" ${quote(vendor)}; s=$?; rm -f "$t"; exit $s`;
60
+ return `${sudo}sh -c ${quote(script)}`;
61
+ }
62
+ /** Exact bundled bytes are checked before showing the one elevated install command (CTC-6180). */
55
63
  export function nativeEgressInstallCommands() {
56
- const root = join(packageRoot(), 'vendor/self-host/linux-session-egress');
64
+ const vendor = join(packageRoot(), 'vendor'), root = join(vendor, 'self-host/linux-session-egress');
57
65
  try {
66
+ if (hash(readFileSync(join(vendor, EGRESS_INSTALLER.name))) !== EGRESS_INSTALLER.digest)
67
+ return null;
58
68
  for (const artifact of EGRESS_ARTIFACTS)
59
69
  if (hash(readFileSync(join(root, artifact.name))) !== artifact.digest)
60
70
  return null;
@@ -62,6 +72,5 @@ export function nativeEgressInstallCommands() {
62
72
  catch {
63
73
  return null;
64
74
  }
65
- const quote = (s) => "'" + s.replaceAll("'", "'\\''") + "'";
66
- return EGRESS_ARTIFACTS.map(a => `sudo install ${a.name === 'session-egress.py' ? '-D ' : ''}-o root -g root -m ${a.mode.toString(8)} ${quote(join(root, a.name))} ${quote(a.path)}`).join('\n') + '\nsudo systemctl daemon-reload\nsudo systemctl enable --now catalyst-session-egress.service catalyst-session-egress-attest.timer\nsudo /usr/bin/python3 /usr/lib/catalyst/session-egress.py attest';
75
+ return egressInstallCommand(vendor);
67
76
  }
@@ -29,7 +29,7 @@ export const RUNNER_HOST_IMAGES = {
29
29
  watchdog: "ghcr.io/coalesce-labs/catalyst-deadline-watchdog@sha256:63a9b20c3be3dc3ea328d77aa68dece942a40be46cb3126d9a7868e082828feb",
30
30
  runner: "ghcr.io/coalesce-labs/catalyst-runner@sha256:a19f7b0859a9814740935f7aadd8fe5c5c9c5974f9a5956a057b983ada16ea01",
31
31
  };
32
- const IMAGE_REF = /^[a-z0-9][a-z0-9._:/-]{0,255}@sha256:[0-9a-f]{64}$/;
32
+ export const IMAGE_REF = /^[a-z0-9][a-z0-9._:/-]{0,255}@sha256:[0-9a-f]{64}$/;
33
33
  const HOST_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
34
34
  const JOIN_TOKEN = /^[A-Za-z0-9._~+/=-]{16,1024}$/;
35
35
  const ENROLLMENTS = "/api/v1/hosts/enrollments";
@@ -122,19 +122,21 @@ export function createSetupRenderer(stream, env, banner = {}) {
122
122
  const indent = /^ */.exec(text)?.[0].length ?? 0;
123
123
  col += indent;
124
124
  text = text.slice(indent);
125
- return wrapAnsi(text, Math.max(1, width - col), {
126
- hard: false,
127
- wordWrap: true,
128
- trim: true,
129
- })
130
- .split("\n")
131
- .map((line) => {
132
- const plain = visible(line);
133
- const indent = /^https?:\/\/\S+$/.test(plain) && stringWidth(plain) > width - col
134
- ? textCol
135
- : col;
136
- return prefix(indent) + line;
137
- });
125
+ // CTC-6180: each line after the first is a command a person copies, so it is never wrapped.
126
+ const [prose = "", ...commands] = text.split("\n");
127
+ return [...wrapAnsi(prose, Math.max(1, width - col), {
128
+ hard: false,
129
+ wordWrap: true,
130
+ trim: true,
131
+ })
132
+ .split("\n")
133
+ .map((line) => {
134
+ const plain = visible(line);
135
+ const indent = /^https?:\/\/\S+$/.test(plain) && stringWidth(plain) > width - col
136
+ ? textCol
137
+ : col;
138
+ return prefix(indent) + line;
139
+ }), ...commands.map((command) => prefix(col) + command)];
138
140
  };
139
141
  const stepRows = (kind, number, title, outcome, plan = false) => {
140
142
  refresh();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.16.19",
3
+ "version": "0.16.20",
4
4
  "description": "Set up Catalyst Cloud, check your tickets and manage your account from the command line.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.19 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.20 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst's GitHub
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Catalyst's view of Linear on the customer's own cloud account. Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the cloud API by default, with local replica reads available as an explicit opt-in, always naming the source; searches tickets, PRs, projects and initiatives; writes comments, card moves, labels and new tickets through the account's agent proxy as the app actor, with every route, stage id, label id and marker read from the account's contract. Knows what a ticket accumulates as Catalyst works it (phase-outcome comments, the document per phase, the agent session, the labels, the single blocks relation, the bookkeeping marker, the eyes acknowledgement). Use when a person says "show me the ticket", "what did Catalyst write on it", "comment on it", "move it", "label it" or "file a ticket". Not for raising a decision for a human (what-needs-me) and not for pull requests (catalyst-github).
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.19 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.20 — written in this repository for customer accounts -->
8
8
 
9
9
  # Catalyst Linear
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Set up Catalyst Cloud and say whether setup is working. Walks a person from nothing to their first ticket running, one step at a time: the coding account, the project, the integrations and connected accounts, each repository's settings file, then one real ticket moving. Also logs this machine in, reads the readiness verdict with who can fix each failure, and checks the optional local replica. Use when someone says "set me up", "onboard me", "I just signed up", "get me started", "what do I do first", "log me in", "which account is this machine on", "am I set up", "what is missing", "why does nothing happen" or "is the replica running", when a login expired, or when a skill script exits 2 saying this machine is not connected. Does every step a key can do through the catalyst CLI, hands over the exact page for the steps only a browser can do, and never claims a step it did not watch succeed.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.19 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.20 — written in this repository for customer accounts -->
8
8
 
9
9
  # Onboard me
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Own one Catalyst Cloud project end to end until it closes. Use when the person says "run this project for me", "own this until it ships", "keep this moving", or hands you a project id or a set of tickets to drive. Subscribes to the account's event stream for the scope through the catalyst CLI, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Writes to Linear as the app actor; never polls.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.19 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.20 — written in this repository for customer accounts -->
8
8
 
9
9
  # Run this project
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Get a stuck Catalyst Cloud ticket moving again. Use when the person asks "why is this parked and can you release it?", "unpark this", "get things flowing again", "the outage is over, retry what failed", or hands you a ticket that is not moving. Reads why nothing runs and what holds the ticket through the catalyst CLI, decides whether the recorded cause is fixed, previews the release, releases every governor holding the ticket the right way (or one failure class across a team), and raises an ask only for what a person has to do. Never releases a cause it cannot show changed without saying so.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.19 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.20 — written in this repository for customer accounts -->
8
8
 
9
9
  # Unstick
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  The human's decision inbox on Catalyst Cloud, and the one way an agent raises a decision on their behalf. Use when the person asks "what needs me?", "what am I blocking?", or when active work is gated on a choice only they can make. Lists open asks ranked by how much open work each one holds, files an ask through the cloud's ask route with the account's own template, and records the answer so the held work releases. Never answers as the human, never duplicates an open ask.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.19 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.20 — written in this repository for customer accounts -->
8
8
 
9
9
  # What needs me
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  What environment variable names does this repository need, and where does each one come from? Scans the repository offline (no login, no network) and lists the names in three groups (build/test, deploy-only, bindings), each with where it was found, what uses it, and where a local value would come from. Never reads a value or prints one. Also checks the environment variable table in `.catalyst/catalyst.toml` offline. Use when someone asks "what does catalyst.env.json mean", "what env vars does this repo need", "why does the container need this", or before reviewing or writing the declaration.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.19 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.20 — written in this repository for customer accounts -->
8
8
 
9
9
  # What this repository needs
10
10
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Use for "what's happening?", "what machines do we have?", "how much capacity do we have?", "where are we?", "why is that stuck?", "what closed?", "what's next?", "how does this work?", "why did it do that?", "how does it prioritise?" and "what does this setting do, where is it set?" in Catalyst Cloud, or asks for something to be done rather than known. Reads the contract via catalyst CLI. Read-only: route work/decisions, never answer as the human.
5
5
  allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
6
6
  ---
7
- <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.19 — written in this repository for customer accounts -->
7
+ <!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.20 — written in this repository for customer accounts -->
8
8
 
9
9
  # What's happening
10
10
 
@@ -0,0 +1,30 @@
1
+ #!/bin/sh
2
+ # Installs Catalyst's session egress service on a native Linux runner machine, from the files
3
+ # that ship with this catalyst CLI. Setup prints the one command that runs it as root from a
4
+ # checked root-owned copy, passing the CLI's vendor folder as $1. It copies the files into a
5
+ # root-owned folder, checks their fingerprints there, and installs only checked bytes.
6
+ set -eu
7
+ if [ "$(id -u)" != 0 ]; then
8
+ echo "Run this with sudo: sudo sh '$0'" >&2
9
+ exit 1
10
+ fi
11
+ src="${1:-$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)}/self-host/linux-session-egress"
12
+ work="$(mktemp -d)"
13
+ trap 'rm -rf "$work"' EXIT
14
+ for name in session-egress.py catalyst-session-egress.service catalyst-session-egress-attest.service catalyst-session-egress-attest.timer; do
15
+ cp "$src/$name" "$work/$name"
16
+ done
17
+ (cd "$work" && sha256sum -c --quiet -) <<'SUMS'
18
+ a20cedfa1e883127ec461e4b7f9588d379fe1633b8244ecd703c81f589d3f142 session-egress.py
19
+ 703c150ab23ec87aea47cfc394f63e061b66c4b8c1454a88a9be879c161ce2a5 catalyst-session-egress.service
20
+ 94fa75ea0616f6a1172fc20ca637a5727c36456357e4a977d5bdd65f2fad2e8d catalyst-session-egress-attest.service
21
+ 176622c11c835601b95f36e375ba3c2e993136976c67e5d49559f13a3454fef9 catalyst-session-egress-attest.timer
22
+ SUMS
23
+ install -D -o root -g root -m 755 "$work/session-egress.py" /usr/lib/catalyst/session-egress.py
24
+ for unit in catalyst-session-egress.service catalyst-session-egress-attest.service catalyst-session-egress-attest.timer; do
25
+ install -o root -g root -m 644 "$work/$unit" "/etc/systemd/system/$unit"
26
+ done
27
+ systemctl daemon-reload
28
+ systemctl enable --now catalyst-session-egress.service catalyst-session-egress-attest.timer
29
+ /usr/bin/python3 /usr/lib/catalyst/session-egress.py attest
30
+ echo "Session egress is installed. Run the same catalyst setup command again."