@catalyst-cloud/cli 0.13.1 → 0.14.1

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -2
  3. package/dist/args.js +291 -69
  4. package/dist/capabilities.js +2 -0
  5. package/dist/cli.js +109 -17
  6. package/dist/config.js +3 -1
  7. package/dist/env/draft.js +134 -0
  8. package/dist/env/legacy.js +54 -0
  9. package/dist/env/parse-env.js +50 -0
  10. package/dist/env/settings-toml.js +66 -0
  11. package/dist/env.js +126 -26
  12. package/dist/legacy.js +16 -17
  13. package/dist/onboard-consent.js +65 -0
  14. package/dist/onboard.js +836 -0
  15. package/dist/project.js +75 -13
  16. package/dist/ready.js +11 -1
  17. package/dist/secret.js +24 -3
  18. package/dist/var.js +118 -0
  19. package/package.json +3 -2
  20. package/skills/catalyst-github/SKILL.md +1 -1
  21. package/skills/catalyst-linear/SKILL.md +1 -1
  22. package/skills/catalyst-onboard/SKILL.md +1 -1
  23. package/skills/catalyst-onboard/references/declaring-a-repository.md +19 -12
  24. package/skills/catalyst-onboard/references/reading-ready.md +1 -1
  25. package/skills/catalyst-onboard/references/the-one-path.md +4 -4
  26. package/skills/catalyst-onboard/references/what-the-browser-owns.md +2 -2
  27. package/skills/catalyst-onboard/scripts/lib/project-coverage.mjs +53 -0
  28. package/skills/catalyst-onboard/scripts/where-am-i.mjs +885 -196
  29. package/skills/run-this-project/SKILL.md +1 -1
  30. package/skills/unstick/SKILL.md +1 -1
  31. package/skills/what-needs-me/SKILL.md +1 -1
  32. package/skills/what-this-repo-needs/SKILL.md +4 -4
  33. package/skills/what-this-repo-needs/scripts/check.mjs +12 -13
  34. package/skills/whats-happening/SKILL.md +19 -35
  35. package/skills/whats-happening/references/coding-accounts.md +6 -19
  36. package/skills/whats-happening/references/routing-work.md +4 -15
  37. package/skills/whats-happening/references/settings-and-where-they-live.md +15 -18
  38. package/skills/whats-happening/references/stages-and-mapping.md +12 -18
  39. package/skills/whats-happening/references/status-reply.md +11 -29
  40. package/skills/whats-happening/references/the-ladder.md +15 -25
  41. package/skills/whats-happening/references/what-runs-next.md +7 -26
  42. package/skills/whats-happening/references/when-a-phase-fails.md +16 -22
  43. package/skills/whats-happening/references/why-is-it-stuck.md +38 -48
  44. package/dist/env/declaration-rules.js +0 -163
package/dist/cli.js CHANGED
@@ -8,14 +8,14 @@ import { CliError, MeError, UsageError } from "./errors.js";
8
8
  import { fetchMe } from "./transport.js";
9
9
  import { bearerFor, deviceFlowLogin } from "./oauth.js";
10
10
  import { openBrowser as defaultOpenBrowser } from "./browser.js";
11
- import { cmdAccounts, cmdExplain, cmdHistory, cmdQueue, cmdRunning } from "./execution.js";
11
+ import { cmdAccounts, cmdExplain, cmdHistory, cmdQueue, cmdRunning, } from "./execution.js";
12
12
  import { cmdQuery } from "./query.js";
13
13
  import { cmdReady } from "./ready.js";
14
14
  import { cmdReplica } from "./replica.js";
15
15
  import { cmdRuntime } from "./runtime-verb.js";
16
16
  import { cmdEvents } from "./events.js";
17
17
  import { stdinIsTty } from "./prompt.js";
18
- import { FIRST_STAMPED_VERSION, PROVENANCE_MARKER, parseProvenanceVersion } from "./skill-shape.js";
18
+ import { FIRST_STAMPED_VERSION, PROVENANCE_MARKER, parseProvenanceVersion, } from "./skill-shape.js";
19
19
  import { installedBundleVersion, installSkills, parseChangelogEntry, readChangelog, resolveSkillsDir, skillsSourceDir, updateNoticeLine, } from "./skills.js";
20
20
  import { cmdWatch } from "./watch.js";
