@catalyst-cloud/cli 0.16.19 → 0.16.21

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.21
4
+
5
+ `catalyst accounts` now leaves out removed AI accounts: ones an admin revoked, or whose paid access ended. They take no work and take no new credential, so they no longer appear as accounts to repair. The list says how many it left out, and `catalyst accounts --all` shows them, marked "removed — kept for history". Setup now names a revoked account as ended instead of asking you to turn it on. Update the Catalyst plugin's skills along with the CLI: setup's account check uses `--all`, which older CLIs do not know.
6
+
7
+ ## 0.16.20
8
+
9
+ 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>`.
10
+
11
+ New: `catalyst env shell` opens your repository's declared environment on your own computer in Docker, with the declared toolchain versions on `PATH`.
12
+
3
13
  ## 0.16.19
4
14
 
5
15
  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
@@ -331,7 +331,9 @@ export const FLAG_TABLES = {
331
331
  help: "skip the published-release check (no network)",
332
332
  },
333
333
  },
334
- accounts: {},
334
+ accounts: {
335
+ all: { value: false, help: "also list removed accounts, which are kept for history and take no work" },
336
+ },
335
337
  seat: {
336
338
  size: { value: true, help: "model size: big or med" },
337
339
  stage: { value: true, help: "sized relay stage; defaults from size" },
@@ -342,7 +344,7 @@ export const FLAG_TABLES = {
342
344
  env: {
343
345
  root: {
344
346
  value: true,
345
- help: "draft: repository directory (default: current directory)",
347
+ help: "draft, shell: repository directory (default: the current repository's top level)",
346
348
  },
347
349
  write: {
348
350
  value: false,
@@ -352,6 +354,19 @@ export const FLAG_TABLES = {
352
354
  value: false,
353
355
  help: "draft: show the generated TOML as an additions-only diff",
354
356
  },
357
+ "env-file": {
358
+ value: true,
359
+ repeat: true,
360
+ help: "shell: read declared variables' values from this dotenv file (repeatable; your terminal's own values win)",
361
+ },
362
+ image: {
363
+ value: true,
364
+ help: "shell: open this digest-pinned image instead of Catalyst's (it must carry bash, and mise when toolchains are declared)",
365
+ },
366
+ check: {
367
+ value: false,
368
+ help: "shell: prepare the environment and report it, without opening a shell",
369
+ },
355
370
  },
356
371
  var: {
357
372
  repo: {
@@ -525,11 +540,11 @@ export const VERB_USAGE = {
525
540
  write: "write <comment <ticket> --body|--stdin [--parent] [--bookkeeping] [--as-user] | state <ticket> --slot|--state-id|--state-type | label <ticket> --add... --remove... | create --team --title [--description|--stdin] [--label] [--priority] | reaction <ticket>|--comment <id> --emoji <e> | attachment <ticket> --title --url | session <ticket> [--title] [--plan-file] [--activity]>",
526
541
  ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] [--gates-pr <n>... --released-by <letter>... [--gates-label hold|hold:preview]... [--gates-repo <owner/name>]] | accept <askTicket> --answer <commentId> --role <role> | list [--anyone] [--json]>",
527
542
  ready: "ready [--onboarding] [--local-sync] [--json] [--offline]",
528
- accounts: "accounts [--json]",
543
+ accounts: "accounts [--all] [--json]",
529
544
  seat: "seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE] [--key-env NAME]",
530
545
  mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>> [--json]",
531
546
  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)",
547
+ 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
548
  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
549
  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
550
  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
+ }