@catalyst-cloud/cli 0.14.6 → 0.14.8

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 (79) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -5
  3. package/dist/args.js +68 -18
  4. package/dist/browser.js +37 -3
  5. package/dist/cli.js +58 -12
  6. package/dist/config.js +17 -5
  7. package/dist/consent-browser.js +58 -0
  8. package/dist/daemon-authority-lifetime.js +171 -0
  9. package/dist/daemon-authority.js +216 -0
  10. package/dist/daemon-cache.js +348 -0
  11. package/dist/daemon-store.js +370 -0
  12. package/dist/daemon-supervisor.js +460 -0
  13. package/dist/env/onboard-draft-store.js +250 -0
  14. package/dist/env/onboard-draft.js +33 -0
  15. package/dist/env/selected-import-writer.js +163 -0
  16. package/dist/env/settings-authority.js +205 -0
  17. package/dist/events-cloud-tail.js +146 -0
  18. package/dist/events-cloud.js +619 -0
  19. package/dist/events.js +186 -31
  20. package/dist/oauth.js +129 -34
  21. package/dist/onboard-accounts.js +259 -0
  22. package/dist/onboard-automation-management.js +15 -0
  23. package/dist/onboard-bootstrap.js +162 -0
  24. package/dist/onboard-capabilities.js +15 -7
  25. package/dist/onboard-capacity.js +226 -0
  26. package/dist/onboard-existing.js +106 -32
  27. package/dist/onboard-file-snapshot.js +45 -0
  28. package/dist/onboard-github.js +14 -3
  29. package/dist/onboard-login-candidate.js +261 -0
  30. package/dist/onboard-next.js +139 -0
  31. package/dist/onboard-personal.js +14 -3
  32. package/dist/onboard-projects.js +350 -0
  33. package/dist/onboard-repositories.js +146 -37
  34. package/dist/onboard-runtime.js +108 -13
  35. package/dist/onboard-settings-context.js +141 -0
  36. package/dist/onboard-settings-import.js +127 -0
  37. package/dist/onboard-settings.js +234 -0
  38. package/dist/onboard-ui.js +85 -59
  39. package/dist/onboard-workflow.js +316 -0
  40. package/dist/onboard-workspace.js +14 -3
  41. package/dist/onboard.js +422 -39
  42. package/dist/owned-fetch.js +182 -0
  43. package/dist/pagination.js +13 -1
  44. package/dist/query.js +104 -31
  45. package/dist/ready.js +143 -35
  46. package/dist/replica.js +11 -4
  47. package/dist/repository-id.js +5 -0
  48. package/dist/sdk.js +33 -0
  49. package/dist/watch.js +2 -2
  50. package/package.json +3 -2
  51. package/skills/catalyst-github/SKILL.md +4 -2
  52. package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
  53. package/skills/catalyst-github/references/local-replica.md +78 -0
  54. package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
  55. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  56. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
  57. package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
  58. package/skills/catalyst-linear/SKILL.md +5 -3
  59. package/skills/catalyst-linear/references/local-replica.md +91 -0
  60. package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
  61. package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
  62. package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
  63. package/skills/catalyst-onboard/SKILL.md +6 -4
  64. package/skills/catalyst-onboard/references/local-replica.md +40 -0
  65. package/skills/catalyst-onboard/references/local-sync.md +8 -4
  66. package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
  67. package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
  68. package/skills/run-this-project/SKILL.md +1 -1
  69. package/skills/unstick/SKILL.md +1 -1
  70. package/skills/what-needs-me/SKILL.md +1 -1
  71. package/skills/what-this-repo-needs/SKILL.md +1 -1
  72. package/skills/whats-happening/SKILL.md +4 -2
  73. package/skills/whats-happening/references/local-replica.md +51 -0
  74. package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
  75. package/skills/whats-happening/references/status-reply.md +1 -1
  76. package/skills/whats-happening/scripts/snapshot.mjs +104 -26
  77. package/vendor/settings/index.d.ts +4 -0
  78. package/vendor/settings/index.js +2937 -0
  79. package/vendor/settings/provenance.json +148 -0
