@catalyst-cloud/cli 0.14.6 → 0.14.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +5 -3
  3. package/dist/args.js +51 -13
  4. package/dist/cli.js +58 -10
  5. package/dist/config.js +16 -3
  6. package/dist/daemon-authority-lifetime.js +171 -0
  7. package/dist/daemon-authority.js +216 -0
  8. package/dist/daemon-cache.js +348 -0
  9. package/dist/daemon-store.js +370 -0
  10. package/dist/daemon-supervisor.js +460 -0
  11. package/dist/env/onboard-draft-store.js +243 -0
  12. package/dist/env/onboard-draft.js +33 -0
  13. package/dist/env/selected-import-writer.js +163 -0
  14. package/dist/env/settings-authority.js +203 -0
  15. package/dist/events-cloud-tail.js +134 -0
  16. package/dist/events-cloud.js +500 -0
  17. package/dist/events.js +13 -4
  18. package/dist/oauth.js +125 -33
  19. package/dist/onboard-accounts.js +259 -0
  20. package/dist/onboard-automation-management.js +15 -0
  21. package/dist/onboard-bootstrap.js +162 -0
  22. package/dist/onboard-capabilities.js +15 -7
  23. package/dist/onboard-capacity.js +226 -0
  24. package/dist/onboard-existing.js +106 -32
  25. package/dist/onboard-file-snapshot.js +45 -0
  26. package/dist/onboard-login-candidate.js +261 -0
  27. package/dist/onboard-projects.js +350 -0
  28. package/dist/onboard-repositories.js +147 -37
  29. package/dist/onboard-runtime.js +104 -9
  30. package/dist/onboard-settings-context.js +141 -0
  31. package/dist/onboard-settings-import.js +127 -0
  32. package/dist/onboard-settings.js +234 -0
  33. package/dist/onboard-ui.js +99 -11
  34. package/dist/onboard-workflow.js +316 -0
  35. package/dist/onboard.js +359 -27
  36. package/dist/owned-fetch.js +182 -0
  37. package/dist/query.js +77 -27
  38. package/dist/ready.js +143 -35
  39. package/dist/replica.js +11 -4
  40. package/dist/sdk.js +33 -0
  41. package/dist/watch.js +2 -2
  42. package/package.json +3 -2
  43. package/skills/catalyst-github/SKILL.md +2 -2
  44. package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
  45. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  46. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
  47. package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
  48. package/skills/catalyst-linear/SKILL.md +3 -3
  49. package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
  50. package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
  51. package/skills/catalyst-onboard/SKILL.md +5 -4
  52. package/skills/catalyst-onboard/references/local-sync.md +3 -1
  53. package/skills/run-this-project/SKILL.md +1 -1
  54. package/skills/unstick/SKILL.md +1 -1
  55. package/skills/what-needs-me/SKILL.md +1 -1
  56. package/skills/what-this-repo-needs/SKILL.md +1 -1
  57. package/skills/whats-happening/SKILL.md +2 -2
  58. package/skills/whats-happening/references/status-reply.md +1 -1
  59. package/skills/whats-happening/scripts/snapshot.mjs +104 -26
  60. package/vendor/settings/index.d.ts +4 -0
  61. package/vendor/settings/index.js +2937 -0
  62. package/vendor/settings/provenance.json +148 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.7
4
+
5
+ - Setup uses cloud reads by default and offers local sync as an option. Onboarding preserves verified account and repository choices when it resumes.
6
+ - Settings drafts stay private for review; keeping a draft does not approve it or import values.
7
+
3
8
  ## 0.14.6
4
9
 
5
10
  - A current Catalyst data folder no longer triggers a legacy-runtime cleanup warning. Legacy checks and removal keep current shared data and report it separately.
package/README.md CHANGED
@@ -126,7 +126,7 @@ catalyst ready --onboarding --json
126
126
 
127
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
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.
129
+ Local sync is optional. Include it in the plan with `--local-sync` when you need local SQL or sustained local reads. A resume keeps that choice. Readiness distinguishes failed checks from missing evidence and reports observed project work separately from unfinished setup.
130
130
 
131
131
  ## Requirements
132
132
 
@@ -156,7 +156,9 @@ Nothing, by default. After `login`, every read, write, ask and explanation goes
156
156
  | `catalyst replica start` | local SQL, cheap repeated reads, and local event `tail`, `wait-for`, and `query` | one SQLite file plus a bounded event cache under `$XDG_STATE_HOME/catalyst/events/` (fallback `~/.local/state/catalyst/events/`) | one long-running process; foreground by default, `--detach` writes a pidfile beside the database and returns; `login --start-replica` does the same at the end of login |
