@catalyst-cloud/cli 0.13.1 → 0.14.0
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 +4 -0
- package/README.md +2 -2
- package/dist/args.js +291 -69
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +109 -17
- package/dist/config.js +3 -1
- package/dist/env/draft.js +134 -0
- package/dist/env/legacy.js +54 -0
- package/dist/env/parse-env.js +50 -0
- package/dist/env/settings-toml.js +66 -0
- package/dist/env.js +126 -26
- package/dist/onboard-consent.js +65 -0
- package/dist/onboard.js +836 -0
- package/dist/project.js +75 -13
- package/dist/ready.js +11 -1
- package/dist/secret.js +24 -3
- package/dist/var.js +118 -0
- package/package.json +2 -1
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/catalyst-onboard/references/declaring-a-repository.md +19 -12
- package/skills/catalyst-onboard/references/reading-ready.md +1 -1
- package/skills/catalyst-onboard/references/the-one-path.md +3 -3
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +2 -2
- package/skills/catalyst-onboard/scripts/lib/project-coverage.mjs +53 -0
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +884 -195
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +4 -4
- package/skills/what-this-repo-needs/scripts/check.mjs +12 -13
- package/skills/whats-happening/SKILL.md +19 -35
- package/skills/whats-happening/references/coding-accounts.md +6 -19
- package/skills/whats-happening/references/routing-work.md +4 -15
- package/skills/whats-happening/references/settings-and-where-they-live.md +15 -18
- package/skills/whats-happening/references/stages-and-mapping.md +12 -18
- package/skills/whats-happening/references/status-reply.md +11 -29
- package/skills/whats-happening/references/the-ladder.md +15 -25
- package/skills/whats-happening/references/what-runs-next.md +7 -26
- package/skills/whats-happening/references/when-a-phase-fails.md +16 -22
- package/skills/whats-happening/references/why-is-it-stuck.md +38 -48
- 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
|
-
|
|
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
|
|
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 &&
|
|
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, {
|
|
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, {
|
|
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
|
|
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([
|
|
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
|
|
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
|
|
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 &&
|
|
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
|
|
404
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
+
}
|