@@ -0,0 +1,460 @@
1
+ const sameBinding = (a, b) => a.account === b.account &&
2
+ a.origin === b.origin &&
3
+ a.replicaDb === b.replicaDb &&
4
+ a.eventDirectory === b.eventDirectory;
5
+ const copy = (state) => ({
6
+ ...state,
7
+ binding: { ...state.binding },
8
+ replica: { ...state.replica },
9
+ events: { ...state.events },
10
+ stopped: state.stopped ? { ...state.stopped } : null,
11
+ });
12
+ function deferred() {
13
+ let resolve;
14
+ const promise = new Promise((done) => {
15
+ resolve = done;
16
+ });
17
+ return { promise, resolve };
18
+ }
19
+ /** Exit 0 means requested or deliberate stop. Unexpected failures reject for OS crash restart.
20
+ * “live” describes both running clients, not an independent current cloud-head freshness proof. */
21
+ export async function runDaemonSupervisor(ports) {
22
+ let claimed = false;
23
+ let failed = false;
24
+ let primary;
25
+ let result = null;
26
+ try {
27
+ result = await runOwnedDaemonSupervisor({
28
+ ...ports,
29
+ claim: async () => {
30
+ const lease = await ports.claim();
31
+ claimed = true;
32
+ return lease;
33
+ },
34
+ });
35
+ }
36
+ catch (error) {
37
+ failed = true;
38
+ primary = error;
39
+ }
40
+ finally {
41
+ // Before claim succeeds the inner lease lifetime has not begun, but this call still owns
42
+ // the injected authority task, including its refused preflight and any entered reader IO.
43
+ if (!claimed && ports.authority) {
44
+ try {
45
+ await ports.authority.stop();
46
+ }
47
+ catch (cleanup) {
48
+ throw new AggregateError(failed ? [primary, cleanup] : [cleanup], "daemon_authority_cleanup_failed");
49
+ }
50
+ }
51
+ }
52
+ if (failed)
53
+ throw primary;
54
+ return result;
55
+ }
56
+ async function runOwnedDaemonSupervisor(ports) {
57
+ const maxFailures = ports.maxFailures ?? 5;
58
+ const liveStableMs = ports.liveStableMs ?? 60_000;
59
+ if (!Number.isSafeInteger(liveStableMs) ||
60
+ liveStableMs < 1 ||
61
+ liveStableMs > 900_000)
62
+ throw new Error("daemon_stability_budget_invalid");
63
+ if (!Number.isSafeInteger(maxFailures) ||
64
+ maxFailures < 1 ||
65
+ maxFailures > 100)
66
+ throw new Error("daemon_failure_cap_invalid");
67
+ if (ports.signal.aborted)
68
+ return null;
69
+ ports.authority?.assertCurrent();
70
+ const lease = await ports.claim();
71
+ const owned = new AbortController();
72
+ const signal = AbortSignal.any([owned.signal, ports.signal]);
73
+ const stop = deferred();
74
+ const stopped = () => stop.resolve(undefined);
75
+ signal.addEventListener("abort", stopped, { once: true });
76
+ if (signal.aborted)
77
+ stopped();
78
+ let record = null;
79
+ // A rejected cleanup has not acknowledged ownership release, even if start has settled.
80
+ let cleanupAcknowledged = true;
81
+ let queue = Promise.resolve();
82
+ const eventClient = { current: null };
83
+ const backgroundFailure = {
84
+ present: false,
85
+ };
86
+ const write = async () => {
87
+ if (!record)
88
+ return;
89
+ record.updatedAt = ports.now();
90
+ record.state =
91
+ record.stopped || signal.aborted
92
+ ? "stopped"
93
+ : record.replica.state === "live" && record.events.state === "live"
94
+ ? "live"
95
+ : record.replica.state === "backoff" ||
96
+ record.events.state === "backoff"
97
+ ? "backoff"
98
+ : "starting";
99
+ const snapshot = copy(record);
100
+ const pending = queue.then(() => lease.write(snapshot));
101
+ queue = pending.catch(() => { });
102
+ await pending;
103
+ };
104
+ const terminal = async (reason) => {
105
+ if (!record || record.stopped)
106
+ return;
107
+ record.stopped = {
108
+ at: ports.now(),
109
+ reason,
110
+ restartWith: "catalyst daemon restart",
111
+ };
112
+ // Stop the other client immediately; the latch is saved before this lifetime releases its lease.
113
+ owned.abort();
114
+ await write();
115
+ };
116
+ const checkAuthority = () => {
117
+ try {
118
+ ports.authority?.assertCurrent();
119
+ return null;
120
+ }
121
+ catch (error) {
122
+ const kind = ports.classify(error);
123
+ if (kind === "authentication" || kind === "identity")
124
+ return terminal(kind === "identity" ? "identity_mismatch" : "authentication_required");
125
+ throw error;
126
+ }
127
+ };
128
+ const failure = async (component, error) => {
129
+ if (!record || signal.aborted)
130
+ return;
131
+ const kind = ports.classify(error);
132
+ if (kind === "unexpected")
133
+ throw error;
134
+ if (kind !== "transient") {
135
+ const reasons = {
136
+ authentication: "authentication_required",
137
+ "history-gap": "event_history_gap",
138
+ "writer-conflict": "writer_conflict",
139
+ identity: "identity_mismatch",
140
+ };
141
+ await terminal(reasons[kind]);
142
+ return;
143
+ }
144
+ const state = record[component];
145
+ state.state = "backoff";
146
+ state.failures++;
147
+ if (state.failures >= maxFailures) {
148
+ await terminal(component === "replica" ? "replica_failure_cap" : "event_failure_cap");
149
+ return;
150
+ }
151
+ await write();
152
+ const jitter = ports.random();
153
+ if (!Number.isFinite(jitter) || jitter < 0 || jitter >= 1)
154
+ throw new Error("daemon_jitter_invalid");
155
+ try {
156
+ await ports.sleep(Math.floor(Math.min(30_000 * 2 ** (state.failures - 1), 900_000) * jitter), signal);
157
+ }
158
+ catch (error) {
159
+ if (!signal.aborted)
160
+ throw error;
161
+ }
162
+ };
163
+ const replicaLoop = async () => {
164
+ while (!signal.aborted) {
165
+ let attempt = null;
166
+ let started = null;
167
+ let problem;
168
+ const failed = deferred();
169
+ try {
170
+ const denied = checkAuthority();
171
+ if (denied) {
172
+ await denied;
173
+ break;
174
+ }
175
+ attempt = await ports.replica();
176
+ if (signal.aborted)
177
+ break;
178
+ const factoryDenied = checkAuthority();
179
+ if (factoryDenied) {
180
+ await factoryDenied;
181
+ break;
182
+ }
183
+ record.replica.state = "starting";
184
+ await write();
185
+ const startDenied = checkAuthority();
186
+ if (startDenied) {
187
+ await startDenied;
188
+ break;
189
+ }
190
+ started = attempt.start((state) => {
191
+ if (signal.aborted)
192
+ return;
193
+ if (state === "auth-required") {
194
+ // A known auth refusal stops both feeds and records its latch immediately, even
195
+ // while this attempt is still draining IO in close().
196
+ void terminal("authentication_required").catch((error) => {
197
+ backgroundFailure.present = true;
198
+ backgroundFailure.error = error;
199
+ owned.abort();
200
+ });
201
+ failed.resolve({ daemonFailure: "authentication" });
202
+ }
203
+ else if (state === "reconnecting") {
204
+ record.replica.state = "backoff";
205
+ record.state = "backoff";
206
+ // Queue the cleared barrier before draining this attempt. Store failures are crashes,
207
+ // even when a transient reconnect has already won the attempt's failure race.
208
+ void write().catch((error) => {
209
+ backgroundFailure.present = true;
210
+ backgroundFailure.error = error;
211
+ owned.abort();
212
+ });
213
+ failed.resolve({ daemonFailure: "transient" });
214
+ }
215
+ });
216
+ // Both branches have rejection handlers; cleanup below awaits the losing start promise too.
217
+ const first = await Promise.race([
218
+ started.then(() => ({ ready: true }), (error) => ({ error })),
219
+ failed.promise.then((error) => ({ error })),
220
+ stop.promise.then(() => ({ stopped: true })),
221
+ ]);
222
+ if ("error" in first)
223
+ problem = first.error;
224
+ else if ("ready" in first && !signal.aborted) {
225
+ const liveDenied = checkAuthority();
226
+ if (liveDenied) {
227
+ await liveDenied;
228
+ break;
229
+ }
230
+ record.replica.state = "live";
231
+ await write();
232
+ // A warm connection can precede a failed resync. Reset persisted failures only after a
233
+ // stable lifetime, so crash/reconnect loops cannot repeatedly erase their failure cap.
234
+ if (record.replica.failures > 0) {
235
+ const stable = new AbortController();
236
+ try {
237
+ const outcome = await Promise.race([
238
+ failed.promise.then((error) => ({ error })),
239
+ stop.promise.then(() => ({ stopped: true })),
240
+ ports
241
+ .sleep(liveStableMs, AbortSignal.any([signal, stable.signal]))
242
+ .then(() => ({ stable: true })),
243
+ ]);
244
+ if ("stable" in outcome && !signal.aborted) {
245
+ const stableDenied = checkAuthority();
246
+ if (stableDenied) {
247
+ await stableDenied;
248
+ break;
249
+ }
250
+ record.replica.failures = 0;
251
+ await write();
252
+ problem = await Promise.race([failed.promise, stop.promise]);
253
+ }
254
+ else if ("error" in outcome)
255
+ problem = outcome.error;
256
+ }
257
+ finally {
258
+ stable.abort();
259
+ }
260
+ }
261
+ else
262
+ problem = await Promise.race([failed.promise, stop.promise]);
263
+ }
264
+ }
265
+ catch (error) {
266
+ problem = error;
267
+ }
268
+ finally {
269
+ // SDK close resolves/cancels start and releases only this attempt's writer lock.
270
+ try {
271
+ if (attempt)
272
+ await attempt.close();
273
+ }
274
+ catch (error) {
275
+ cleanupAcknowledged = false;
276
+ // A failed close cannot keep its peer running while the entered start task drains.
277
+ owned.abort();
278
+ throw error;
279
+ }
280
+ finally {
281
+ if (started)
282
+ await started.catch(() => { });
283
+ }
284
+ }
285
+ if (!signal.aborted)
286
+ await failure("replica", problem);
287
+ }
288
+ };
289
+ const eventLoop = async () => {
290
+ if (signal.aborted)
291
+ return;
292
+ const denied = checkAuthority();
293
+ if (denied) {
294
+ await denied;
295
+ return;
296
+ }
297
+ eventClient.current = await ports.events();
298
+ let idle = 1_000;
299
+ while (!signal.aborted) {
300
+ const denied = checkAuthority();
301
+ if (denied) {
302
+ await denied;
303
+ break;
304
+ }
305
+ const request = new AbortController();
306
+ const requestSignal = AbortSignal.any([signal, request.signal]);
307
+ const timer = setTimeout(() => request.abort(), 30_000);
308
+ let problem;
309
+ let failed = false;
310
+ try {
311
+ // Direct syncOnce is not tracked by SDK stop(). Await it here even after cancellation:
312
+ // releasing its lock early would permit a second writer while old filesystem IO continues.
313
+ const result = await eventClient.current.syncOnce(requestSignal);
314
+ if (signal.aborted)
315
+ break;
316
+ if (request.signal.aborted)
317
+ throw { daemonFailure: "transient" };
318
+ const resultDenied = checkAuthority();
319
+ if (resultDenied) {
320
+ await resultDenied;
321
+ break;
322
+ }
323
+ if (![result.appended, result.cursor, result.head].every((value) => Number.isSafeInteger(value) && value >= 0) ||
324
+ result.cursor > result.head)
325
+ throw new Error("daemon_event_result_invalid");
326
+ record.events = {
327
+ state: result.cursor === result.head ? "live" : "starting",
328
+ failures: 0,
329
+ cursor: result.cursor,
330
+ head: result.head,
331
+ };
332
+ await write();
333
+ idle = result.appended > 0 ? 1_000 : Math.min(idle * 2, 30_000);
334
+ }
335
+ catch (error) {
336
+ problem = error;
337
+ failed = true;
338
+ }
339
+ finally {
340
+ clearTimeout(timer);
341
+ }
342
+ if (signal.aborted)
343
+ break;
344
+ if (failed)
345
+ await failure("events", problem);
346
+ else {
347
+ try {
348
+ await ports.sleep(idle, signal);
349
+ }
350
+ catch (error) {
351
+ if (!signal.aborted)
352
+ throw error;
353
+ }
354
+ }
355
+ }
356
+ };
357
+ try {
358
+ const previous = await lease.read();
359
+ if (previous && !sameBinding(previous.binding, ports.binding))
360
+ throw new Error("daemon_record_identity_mismatch");
361
+ if (previous?.stopped && !ports.restart)
362
+ return previous;
363
+ if (previous &&
364
+ ![previous.replica.failures, previous.events.failures].every((value) => Number.isSafeInteger(value) && value >= 0))
365
+ throw new Error("daemon_record_invalid");
366
+ if (signal.aborted)
367
+ return null;
368
+ record = {
369
+ schema: 1,
370
+ binding: { ...ports.binding },
371
+ runId: ports.runId,
372
+ pid: ports.pid,
373
+ updatedAt: ports.now(),
374
+ state: "starting",
375
+ replica: {
376
+ state: "starting",
377
+ failures: ports.restart ? 0 : (previous?.replica.failures ?? 0),
378
+ },
379
+ events: {
380
+ state: "starting",
381
+ failures: ports.restart ? 0 : (previous?.events.failures ?? 0),
382
+ cursor: previous?.events.cursor ?? null,
383
+ head: previous?.events.head ?? null,
384
+ },
385
+ stopped: null,
386
+ };
387
+ await write();
388
+ if (record.replica.failures >= maxFailures ||
389
+ record.events.failures >= maxFailures) {
390
+ await terminal(record.replica.failures >= maxFailures
391
+ ? "replica_failure_cap"
392
+ : "event_failure_cap");
393
+ return record;
394
+ }
395
+ const denied = checkAuthority();
396
+ if (denied) {
397
+ await denied;
398
+ return record;
399
+ }
400
+ const tasks = [
401
+ ...(ports.authority ? [ports.authority.run(signal, terminal)] : []),
402
+ replicaLoop(),
403
+ eventLoop(),
404
+ ].map((task) => task.catch((error) => {
405
+ owned.abort();
406
+ throw error;
407
+ }));
408
+ const results = await Promise.allSettled(tasks);
409
+ if (backgroundFailure.present)
410
+ throw backgroundFailure.error;
411
+ const failed = results.find((result) => result.status === "rejected");
412
+ if (failed?.status === "rejected")
413
+ throw failed.reason;
414
+ return record;
415
+ }
416
+ finally {
417
+ owned.abort();
418
+ // Event loop has fully acknowledged direct syncOnce settlement before stop releases its lock.
419
+ try {
420
+ // Join every entered cleanup even if its peer rejects. A failed authority reader ACK
421
+ // retains the same lease as a failed SDK close/stop.
422
+ const cleanups = await Promise.allSettled([
423
+ ...(eventClient.current
424
+ ? [Promise.resolve().then(() => eventClient.current.stop())]
425
+ : []),
426
+ ...(ports.authority
427
+ ? [Promise.resolve().then(() => ports.authority.stop())]
428
+ : []),
429
+ ]);
430
+ const failures = cleanups.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
431
+ if (failures.length === 1)
432
+ throw failures[0];
433
+ if (failures.length > 1)
434
+ throw new AggregateError(failures, "daemon_cleanup_failed");
435
+ }
436
+ catch (error) {
437
+ cleanupAcknowledged = false;
438
+ throw error;
439
+ }
440
+ finally {
441
+ try {
442
+ if (record && cleanupAcknowledged) {
443
+ record.replica.state = "stopped";
444
+ record.events.state = "stopped";
445
+ await write();
446
+ }
447
+ await queue;
448
+ if (backgroundFailure.present)
449
+ throw backgroundFailure.error;
450
+ }
451
+ finally {
452
+ signal.removeEventListener("abort", stopped);
453
+ // Retain the live PID-bound lease on cleanup failure. Only positive process death
454
+ // permits a later claimant to recover it; SDK writer locks remain owned as well.
455
+ if (cleanupAcknowledged)
456
+ await lease.release();
457
+ }
458
+ }
459
+ }
460
+ }