157
157
  | `catalyst watch` | a project owner reacting to its scope | one cursor file (`~/.config/catalyst-cloud/watch-cursor.json`) stamped with the account | lives inside the session that armed it; exits with it |
158
158
 
159
- The check every skill runs first is `catalyst replica status`, which needs no network: is the pidfile's process alive, is the writer-lock heartbeat younger than the staleness threshold, and is the cursor non-empty. It exits `0` for fresh, `1` for present but stale, `2` for not connected, `3` for absent, and prints one line either way (`--json` for scripts). A fresh replica is used; anything else falls back to the API and the skill says so in its answer. A skill never refuses to work because the replica is down and never silently reads a stale one. `replica status --probe` compares the local cursor against the cloud's head for the honest "how far behind" number; that is the only form that touches the network. `catalyst replica stop` stops a detached writer.
159
+ CLI and skill reads use the cloud API by default, including on a machine with a fresh local replica. No background writer or local database is needed. `catalyst query ... --source replica` explicitly chooses the optional local view; the first stderr line names the source. Local sync does not promise offline browsing.
160
+
161
+ For a local view, `catalyst replica status` checks the writer, heartbeat and cursor. It exits `0` for fresh, `1` for present but stale, `2` for not connected, `3` for absent. `replica status --probe` compares its cursor with the cloud head. These diagnostics do not change the default read source, and an absent replica does not make `catalyst ready` fail.
160
162
 
161
163
  `catalyst events status --probe` checks the event cache's own cursor and compares it with the cloud head; `replica status --probe` checks the separate entity-replica cursor. A fresh replica does not prove event freshness. `events tail` follows new cached events, `events wait-for --ticket ... --timeout ...` performs a bounded wait, and `events query` reads retained history. Those three commands only read the cache; `events status --probe` makes the cloud comparison. The replica process is the single writer and reports an event-sync failure without terminating a healthy entity replica. The event cache keeps closed daily segments for at most seven days or 256 MiB per account and reports an explicit gap when a requested sequence has retired.
162
164
 
@@ -170,7 +172,7 @@ The replica is a Node process, not a service: the supported path is the plain co
170
172
  | `whats-happening` | "What's happening? Why is that stuck? How does this work? How does it prioritise?" | The desk for your account: reads the contract, what is running and queued, the eligibility explainer, the coding accounts and the open asks, and answers in one reply with ticket ids. Carries the facts behind the answers: the ladder, the stage map, failures, parks and holds, the queue order and every reason a ticket is excluded. Routes work to a project owner and decisions to `what-needs-me`. | [`SKILL.md`](skills/whats-happening/SKILL.md) |
171
173
  | `what-needs-me` | "What needs me? What am I blocking?" | The human's decision inbox, ranked by how much open work each ask holds, and the one way an agent raises a decision on their behalf: files an ask through the cloud's ask route with the account's own template and records the answer so the held work releases. | [`SKILL.md`](skills/what-needs-me/SKILL.md) |
172
174
  | `run-this-project` | "Run this project for me. Own it until it closes." | Single-threaded owner of one project: subscribes to the account's event stream for its scope, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Never polls. | [`SKILL.md`](skills/run-this-project/SKILL.md) |
173
- | `catalyst-linear` | "Show me the ticket, the history, what Catalyst wrote on it." | Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the replica when fresh and the API otherwise, always naming the source; writes comments, card moves, labels and new tickets as the app actor; knows what a ticket accumulates as Catalyst works it. | [`SKILL.md`](skills/catalyst-linear/SKILL.md) |
175
+ | `catalyst-linear` | "Show me the ticket, the history, what Catalyst wrote on it." | Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the cloud API by default, with explicit local opt-in and the source named; writes comments, card moves, labels and new tickets as the app actor; knows what a ticket accumulates as Catalyst works it. | [`SKILL.md`](skills/catalyst-linear/SKILL.md) |
174
176
  | `catalyst-github` | "Show me the PR, the checks, the review, the queue." | A ticket's pull request with its checks, reviews and review threads; whether it is mergeable under the repository's policy; what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). | [`SKILL.md`](skills/catalyst-github/SKILL.md) |
175
177
  | `unstick` | "Why is this parked? Unpark it. Get things flowing again." | Reads why a ticket is not running and every park or hold on it, decides whether the recorded cause is fixed, previews the release and releases it the right way from your own login — or one failure class across a team — and raises an ask only for what a person has to do. | [`SKILL.md`](skills/unstick/SKILL.md) |