21
21
  import { cmdWrite } from "./write.js";
@@ -25,13 +25,15 @@ import { cmdRelease } from "./release.js";
25
25
  import { cmdEnvironment } from "./environment.js";
26
26
  import { cmdEnv } from "./env.js";
27
27
  import { cmdSecret } from "./secret.js";
28
+ import { cmdVar } from "./var.js";
28
29
  import { cmdTeam } from "./team.js";
29
30
  import { cmdCapabilities } from "./capabilities.js";
30
31
  import { cmdProject } from "./project.js";
31
32
  import { cmdRepo } from "./repo.js";
32
- import { cmdLegacy } from "./legacy.js";
33
+ import { cmdLegacy, findLegacy } from "./legacy.js";
33
34
  import { cmdIdentity } from "./identity.js";
34
35
  import { cmdConnections } from "./connections.js";
36
+ import { cmdOnboard } from "./onboard.js";
35
37
  export { CONFIG_MODE, DEFAULT_BASE_URL, FIRST_STAMPED_VERSION, LEGACY_PACKAGE_NAME, PACKAGE_NAME, PROVENANCE_MARKER, CliError, MeError, UsageError, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, fetchMe, formatMode, installedBundleVersion, installSkills, loadConfig, modernCliPath, normalizeBaseUrl, parseArgs, parseChangelogEntry, parseProvenanceVersion, readChangelog, readManifest, resolveSkillsDir, saveConfig, skillsSourceDir, updateNoticeLine, writeConfig, };
