@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 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, findLegacy } from "./legacy.js";
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
- migrateLegacyCliPath(ctx);
218
- maybePrintUpdateNotice(args, ctx);
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
- 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);
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");
@@ -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 ") ? trimmed.slice("export ".length) : trimmed;
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 { text: lines.length === 0 ? "" : `${lines.join("\n")}\n`, found: [...found].sort((a, b) => a.localeCompare(b, "en")) };
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 ") ? trimmed.slice("export ".length) : trimmed;
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 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
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
+ }