176
178
  | `what-this-repo-needs` | "What env vars does this repo need? What belongs in its environment declaration?" | Scans a repository offline — no login, no network — and lists the environment variable names it needs, grouped build/test, deploy-only and bindings, each with where it was found, what uses it, and where a local value would come from. Never reads or prints a value. Also checks TOML syntax and the environment variable table in `.catalyst/catalyst.toml`. | [`SKILL.md`](skills/what-this-repo-needs/SKILL.md) |
package/dist/args.js CHANGED
@@ -41,13 +41,29 @@ 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" },
47
- "resume-from": { value: true, help: "resume at a verified onboarding step" },
44
+ team: {
45
+ value: true,
46
+ help: "explicitly select an existing Linear team by ID or unique key",
47
+ },
48
+ repo: {
49
+ value: true,
50
+ repeat: true,
51
+ help: "explicitly select an accessible repository as owner/name (repeatable)",
52
+ },
53
+ "local-sync": {
54
+ value: false,
55
+ help: "include the optional local sync setup in this plan",
56
+ },
57
+ "resume-from": {
58
+ value: true,
59
+ help: "resume at a verified onboarding step",
60
+ },
48
61
  only: { value: true, help: "run only the named onboarding step" },
49
62
  yes: { value: false, help: "accept the plan's default choices" },
50
- "dry-run": { value: false, help: "print the plan without taking the lock or changing files" },
63
+ "dry-run": {
64
+ value: false,
65
+ help: "print the plan without taking the lock or changing files",
66
+ },
51
67
  },
52
68
  status: {},
53
69
  notice: {},