36
38
  export const CUSTOMER_SKILLS = [
37
39
  "catalyst-github",
@@ -57,11 +59,12 @@ export function usageText() {
57
59
  " catalyst login --key <personal-key> [--base-url <url>] (or CATALYST_CLOUD_TOKEN, for a key)",
58
60
  " catalyst join ... (deprecated alias of login; removed in the next minor version)",
59
61
  " catalyst install [--skills-dir <dir>] [--force] (repair path; your agent's own command installs the skills)",
62
+ " catalyst onboard [--resume-from <step>] [--only <step>] [--yes] [--dry-run] [--json]",
60
63
  " catalyst status | notice | me | ready | accounts",
61
64
  " catalyst mcp add|list|remove (vault references only)",
62
65
  " catalyst contract [--refresh] [--path <a.b.c>]",
63
66
  " catalyst query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
64
- " catalyst replica <start [--detach]|stop|status [--probe]|sql \"<select>\"|schema [table]>",
67
+ ' catalyst replica <start [--detach]|stop|status [--probe]|sql "<select>"|schema [table]>',
65
68
  " catalyst runtime <status [--json]|install|path|uninstall> (a pinned Node this CLI manages itself)",
66
69
  " catalyst events <tail|wait-for|query|status [--probe]> [--type NAME] [--ticket CTC-N] [--after SEQUENCE]",
67
70
  " catalyst explain <ticket> | history <ticket> | running [--ticket T --phase P] | queue [--team K]",
@@ -70,10 +73,12 @@ export function usageText() {
70
73
  " catalyst ask <raise|accept|list> ...",
71
74
  " catalyst release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] | release --class <c> --team <K> ...",
72
75
  " catalyst environment [read] | environment propose --file <path>|--stdin [--approve] | environment approve (your account; needs login)",
73
- " catalyst env <inventory [path] | check <file>> (this repository, offline — no login, no network)",
74
- " catalyst secret set <NAME> --repo <owner/name> [--command '<cmd>'] | secret import <file> --repo <owner/name>",
76
+ " catalyst env <inventory [path] | check <file> | migrate [catalyst.env.json] | draft [--root DIR] [--diff] [--write]> [--json] (this repository, offline — no login, no network)",
77
+ " catalyst secret set <NAME> --repo <owner/name> [--command '<cmd>'] | secret import <file> --repo <owner/name> [--names NAME[,NAME...]]",
78
+ " catalyst var set <NAME> | var import <file> [--repo <owner/name>] [--names NAME[,NAME...]] (plain environment variables)",
75
79
  " catalyst team <list|check|map|adopt|migrate|checklist> ...",
76
80
  " catalyst capabilities [--json] (what this CLI can do, the role each verb needs, and whether this cloud serves it)",
81
+ " catalyst project list [--json] (all tenant projects, including those with no stage mapping)",
77
82
  " catalyst project wip-limit <get|set <n>|set default> [--team K] (a project's new-start WIP limit; members read, owners and admins set)",
78
83
  " catalyst repo <agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> (a checkout's AGENTS.md block and portable agent layout; working tree only)",
79
84
  " catalyst legacy [--remove [--data] [--yes]] (leftovers of the old local Catalyst runtime: list them; remove them only on a yes)",
@@ -114,7 +119,10 @@ function migrateLegacyCliPath(ctx) {
114
119
  // An upgrade from @catalyst-cloud/catalyst-skills to @catalyst-cloud/cli removes the old package
115
120
  // folder, so the recorded launcher no longer exists and has no sibling. Point it at the launcher
116
121
  // that is running now, so `ready` and every skill script work without another login.
117
- if (next === null && typeof cfg.cliPath === "string" && cfg.cliPath !== "" && !existsSync(cfg.cliPath)) {
122
+ if (next === null &&
123
+ typeof cfg.cliPath === "string" &&
124
+ cfg.cliPath !== "" &&
125
+ !existsSync(cfg.cliPath)) {
118
126
  next = cliPath();
119
127
  }
120
128
  if (next === null || next === cfg.cliPath)
@@ -213,6 +221,36 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
213
221
  return await cmdLogin(args, ctx, deps);
214
222
  case "install":
215
223
  return cmdInstall(args, ctx);
224
+ case "onboard":
225
+ return await cmdOnboard(args, ctx, {
226
+ runStep: async (id, stepCtx) => {
227
+ if (id !== "legacy")
228
+ return { state: "skipped" };
229
+ const before = findLegacy(stepCtx.home, process.platform).filter((item) => !item.data).length;
230
+ const safeCtx = args.json
231
+ ? { ...stepCtx, stdout: stepCtx.stderr }
232
+ : stepCtx;
233
+ const legacyArgs = {
234
+ ...args,
235
+ command: "legacy",
236
+ subcommand: null,
237
+ rest: [],
238
+ flags: { remove: true, yes: true },
239
+ json: false,
240
+ help: false,
241
+ version: false,
242
+ };
243
+ const code = await cmdLegacy(legacyArgs, safeCtx, deps.legacy ?? {});
244
+ if (code !== 0)
245
+ throw new CliError("legacy cleanup did not finish", "onboard-legacy-failed", 10);
246
+ const after = findLegacy(stepCtx.home, process.platform).filter((item) => !item.data).length;
247
+ return {
248
+ state: after === 0 ? "done" : "skipped",
249
+ evidence: { found: before, remaining: after },
250
+ };
251
+ },
252
+ isTty: deps.isTty ?? stdinIsTty,
253
+ }, readManifest().version);
216
254
  case "notice":
217
255
  return 0;
218
256
  case "status":
@@ -222,7 +260,9 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
222
260
  case "contract":
223
261
  return await cmdContract(args, ctx);
224
262
  case "query":
225
- return await cmdQuery(args, ctx, { engineDeps: deps.replica?.engineDeps });
263
+ return await cmdQuery(args, ctx, {
264
+ engineDeps: deps.replica?.engineDeps,
265
+ });
226
266
  case "replica":
227
267
  return await cmdReplica(args, ctx, deps.replica);
228
268
  case "runtime":
@@ -244,7 +284,11 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
244
284
  case "ask":
245
285
  return await cmdAsk(args, ctx);
246
286
  case "ready":
247
- return await cmdReady(args, ctx, { skillNames: CUSTOMER_SKILLS, loadSdk: deps.loadSdk, offline: args.flags.offline === true });
287
+ return await cmdReady(args, ctx, {
288
+ skillNames: CUSTOMER_SKILLS,
289
+ loadSdk: deps.loadSdk,
290
+ offline: args.flags.offline === true,
291
+ });
248
292
  case "mcp":
249
293
  return await cmdMcp(args, ctx);
250
294
  case "accounts":
@@ -257,6 +301,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
257
301
  return await cmdEnv(args, ctx, deps.env ?? {});
258
302
  case "secret":
259
303
  return await cmdSecret(args, ctx, deps.secret ?? {});
304
+ case "var":
305
+ return await cmdVar(args, ctx, deps.var ?? {});
260
306
  case "team":
261
307
  return await cmdTeam(args, ctx, deps.team);
262
308
  case "capabilities":
@@ -280,7 +326,9 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
280
326
  catch (err) {
281
327
  if (err instanceof UsageError) {
282
328
  ctx.stderr(err.message);
283
- ctx.stderr(args.command && args.command in VERB_HELP_KNOWN ? verbHelp(args.command) : usageText());
329
+ ctx.stderr(args.command && args.command in VERB_HELP_KNOWN
330
+ ? verbHelp(args.command)
331
+ : usageText());
284
332
  return 1;
285
333
  }
286
334
  if (err instanceof CliError) {
@@ -294,7 +342,40 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
294
342
  throw err;
295
343
  }
296
344
  }
297
- const VERB_HELP_KNOWN = Object.fromEntries(["login", "join", "install", "status", "notice", "me", "contract", "query", "replica", "runtime", "events", "explain", "running", "queue", "watch", "write", "ask", "ready", "accounts", "release", "secret", "env", "environment", "connections", "identity", "mcp", "team", "capabilities", "project", "repo", "legacy"].map((v) => [v, true]));
345
+ const VERB_HELP_KNOWN = Object.fromEntries([
346
+ "login",
347
+ "join",
348
+ "install",
349
+ "status",
350
+ "notice",
351
+ "me",
352
+ "contract",
353
+ "query",
354
+ "replica",
355
+ "runtime",
356
+ "events",
357
+ "explain",
358
+ "running",
359
+ "queue",
360
+ "watch",
361
+ "write",
362
+ "ask",
363
+ "ready",
364
+ "accounts",
365
+ "release",
366
+ "secret",
367
+ "env",
368
+ "environment",
369
+ "connections",
370
+ "identity",
371
+ "mcp",
372
+ "team",
373
+ "capabilities",
374
+ "project",
375
+ "repo",
376
+ "legacy",
377
+ "onboard",
378
+ ].map((v) => [v, true]));
298
379
  async function cmdLogin(args, ctx, deps) {
299
380
  const manifest = readManifest();
300
381
  const key = (args.key ?? ctx.env.CATALYST_CLOUD_TOKEN ?? "").trim();
@@ -338,19 +419,24 @@ async function cmdLogin(args, ctx, deps) {
338
419
  const written = writeConfig(ctx.home, config);
339
420
  ctx.stdout(`Connected to ${me.name} (${me.slug}) — account ${me.account}`);
340
421
  if (me.user) {
341
- ctx.stdout(`Connected as ${me.user.label} (${me.user.role})${me.user.linearUserId ? "" : " — your Linear identity is not matched yet, so \"what needs me\" will show everyone's asks until an admin matches it in Settings"}`);
422
+ ctx.stdout(`Connected as ${me.user.label} (${me.user.role})${me.user.linearUserId
423
+ ? ""
424
+ : ' — your Linear identity is not matched yet, so "what needs me" will show everyone\'s asks until an admin matches it in Settings'}`);
342
425
  }
343
426
  else {
344
427
  ctx.stderr(`[catalyst] this is the tenant's account key (a host credential), not your own — the skills work, but nothing your agent writes will carry your name and "what needs me" cannot mean you. Mint a personal key at Settings → API keys and log in with that.`);
345
428
  }
346
- ctx.stdout(`Config written to ${written.path} (mode ${formatMode(written.mode)}, holds your ${auth ? "login session" : "key"} and the CLI path)${written.mode === CONFIG_MODE ? "" : ` — expected ${formatMode(CONFIG_MODE)}; chmod it by hand`}`);
429
+ ctx.stdout(`Config written to ${written.path} (mode ${formatMode(written.mode)}, holds your ${auth ? "login session" : "key"} and the CLI path)${written.mode === CONFIG_MODE
430
+ ? ""
431
+ : ` — expected ${formatMode(CONFIG_MODE)}; chmod it by hand`}`);
347
432
  ctx.stdout(`Tenant contract range: ${manifest.tenantContractRange}`);
348
433
  try {
349
434
  const loaded = await loadContract(ctx, config, { refresh: true });
350
435
  ctx.stdout(`Tenant contract ${loaded.doc.contractVersion} cached at ${loaded.path}`);
351
436
  }
352
437
  catch (err) {
353
- if (err instanceof CliError && (err.code === "contract-forbidden" || err.code === "contract-version")) {
438
+ if (err instanceof CliError &&
439
+ (err.code === "contract-forbidden" || err.code === "contract-version")) {
354
440
  ctx.stderr(`[catalyst] ${err.message}`);
355
441
  }
356
442
  else {
@@ -400,8 +486,12 @@ function cmdStatus(ctx) {
400
486
  }
401
487
  ctx.stdout(`Tenant: ${cfg.name} (${cfg.slug}) — account ${cfg.account}`);
402
488
  ctx.stdout(`API: ${cfg.baseUrl} (principal: ${cfg.principal})`);
403
- ctx.stdout(cfg.user ? `As: ${cfg.user.label} (${cfg.user.role})` : "As: the tenant's account key (a host credential — no person)");
404
- ctx.stdout(cfg.auth ? `Credential: your login (expires ${relativeExpiry(cfg.auth.expiresAt, ctx.now())})` : "Credential: personal key");
489
+ ctx.stdout(cfg.user
490
+ ? `As: ${cfg.user.label} (${cfg.user.role})`
491
+ : "As: the tenant's account key (a host credential — no person)");
492
+ ctx.stdout(cfg.auth
493
+ ? `Credential: your login (expires ${relativeExpiry(cfg.auth.expiresAt, ctx.now())})`
494
+ : "Credential: personal key");
405
495
  ctx.stdout(`Bundle: ${PACKAGE_NAME} ${manifest.version} (tenant contract range: ${manifest.tenantContractRange})`);
406
496
  if (cfg.cliPath)
407
497
  ctx.stdout(`CLI: ${cfg.cliPath}${existsSync(cfg.cliPath) ? "" : " (missing — re-run login)"}`);
@@ -446,6 +536,8 @@ async function cmdContract(args, ctx) {
446
536
  const value = path ? pickPath(loaded.doc, path) : loaded.doc;
447
537
  if (path && value === undefined)
448
538
  throw new CliError(`the contract has nothing at "${path}"`, "contract-path");
449
- ctx.stdout(typeof value === "string" ? value : JSON.stringify(value, null, args.json ? 0 : 2));
539
+ ctx.stdout(typeof value === "string"
540
+ ? value
541
+ : JSON.stringify(value, null, args.json ? 0 : 2));
450
542
  return 0;
451
543
  }
package/dist/config.js CHANGED
@@ -111,7 +111,9 @@ export function modernCliPath(recorded) {
111
111
  return existsSync(sibling) ? sibling : null;
112
112
  }
113
113
  export function normalizeBaseUrl(url) {
114
- return url.replace(/\/+$/, "");
114
+ while (url.endsWith("/"))
115
+ url = url.slice(0, -1);
116
+ return url;
115
117
  }
116
118
  /** The origin WITH the versioned prefix — what the SDK and every API read/write take. */
117
119
  export function apiBase(cfg) {
@@ -0,0 +1,134 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { convertLegacyEnvironmentJson } from "./legacy.js";
4
+ import { inventoryRepo } from "./inventory.js";
5
+ import { walkRepo } from "./walk.js";
6
+ const SECRET_NAME_RE = /(?:^|_)(?:API_?KEY|AUTH|CREDENTIALS?|PASSWORD|PRIVATE_?KEY|SECRET|TOKEN)(?:_|$)/i;
7
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
+ const MAX_VARIABLES = 128;
9
+ /** Build a stable, value-free environment table from repository evidence. `.env` is never read:
10
+ * inventoryRepo's walker only opens supported examples and source/config files. The legacy
11
+ * declaration is parsed for names and explicit secret flags, and no legacy value escapes. */
12
+ export function draftEnvironment(root, deps = {}) {
13
+ const inventory = inventoryRepo(root, deps);
14
+ const files = (deps.listFiles ?? walkRepo)(root);
15
+ const names = new Set();
16
+ const secrets = new Set();
17
+ const sources = new Set();
18
+ for (const entry of inventory.entries) {
19
+ if (entry.group === "bindings" || !entry.needsDeclaration || !ENV_NAME_RE.test(entry.name))
20
+ continue;
21
+ names.add(entry.name);
22
+ if (SECRET_NAME_RE.test(entry.name))
23
+ secrets.add(entry.name);
24
+ for (const finding of entry.found)
25
+ sources.add(finding.file);
26
+ }
27
+ const legacyPath = join(root, "catalyst.env.json");
28
+ const legacy = deps.readFile !== undefined ? deps.readFile(legacyPath) : readOptional(legacyPath);
29
+ if (legacy !== undefined) {
30
+ const converted = convertLegacyEnvironmentJson(legacy);
31
+ if (converted.state === "invalid")
32
+ return converted;
33
+ for (const name of converted.variableNames)
34
+ names.add(name);
35
+ for (const name of converted.secretNames)
36
+ secrets.add(name);
37
+ if (converted.variableNames.length > 0)
38
+ sources.add("catalyst.env.json");
39
+ }
40
+ const sortedNames = [...names].sort((a, b) => a.localeCompare(b, "en"));
41
+ if (sortedNames.length > MAX_VARIABLES) {
42
+ return { state: "invalid", errors: [`environment has more than ${MAX_VARIABLES} names`] };
43
+ }
44
+ const secretNames = [...secrets].filter((name) => names.has(name)).sort((a, b) => a.localeCompare(b, "en"));
45
+ const standard = standardSteps(root, files, deps);
46
+ if (standard.error !== undefined)
47
+ return { state: "invalid", errors: [standard.error] };
48
+ for (const source of standard.sources)
49
+ sources.add(source);
50
+ for (const path of files)
51
+ if (path.startsWith(".github/workflows/"))
52
+ sources.add(path);
53
+ const chunks = ["[environment]"];
54
+ for (const name of sortedNames)
55
+ chunks.push([
56
+ "[[environment.variables]]",
57
+ `name = ${JSON.stringify(name)}`,
58
+ ...(secrets.has(name) ? ["secret = true"] : []),
59
+ "required = false",
60
+ "",
61
+ ].join("\n"));
62
+ chunks.push(...standard.setup, ...standard.verify);
63
+ const toml = `${chunks.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
64
+ return {
65
+ state: "valid",
66
+ names: sortedNames,
67
+ secretNames,
68
+ setup: standard.setup,
69
+ verify: standard.verify,
70
+ toml,
71
+ sources: [...sources].sort((a, b) => a.localeCompare(b, "en")),
72
+ };
73
+ }
74
+ /** Conservative package.json/lockfile inference. No repository script is executed. */
75
+ function standardSteps(root, files, deps) {
76
+ if (!files.includes("package.json"))
77
+ return { setup: [], verify: [], sources: [] };
78
+ const raw = deps.readFile !== undefined ? deps.readFile(join(root, "package.json")) : readOptional(join(root, "package.json"));
79
+ if (raw === undefined)
80
+ return { setup: [], verify: [], sources: [] };
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(raw);
84
+ }
85
+ catch {
86
+ return { setup: [], verify: [], sources: [], error: "package.json is not valid JSON; refusing to guess setup commands" };
87
+ }
88
+ if (!isRecord(parsed))
89
+ return { setup: [], verify: [], sources: [], error: "package.json must contain a JSON object; refusing to guess setup commands" };
90
+ const scripts = isRecord(parsed.scripts) ? parsed.scripts : {};
91
+ const lock = ["bun.lock", "bun.lockb", "pnpm-lock.yaml", "yarn.lock", "package-lock.json"].find((name) => files.includes(name));
92
+ const manager = lock?.startsWith("bun.") ? "bun"
93
+ : lock === "pnpm-lock.yaml" ? "pnpm"
94
+ : lock === "yarn.lock" ? "yarn"
95
+ : lock === "package-lock.json" ? "npm" : undefined;
96
+ const setup = [];
97
+ const verify = [];
98
+ const sources = ["package.json"];
99
+ if (manager !== undefined && lock !== undefined) {
100
+ const install = manager === "npm" ? ["npm", "ci"]
101
+ : manager === "yarn" ? ["yarn", "install", "--immutable"]
102
+ : [manager, "install", "--frozen-lockfile"];
103
+ setup.push(stepToml("setup", "install-dependencies", install, [lock]));
104
+ sources.push(lock);
105
+ }
106
+ const selected = typeof scripts["check"] === "string"
107
+ ? ["check"]
108
+ : ["typecheck", "test"].filter((name) => typeof scripts[name] === "string");
109
+ if (manager !== undefined) {
110
+ for (const name of selected)
111
+ verify.push(stepToml("verify", name, [manager, "run", name]));
112
+ }
113
+ return { setup, verify, sources };
114
+ }
115
+ function stepToml(section, name, run, cacheKeyFiles = []) {
116
+ return [
117
+ `[[environment.${section}]]`,
118
+ `name = ${JSON.stringify(name)}`,
119
+ `run = [${run.map((arg) => JSON.stringify(arg)).join(", ")}]`,
120
+ ...(cacheKeyFiles.length ? [`cache_key_files = [${cacheKeyFiles.map((path) => JSON.stringify(path)).join(", ")}]`] : []),
121
+ "",
122
+ ].join("\n");
123
+ }
124
+ function readOptional(path) {
125
+ try {
126
+ return readFileSync(path, "utf8");
127
+ }
128
+ catch {
129
+ return undefined;
130
+ }
131
+ }
132
+ function isRecord(value) {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value);
134
+ }
@@ -0,0 +1,54 @@
1
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
2
+ const SECRET_NAME_RE = /(?:^|_)(?:API_?KEY|AUTH|CREDENTIALS?|PASSWORD|PRIVATE_?KEY|SECRET|TOKEN)(?:_|$)/i;
3
+ /** Convert legacy catalyst.env.json names only. Values and provenance are never copied. */
4
+ export function convertLegacyEnvironmentJson(text) {
5
+ let parsed;
6
+ try {
7
+ parsed = JSON.parse(text);
8
+ }
9
+ catch {
10
+ return { state: "invalid", errors: ["legacy declaration is not valid JSON"] };
11
+ }
12
+ if (!isRecord(parsed) || !Array.isArray(parsed.environment)) {
13
+ return {
14
+ state: "invalid",
15
+ errors: ["legacy declaration must contain an environment array"],
16
+ };
17
+ }
18
+ const errors = [];
19
+ const names = new Set();
20
+ const secretNames = [];
21
+ for (const [index, entry] of parsed.environment.entries()) {
22
+ if (!isRecord(entry) ||
23
+ typeof entry.name !== "string" ||
24
+ !ENV_NAME_RE.test(entry.name)) {
25
+ errors.push(`environment[${index}].name is not a valid environment variable name`);
26
+ continue;
27
+ }
28
+ names.add(entry.name);
29
+ const isSecret = entry.secret === true || SECRET_NAME_RE.test(entry.name);
30
+ if (isSecret && !secretNames.includes(entry.name)) {
31
+ secretNames.push(entry.name);
32
+ }
33
+ }
34
+ const variableNames = [...names].sort();
35
+ if (variableNames.length > 128)
36
+ errors.push("environment has more than 128 names");
37
+ if (errors.length)
38
+ return { state: "invalid", errors };
39
+ const toml = variableNames.length === 0
40
+ ? "[environment]\n"
41
+ : variableNames
42
+ .map((name) => [
43
+ "[[environment.variables]]",
44
+ `name = ${JSON.stringify(name)}`,
45
+ ...(secretNames.includes(name) ? ["secret = true"] : []),
46
+ "required = false",
47
+ "",
48
+ ].join("\n"))
49
+ .join("\n");
50
+ return { state: "valid", variableNames, secretNames, toml };
51
+ }
52
+ function isRecord(value) {
53
+ return typeof value === "object" && value !== null && !Array.isArray(value);
54
+ }
@@ -0,0 +1,50 @@
1
+ /** Select dotenv assignment lines by name while keeping their original bytes for the cloud's parser.
2
+ * Unselected values never cross the network boundary. This mirrors the cloud parser's line grammar. */
3
+ export function filterEnvFileNames(text, selected) {
4
+ const lines = [];
5
+ const found = new Set();
6
+ for (const line of text.split(/\r?\n/)) {
7
+ const trimmed = line.trim();
8
+ if (trimmed === "" || trimmed.startsWith("#"))
9
+ continue;
10
+ const assignment = trimmed.startsWith("export ") ? trimmed.slice("export ".length) : trimmed;
11
+ const equals = assignment.indexOf("=");
12
+ if (equals < 0)
13
+ continue;
14
+ const name = assignment.slice(0, equals).trim();
15
+ if (!selected.has(name))
16
+ continue;
17
+ lines.push(line);
18
+ found.add(name);
19
+ }
20
+ return { text: lines.length === 0 ? "" : `${lines.join("\n")}\n`, found: [...found].sort((a, b) => a.localeCompare(b, "en")) };
21
+ }
22
+ /** Parse dotenv assignments using the mirror import grammar, after the caller has locally selected
23
+ * names. Values stay in this process and are posted one at a time to the documented write route. */
24
+ export function parseEnvAssignments(text, selected) {
25
+ const entries = [];
26
+ for (const rawLine of text.split("\n")) {
27
+ const trimmed = rawLine.trim();
28
+ if (trimmed === "" || trimmed.startsWith("#"))
29
+ continue;
30
+ const assignment = trimmed.startsWith("export ") ? trimmed.slice("export ".length) : trimmed;
31
+ const equals = assignment.indexOf("=");
32
+ if (equals < 0)
33
+ continue;
34
+ const name = assignment.slice(0, equals).trim();
35
+ if (name === "" || (selected !== undefined && !selected.has(name)))
36
+ continue;
37
+ const rawValue = assignment.slice(equals + 1);
38
+ let value = rawValue.trim();
39
+ if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
40
+ value = value.slice(1, -1);
41
+ }
42
+ else {
43
+ const comment = rawValue.search(/\s#/);
44
+ if (comment >= 0)
45
+ value = rawValue.slice(0, comment).trim();
46
+ }
47
+ entries.push({ name, value });
48
+ }
49
+ return entries;
50
+ }
@@ -0,0 +1,66 @@
1
+ import { parse, TomlError } from "smol-toml";
2
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
+ /** Validate TOML syntax and the repo declaration's environment variable table without printing values. */
4
+ export function validateSettingsToml(text) {
5
+ let parsed;
6
+ try {
7
+ parsed = parse(text);
8
+ }
9
+ catch (error) {
10
+ const line = error instanceof TomlError && typeof error.line === "number"
11
+ ? ` at line ${error.line}`
12
+ : "";
13
+ return { state: "invalid", errors: [`TOML could not be parsed${line}`] };
14
+ }
15
+ if (!isRecord(parsed))
16
+ return {
17
+ state: "invalid",
18
+ errors: ["the settings file must have a TOML table at its root"],
19
+ };
20
+ if (parsed.environment === undefined)
21
+ return { state: "no-source", errors: [] };
22
+ if (!isRecord(parsed.environment))
23
+ return { state: "invalid", errors: ["environment must be a table"] };
24
+ const section = parsed.environment;
25
+ if (section.variables === undefined)
26
+ return { state: "valid", errors: [], variableNames: [] };
27
+ if (!Array.isArray(section.variables))
28
+ return {
29
+ state: "invalid",
30
+ errors: ["environment.variables must be an array of tables"],
31
+ };
32
+ const errors = [];
33
+ const names = new Set();
34
+ const variableNames = [];
35
+ if (section.variables.length > 128)
36
+ errors.push("environment.variables has more than 128 entries");
37
+ for (const [index, raw] of section.variables.entries()) {
38
+ const prefix = `environment.variables[${index}]`;
39
+ if (!isRecord(raw)) {
40
+ errors.push(`${prefix} must be a table`);
41
+ continue;
42
+ }
43
+ const name = raw.name;
44
+ if (typeof name !== "string" || !ENV_NAME_RE.test(name)) {
45
+ errors.push(`${prefix}.name is not a valid environment variable name`);
46
+ }
47
+ else {
48
+ variableNames.push(name);
49
+ if (names.has(name))
50
+ errors.push(`${prefix}.name duplicates ${name}`);
51
+ names.add(name);
52
+ }
53
+ if (raw.required !== undefined && typeof raw.required !== "boolean")
54
+ errors.push(`${prefix}.required must be a boolean`);
55
+ if (raw.secret !== undefined && typeof raw.secret !== "boolean")
56
+ errors.push(`${prefix}.secret must be a boolean`);
57
+ if ("value" in raw)
58
+ errors.push(`${prefix}.value is not allowed; store values in the cloud`);
59
+ }
60
+ return errors.length > 0
61
+ ? { state: "invalid", errors }
62
+ : { state: "valid", errors: [], variableNames };
63
+ }
64
+ function isRecord(value) {
65
+ return typeof value === "object" && value !== null && !Array.isArray(value);
66
+ }