@catalyst-cloud/cli 0.16.23 → 0.16.25

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.
@@ -0,0 +1,274 @@
1
+ // env-import.ts — `catalyst env import` and `catalyst env draft --write --import-values`: store this
2
+ // computer's values for the names a reviewed `.catalyst/catalyst.toml` declares. The only `env` code
3
+ // that signs in; `env.ts` stays offline and delegates here.
4
+ //
5
+ // ⛔ A VALUE NEVER REACHES STDOUT, STDERR, THE JSON OR AN ERROR MESSAGE. Values are resolved in
6
+ // memory (`env/machine-values.ts`), sent in request bodies, and dropped. Every printed line is built
7
+ // from names, counts and the cloud's own name-only answers.
8
+ //
9
+ // The file is the allowlist: only declared names are sent, and its `secret` flag picks the route.
10
+ // Secrets go in one `POST /me/secrets/import` and plain values one `POST /me/env-vars` each, the same
11
+ // routes and bodies `catalyst secret import` and `catalyst var import` use. Secrets already stored are
12
+ // kept (the import route's `name_exists`); plain variables are replaced, as `var import` does.
13
+ import { readFileSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { flagBool, flagString, positionals } from "./args.js";
16
+ import { loadConfig } from "./config.js";
17
+ import { loadContract } from "./contract.js";
18
+ import { UsageError } from "./errors.js";
19
+ import { repositoryNames } from "./env/draft.js";
20
+ import { resolveMachineValues } from "./env/machine-values.js";
21
+ import { readDeclaredVariables, validateSettingsToml } from "./env/settings-toml.js";
22
+ import { readLocalRepositoryName } from "./onboard-settings-context.js";
23
+ import { confirmYesNo, stdinIsTty } from "./prompt.js";
24
+ import { ACCEPT, IMPORT_REASONS, refusalLine, SECRETS_IMPORT_PATH } from "./secret.js";
25
+ import { apiClient } from "./transport.js";
26
+ import { ENV_VARS_PATH } from "./var.js";
27
+ const DESTINATION = ".catalyst/catalyst.toml";
28
+ const REPO_RE = /^[^/\s]+\/[^/\s]+$/;
29
+ const SOURCE_ORDER = [".envrc", "direnv environment", "~/.claude.json", "shell environment"];
30
+ /** Shapes a credential has even when its name does not say so. Checked for plain-declared values only. */
31
+ const CREDENTIAL_RES = [
32
+ /^gh[pousr]_[A-Za-z0-9]{16,}/,
33
+ /^github_pat_/,
34
+ /^glpat-/,
35
+ /^sk-[A-Za-z0-9_-]{8,}/,
36
+ /^sk_(?:live|test)_/,
37
+ /^xox[a-z]-/,
38
+ /^(?:AKIA|ASIA)[A-Z0-9]{12,}/,
39
+ /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/,
40
+ /-----BEGIN/,
41
+ /^[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/i,
42
+ ];
43
+ function emptyReport(state, repo, refusal) {
44
+ return {
45
+ state,
46
+ repo,
47
+ from: [],
48
+ stored: { secrets: [], variables: [] },
49
+ updated: [],
50
+ kept: [],
51
+ noValue: [],
52
+ notStored: [],
53
+ ...(refusal === undefined ? {} : { refusal }),
54
+ };
55
+ }
56
+ /** A secret value the dotenv import line carries unchanged under every parser that reads it. */
57
+ export function lineSafe(value) {
58
+ return !/[\r\n]/.test(value) && value === value.trim() && !/^["']/.test(value) && !/\s#/.test(value);
59
+ }
60
+ export function looksLikeCredential(value) {
61
+ return CREDENTIAL_RES.some((re) => re.test(value));
62
+ }
63
+ async function resolveRepo(root, flag, deps) {
64
+ if (flag !== undefined) {
65
+ if (!REPO_RE.test(flag))
66
+ throw new UsageError(`--repo must be owner/name (got "${flag}")`);
67
+ return flag;
68
+ }
69
+ return (deps.repoName ?? readLocalRepositoryName)(root);
70
+ }
71
+ function sourceText(from) {
72
+ const words = from.map((source) => (source === "direnv environment" && from.includes(".envrc") ? "its direnv environment" : source));
73
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`;
74
+ }
75
+ /** One line per name that has a value: the name, secret or plain, and where the value came from. No value. */
76
+ export function valueLines(withValue, values) {
77
+ return withValue.map(({ name, secret }) => ` ${name} (${secret ? "secret" : "plain"}) from ${values.get(name)?.from ?? "?"}`);
78
+ }
79
+ /** Resolve, ask, send, report. Human lines go to stdout unless `json`; the caller prints the JSON. */
80
+ async function importValues(input) {
81
+ const { root, repo, cfg, ctx, deps, declared } = input;
82
+ const say = (line) => { if (!input.json)
83
+ ctx.stdout(line); };
84
+ const machine = { home: ctx.home, env: ctx.env, ...deps.machine };
85
+ // The same repository names the draft saw, so a header variable resolves only where it was drafted.
86
+ const repository = repositoryNames(root);
87
+ const resolved = resolveMachineValues(root, machine, declared, repository.state === "valid" ? repository.names : new Set());
88
+ const fromSet = new Set();
89
+ for (const { from } of resolved.values.values()) {
90
+ if (from === "direnv environment")
91
+ fromSet.add(".envrc");
92
+ fromSet.add(from);
93
+ }
94
+ const from = SOURCE_ORDER.filter((source) => fromSet.has(source));
95
+ const report = { ...emptyReport("stored", repo), from, noValue: resolved.noValue };
96
+ const secretFlag = new Map(declared.map(({ name, secret }) => [name, secret]));
97
+ const withValue = declared.filter(({ name }) => resolved.values.has(name));
98
+ const secretCount = withValue.filter(({ secret }) => secret).length;
99
+ say(`This computer has values for ${withValue.length} of the ${declared.length} declared names${withValue.length > 0 ? ` (${sourceText(from)})` : ""}.`);
100
+ if (withValue.length === 0) {
101
+ renderNoValue(say, report, repo, secretFlag);
102
+ return { code: 0, report };
103
+ }
104
+ for (const line of valueLines(withValue, resolved.values))
105
+ say(line);
106
+ if (input.confirm === "ask") {
107
+ if (input.json || !(deps.isTty ?? stdinIsTty)()) {
108
+ say("env import stores values in Catalyst; pass --yes to confirm without a terminal. Nothing was stored.");
109
+ return { code: 1, report: { ...report, state: "confirmation_required" } };
110
+ }
111
+ const question = `Store them for ${repo} now? ${secretCount} as secrets, ${withValue.length - secretCount} as plain variables. Values go to Catalyst and are never printed. Secrets already stored are kept; plain variables already stored are replaced. [y/N] `;
112
+ if (!(await (deps.confirm ?? confirmYesNo)(question))) {
113
+ say("Nothing was stored. Next: catalyst env import stores this computer's values for these names. It signs in and asks first.");
114
+ return { code: 0, report: { ...emptyReport("refused", repo, "declined"), from, noValue: resolved.noValue } };
115
+ }
116
+ }
117
+ const repositories = await (deps.repositories ?? (async () => (await loadContract(ctx, cfg)).doc.merge.repositories))();
118
+ const [owner, name] = repo.toLowerCase().split("/");
119
+ const entry = repositories.find((r) => r.owner.toLowerCase() === owner && r.name.toLowerCase() === name);
120
+ if (entry === undefined) {
121
+ const refusal = `${repo} is not a repository of this workspace; nothing was stored. Register it first with catalyst onboard --team <KEY> --repo ${repo}`;
122
+ say(refusal);
123
+ return { code: 1, report: { ...report, state: "refused", refusal } };
124
+ }
125
+ const secretLines = [];
126
+ const plain = [];
127
+ for (const { name: declaredName, secret } of withValue) {
128
+ const found = resolved.values.get(declaredName);
129
+ if (found === undefined)
130
+ continue;
131
+ const value = found.value;
132
+ if (secret) {
133
+ if (lineSafe(value))
134
+ secretLines.push(`${declaredName}=${value}`);
135
+ else
136
+ report.notStored.push({ name: declaredName, reason: `its value has a line break, spaces at either end, a leading quote or " #", which an import line cannot carry; store it with: catalyst secret set ${declaredName} --repo ${repo}` });
137
+ }
138
+ else if (looksLikeCredential(value)) {
139
+ report.notStored.push({ name: declaredName, reason: "its value looks like a credential, but the file declares it plain; set secret = true for it and run catalyst env import again" });
140
+ }
141
+ else
142
+ plain.push({ name: declaredName, value });
143
+ }
144
+ const client = deps.client ?? apiClient(cfg, ctx);
145
+ if (secretLines.length > 0) {
146
+ const res = await client.postJson(SECRETS_IMPORT_PATH, { repo, text: `${secretLines.join("\n")}\n`, rotateExisting: [], source: `env import: ${from.join(", ")}` }, { accept: ACCEPT });
147
+ if (res.status !== 200) {
148
+ const refusal = refusalLine(res.status, res.body, repo);
149
+ say(refusal);
150
+ return { code: 1, report: { ...report, state: "refused", refusal } };
151
+ }
152
+ report.stored.secrets.push(...(res.body.created ?? []), ...(res.body.rotated ?? []));
153
+ for (const error of res.body.errors ?? []) {
154
+ if (error.reason === "name_exists")
155
+ report.kept.push(error.name);
156
+ else
157
+ report.notStored.push({ name: error.name, reason: IMPORT_REASONS[error.reason] ?? error.reason });
158
+ }
159
+ }
160
+ for (const [index, item] of plain.entries()) {
161
+ let reply;
162
+ try {
163
+ reply = await client.postJson(ENV_VARS_PATH, { scope: "repo", repoId: entry.repoId, name: item.name, value: item.value }, { accept: ACCEPT });
164
+ }
165
+ catch {
166
+ report.unknown = plain.slice(index).map(({ name: unknownName }) => unknownName);
167
+ break;
168
+ }
169
+ if (reply.status !== 200 && reply.status !== 201) {
170
+ report.notStored.push({ name: item.name, reason: reply.body.message ?? reply.body.error ?? `HTTP ${reply.status}` });
171
+ continue;
172
+ }
173
+ report.stored.variables.push(item.name);
174
+ if (!reply.body.created)
175
+ report.updated.push(item.name);
176
+ }
177
+ const storedCount = report.stored.secrets.length + report.stored.variables.length;
178
+ say(storedCount === 0
179
+ ? `Stored nothing for ${repo}.`
180
+ : `Stored ${storedCount} values for ${repo}: ${report.stored.secrets.length} secrets, ${report.stored.variables.length} plain variables.`);
181
+ if (report.updated.length > 0)
182
+ say(` updated (were already stored): ${report.updated.join(", ")}`);
183
+ if (report.kept.length > 0)
184
+ say(`Kept ${report.kept.length} secrets already stored for ${repo}: ${report.kept.join(", ")} (replace one with: catalyst secret set NAME --repo ${repo})`);
185
+ for (const item of report.notStored)
186
+ say(`Not stored: ${item.name} (${item.reason})`);
187
+ if (report.unknown !== undefined)
188
+ say(`Stopped: the write did not answer, so these are not confirmed: ${report.unknown.join(", ")}. Run catalyst env import again.`);
189
+ renderNoValue(say, report, repo, secretFlag);
190
+ return { code: report.notStored.length > 0 || report.unknown !== undefined ? 1 : 0, report };
191
+ }
192
+ function renderNoValue(say, report, repo, secretFlag) {
193
+ if (report.noValue.length === 0) {
194
+ say("Every declared name had a value on this computer.");
195
+ return;
196
+ }
197
+ const count = report.noValue.length;
198
+ say(`No value on this computer for ${count} declared ${count === 1 ? "name" : "names"} (looked in .envrc, its direnv environment and ~/.claude.json):`);
199
+ for (const { name, computed } of report.noValue) {
200
+ const store = secretFlag.get(name) ? `catalyst secret set ${name} --repo ${repo}` : `catalyst var set ${name} --repo ${repo}`;
201
+ say(computed
202
+ ? ` ${name}: .envrc computes it. Run catalyst env import in a shell where direnv has loaded this repository, or store it with: ${store}`
203
+ : ` ${name}: store it with: ${store}`);
204
+ }
205
+ }
206
+ /** `catalyst env import`: store this computer's values for the names the reviewed file declares. */
207
+ export async function cmdEnvImport(args, ctx, deps = {}) {
208
+ const rest = positionals(args).slice(1);
209
+ if (rest.length > 0)
210
+ throw new UsageError("env import takes no positional arguments; use --root DIR");
211
+ const json = Boolean(args.json);
212
+ const refuse = (refusal, state = "refused", repo = null) => {
213
+ if (json)
214
+ ctx.stdout(JSON.stringify(emptyReport(state, repo, refusal)));
215
+ else
216
+ ctx.stderr(refusal);
217
+ return 1;
218
+ };
219
+ const root = resolve(flagString(args, "root") ?? ".");
220
+ const cfg = loadConfig(ctx.home);
221
+ if (cfg === null)
222
+ return refuse("env import stores values in Catalyst; sign in with catalyst login first. Nothing was stored.");
223
+ const repo = await resolveRepo(root, flagString(args, "repo"), deps);
224
+ if (repo === null)
225
+ throw new UsageError("env import needs --repo owner/name: this checkout has no GitHub origin to read it from");
226
+ let text;
227
+ try {
228
+ text = readFileSync(join(root, DESTINATION), "utf8");
229
+ }
230
+ catch {
231
+ return refuse(`no ${DESTINATION} found in this repository; draft one with catalyst env draft --write`, "refused", repo);
232
+ }
233
+ const declared = readDeclaredVariables(text);
234
+ if (declared === null) {
235
+ const checked = validateSettingsToml(text);
236
+ const errors = checked.state === "invalid" ? checked.errors : [`no [environment] table in ${DESTINATION}`];
237
+ return refuse(errors.map((error) => `${DESTINATION}: ${error}`).join("\n"), "refused", repo);
238
+ }
239
+ if (!json)
240
+ ctx.stdout(`Reading ${DESTINATION}: ${declared.length} declared names (${declared.filter(({ secret }) => secret).length} secret).`);
241
+ const result = await importValues({ root, repo, cfg, ctx, deps, declared, confirm: flagBool(args, "yes") ? "yes" : "ask", json });
242
+ if (json)
243
+ ctx.stdout(JSON.stringify(result.report));
244
+ return result.code;
245
+ }
246
+ /** After `env draft --write`: store the values now (`yes`), or ask first in a terminal (`ask`). */
247
+ export async function offerMachineImport(input) {
248
+ const { root, args, ctx, mode } = input;
249
+ const deps = input.deps ?? {};
250
+ const json = Boolean(args.json);
251
+ const failCode = mode === "yes" ? 1 : 0;
252
+ const declared = readDeclaredVariables(input.toml) ?? [];
253
+ if (declared.length === 0)
254
+ return { code: 0 };
255
+ const say = (line) => { if (json)
256
+ ctx.stderr(line);
257
+ else
258
+ ctx.stdout(line); };
259
+ const cfg = loadConfig(ctx.home);
260
+ if (cfg === null) {
261
+ const refusal = "To store this computer's values for these names, sign in with catalyst login, then run: catalyst env import";
262
+ say(refusal);
263
+ return { code: failCode, report: emptyReport("refused", null, refusal) };
264
+ }
265
+ const repo = await resolveRepo(root, flagString(args, "repo"), deps);
266
+ if (repo === null) {
267
+ const refusal = "To store this computer's values, run: catalyst env import --repo owner/name (this checkout has no GitHub origin to read the repository from)";
268
+ say(refusal);
269
+ return { code: failCode, report: emptyReport("refused", null, refusal) };
270
+ }
271
+ if (!json)
272
+ ctx.stdout("");
273
+ return importValues({ root, repo, cfg, ctx, deps, declared, confirm: mode, json });
274
+ }
package/dist/env.js CHANGED
@@ -4,9 +4,13 @@
4
4
  // point of the feature: a person reviews what a repository declares without connecting anything first.
5
5
  // `env shell` (env/shell.ts) talks to Docker, and asks the cloud only when a credential-free download
6
6
  // of the image is refused; Catalyst's own image needs no login.
7
+ //
8
+ // The two exceptions store values and so sign in: `env import` and `env draft --write`'s offer to
9
+ // store this computer's values. Both are delegated to `env-import.ts`; this module itself stays offline.
7
10
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
8
11
  import { dirname, join, resolve } from "node:path";
9
12
  import { flagBool, flagString, positionals } from "./args.js";
13
+ import { cmdEnvImport, offerMachineImport } from "./env-import.js";
10
14
  import { validateSettingsToml } from "./env/settings-toml.js";
11
15
  import { convertLegacyEnvironmentJson } from "./env/legacy.js";
12
16
  import { draftEnvironment } from "./env/draft.js";
@@ -14,18 +18,27 @@ import { inventoryRepo } from "./env/inventory.js";
14
18
  import { inventoryToJson, renderInventory } from "./env/render.js";
15
19
  import { runEnvShell } from "./env/shell.js";
16
20
  import { UsageError } from "./errors.js";
21
+ import { stdinIsTty } from "./prompt.js";
22
+ import { validateFullSettings } from "../vendor/settings/index.js";
23
+ const NEXT_IMPORT_LINE = "Next: catalyst env import stores this computer's values for these names. It signs in and asks first.";
17
24
  export async function cmdEnv(args, ctx, deps = {}) {
18
25
  const [sub, ...rest] = positionals(args);
19
26
  if (sub === undefined)
20
- throw new UsageError("env needs a subcommand: inventory | check | migrate | draft | shell");
27
+ throw new UsageError("env needs a subcommand: inventory | check | migrate | draft | import | shell");
28
+ if (sub === "import")
29
+ return cmdEnvImport(args, ctx, deps.import ?? {});
21
30
  if (sub === "shell")
22
31
  return await runEnvShell(args, ctx, deps.shell ?? {});
23
32
  if (sub === "draft") {
24
33
  if (rest.length > 0)
25
34
  throw new UsageError("env draft takes no positional arguments; use --root DIR");
35
+ const importValues = flagBool(args, "import-values");
36
+ if (importValues && !flagBool(args, "write")) {
37
+ throw new UsageError("--import-values needs --write; for an existing .catalyst/catalyst.toml run: catalyst env import");
38
+ }
26
39
  const root = resolve(flagString(args, "root") ?? ".");
27
40
  const target = join(root, ".catalyst/catalyst.toml");
28
- const draft = draftEnvironment(root, deps);
41
+ const draft = draftEnvironment(root, deps, { machine: { home: ctx.home, env: ctx.env } });
29
42
  if (draft.state === "invalid") {
30
43
  if (args.json)
31
44
  ctx.stdout(JSON.stringify(draft));
@@ -43,10 +56,13 @@ export async function cmdEnv(args, ctx, deps = {}) {
43
56
  sources: draft.sources,
44
57
  destination: ".catalyst/catalyst.toml",
45
58
  toml: draft.toml,
59
+ mcpServers: draft.mcpServers.map(({ name, configName, scope, url, headers }) => ({ name, configName, scope, url, headers })),
60
+ machine: machineJson(draft.machine),
46
61
  };
47
62
  const write = flagBool(args, "write");
48
63
  if (write && existsSync(target)) {
49
64
  ctx.stderr(`${summary.destination} already exists; no file was written. Review the draft with --diff and merge it by hand.`);
65
+ ctx.stderr("To store this computer's values for the names it declares, run: catalyst env import");
50
66
  return 1;
51
67
  }
52
68
  if (write) {
@@ -55,6 +71,10 @@ export async function cmdEnv(args, ctx, deps = {}) {
55
71
  ctx.stderr(`draft failed local validation: ${checked.state === "invalid" ? checked.errors.join("; ") : "no environment table"}`);
56
72
  return 1;
57
73
  }
74
+ if (!validateFullSettings(`[project]\nlinear_team = "DRAFT"\n\n${draft.toml}`).ok) {
75
+ ctx.stderr("draft failed Catalyst's settings check; no file was written");
76
+ return 1;
77
+ }
58
78
  if (deps.writeFile)
59
79
  deps.writeFile(target, draft.toml);
60
80
  else {
@@ -62,20 +82,27 @@ export async function cmdEnv(args, ctx, deps = {}) {
62
82
  writeFileSync(target, draft.toml, { encoding: "utf8", flag: "wx" });
63
83
  }
64
84
  }
65
- if (args.json)
66
- ctx.stdout(JSON.stringify({ ...summary, written: write }));
67
- else if (flagBool(args, "diff")) {
85
+ if (args.json) {
86
+ // The import's answer joins the one JSON object, so it runs first; its lines go to stderr.
87
+ const offered = write ? await afterWrite(root, draft, args, ctx, deps, importValues) : { code: 0 };
88
+ ctx.stdout(JSON.stringify({ ...summary, written: write, ...(offered.report === undefined ? {} : { import: offered.report }) }));
89
+ return offered.code;
90
+ }
91
+ if (flagBool(args, "diff")) {
68
92
  ctx.stdout(`--- /dev/null\n+++ b/${summary.destination} (draft)\n@@\n${draft.toml.split("\n").filter(Boolean).map((line) => `+${line}`).join("\n")}`);
69
93
  }
70
94
  else {
71
95
  ctx.stdout(`Drafted ${summary.destination} from ${draft.sources.length ? draft.sources.join(", ") : "no environment sources"}.`);
72
96
  ctx.stdout(`Names: ${draft.names.length} (${draft.secretNames.length} secret; all optional)`);
97
+ for (const line of machineLines(draft.machine, draft.mcpServers))
98
+ ctx.stdout(line);
73
99
  ctx.stdout(`Setup: ${draft.setup.length}; verify: ${draft.verify.length}`);
74
100
  ctx.stdout(draft.toml);
75
101
  }
76
- if (write && !args.json)
77
- ctx.stdout(`Wrote ${summary.destination}; review and commit it through your repository's pull request.`);
78
- return 0;
102
+ if (!write)
103
+ return 0;
104
+ ctx.stdout(`Wrote ${summary.destination}; review and commit it through your repository's pull request.`);
105
+ return (await afterWrite(root, draft, args, ctx, deps, importValues)).code;
79
106
  }
80
107
  if (sub === "migrate") {
81
108
  if (rest.length > 1)
@@ -177,5 +204,70 @@ export async function cmdEnv(args, ctx, deps = {}) {
177
204
  ctx.stderr(`${file}: ${error}`);
178
205
  return result.state === "invalid" ? 1 : 0;
179
206
  }
180
- throw new UsageError(`unknown env subcommand "${sub}": inventory | check | migrate | draft | shell`);
207
+ throw new UsageError(`unknown env subcommand "${sub}": inventory | check | migrate | draft | import | shell`);
208
+ }
209
+ /** After a successful write: store now (`--import-values`), ask in a terminal, or name the next command. */
210
+ async function afterWrite(root, draft, args, ctx, deps, importValues) {
211
+ if (draft.names.length === 0)
212
+ return { code: 0 };
213
+ const toml = draft.toml;
214
+ const importDeps = deps.import ?? {};
215
+ if (importValues)
216
+ return offerMachineImport({ root, toml, args, ctx, deps: importDeps, mode: "yes" });
217
+ if (!args.json && (importDeps.isTty ?? stdinIsTty)())
218
+ return offerMachineImport({ root, toml, args, ctx, deps: importDeps, mode: "ask" });
219
+ if (args.json)
220
+ ctx.stderr(NEXT_IMPORT_LINE);
221
+ else
222
+ ctx.stdout(NEXT_IMPORT_LINE);
223
+ return { code: 0 };
224
+ }
225
+ function machineJson(machine) {
226
+ if (machine === null)
227
+ return null;
228
+ return {
229
+ envrc: machine.envrc === null ? null : {
230
+ exported: machine.envrc.exported.map(({ name }) => name),
231
+ notRead: machine.envrc.notRead,
232
+ skippedNames: machine.envrc.skippedNames,
233
+ },
234
+ claudeJson: machine.claude === null ? null : {
235
+ servers: machine.claude.servers.map(({ name }) => name),
236
+ notDrafted: machine.claude.notDrafted,
237
+ ...(machine.claude.note === undefined ? {} : { note: machine.claude.note }),
238
+ },
239
+ notes: machine.notes,
240
+ };
241
+ }
242
+ /** What came from this computer and what was left out, names and reasons only. */
243
+ function machineLines(machine, servers) {
244
+ if (machine === null || (machine.envrc === null && machine.claude === null && machine.notes.length === 0))
245
+ return [];
246
+ const lines = ["From this computer:"];
247
+ if (machine.envrc !== null) {
248
+ const notRead = machine.envrc.notRead.length === 0
249
+ ? ""
250
+ : ` Not read: ${machine.envrc.notRead.map(({ line, reason }) => `line ${line} ${reason}`).join(", ")}; names those lines load are not drafted.`;
251
+ lines.push(` .envrc: ${machine.envrc.exported.length} exported names.${notRead}`);
252
+ if (machine.envrc.skippedNames.length > 0)
253
+ lines.push(` Not drafted (settings of this computer): ${machine.envrc.skippedNames.join(", ")}`);
254
+ }
255
+ if (machine.claude !== null) {
256
+ if (machine.claude.note !== undefined)
257
+ lines.push(` ${machine.claude.note}`);
258
+ else {
259
+ const names = servers.map(({ name, configName }) => (name === configName ? name : `${name} (from ${JSON.stringify(configName)})`));
260
+ lines.push(` ~/.claude.json: ${servers.length} HTTP MCP servers${names.length ? ` (${names.join(", ")})` : ""}. Each header names a variable; no header value is copied.`);
261
+ }
262
+ for (const server of servers) {
263
+ for (const { variable, header } of server.derived) {
264
+ lines.push(` ${server.name}: its ${header} header held a value; drafted as $${variable}, which env import reads from ~/.claude.json`);
265
+ }
266
+ }
267
+ for (const { name, reason } of machine.claude.notDrafted)
268
+ lines.push(` Not drafted: ${name} (${reason})`);
269
+ }
270
+ for (const note of machine.notes)
271
+ lines.push(` ${note}`);
272
+ return lines;
181
273
  }
package/dist/hosts.js CHANGED
@@ -1,23 +1,11 @@
1
- import { createInterface } from "node:readline/promises";
2
1
  import { positionals } from "./args.js";
3
2
  import { requireConfig } from "./config.js";
4
3
  import { CliError, UsageError } from "./errors.js";
5
4
  import { apiClient } from "./transport.js";
6
5
  import { isMachineInventory, isMachineMutation, } from "./machine-inventory.js";
7
- import { stdinIsTty } from "./prompt.js";
6
+ import { confirmYesNo } from "./prompt.js";
8
7
  async function confirmRemove(name) {
9
- if (!stdinIsTty())
10
- return false;
11
- const prompt = createInterface({
12
- input: process.stdin,
13
- output: process.stderr,
14
- });
15
- try {
16
- return /^(y|yes)$/i.test((await prompt.question(`Remove ${name} from this account? It will stop receiving work. [y/N] `)).trim());
17
- }
18
- finally {
19
- prompt.close();
20
- }
8
+ return confirmYesNo(`Remove ${name} from this account? It will stop receiving work. [y/N] `);
21
9
  }
22
10
  export function machineLines(inventory) {
23
11
  const lines = inventory.machines.map((m) => `${m.name} | ${m.ownership === "self_hosted" ? "Self-hosted" : "Catalyst's"} | ${m.teams.join(", ")} | ${m.slots} slots, ${m.inUse} in use, ${m.free} free | ${m.status} | last check-in ${m.lastCheckInAtMs === null ? "never" : new Date(m.lastCheckInAtMs).toISOString()}`);
@@ -0,0 +1,111 @@
1
+ import { normalizeBaseUrl, requireConfig } from "./config.js";
2
+ import { loadContract } from "./contract.js";
3
+ import { CliError, MeError } from "./errors.js";
4
+ import { needsNewerCloud } from "./execution.js";
5
+ import { bearerFor } from "./oauth.js";
6
+ import { ApiClient } from "./transport.js";
7
+ export const CALLS_WINDOW = 50;
8
+ function portalBase(doc, cfg) {
9
+ const raw = doc.portal?.url;
10
+ if (doc.portal && !raw) {
11
+ // A current cloud that names why it publishes no portal URL: refuse with that reason, not "upgrade".
12
+ throw new CliError(`mcp status cannot reach this workspace's MCP portal: the cloud publishes no portal.url (${doc.portal.unavailable ?? "no reason given"}). Ask your operator to configure the portal host.`, "portal-unavailable");
13
+ }
14
+ if (!raw) {
15
+ throw new CliError(`mcp status needs a newer Catalyst Cloud than ${normalizeBaseUrl(cfg.baseUrl)} is running: its contract names no MCP portal (portal.url). Nothing is wrong with your workspace.`, "route-not-deployed", 3);
16
+ }
17
+ let url;
18
+ try {
19
+ url = new URL(raw);
20
+ }
21
+ catch {
22
+ throw new CliError(`the contract's portal.url is not a URL: ${raw}`, "contract-shape");
23
+ }
24
+ const loginOrigin = new URL(normalizeBaseUrl(cfg.baseUrl)).origin;
25
+ if (url.origin !== loginOrigin) {
26
+ throw new CliError(`the contract's portal.url (${url.origin}) is not on the cloud you logged in to (${loginOrigin}); not sending your key there`, "portal-origin");
27
+ }
28
+ return { origin: url.origin, path: url.pathname.replace(/\/+$/, "") };
29
+ }
30
+ async function portalGet(api, path, what, cfg, query) {
31
+ const res = await api.getJson(path, { query, accept: [403, 404], parseAccepted: true });
32
+ if (res.status === 404)
33
+ throw needsNewerCloud(what, cfg);
34
+ if (res.status === 403) {
35
+ const body = res.body;
36
+ const reason = typeof body === "object" && body !== null
37
+ ? ("reason" in body && typeof body.reason === "string" ? body.reason
38
+ : "error" in body && typeof body.error === "string" ? body.error : "forbidden")
39
+ : "forbidden";
40
+ throw new CliError(`GET ${path} failed (403): the MCP portal refused this key (${reason}). It admits an active member's personal key, or an account key scoped portal:access.`, "forbidden", 2, 403);
41
+ }
42
+ return res.body;
43
+ }
44
+ function isStatus(body) {
45
+ return typeof body === "object" && body !== null && "server" in body && typeof body.server === "string"
46
+ && "state" in body && typeof body.state === "string";
47
+ }
48
+ function isCalls(body) {
49
+ return typeof body === "object" && body !== null && "server" in body && typeof body.server === "string"
50
+ && "calls" in body && Array.isArray(body.calls);
51
+ }
52
+ /** Numeric display fields are checked at runtime because older or malformed responses may omit them. */
53
+ export function renderServerStatus(status, calls) {
54
+ const lines = [`${status.server} — ${status.state}`];
55
+ if (status.detail)
56
+ lines.push(` ${status.detail}`);
57
+ lines.push(`auth: ${status.auth ?? "not reported (no registration)"}`);
58
+ if (status.credentials?.length)
59
+ lines.push(`credentials: ${status.credentials.join(", ")}`);
60
+ const tools = status.tools;
61
+ lines.push(tools
62
+ ? `tools: ${tools.offered} offered, ${tools.catalog} in catalog${tools.missing_upstream?.length ? ` (missing upstream: ${tools.missing_upstream.join(", ")})` : ""}`
63
+ : `tools: not reported (${status.state})`);
64
+ const grants = status.grants;
65
+ lines.push(grants
66
+ ? `grants for this key: ${grants.granted} of ${grants.cataloged}${grants.missing?.length ? ` (missing: ${grants.missing.join(", ")})` : ""}`
67
+ : "grants for this key: not reported (no registration)");
68
+ if (calls.length === 0)
69
+ return [...lines, `No tool calls recorded for ${status.server}.`];
70
+ lines.push(`last ${calls.length} call${calls.length === 1 ? "" : "s"} (newest first, up to ${CALLS_WINDOW}):`);
71
+ const rows = calls.map((call) => {
72
+ const date = new Date(typeof call.at === "number" ? call.at : NaN);
73
+ return [
74
+ Number.isFinite(date.getTime()) ? date.toISOString() : "?",
75
+ call.outcome,
76
+ call.tool_name,
77
+ `${typeof call.latency_ms === "number" && Number.isFinite(call.latency_ms) ? call.latency_ms : "?"}ms`,
78
+ call.dispatch ? `${call.dispatch.ticket} ${call.dispatch.phase}` : "-",
79
+ call.outcome === "ok" ? "" : [call.denied_reason, call.explanation].filter((part) => typeof part === "string" && part.length > 0).join(": "),
80
+ ];
81
+ });
82
+ const widths = Array.from({ length: 5 }, (_, column) => Math.max(...rows.map((row) => (row[column] ?? "").length)));
83
+ for (const row of rows) {
84
+ lines.push(` ${row.map((value, column) => column === 3 ? value.padStart(widths[column] ?? 0) : value.padEnd(widths[column] ?? 0)).join(" ")}`.trimEnd());
85
+ }
86
+ return lines;
87
+ }
88
+ export async function cmdMcpStatus(args, ctx, name) {
89
+ const cfg = requireConfig(ctx);
90
+ let loaded = await loadContract(ctx, cfg);
91
+ // A fresh cache may predate portal discovery. Revalidate once before refusing it.
92
+ if (loaded.source === "cache" && !loaded.doc.portal?.url)
93
+ loaded = await loadContract(ctx, cfg, { refresh: true });
94
+ const base = portalBase(loaded.doc, cfg);
95
+ const api = new ApiClient(base.origin, () => bearerFor(ctx, cfg), ctx.fetch);
96
+ const server = `${base.path}/servers/${encodeURIComponent(name)}`;
97
+ const [status, calls] = await Promise.all([
98
+ portalGet(api, `${server}/status`, `MCP server status for ${name}`, cfg),
99
+ portalGet(api, `${server}/calls`, `recent tool calls for ${name}`, cfg, { limit: CALLS_WINDOW }),
100
+ ]);
101
+ if (!isStatus(status))
102
+ throw new MeError(`GET ${server}/status returned an unexpected shape`, "shape");
103
+ if (!isCalls(calls))
104
+ throw new MeError(`GET ${server}/calls returned an unexpected shape`, "shape");
105
+ if (args.json)
106
+ ctx.stdout(JSON.stringify({ status, calls }));
107
+ else
108
+ for (const line of renderServerStatus(status, calls.calls))
109
+ ctx.stdout(line);
110
+ return 0;
111
+ }
package/dist/mcp.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { flagList, flagString, positionals } from "./args.js";
2
2
  import { requireConfig } from "./config.js";
3
3
  import { CliError, UsageError } from "./errors.js";
4
+ import { cmdMcpStatus } from "./mcp-status.js";
4
5
  import { bearerFor } from "./oauth.js";
5
6
  import { loadTenantSdk } from "./sdk.js";
6
7
  const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;
@@ -66,8 +67,8 @@ function describeServer(server) {
66
67
  }
67
68
  export async function cmdMcp(args, ctx) {
68
69
  const [verb, ...rest] = positionals(args);
69
- if (verb !== "add" && verb !== "list" && verb !== "remove")
70
- throw new UsageError("mcp needs add, list or remove");
70
+ if (verb !== "add" && verb !== "list" && verb !== "remove" && verb !== "status")
71
+ throw new UsageError("mcp needs add, list, remove or status");
71
72
  if (rest.length !== (verb === "list" ? 0 : 1))
72
73
  throw new UsageError(`mcp ${verb}${verb === "list" ? "" : " <name>"}`);
73
74
  const name = rest[0] ?? "";
@@ -76,6 +77,8 @@ export async function cmdMcp(args, ctx) {
76
77
  if (verb !== "add" && ["url", "auth", "bearer", "header"].some((key) => key in args.flags)) {
77
78
  throw new UsageError("URL and auth options are only accepted by mcp add");
78
79
  }
80
+ if (verb === "status")
81
+ return await cmdMcpStatus(args, ctx, name);
79
82
  const input = verb === "add" ? registration(args, name) : null;
80
83
  const cfg = requireConfig(ctx);
81
84
  const key = await bearerFor(ctx, cfg);
@@ -130,7 +130,11 @@ const REASONS = {
130
130
  runner_thoughts_custody_unavailable: "Setup could not verify protected access to this Mac's shared work files. Keep their ownership and permissions. Check Docker access, then run catalyst onboard --runner. If the check still fails, ask Catalyst support to inspect it.",
131
131
  runner_installation_unverified: "Setup found a possible existing machine installation but could not verify its Compose files and identity. Keep its files and credentials. Run docker compose -p catalyst-host logs supervisor to check it, then run catalyst setup --runner again.",
132
132
  runner_enrollment_unavailable: "Setup could not read this workspace's runner hosts. Run catalyst onboard again to retry.",
133
- runner_enrolled_for_other_team: "This runner is enrolled for another Linear team. Keep using that team, or ask your administrator to revoke the old enrollment before setting up this machine for the selected team.",
133
+ runner_enrolled_for_other_team: "This runner is enrolled for another Linear team, and Catalyst could not add the selected team to it. Keep using that team, or ask your administrator to revoke the old enrollment before setting up this machine for the selected team.",
134
+ runner_team_not_added: "This runner does not serve the selected Linear team yet. Run catalyst setup --runner to add the team to it.",
135
+ runner_team_limit: "This runner already serves 16 Linear teams, the most one runner can serve. Set up another machine for the selected team.",
136
+ runner_team_add_failed: "Setup could not add the selected Linear team to this runner. Run catalyst setup --runner again to retry.",
137
+ runner_supervisor_outdated: "This runner's supervisor has not reported that it can serve several teams yet. Run docker compose -p catalyst-host logs supervisor to check it is running, then run catalyst setup --runner again.",
134
138
  runner_enrollment_revoked: "Catalyst confirms that this machine's saved enrollment was revoked. Keep its credential and organization key. Ask your workspace administrator to review the enrollment before replacing it, then run catalyst setup --runner.",
135
139
  runner_credential_unverified: "Setup could not read this machine's saved enrollment. Keep its credential and organization key. Run docker compose -p catalyst-host logs supervisor to check the error, then run catalyst setup --runner.",
136
140
  runner_enrollment_stale: "Setup could not match this machine's saved enrollment with Catalyst. Keep its credential and organization key, then run catalyst setup --runner to check again.",