@@ -67,7 +83,7 @@ export const FLAG_TABLES = {
67
83
  since: { value: true, help: "changes: the cursor to read after" },
68
84
  source: {
69
85
  value: true,
70
- help: "replica | api (default: replica when fresh, else api)",
86
+ help: "replica | api (default: api; replica is an explicit local opt-in)",
71
87
  },
72
88
  ticket: { value: true, help: "pulls: only PRs linked to this ticket" },
73
89
  all: {
@@ -235,8 +251,14 @@ export const FLAG_TABLES = {
235
251
  role: { value: true, help: "accept: the role recording the answer" },
236
252
  },
237
253
  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" },
254
+ onboarding: {
255
+ value: false,
256
+ help: "check current onboarding evidence and observed project work",
257
+ },
258
+ "local-sync": {
259
+ value: false,
260
+ help: "include the selected local sync mode",
261
+ },
240
262
  offline: {
241
263
  value: false,
242
264
  help: "skip the published-release check (no network)",
@@ -244,13 +266,29 @@ export const FLAG_TABLES = {
244
266
  },
245
267
  accounts: {},
246
268
  env: {
247
- root: { value: true, help: "draft: repository directory (default: current directory)" },
248
- write: { value: false, help: "draft: write a new .catalyst/catalyst.toml; refuse to overwrite" },
249
- diff: { value: false, help: "draft: show the generated TOML as an additions-only diff" },
269
+ root: {
270
+ value: true,
271
+ help: "draft: repository directory (default: current directory)",
272
+ },
273
+ write: {
274
+ value: false,
275
+ help: "draft: write a new .catalyst/catalyst.toml; refuse to overwrite",
276
+ },
277
+ diff: {
278
+ value: false,
279
+ help: "draft: show the generated TOML as an additions-only diff",
280
+ },
250
281
  },
251
282
  var: {
252
- repo: { value: true, help: "repository scope (owner/name); omit for tenant scope" },
253
- names: { value: true, repeat: true, help: "import: only send these names (repeatable or comma-separated)" },
283
+ repo: {
284
+ value: true,
285
+ help: "repository scope (owner/name); omit for tenant scope",
286
+ },
287
+ names: {
288
+ value: true,
289
+ repeat: true,
290
+ help: "import: only send these names (repeatable or comma-separated)",
291
+ },
254
292
  },
255
293
  team: {
256
294
  all: { value: false, help: "check every team, one request at a time" },
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import { cmdReady } from "./ready.js";
15
15
  import { cmdReplica } from "./replica.js";
16
16
  import { cmdRuntime } from "./runtime-verb.js";
17
17
  import { cmdEvents } from "./events.js";
18
+ import { stageOnboardLogin } from "./onboard-login-candidate.js";
18
19
  import { stdinIsTty } from "./prompt.js";
19
20
  import { FIRST_STAMPED_VERSION, PROVENANCE_MARKER, parseProvenanceVersion, } from "./skill-shape.js";
20
21
  import { installedBundleVersion, installSkills, parseChangelogEntry, readChangelog, resolveSkillsDir, skillsSourceDir, updateNoticeLine, } from "./skills.js";
@@ -35,9 +36,9 @@ import { cmdLegacy } from "./legacy.js";
35
36
  import { cmdIdentity } from "./identity.js";
36
37
  import { cmdConnections } from "./connections.js";
37
38
  import { cmdOnboard, onboardErrorJournal } from "./onboard.js";
38
- import { createClackOnboardUi, shouldUseOnboardUi } from "./onboard-ui.js";
39
+ import { createClackOnboardUi, shouldUseOnboardUi, } from "./onboard-ui.js";
39
40
  import { createOnboardRuntime } from "./onboard-runtime.js";
40
- import { onboardingReadyReport, observeCloudOnboarding } from "./onboard-ready.js";
41
+ import { onboardingReadyReport, observeCloudOnboarding, } from "./onboard-ready.js";
41
42
  import { selectedOnboardTeam } from "./onboard-existing.js";
42
43
  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, };
43
44
  export const CUSTOMER_SKILLS = [
@@ -252,7 +253,10 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
252
253
  }
253
254
  if (input.isTTY) {
254
255
  try {
255
- ui = createClackOnboardUi(await import("@clack/prompts"), { input, output: process.stdout });
256
+ ui = createClackOnboardUi(await import("@clack/prompts"), {
257
+ input,
258
+ output: process.stdout,
259
+ });
256
260
  }
257
261
  catch (error) {
258
262
  terminal?.destroy();
@@ -265,23 +269,64 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
265
269
  ...createOnboardRuntime(args, ctx, {
266
270
  ui,
267
271
  signinTimeoutMs: deps.onboardSigninTimeoutMs,
272
+ stageSignin: (signal) => {
273
+ if (args.key || ctx.env.CATALYST_CLOUD_TOKEN)
274
+ throw new CliError("Connect your personal key with catalyst login, then run catalyst onboard. Setup will not replace a supplied key with browser sign-in.", "onboard-person-required", 12);
275
+ return stageOnboardLogin({ ...ctx, stdout: ctx.stderr }, {
276
+ baseUrl: args.baseUrl,
277
+ signal,
278
+ timeoutMs: deps.onboardSigninTimeoutMs,
279
+ device: {
280
+ isTty: deps.isTty ?? (() => Boolean(process.stdout.isTTY)),
281
+ openBrowser: deps.openBrowser ?? defaultOpenBrowser,
282
+ sleep: deps.sleep,
283
+ waitForApproval: ui
284
+ ? (run) => ui.wait("Waiting for Catalyst approval", run)
285
+ : undefined,
286
+ },
287
+ });
288
+ },
268
289
  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,
290
+ ...deps,
291
+ signal,
292
+ isTty: () => true,
293
+ waitForApproval: ui
294
+ ? (run) => ui.wait("Waiting for Catalyst approval", run)
295
+ : undefined,
271
296
  }, true),
272
297
  ready: async (stepCtx, journal) => {
273
298
  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 } };
299
+ const report = await onboardingReadyReport(stepCtx, {
300
+ teamIds: team ? [team] : undefined,
301
+ localSync: journal?.localSync ?? args.flags["local-sync"] === true,
302
+ observe: observeCloudOnboarding,
303
+ });
304
+ const failed = report.checks.some((check) => check.required && check.state === "fail");
305
+ return {
306
+ state: report.state === "complete"
307
+ ? "done"
308
+ : failed
309
+ ? "failed"
310
+ : "waiting",
311
+ reason: report.state === "complete"
312
+ ? undefined
313
+ : "onboarding_checks_pending",
314
+ evidence: {
315
+ checks: report.checks.length,
316
+ passed: report.checks.filter((check) => check.state === "pass").length,
317
+ },
318
+ };
278
319
  },
279
320
  legacy: deps.legacy,
280
321
  openBrowser: deps.openBrowser,
281
322
  sleep: deps.sleep,
282
323
  skillNames: CUSTOMER_SKILLS,
283
324
  }),
325
+ ...(deps.onboardBootstrap
326
+ ? { bootstrap: deps.onboardBootstrap }
327
+ : {}),
284
328
  ...(deps.isTty ? { isTty: deps.isTty } : {}),
329
+ ...(deps.signal ? { signal: deps.signal } : {}),
285
330
  }, readManifest().version);
