@catalyst-cloud/cli 0.14.1 → 0.14.3
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 +15 -0
- package/README.md +14 -0
- package/dist/args.js +7 -2
- package/dist/cli.js +94 -37
- package/dist/contract.js +5 -0
- package/dist/env/parse-env.js +40 -4
- package/dist/env/selected-import.js +249 -0
- package/dist/oauth.js +57 -6
- package/dist/onboard-existing.js +169 -0
- package/dist/onboard-progress.js +70 -0
- package/dist/onboard-ready.js +67 -0
- package/dist/onboard-repositories.js +160 -0
- package/dist/onboard-runtime.js +157 -0
- package/dist/onboard-signin.js +43 -0
- package/dist/onboard-ui.js +155 -0
- package/dist/onboard-workspace.js +185 -0
- package/dist/onboard.js +151 -240
- package/dist/ready.js +28 -1
- 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 +18 -5
- 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 +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.3
|
|
4
|
+
|
|
5
|
+
- Setup can open the workspace's Linear approval from a personal login and wait for a fresh connection check before continuing. A saved credential alone does not count as connected.
|
|
6
|
+
- Workspace approval links stay out of logs and receipts. Interrupted setup resumes with a fresh check, and older clouds keep their supported read-only verification path.
|
|
7
|
+
|
|
8
|
+
## 0.14.2
|
|
9
|
+
|
|
10
|
+
Choose an existing Linear team and its accessible repositories in the setup flow. Saved repository choices are checked again against current access and IDs before later setup.
|
|
11
|
+
|
|
12
|
+
Interactive onboarding uses Clack for the shared plan choice, grouped progress and browser-wait spinners. Local sync is offered within the plan; unattended, JSON and dry-run output keep their plain defaults. Cancelling saves resumable progress and releases only the current setup lock.
|
|
13
|
+
|
|
14
|
+
`catalyst onboard` runs setup steps with a private saved record and resumes by checking their current state. `--dry-run --json` prints a plan without changing files; `--only` completes a named step without claiming full onboarding. Sign-in validates the live person and workspace, and fake-HOME runs preserve legacy services and data.
|
|
15
|
+
|
|
16
|
+
`catalyst ready --onboarding` distinguishes failed checks from missing evidence and reports observed work separately. Local sync is optional and can be included with `--local-sync`. Steps whose cloud capabilities are unavailable remain unfinished with a supported resume command. The installer handoff stays gated until its live prerequisites are verified.
|
|
17
|
+
|
|
3
18
|
## 0.14.1
|
|
4
19
|
|
|
5
20
|
Legacy cleanup keeps all shared Catalyst config and data folders, including with the historical --data flag. It no longer removes ~/.catalyst/bin wholesale or recursively deletes directories. The onboarding skill keeps data and no longer offers data deletion.
|
package/README.md
CHANGED
|
@@ -114,6 +114,20 @@ A keyless session's access token is short-lived and rotates on its own: every re
|
|
|
114
114
|
|
|
115
115
|
The setup skill Catalyst seeds into your repository ends by pointing at this same command. That served skill lives in the Catalyst Cloud application, not here; this is the only connect step a customer runs. Your cloud account's **account key** (Settings → Account keys, admin-minted) is a different credential — for a host or daemon that runs unattended — and is not what a person connects their own agent with: it would strip your name from everything your agent writes, and `login` says so if you use one.
|
|
116
116
|
|
|
117
|
+
## Run and resume setup
|
|
118
|
+
|
|
119
|
+
Run `catalyst onboard` for the steps supported by your installed CLI and cloud. The command shows its plan, opens supported browser consent links and saves progress. Resume with the same command; it rechecks completed steps before continuing. A missing cloud capability stays unfinished. In an interactive terminal, use the arrow keys to review the plan and choose cloud reads or optional local sync. Browser waits show progress. `--yes`, `--json`, `--dry-run` and non-terminal output stay plain.
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
catalyst onboard --dry-run --json
|
|
123
|
+
catalyst onboard
|
|
124
|
+
catalyst ready --onboarding --json
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The dry run writes no files. `--only <step>` runs one step and reports its scope separately from full onboarding. Exit 10 means a failed step, 11 means waiting, and 12 means a guard refused the action. The private receipt is `install/last-run.json` under your machine's Catalyst state directory; credentials stay in the login config. A fake HOME reports old services and keeps them intact.
|
|
128
|
+
|
|
129
|
+
Local sync is optional. Include it in the plan with `--local-sync` when you need local SQL or offline access. A resume keeps that choice. Readiness distinguishes failed checks from missing evidence and reports observed project work separately from unfinished setup.
|
|
130
|
+
|
|
117
131
|
## Requirements
|
|
118
132
|
|
|
119
133
|
- Node 22.15 or newer (Node 26 works), or bun 1.4 or newer. The bundle uses Node's built-in SQLite module (and, on bun, bun's own `node:sqlite`) for the optional local replica, so there is no native dependency to build; if `better-sqlite3` resolves on the machine it is used instead. `catalyst ready` names the exact reason when the runtime is too old, and `catalyst runtime install` installs a pinned Node under this CLI's own cache — without touching your machine's default Node — if you would rather not upgrade it.
|
package/dist/args.js
CHANGED
|
@@ -41,6 +41,9 @@ export const FLAG_TABLES = {
|
|
|
41
41
|
},
|
|
42
42
|
install: {},
|
|
43
43
|
onboard: {
|
|
44
|
+
team: { value: true, help: "explicitly select an existing Linear team by ID or unique key" },
|
|
45
|
+
repo: { value: true, repeat: true, help: "explicitly select an accessible repository as owner/name (repeatable)" },
|
|
46
|
+
"local-sync": { value: false, help: "include the optional local sync setup in this plan" },
|
|
44
47
|
"resume-from": { value: true, help: "resume at a verified onboarding step" },
|
|
45
48
|
only: { value: true, help: "run only the named onboarding step" },
|
|
46
49
|
yes: { value: false, help: "accept the plan's default choices" },
|
|
@@ -232,6 +235,8 @@ export const FLAG_TABLES = {
|
|
|
232
235
|
role: { value: true, help: "accept: the role recording the answer" },
|
|
233
236
|
},
|
|
234
237
|
ready: {
|
|
238
|
+
onboarding: { value: false, help: "check current onboarding evidence and observed project work" },
|
|
239
|
+
"local-sync": { value: false, help: "include the selected local sync mode" },
|
|
235
240
|
offline: {
|
|
236
241
|
value: false,
|
|
237
242
|
help: "skip the published-release check (no network)",
|
|
@@ -381,7 +386,7 @@ export const FLAG_TABLES = {
|
|
|
381
386
|
};
|
|
382
387
|
export const VERB_USAGE = {
|
|
383
388
|
login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
|
|
384
|
-
onboard: "onboard [--resume-from <step>] [--only <step>] [--yes] [--dry-run] [--json]",
|
|
389
|
+
onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--resume-from <step>] [--only <step>] [--local-sync] [--yes] [--dry-run] [--json]",
|
|
385
390
|
install: "install [--skills-dir <dir>] [--force]",
|
|
386
391
|
status: "status",
|
|
387
392
|
notice: "notice",
|
|
@@ -398,7 +403,7 @@ export const VERB_USAGE = {
|
|
|
398
403
|
watch: "watch [--team K] [--ticket T]... [--project P] [--exec CMD] [--cursor-file <path>] [--from cursor|head]",
|
|
399
404
|
write: "write <comment <ticket> --body|--stdin [--parent] [--bookkeeping] [--as-user] | state <ticket> --slot|--state-id|--state-type | label <ticket> --add... --remove... | create --team --title [--description|--stdin] [--label] [--priority] | reaction <ticket>|--comment <id> --emoji <e> | attachment <ticket> --title --url | session <ticket> [--title] [--plan-file] [--activity]>",
|
|
400
405
|
ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] | accept <askTicket> --answer <commentId> --role <role> | list [--anyone] [--json]>",
|
|
401
|
-
ready: "ready [--json] [--offline]",
|
|
406
|
+
ready: "ready [--onboarding] [--local-sync] [--json] [--offline]",
|
|
402
407
|
accounts: "accounts [--json]",
|
|
403
408
|
mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>> [--json]",
|
|
404
409
|
team: "team <list|check <KEY>|check --all|map <KEY> [--stage role=StateName]... [--yes --plan-hash H]|adopt <KEY> [--undo] [--yes --plan-hash H]|migrate <KEY> [--choice sourceId=destinationId]... [--retire] [--yes --plan-hash H]|checklist <KEY>> [--json]",
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
|
|
2
2
|
// their own modules; this file keeps every export the 0.1 tests and the bin import.
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
3
|
+
import { existsSync, openSync, closeSync } from "node:fs";
|
|
4
|
+
import { ReadStream as TerminalInput } from "node:tty";
|
|
4
5
|
import { parseArgs, positionals, verbHelp } from "./args.js";
|
|
5
6
|
import { CONFIG_MODE, DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, installerOwnsSkills, INSTALL_COMMAND, loadConfig, modernCliPath, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
|
|
6
7
|
import { loadContract, pickPath } from "./contract.js";
|
|
@@ -30,10 +31,14 @@ import { cmdTeam } from "./team.js";
|
|
|
30
31
|
import { cmdCapabilities } from "./capabilities.js";
|
|
31
32
|
import { cmdProject } from "./project.js";
|
|
32
33
|
import { cmdRepo } from "./repo.js";
|
|
33
|
-
import { cmdLegacy
|
|
34
|
+
import { cmdLegacy } from "./legacy.js";
|
|
34
35
|
import { cmdIdentity } from "./identity.js";
|
|
35
36
|
import { cmdConnections } from "./connections.js";
|
|
36
|
-
import { cmdOnboard } from "./onboard.js";
|
|
37
|
+
import { cmdOnboard, onboardErrorJournal } from "./onboard.js";
|
|
38
|
+
import { createClackOnboardUi, shouldUseOnboardUi } from "./onboard-ui.js";
|
|
39
|
+
import { createOnboardRuntime } from "./onboard-runtime.js";
|
|
40
|
+
import { onboardingReadyReport, observeCloudOnboarding } from "./onboard-ready.js";
|
|
41
|
+
import { selectedOnboardTeam } from "./onboard-existing.js";
|
|
37
42
|
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, };
|
|
38
43
|
export const CUSTOMER_SKILLS = [
|
|
39
44
|
"catalyst-github",
|
|
@@ -214,43 +219,78 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
|
|
|
214
219
|
return 0;
|
|
215
220
|
}
|
|
216
221
|
try {
|
|
217
|
-
|
|
218
|
-
|
|
222
|
+
if (args.command !== "onboard") {
|
|
223
|
+
migrateLegacyCliPath(ctx);
|
|
224
|
+
maybePrintUpdateNotice(args, ctx);
|
|
225
|
+
}
|
|
219
226
|
switch (args.command) {
|
|
220
227
|
case "login":
|
|
221
228
|
return await cmdLogin(args, ctx, deps);
|
|
222
229
|
case "install":
|
|
223
230
|
return cmdInstall(args, ctx);
|
|
224
|
-
case "onboard":
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
231
|
+
case "onboard": {
|
|
232
|
+
let ui;
|
|
233
|
+
let terminal;
|
|
234
|
+
let input = process.stdin;
|
|
235
|
+
const originalRaw = Boolean(input.isRaw);
|
|
236
|
+
if (shouldUseOnboardUi(args, Boolean(process.stdout.isTTY))) {
|
|
237
|
+
if (!input.isTTY) {
|
|
238
|
+
try {
|
|
239
|
+
const fd = openSync("/dev/tty", "r");
|
|
240
|
+
try {
|
|
241
|
+
terminal = new TerminalInput(fd);
|
|
242
|
+
input = terminal;
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
closeSync(fd);
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
ctx.stderr("No controlling terminal is available; using the displayed defaults.");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (input.isTTY) {
|
|
254
|
+
try {
|
|
255
|
+
ui = createClackOnboardUi(await import("@clack/prompts"), { input, output: process.stdout });
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
terminal?.destroy();
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
return await cmdOnboard(args, ctx, {
|
|
265
|
+
...createOnboardRuntime(args, ctx, {
|
|
266
|
+
ui,
|
|
267
|
+
signinTimeoutMs: deps.onboardSigninTimeoutMs,
|
|
268
|
+
login: (stepCtx, signal) => cmdLogin({ ...args, command: "login", flags: {}, json: false }, stepCtx, {
|
|
269
|
+
...deps, signal, isTty: () => true,
|
|
270
|
+
waitForApproval: ui ? run => ui.wait("Waiting for Catalyst approval", run) : undefined,
|
|
271
|
+
}, true),
|
|
272
|
+
ready: async (stepCtx, journal) => {
|
|
273
|
+
const team = selectedOnboardTeam(journal);
|
|
274
|
+
const report = await onboardingReadyReport(stepCtx, { teamIds: team ? [team] : undefined, localSync: journal?.localSync ?? args.flags["local-sync"] === true, observe: observeCloudOnboarding });
|
|
275
|
+
const failed = report.checks.some(check => check.required && check.state === "fail");
|
|
276
|
+
return { state: report.state === "complete" ? "done" : failed ? "failed" : "waiting", reason: report.state === "complete" ? undefined : "onboarding_checks_pending",
|
|
277
|
+
evidence: { checks: report.checks.length, passed: report.checks.filter(check => check.state === "pass").length } };
|
|
278
|
+
},
|
|
279
|
+
legacy: deps.legacy,
|
|
280
|
+
openBrowser: deps.openBrowser,
|
|
281
|
+
sleep: deps.sleep,
|
|
282
|
+
skillNames: CUSTOMER_SKILLS,
|
|
283
|
+
}),
|
|
284
|
+
...(deps.isTty ? { isTty: deps.isTty } : {}),
|
|
285
|
+
}, readManifest().version);
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
ui?.dispose();
|
|
289
|
+
if (input.isTTY && input.isRaw !== originalRaw)
|
|
290
|
+
input.setRawMode(originalRaw);
|
|
291
|
+
terminal?.destroy();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
254
294
|
case "notice":
|
|
255
295
|
return 0;
|
|
256
296
|
case "status":
|
|
@@ -332,6 +372,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
|
|
|
332
372
|
return 1;
|
|
333
373
|
}
|
|
334
374
|
if (err instanceof CliError) {
|
|
375
|
+
if (args.command === "onboard" && args.json)
|
|
376
|
+
ctx.stdout(JSON.stringify(onboardErrorJournal(ctx, manifest.version, err.exitCode)));
|
|
335
377
|
ctx.stderr(`catalyst: ${err.message}`);
|
|
336
378
|
return err.exitCode;
|
|
337
379
|
}
|
|
@@ -376,7 +418,12 @@ const VERB_HELP_KNOWN = Object.fromEntries([
|
|
|
376
418
|
"legacy",
|
|
377
419
|
"onboard",
|
|
378
420
|
].map((v) => [v, true]));
|
|
379
|
-
async function cmdLogin(args, ctx, deps) {
|
|
421
|
+
async function cmdLogin(args, ctx, deps, personalOnly = false) {
|
|
422
|
+
const checkCancelled = () => {
|
|
423
|
+
if (deps.signal?.aborted)
|
|
424
|
+
throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11);
|
|
425
|
+
};
|
|
426
|
+
checkCancelled();
|
|
380
427
|
const manifest = readManifest();
|
|
381
428
|
const key = (args.key ?? ctx.env.CATALYST_CLOUD_TOKEN ?? "").trim();
|
|
382
429
|
const baseUrl = normalizeBaseUrl(args.baseUrl ?? ctx.env.CATALYST_CLOUD_BASE_URL ?? DEFAULT_BASE_URL);
|
|
@@ -389,8 +436,14 @@ async function cmdLogin(args, ctx, deps) {
|
|
|
389
436
|
isTty: deps.isTty ?? stdinIsTty,
|
|
390
437
|
openBrowser: deps.openBrowser ?? defaultOpenBrowser,
|
|
391
438
|
sleep: deps.sleep,
|
|
439
|
+
signal: deps.signal,
|
|
440
|
+
waitForApproval: deps.waitForApproval,
|
|
392
441
|
})).accessToken;
|
|
442
|
+
checkCancelled();
|
|
393
443
|
const me = await fetchMe(baseUrl, bearer, ctx.fetch);
|
|
444
|
+
checkCancelled();
|
|
445
|
+
if (personalOnly && !me.user)
|
|
446
|
+
throw new CliError("onboarding needs your personal login; sign in as yourself", "onboard-person-required", 12);
|
|
394
447
|
let existing;
|
|
395
448
|
try {
|
|
396
449
|
existing = loadConfig(ctx.home);
|
|
@@ -416,6 +469,7 @@ async function cmdLogin(args, ctx, deps) {
|
|
|
416
469
|
cliPath: cliPath(),
|
|
417
470
|
replicaDb: existing?.replicaDb ?? defaultReplicaDbFor(ctx.home),
|
|
418
471
|
};
|
|
472
|
+
checkCancelled();
|
|
419
473
|
const written = writeConfig(ctx.home, config);
|
|
420
474
|
ctx.stdout(`Connected to ${me.name} (${me.slug}) — account ${me.account}`);
|
|
421
475
|
if (me.user) {
|
|
@@ -431,10 +485,12 @@ async function cmdLogin(args, ctx, deps) {
|
|
|
431
485
|
: ` — expected ${formatMode(CONFIG_MODE)}; chmod it by hand`}`);
|
|
432
486
|
ctx.stdout(`Tenant contract range: ${manifest.tenantContractRange}`);
|
|
433
487
|
try {
|
|
434
|
-
const loaded = await loadContract(ctx, config, { refresh: true });
|
|
488
|
+
const loaded = await loadContract(ctx, config, { refresh: true, signal: deps.signal });
|
|
489
|
+
checkCancelled();
|
|
435
490
|
ctx.stdout(`Tenant contract ${loaded.doc.contractVersion} cached at ${loaded.path}`);
|
|
436
491
|
}
|
|
437
492
|
catch (err) {
|
|
493
|
+
checkCancelled();
|
|
438
494
|
if (err instanceof CliError &&
|
|
439
495
|
(err.code === "contract-forbidden" || err.code === "contract-version")) {
|
|
440
496
|
ctx.stderr(`[catalyst] ${err.message}`);
|
|
@@ -443,6 +499,7 @@ async function cmdLogin(args, ctx, deps) {
|
|
|
443
499
|
throw err;
|
|
444
500
|
}
|
|
445
501
|
}
|
|
502
|
+
checkCancelled();
|
|
446
503
|
if (previous && previous !== manifest.version) {
|
|
447
504
|
const entry = parseChangelogEntry(readChangelog(), manifest.version);
|
|
448
505
|
ctx.stdout(updateNoticeLine(previous, manifest.version, entry));
|
package/dist/contract.js
CHANGED
|
@@ -63,6 +63,9 @@ export function assertContractRange(version, range) {
|
|
|
63
63
|
* naming both versions.
|
|
64
64
|
*/
|
|
65
65
|
export async function loadContract(ctx, cfg, opts = {}) {
|
|
66
|
+
const cancelled = () => { if (opts.signal?.aborted)
|
|
67
|
+
throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11); };
|
|
68
|
+
cancelled();
|
|
66
69
|
const path = contractPathFor(ctx.home);
|
|
67
70
|
const cached = readContractCache(ctx.home);
|
|
68
71
|
const nowMs = ctx.now().getTime();
|
|
@@ -85,6 +88,7 @@ export async function loadContract(ctx, cfg, opts = {}) {
|
|
|
85
88
|
res = await api.getJson(CONTRACT_ROUTE, { etag: cached?.etag ?? null, accept: [304] });
|
|
86
89
|
}
|
|
87
90
|
catch (err) {
|
|
91
|
+
cancelled();
|
|
88
92
|
if (err instanceof CliError && err.status === 403) {
|
|
89
93
|
throw new CliError(`GET ${CONTRACT_ROUTE} refused (403): this cloud is older than the bundle and does not yet admit a personal key on the contract — update the cloud, or log in with the tenant's account key until it is`, "contract-forbidden", 2, 403);
|
|
90
94
|
}
|
|
@@ -97,6 +101,7 @@ export async function loadContract(ctx, cfg, opts = {}) {
|
|
|
97
101
|
}
|
|
98
102
|
throw err;
|
|
99
103
|
}
|
|
104
|
+
cancelled();
|
|
100
105
|
if (res.status === 304) {
|
|
101
106
|
if (!cached)
|
|
102
107
|
throw new CliError(`GET ${CONTRACT_ROUTE} answered 304 with no cache to revalidate`, "contract-shape");
|
package/dist/env/parse-env.js
CHANGED
|
@@ -7,7 +7,9 @@ export function filterEnvFileNames(text, selected) {
|
|
|
7
7
|
const trimmed = line.trim();
|
|
8
8
|
if (trimmed === "" || trimmed.startsWith("#"))
|
|
9
9
|
continue;
|
|
10
|
-
const assignment = trimmed.startsWith("export ")
|
|
10
|
+
const assignment = trimmed.startsWith("export ")
|
|
11
|
+
? trimmed.slice("export ".length)
|
|
12
|
+
: trimmed;
|
|
11
13
|
const equals = assignment.indexOf("=");
|
|
12
14
|
if (equals < 0)
|
|
13
15
|
continue;
|
|
@@ -17,7 +19,37 @@ export function filterEnvFileNames(text, selected) {
|
|
|
17
19
|
lines.push(line);
|
|
18
20
|
found.add(name);
|
|
19
21
|
}
|
|
20
|
-
return {
|
|
22
|
+
return {
|
|
23
|
+
text: lines.length === 0 ? "" : `${lines.join("\n")}\n`,
|
|
24
|
+
found: [...found].sort((a, b) => a.localeCompare(b, "en")),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** List dotenv assignment names without returning or logging values. */
|
|
28
|
+
export function envFileNames(text) {
|
|
29
|
+
return envFileInventory(text).names;
|
|
30
|
+
}
|
|
31
|
+
/** Inventory assignments without values, preserving duplicate names (including `export NAME`). */
|
|
32
|
+
export function envFileInventory(text) {
|
|
33
|
+
const counts = new Map();
|
|
34
|
+
for (const line of text.split(/\r?\n/)) {
|
|
35
|
+
const trimmed = line.trim();
|
|
36
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
37
|
+
continue;
|
|
38
|
+
const assignment = trimmed.startsWith("export ")
|
|
39
|
+
? trimmed.slice("export ".length)
|
|
40
|
+
: trimmed;
|
|
41
|
+
const equals = assignment.indexOf("=");
|
|
42
|
+
if (equals < 0)
|
|
43
|
+
continue;
|
|
44
|
+
const name = assignment.slice(0, equals).trim();
|
|
45
|
+
if (name !== "")
|
|
46
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
const sorted = [...counts.keys()].sort((a, b) => a.localeCompare(b, "en"));
|
|
49
|
+
return {
|
|
50
|
+
names: sorted,
|
|
51
|
+
duplicateNames: sorted.filter((name) => (counts.get(name) ?? 0) > 1),
|
|
52
|
+
};
|
|
21
53
|
}
|
|
22
54
|
/** Parse dotenv assignments using the mirror import grammar, after the caller has locally selected
|
|
23
55
|
* names. Values stay in this process and are posted one at a time to the documented write route. */
|
|
@@ -27,7 +59,9 @@ export function parseEnvAssignments(text, selected) {
|
|
|
27
59
|
const trimmed = rawLine.trim();
|
|
28
60
|
if (trimmed === "" || trimmed.startsWith("#"))
|
|
29
61
|
continue;
|
|
30
|
-
const assignment = trimmed.startsWith("export ")
|
|
62
|
+
const assignment = trimmed.startsWith("export ")
|
|
63
|
+
? trimmed.slice("export ".length)
|
|
64
|
+
: trimmed;
|
|
31
65
|
const equals = assignment.indexOf("=");
|
|
32
66
|
if (equals < 0)
|
|
33
67
|
continue;
|
|
@@ -36,7 +70,9 @@ export function parseEnvAssignments(text, selected) {
|
|
|
36
70
|
continue;
|
|
37
71
|
const rawValue = assignment.slice(equals + 1);
|
|
38
72
|
let value = rawValue.trim();
|
|
39
|
-
if (value.length >= 2 &&
|
|
73
|
+
if (value.length >= 2 &&
|
|
74
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
75
|
+
(value.startsWith("'") && value.endsWith("'")))) {
|
|
40
76
|
value = value.slice(1, -1);
|
|
41
77
|
}
|
|
42
78
|
else {
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { envFileInventory, filterEnvFileNames } from "./parse-env.js";
|
|
3
|
+
const NAME_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
4
|
+
const SNAPSHOT_KEY = randomBytes(32);
|
|
5
|
+
function normalizeNames(names) {
|
|
6
|
+
const valid = new Set();
|
|
7
|
+
let invalidNameCount = 0;
|
|
8
|
+
for (const entry of names) {
|
|
9
|
+
if (typeof entry !== "string") {
|
|
10
|
+
invalidNameCount += 1;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
for (const raw of entry.split(",")) {
|
|
14
|
+
const name = raw.trim();
|
|
15
|
+
if (name === "")
|
|
16
|
+
continue;
|
|
17
|
+
if (!NAME_RE.test(name))
|
|
18
|
+
invalidNameCount += 1;
|
|
19
|
+
else
|
|
20
|
+
valid.add(name);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
names: [...valid].sort((a, b) => a.localeCompare(b, "en")),
|
|
25
|
+
invalidNameCount,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function snapshotId(text, requestedNames, allowedNames, invalidNameCount, invalidAuthorityNameCount) {
|
|
29
|
+
return createHmac("sha256", SNAPSHOT_KEY)
|
|
30
|
+
.update(JSON.stringify([
|
|
31
|
+
text,
|
|
32
|
+
requestedNames,
|
|
33
|
+
normalizeNames(allowedNames),
|
|
34
|
+
invalidNameCount,
|
|
35
|
+
invalidAuthorityNameCount,
|
|
36
|
+
]))
|
|
37
|
+
.digest("hex");
|
|
38
|
+
}
|
|
39
|
+
function isSafeCount(value) {
|
|
40
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
41
|
+
}
|
|
42
|
+
function matchesSnapshot(text, names, allowedNames, invalidNameCount, invalidAuthorityNameCount, expected) {
|
|
43
|
+
if (!isSafeCount(invalidNameCount) ||
|
|
44
|
+
!isSafeCount(invalidAuthorityNameCount) ||
|
|
45
|
+
invalidAuthorityNameCount > invalidNameCount) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const actual = Buffer.from(snapshotId(text, names, allowedNames, invalidNameCount, invalidAuthorityNameCount), "hex");
|
|
49
|
+
const wanted = Buffer.from(expected, "hex");
|
|
50
|
+
return actual.length === wanted.length && timingSafeEqual(actual, wanted);
|
|
51
|
+
}
|
|
52
|
+
/** Plan one explicit Q4 selection against a file and declaration allowlist. Invalid input is
|
|
53
|
+
* reduced to a count before it can enter the plan. `allowedNames` is deliberately not retained. */
|
|
54
|
+
function planSelectedNameImport(input) {
|
|
55
|
+
const requested = normalizeNames(input.requestedNames);
|
|
56
|
+
const available = normalizeNames(input.availableNames);
|
|
57
|
+
const allowlist = normalizeNames(input.allowedNames);
|
|
58
|
+
const duplicates = normalizeNames(input.duplicateNames ?? []);
|
|
59
|
+
const allowed = new Set(allowlist.names);
|
|
60
|
+
const availableSet = new Set(available.names);
|
|
61
|
+
const requestedNames = requested.names;
|
|
62
|
+
const selectedNames = requestedNames.filter((name) => allowed.has(name) && availableSet.has(name));
|
|
63
|
+
const unknownNames = requestedNames.filter((name) => !allowed.has(name));
|
|
64
|
+
const missingNames = requestedNames.filter((name) => allowed.has(name) && !availableSet.has(name));
|
|
65
|
+
const selectedDuplicates = duplicates.names.filter((name) => selectedNames.includes(name));
|
|
66
|
+
const invalidNameCount = requested.invalidNameCount +
|
|
67
|
+
available.invalidNameCount +
|
|
68
|
+
allowlist.invalidNameCount +
|
|
69
|
+
duplicates.invalidNameCount;
|
|
70
|
+
const invalidAuthorityNameCount = available.invalidNameCount +
|
|
71
|
+
allowlist.invalidNameCount +
|
|
72
|
+
duplicates.invalidNameCount;
|
|
73
|
+
const state = invalidNameCount === 0 &&
|
|
74
|
+
unknownNames.length === 0 &&
|
|
75
|
+
missingNames.length === 0 &&
|
|
76
|
+
selectedDuplicates.length === 0
|
|
77
|
+
? "ready"
|
|
78
|
+
: "rejected";
|
|
79
|
+
const plan = {
|
|
80
|
+
state,
|
|
81
|
+
requestedNames,
|
|
82
|
+
selectedNames,
|
|
83
|
+
missingNames,
|
|
84
|
+
unknownNames,
|
|
85
|
+
duplicateNames: selectedDuplicates,
|
|
86
|
+
invalidNameCount,
|
|
87
|
+
invalidAuthorityNameCount,
|
|
88
|
+
snapshotId: "",
|
|
89
|
+
};
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
...plan,
|
|
92
|
+
requestedNames: Object.freeze([...requestedNames]),
|
|
93
|
+
selectedNames: Object.freeze([...selectedNames]),
|
|
94
|
+
missingNames: Object.freeze([...missingNames]),
|
|
95
|
+
unknownNames: Object.freeze([...unknownNames]),
|
|
96
|
+
duplicateNames: Object.freeze([...selectedDuplicates]),
|
|
97
|
+
invalidAuthorityNameCount,
|
|
98
|
+
snapshotId: "",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function planWithSnapshot(input) {
|
|
102
|
+
const inventory = envFileInventory(input.text);
|
|
103
|
+
const plan = planSelectedNameImport({
|
|
104
|
+
requestedNames: input.requestedNames,
|
|
105
|
+
availableNames: inventory.names,
|
|
106
|
+
duplicateNames: inventory.duplicateNames,
|
|
107
|
+
allowedNames: input.allowedNames,
|
|
108
|
+
});
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
...plan,
|
|
111
|
+
snapshotId: snapshotId(input.text, plan.requestedNames, input.allowedNames, plan.invalidNameCount, plan.invalidAuthorityNameCount),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function uncertainResult(base, selectedNames) {
|
|
115
|
+
return {
|
|
116
|
+
...base,
|
|
117
|
+
state: "uncertain",
|
|
118
|
+
writtenNames: [],
|
|
119
|
+
failedNames: [],
|
|
120
|
+
uncertainNames: [...selectedNames],
|
|
121
|
+
count: {
|
|
122
|
+
selected: selectedNames.length,
|
|
123
|
+
written: 0,
|
|
124
|
+
failed: 0,
|
|
125
|
+
uncertain: selectedNames.length,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function sanitizeWriterResult(value, selectedNames) {
|
|
130
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const record = value;
|
|
134
|
+
if (!Object.hasOwn(record, "writtenNames") &&
|
|
135
|
+
!Object.hasOwn(record, "failedNames")) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const readNames = (field) => {
|
|
139
|
+
if (!Object.hasOwn(record, field))
|
|
140
|
+
return [];
|
|
141
|
+
const raw = record[field];
|
|
142
|
+
if (raw === undefined)
|
|
143
|
+
return null;
|
|
144
|
+
if (!Array.isArray(raw))
|
|
145
|
+
return null;
|
|
146
|
+
const entries = Array.from(raw);
|
|
147
|
+
if (!entries.every((name) => typeof name === "string" && NAME_RE.test(name)) ||
|
|
148
|
+
entries.some((name) => !selectedNames.includes(name))) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return [...new Set(entries)];
|
|
152
|
+
};
|
|
153
|
+
const writtenNames = readNames("writtenNames");
|
|
154
|
+
const failedNames = readNames("failedNames");
|
|
155
|
+
if (writtenNames === null || failedNames === null)
|
|
156
|
+
return null;
|
|
157
|
+
return { writtenNames, failedNames };
|
|
158
|
+
}
|
|
159
|
+
/** Convenience for Q4: inventory and build the value-free plan in one call. */
|
|
160
|
+
export function planSelectedNameImportFromText(input) {
|
|
161
|
+
return planWithSnapshot(input);
|
|
162
|
+
}
|
|
163
|
+
/** Execute only the selection reviewed against this exact source snapshot. The authoritative
|
|
164
|
+
* declaration allowlist is supplied separately from the plan so mutating/forging a plan cannot
|
|
165
|
+
* widen write capability. */
|
|
166
|
+
export async function executeSelectedNameImport(text, plan, allowedNames, writer) {
|
|
167
|
+
const currentPlan = planWithSnapshot({
|
|
168
|
+
text,
|
|
169
|
+
requestedNames: plan.requestedNames,
|
|
170
|
+
allowedNames,
|
|
171
|
+
});
|
|
172
|
+
const matchesReviewedPlan = matchesSnapshot(text, plan.requestedNames, allowedNames, plan.invalidNameCount, plan.invalidAuthorityNameCount, plan.snapshotId);
|
|
173
|
+
const authenticatedAuthorityCount = matchesReviewedPlan &&
|
|
174
|
+
plan.invalidAuthorityNameCount === currentPlan.invalidAuthorityNameCount;
|
|
175
|
+
const base = {
|
|
176
|
+
requestedNames: [...currentPlan.requestedNames],
|
|
177
|
+
selectedNames: [...currentPlan.selectedNames],
|
|
178
|
+
missingNames: [...currentPlan.missingNames],
|
|
179
|
+
unknownNames: [...currentPlan.unknownNames],
|
|
180
|
+
duplicateNames: [...currentPlan.duplicateNames],
|
|
181
|
+
invalidNameCount: authenticatedAuthorityCount
|
|
182
|
+
? plan.invalidNameCount
|
|
183
|
+
: currentPlan.invalidAuthorityNameCount,
|
|
184
|
+
};
|
|
185
|
+
if (plan.state !== "ready" ||
|
|
186
|
+
currentPlan.state !== "ready" ||
|
|
187
|
+
!matchesReviewedPlan ||
|
|
188
|
+
currentPlan.snapshotId !== plan.snapshotId) {
|
|
189
|
+
return {
|
|
190
|
+
...base,
|
|
191
|
+
state: "rejected",
|
|
192
|
+
writtenNames: [],
|
|
193
|
+
failedNames: [],
|
|
194
|
+
uncertainNames: [],
|
|
195
|
+
count: {
|
|
196
|
+
selected: currentPlan.selectedNames.length,
|
|
197
|
+
written: 0,
|
|
198
|
+
failed: 0,
|
|
199
|
+
uncertain: 0,
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (currentPlan.selectedNames.length === 0) {
|
|
204
|
+
return {
|
|
205
|
+
...base,
|
|
206
|
+
state: "written",
|
|
207
|
+
writtenNames: [],
|
|
208
|
+
failedNames: [],
|
|
209
|
+
uncertainNames: [],
|
|
210
|
+
count: { selected: 0, written: 0, failed: 0, uncertain: 0 },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const filtered = filterEnvFileNames(text, new Set(currentPlan.selectedNames));
|
|
214
|
+
let outcome;
|
|
215
|
+
try {
|
|
216
|
+
const rawOutcome = await writer(filtered.text, [
|
|
217
|
+
...currentPlan.selectedNames,
|
|
218
|
+
]);
|
|
219
|
+
outcome = sanitizeWriterResult(rawOutcome, currentPlan.selectedNames);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return uncertainResult(base, currentPlan.selectedNames);
|
|
223
|
+
}
|
|
224
|
+
if (outcome === null)
|
|
225
|
+
return uncertainResult(base, currentPlan.selectedNames);
|
|
226
|
+
const selected = new Set(currentPlan.selectedNames);
|
|
227
|
+
const reportedWritten = normalizeNames(outcome.writtenNames ?? []).names.filter((name) => selected.has(name));
|
|
228
|
+
const reportedFailed = normalizeNames(outcome.failedNames ?? []).names.filter((name) => selected.has(name));
|
|
229
|
+
const conflicting = new Set(reportedWritten.filter((name) => reportedFailed.includes(name)));
|
|
230
|
+
const writtenNames = reportedWritten.filter((name) => !conflicting.has(name));
|
|
231
|
+
const failed = new Set(reportedFailed);
|
|
232
|
+
for (const name of currentPlan.selectedNames)
|
|
233
|
+
if (!writtenNames.includes(name))
|
|
234
|
+
failed.add(name);
|
|
235
|
+
const failedNames = currentPlan.selectedNames.filter((name) => failed.has(name));
|
|
236
|
+
return {
|
|
237
|
+
...base,
|
|
238
|
+
state: "written",
|
|
239
|
+
writtenNames,
|
|
240
|
+
failedNames,
|
|
241
|
+
uncertainNames: [],
|
|
242
|
+
count: {
|
|
243
|
+
selected: currentPlan.selectedNames.length,
|
|
244
|
+
written: writtenNames.length,
|
|
245
|
+
failed: failedNames.length,
|
|
246
|
+
uncertain: 0,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|