286
331
  }
287
332
  finally {
@@ -485,7 +530,10 @@ async function cmdLogin(args, ctx, deps, personalOnly = false) {
485
530
  : ` — expected ${formatMode(CONFIG_MODE)}; chmod it by hand`}`);
486
531
  ctx.stdout(`Tenant contract range: ${manifest.tenantContractRange}`);
487
532
  try {
488
- const loaded = await loadContract(ctx, config, { refresh: true, signal: deps.signal });
533
+ const loaded = await loadContract(ctx, config, {
534
+ refresh: true,
535
+ signal: deps.signal,
536
+ });
489
537
  checkCancelled();
490
538
  ctx.stdout(`Tenant contract ${loaded.doc.contractVersion} cached at ${loaded.path}`);
491
539
  }
package/dist/config.js CHANGED
@@ -144,12 +144,25 @@ function machineFilePresent(file) {
144
144
  throw error;
145
145
  }
146
146
  }
147
+ /** The machine paths record, or null on a machine that has not run paths setup. */
148
+ function declaredMachinePaths(home, env) {
149
+ const file = machinePathsFile({ env: { ...env, HOME: home } });
150
+ if (!file || (env.CATALYST_PATHS_FILE === undefined && !machineFilePresent(file)))
151
+ return null;
152
+ return parseMachinePaths(JSON.parse(readFileSync(file, "utf8")));
153
+ }
154
+ /** True only when this machine opted in to the local replica: CATALYST_REPLICA_DB is set, or the
155
+ * machine paths file declares replicaDb. Every other machine reads the cloud (CTC-4508). */
156
+ export function replicaOptedIn(home, env = {}) {
157
+ if (env.CATALYST_REPLICA_DB !== undefined)
158
+ return true;
159
+ return declaredMachinePaths(home, env)?.paths.replicaDb !== undefined;
160
+ }
147
161
  export function replicaDbPath(cfg, home, env = {}) {
148
162
  if (env.CATALYST_REPLICA_DB !== undefined)
149
163
  return resolveCatalystPath("replicaDb", { env });
150
- const file = machinePathsFile({ env: { ...env, HOME: home } });
151
- if (file && (env.CATALYST_PATHS_FILE !== undefined || machineFilePresent(file))) {
152
- const machine = parseMachinePaths(JSON.parse(readFileSync(file, "utf8")));
164
+ const machine = declaredMachinePaths(home, env);
165
+ if (machine) {
153
166
  if (machine.paths.replicaDb === undefined)
154
167
  throw new CliError("optional replica is not configured; set CATALYST_REPLICA_DB or declare replicaDb in the machine paths file", "replica-not-configured");
155
168
  return resolveCatalystPath("replicaDb", { env, machine });
@@ -0,0 +1,171 @@
1
+ import { DaemonAuthorityError, readDaemonAuthority, } from "./daemon-authority.js";
2
+ function sleep(ms, signal) {
3
+ return new Promise((resolve, reject) => {
4
+ if (signal.aborted) {
5
+ reject(signal.reason);
6
+ return;
7
+ }
8
+ const abort = () => {
9
+ clearTimeout(timer);
10
+ signal.removeEventListener("abort", abort);
11
+ reject(signal.reason);
12
+ };
13
+ const timer = setTimeout(() => {
14
+ signal.removeEventListener("abort", abort);
15
+ resolve();
16
+ }, ms);
17
+ signal.addEventListener("abort", abort, { once: true });
18
+ });
19
+ }
20
+ /** One joined task owns fresh /me renewal; no config refresh/write or token publication shortcut.
21
+ * An expired/changed proof aborts peers through onFailure before waiting for held reader cleanup. */
22
+ export async function createDaemonAuthorityLifetime(ctx, parent) {
23
+ const owned = new AbortController(), lifetime = AbortSignal.any([parent, owned.signal]);
24
+ let proof = await readDaemonAuthority(ctx, lifetime);
25
+ const scope = proof.scope;
26
+ let running;
27
+ let fault;
28
+ let report;
29
+ let notice;
30
+ const failures = [];
31
+ const fail = (error) => {
32
+ if (fault)
33
+ return;
34
+ fault = error;
35
+ // Callback starts synchronously so the supervisor aborts both feeds before drain waits.
36
+ if (report) {
37
+ try {
38
+ notice = report(error.kind === "identity"
39
+ ? "identity_mismatch"
40
+ : "authentication_required");
41
+ }
42
+ catch (error) {
43
+ notice = Promise.reject(error);
44
+ }
45
+ void notice.catch(() => { });
46
+ }
47
+ owned.abort(error);
48
+ };
49
+ const assertCurrent = () => {
50
+ if (fault)
51
+ throw fault;
52
+ try {
53
+ proof.assertCurrent();
54
+ }
55
+ catch (error) {
56
+ if (error instanceof DaemonAuthorityError)
57
+ fail(error);
58
+ throw error;
59
+ }
60
+ };
61
+ return {
62
+ scope,
63
+ assertCurrent,
64
+ async getToken() {
65
+ assertCurrent();
66
+ const selected = proof;
67
+ try {
68
+ const token = await selected.getToken();
69
+ // Renewal may replace proof during the await; the returned bearer still needs its own
70
+ // ORIGINAL proof expiry, not the replacement's later authority window.
71
+ selected.assertCurrent();
72
+ assertCurrent();
73
+ return token;
74
+ }
75
+ catch (error) {
76
+ if (error instanceof DaemonAuthorityError)
77
+ fail(error);
78
+ throw error;
79
+ }
80
+ },
81
+ run(signal, onFailure) {
82
+ if (running)
83
+ throw new Error("daemon_authority_already_running");
84
+ report = onFailure;
85
+ // An earlier synchronous cache guard may already have latched a fault before run starts.
86
+ if (fault && !notice) {
87
+ try {
88
+ notice = report(fault.kind === "identity"
89
+ ? "identity_mismatch"
90
+ : "authentication_required");
91
+ }
92
+ catch (error) {
93
+ notice = Promise.reject(error);
94
+ }
95
+ void notice.catch(() => { });
96
+ }
97
+ const active = AbortSignal.any([lifetime, signal]);
98
+ running = (async () => {
99
+ let timer;
100
+ const expire = () => {
101
+ if (!active.aborted)
102
+ fail(new DaemonAuthorityError("authentication", "daemon_authority_expired"));
103
+ };
104
+ const arm = () => {
105
+ if (timer !== undefined)
106
+ clearTimeout(timer);
107
+ const remaining = proof.expiresAt - ctx.now().getTime();
108
+ if (remaining <= 0) {
109
+ expire();
110
+ return;
111
+ }
112
+ timer = setTimeout(expire, remaining);
113
+ };
114
+ try {
115
+ assertCurrent();
116
+ arm();
117
+ while (!active.aborted) {
118
+ await sleep(10_000, active);
119
+ if (active.aborted)
120
+ break;
121
+ // Old proof's timer stays armed throughout the read and its cleanup ACK.
122
+ const fresh = await readDaemonAuthority(ctx, active);
123
+ if (active.aborted)
124
+ break;
125
+ if (JSON.stringify(fresh.scope) !== JSON.stringify(scope))
126
+ throw new DaemonAuthorityError("identity", "daemon_authority_changed");
127
+ fresh.assertCurrent();
128
+ proof = fresh;
129
+ arm();
130
+ }
131
+ }
132
+ catch (error) {
133
+ if (error instanceof AggregateError) {
134
+ if (!active.aborted) {
135
+ const primary = error.errors[0];
136
+ fail(primary instanceof DaemonAuthorityError
137
+ ? primary
138
+ : new DaemonAuthorityError("authentication", "daemon_authority_cleanup_failed"));
139
+ }
140
+ failures.push(error);
141
+ throw error;
142
+ }
143
+ if (!active.aborted)
144
+ fail(error instanceof DaemonAuthorityError
145
+ ? error
146
+ : new DaemonAuthorityError("authentication", "daemon_authority_unavailable"));
147
+ // Only an actual parent/owned cancellation is an ordinary requested stop. The proof
148
+ // failure callback persists its reason; no automatic restart renews that latch.
149
+ }
150
+ finally {
151
+ if (timer !== undefined)
152
+ clearTimeout(timer);
153
+ if (notice)
154
+ await notice;
155
+ }
156
+ })();
157
+ void running.catch((error) => {
158
+ if (!failures.includes(error))
159
+ failures.push(error);
160
+ });
161
+ return running;
162
+ },
163
+ async stop() {
164
+ owned.abort(new Error("daemon_authority_stop"));
165
+ if (running)
166
+ await running.catch(() => { });
167
+ if (failures.length)
168
+ throw new AggregateError(failures, "daemon_authority_cleanup_failed");
169
+ },
170
+ };
171
+ }