@delegance/claude-autopilot 5.2.2 → 6.2.2

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 (130) hide show
  1. package/CHANGELOG.md +1027 -1
  2. package/README.md +104 -17
  3. package/dist/src/adapters/council/claude.js +2 -1
  4. package/dist/src/adapters/council/openai.js +14 -7
  5. package/dist/src/adapters/deploy/_http.d.ts +43 -0
  6. package/dist/src/adapters/deploy/_http.js +99 -0
  7. package/dist/src/adapters/deploy/fly.d.ts +206 -0
  8. package/dist/src/adapters/deploy/fly.js +696 -0
  9. package/dist/src/adapters/deploy/generic.d.ts +39 -0
  10. package/dist/src/adapters/deploy/generic.js +98 -0
  11. package/dist/src/adapters/deploy/index.d.ts +15 -0
  12. package/dist/src/adapters/deploy/index.js +78 -0
  13. package/dist/src/adapters/deploy/render.d.ts +181 -0
  14. package/dist/src/adapters/deploy/render.js +550 -0
  15. package/dist/src/adapters/deploy/types.d.ts +221 -0
  16. package/dist/src/adapters/deploy/types.js +15 -0
  17. package/dist/src/adapters/deploy/vercel.d.ts +143 -0
  18. package/dist/src/adapters/deploy/vercel.js +426 -0
  19. package/dist/src/adapters/pricing.d.ts +36 -0
  20. package/dist/src/adapters/pricing.js +40 -0
  21. package/dist/src/adapters/review-engine/claude.js +2 -1
  22. package/dist/src/adapters/review-engine/codex.js +12 -8
  23. package/dist/src/adapters/review-engine/gemini.js +2 -1
  24. package/dist/src/adapters/review-engine/openai-compatible.js +2 -1
  25. package/dist/src/adapters/sdk-loader.d.ts +15 -0
  26. package/dist/src/adapters/sdk-loader.js +77 -0
  27. package/dist/src/cli/autopilot.d.ts +71 -0
  28. package/dist/src/cli/autopilot.js +735 -0
  29. package/dist/src/cli/brainstorm.d.ts +23 -0
  30. package/dist/src/cli/brainstorm.js +131 -0
  31. package/dist/src/cli/costs.d.ts +15 -1
  32. package/dist/src/cli/costs.js +99 -10
  33. package/dist/src/cli/deploy.d.ts +71 -0
  34. package/dist/src/cli/deploy.js +539 -0
  35. package/dist/src/cli/fix.d.ts +18 -0
  36. package/dist/src/cli/fix.js +105 -11
  37. package/dist/src/cli/help-text.d.ts +52 -0
  38. package/dist/src/cli/help-text.js +400 -0
  39. package/dist/src/cli/implement.d.ts +91 -0
  40. package/dist/src/cli/implement.js +196 -0
  41. package/dist/src/cli/index.js +784 -222
  42. package/dist/src/cli/json-envelope.d.ts +187 -0
  43. package/dist/src/cli/json-envelope.js +270 -0
  44. package/dist/src/cli/json-mode.d.ts +33 -0
  45. package/dist/src/cli/json-mode.js +201 -0
  46. package/dist/src/cli/migrate.d.ts +111 -0
  47. package/dist/src/cli/migrate.js +305 -0
  48. package/dist/src/cli/plan.d.ts +81 -0
  49. package/dist/src/cli/plan.js +149 -0
  50. package/dist/src/cli/pr.d.ts +106 -0
  51. package/dist/src/cli/pr.js +191 -19
  52. package/dist/src/cli/preflight.js +102 -1
  53. package/dist/src/cli/review.d.ts +27 -0
  54. package/dist/src/cli/review.js +126 -0
  55. package/dist/src/cli/runs-watch-renderer.d.ts +45 -0
  56. package/dist/src/cli/runs-watch-renderer.js +275 -0
  57. package/dist/src/cli/runs-watch.d.ts +41 -0
  58. package/dist/src/cli/runs-watch.js +395 -0
  59. package/dist/src/cli/runs.d.ts +122 -0
  60. package/dist/src/cli/runs.js +902 -0
  61. package/dist/src/cli/scan.d.ts +93 -0
  62. package/dist/src/cli/scan.js +166 -40
  63. package/dist/src/cli/spec.d.ts +66 -0
  64. package/dist/src/cli/spec.js +132 -0
  65. package/dist/src/cli/validate.d.ts +29 -0
  66. package/dist/src/cli/validate.js +131 -0
  67. package/dist/src/core/config/schema.d.ts +43 -0
  68. package/dist/src/core/config/schema.js +25 -0
  69. package/dist/src/core/config/types.d.ts +17 -0
  70. package/dist/src/core/council/runner.d.ts +10 -1
  71. package/dist/src/core/council/runner.js +25 -3
  72. package/dist/src/core/council/types.d.ts +7 -0
  73. package/dist/src/core/errors.d.ts +1 -1
  74. package/dist/src/core/errors.js +12 -0
  75. package/dist/src/core/logging/redaction.d.ts +13 -0
  76. package/dist/src/core/logging/redaction.js +20 -0
  77. package/dist/src/core/migrate/detector-rules.js +6 -0
  78. package/dist/src/core/migrate/schema-validator.js +22 -1
  79. package/dist/src/core/phases/static-rules.d.ts +5 -1
  80. package/dist/src/core/phases/static-rules.js +2 -5
  81. package/dist/src/core/run-state/budget.d.ts +88 -0
  82. package/dist/src/core/run-state/budget.js +141 -0
  83. package/dist/src/core/run-state/cli-internal.d.ts +21 -0
  84. package/dist/src/core/run-state/cli-internal.js +174 -0
  85. package/dist/src/core/run-state/events.d.ts +59 -0
  86. package/dist/src/core/run-state/events.js +504 -0
  87. package/dist/src/core/run-state/lock.d.ts +61 -0
  88. package/dist/src/core/run-state/lock.js +206 -0
  89. package/dist/src/core/run-state/phase-context.d.ts +60 -0
  90. package/dist/src/core/run-state/phase-context.js +108 -0
  91. package/dist/src/core/run-state/phase-registry.d.ts +137 -0
  92. package/dist/src/core/run-state/phase-registry.js +162 -0
  93. package/dist/src/core/run-state/phase-runner.d.ts +80 -0
  94. package/dist/src/core/run-state/phase-runner.js +447 -0
  95. package/dist/src/core/run-state/provider-readback.d.ts +130 -0
  96. package/dist/src/core/run-state/provider-readback.js +426 -0
  97. package/dist/src/core/run-state/replay-decision.d.ts +69 -0
  98. package/dist/src/core/run-state/replay-decision.js +144 -0
  99. package/dist/src/core/run-state/resolve-engine.d.ts +100 -0
  100. package/dist/src/core/run-state/resolve-engine.js +190 -0
  101. package/dist/src/core/run-state/resume-preflight.d.ts +66 -0
  102. package/dist/src/core/run-state/resume-preflight.js +116 -0
  103. package/dist/src/core/run-state/run-phase-with-lifecycle.d.ts +73 -0
  104. package/dist/src/core/run-state/run-phase-with-lifecycle.js +186 -0
  105. package/dist/src/core/run-state/runs.d.ts +57 -0
  106. package/dist/src/core/run-state/runs.js +288 -0
  107. package/dist/src/core/run-state/snapshot.d.ts +14 -0
  108. package/dist/src/core/run-state/snapshot.js +114 -0
  109. package/dist/src/core/run-state/state.d.ts +40 -0
  110. package/dist/src/core/run-state/state.js +164 -0
  111. package/dist/src/core/run-state/types.d.ts +278 -0
  112. package/dist/src/core/run-state/types.js +13 -0
  113. package/dist/src/core/run-state/ulid.d.ts +11 -0
  114. package/dist/src/core/run-state/ulid.js +95 -0
  115. package/dist/src/core/schema-alignment/extractor/index.d.ts +1 -1
  116. package/dist/src/core/schema-alignment/extractor/index.js +2 -2
  117. package/dist/src/core/schema-alignment/extractor/prisma.d.ts +13 -1
  118. package/dist/src/core/schema-alignment/extractor/prisma.js +65 -10
  119. package/dist/src/core/schema-alignment/git-history.d.ts +19 -0
  120. package/dist/src/core/schema-alignment/git-history.js +53 -0
  121. package/dist/src/core/static-rules/rules/brand-tokens.js +2 -2
  122. package/dist/src/core/static-rules/rules/schema-alignment.js +14 -4
  123. package/package.json +9 -5
  124. package/scripts/autoregress.ts +3 -2
  125. package/skills/claude-autopilot.md +1 -1
  126. package/skills/make-interfaces-feel-better/SKILL.md +104 -0
  127. package/skills/migrate/SKILL.md +193 -47
  128. package/skills/simplify-ui/SKILL.md +103 -0
  129. package/skills/ui/SKILL.md +117 -0
  130. package/skills/ui-ux-pro-max/SKILL.md +90 -0
@@ -0,0 +1,696 @@
1
+ // src/adapters/deploy/fly.ts
2
+ //
3
+ // First-class Fly.io deploy adapter. Phase 1 of the v5.6 spec.
4
+ //
5
+ // Implements `deploy()` (POST a new release with a pre-pushed image, then
6
+ // poll until terminal) and `status()` (one-shot GET). Log streaming is
7
+ // Phase 3 (Fly uses WebSockets, not yet wired) and rollback is Phase 4.
8
+ //
9
+ // All HTTP calls go through an injectable `fetchImpl` so unit tests never
10
+ // hit the real Fly Machines API. The endpoint shapes below mirror the
11
+ // Codex-reviewed v5.6 spec at docs/specs/v5.6-fly-render-adapters.md and
12
+ // will be reconciled with captured fixtures during Phase 2 if the published
13
+ // API has drifted; the adapter's surface (auth, error mapping, redaction,
14
+ // capability metadata) is stable regardless of which exact body Fly accepts.
15
+ //
16
+ // Spec: docs/specs/v5.6-fly-render-adapters.md
17
+ import { GuardrailError } from "../../core/errors.js";
18
+ import { redactLogLines } from "../../core/logging/redaction.js";
19
+ import { fetchWithRetry, safeReadBody } from "./_http.js";
20
+ const FLY_API_BASE = 'https://api.machines.dev';
21
+ const FLY_DASHBOARD_BASE = 'https://fly.io/apps';
22
+ const FLY_TOKEN_DOC_URL = 'https://fly.io/dashboard/personal/tokens';
23
+ /**
24
+ * Fly.io deploy adapter.
25
+ *
26
+ * Construct once per pipeline run. The adapter is stateless across calls —
27
+ * all configuration (token, app, image, region) is captured at construction
28
+ * time. Per the v5.6 spec, only `deploy()` and `status()` are wired in
29
+ * Phase 1; `streamLogs` (WebSocket) and `rollback` (native + simulated)
30
+ * land in Phases 3 and 4 respectively.
31
+ */
32
+ export class FlyDeployAdapter {
33
+ name = 'fly';
34
+ capabilities = {
35
+ streamMode: 'websocket',
36
+ nativeRollback: true,
37
+ };
38
+ token;
39
+ app;
40
+ image;
41
+ region;
42
+ pollIntervalMs;
43
+ maxPollMs;
44
+ fetchImpl;
45
+ sleep;
46
+ now;
47
+ redactionPatterns;
48
+ wsImpl;
49
+ buildLogsWsUrlFn;
50
+ constructor(opts) {
51
+ const token = opts.token ?? process.env.FLY_API_TOKEN;
52
+ if (!token) {
53
+ throw new GuardrailError(`Fly deploy adapter requires FLY_API_TOKEN. Create one at ${FLY_TOKEN_DOC_URL}`, { code: 'auth', provider: 'fly' });
54
+ }
55
+ if (!opts.app) {
56
+ throw new GuardrailError('Fly deploy adapter requires `app` (Fly app slug)', { code: 'invalid_config', provider: 'fly' });
57
+ }
58
+ if (!opts.image) {
59
+ throw new GuardrailError('Fly deploy adapter requires `image` (e.g. registry.fly.io/<app>:<tag>). Push first via `fly deploy --build-only --push`.', { code: 'invalid_config', provider: 'fly' });
60
+ }
61
+ this.token = token;
62
+ this.app = opts.app;
63
+ this.image = opts.image;
64
+ this.region = opts.region;
65
+ this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
66
+ this.maxPollMs = opts.maxPollMs ?? 15 * 60 * 1000;
67
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
68
+ this.sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
69
+ this.now = opts.nowImpl ?? Date.now;
70
+ this.redactionPatterns = opts.redactionPatterns;
71
+ // Node 22 ships a global `WebSocket`. We don't fall back to a thrown
72
+ // error here — when a caller invokes `streamLogs` and `wsImpl` is
73
+ // undefined we'd surface that there. Most production runtimes have
74
+ // `globalThis.WebSocket` defined; tests inject `wsImpl` directly.
75
+ this.wsImpl = opts.wsImpl ?? globalThis.WebSocket;
76
+ this.buildLogsWsUrlFn = opts.buildLogsWsUrl ?? defaultFlyLogsWsUrl;
77
+ }
78
+ async deploy(input) {
79
+ const start = this.now();
80
+ const url = `${FLY_API_BASE}/v1/apps/${encodeURIComponent(this.app)}/releases`;
81
+ const body = {
82
+ image: this.image,
83
+ };
84
+ if (this.region)
85
+ body.region = this.region;
86
+ if (input.meta)
87
+ body.meta = input.meta;
88
+ if (input.commitSha)
89
+ body.commit_sha = input.commitSha;
90
+ if (input.ref)
91
+ body.ref = input.ref;
92
+ const res = await fetchWithRetry(this.fetchImpl, url, {
93
+ method: 'POST',
94
+ headers: this.headers(),
95
+ body: JSON.stringify(body),
96
+ signal: input.signal,
97
+ }, { sleepImpl: this.sleep, provider: 'fly' });
98
+ await this.assertOkOrThrow(res, 'create release');
99
+ const created = (await res.json());
100
+ if (!created.id) {
101
+ throw new GuardrailError(`Fly returned no release id (got: ${JSON.stringify(created).slice(0, 200)})`, { code: 'adapter_bug', provider: 'fly' });
102
+ }
103
+ // Fire onDeployStart so callers can subscribe to side-channel work
104
+ // (log streaming once Phase 3 lands) in parallel with polling. Wrap in
105
+ // try/catch — a buggy callback must not crash the deploy.
106
+ try {
107
+ input.onDeployStart?.(created.id);
108
+ }
109
+ catch {
110
+ /* swallow — observability concern only */
111
+ }
112
+ return this.pollUntilTerminal(created.id, start, input.signal);
113
+ }
114
+ async status(input) {
115
+ const start = this.now();
116
+ const url = `${FLY_API_BASE}/v1/apps/${encodeURIComponent(this.app)}/releases/${encodeURIComponent(input.deployId)}`;
117
+ const res = await fetchWithRetry(this.fetchImpl, url, {
118
+ method: 'GET',
119
+ headers: this.headers(),
120
+ signal: input.signal,
121
+ }, { sleepImpl: this.sleep, provider: 'fly' });
122
+ await this.assertOkOrThrow(res, 'get release');
123
+ const data = (await res.json());
124
+ const state = data.state ?? data.status;
125
+ const result = this.shapeResult(input.deployId, data, state, this.now() - start);
126
+ return { ...result, deployId: input.deployId };
127
+ }
128
+ /**
129
+ * Phase 3 of v5.6 — subscribe to real-time build logs for a release via
130
+ * Fly's WebSocket log endpoint.
131
+ *
132
+ * Wire shape:
133
+ * - Connect to `wss://api.machines.dev/v1/apps/{app}/machines/{releaseId}/logs`
134
+ * (intent-level URL per the v5.6 spec's "Logs" bullet — exact path will
135
+ * be reconciled against captured fixtures in Phase 7; the `wsImpl` and
136
+ * `buildLogsWsUrl` injection points keep this overridable until then).
137
+ * - Each WS message is a single NDJSON line containing one log entry.
138
+ * Multiple lines per message are also tolerated (split on `\n`). Malformed
139
+ * JSON lines are skipped silently rather than crashing the iterator.
140
+ * - Auth via `Authorization: Bearer <FLY_API_TOKEN>` is passed through the
141
+ * `protocols` argument (Node's built-in WebSocket doesn't accept custom
142
+ * `headers` directly the way `ws` does); Fly accepts the token as the
143
+ * first protocol value. This is the documented pattern for browsers and
144
+ * matches Node 22's WS surface.
145
+ * - One reconnect with exponential backoff (1s, 2s) on disconnect, then
146
+ * yield a final `level: 'warn'` line referencing `buildLogsUrl` and
147
+ * finish the iterator.
148
+ * - `signal.aborted` is honored at every await boundary; the underlying
149
+ * socket is closed eagerly.
150
+ * - Every yielded line's `text` is run through `redactLogLines()` before
151
+ * leaving the adapter.
152
+ */
153
+ async *streamLogs(input) {
154
+ if (!this.wsImpl) {
155
+ throw new GuardrailError('Fly streamLogs requires a WebSocket implementation (Node 22+ ships one as globalThis.WebSocket; tests can inject `wsImpl`)', { code: 'adapter_bug', provider: 'fly' });
156
+ }
157
+ const buildLogsUrl = this.buildLogsUrl(input.deployId);
158
+ let attempt = 0;
159
+ const maxAttempts = 2; // initial + one reconnect, per spec
160
+ while (attempt < maxAttempts) {
161
+ if (input.signal?.aborted)
162
+ return;
163
+ // Re-build the URL each connection attempt — ensures any caller-side
164
+ // state (counters, freshly-rotated tokens) is sampled per-attempt.
165
+ const url = this.buildLogsWsUrlFn(this.app, input.deployId);
166
+ const queue = new AsyncMessageQueue();
167
+ let socket;
168
+ try {
169
+ // Fly accepts the API token as the first protocol value — see method
170
+ // doc-comment for why we don't use the `headers` option here.
171
+ socket = new this.wsImpl(url, [this.token]);
172
+ }
173
+ catch (err) {
174
+ // Constructor threw synchronously (rare — usually for invalid URL).
175
+ // Treat as a disconnect for retry purposes.
176
+ if (attempt === maxAttempts - 1) {
177
+ yield this.redactLine({
178
+ timestamp: this.now(),
179
+ level: 'warn',
180
+ text: `log stream lost — see ${buildLogsUrl} (constructor: ${err?.message ?? String(err)})`,
181
+ });
182
+ return;
183
+ }
184
+ attempt += 1;
185
+ await this.sleep(1000 * 2 ** (attempt - 1));
186
+ continue;
187
+ }
188
+ const onMessage = (ev) => {
189
+ const data = typeof ev.data === 'string' ? ev.data : safeBufferToString(ev.data);
190
+ if (!data)
191
+ return;
192
+ // NDJSON: one or more newline-separated JSON lines per message.
193
+ for (const raw of data.split('\n')) {
194
+ const line = parseFlyLogLine(raw, this.now());
195
+ if (line)
196
+ queue.push(line);
197
+ }
198
+ };
199
+ const onError = (_ev) => {
200
+ // We let `onClose` drive the reconnect/teardown decision — `error`
201
+ // is purely informational on the standard WS surface.
202
+ };
203
+ const onClose = (_ev) => {
204
+ queue.push({ __end: true });
205
+ };
206
+ const abortHandler = () => {
207
+ try {
208
+ socket.close();
209
+ }
210
+ catch { /* ignore */ }
211
+ queue.push({ __end: true, reason: 'aborted' });
212
+ };
213
+ socket.addEventListener('message', onMessage);
214
+ socket.addEventListener('error', onError);
215
+ socket.addEventListener('close', onClose);
216
+ input.signal?.addEventListener('abort', abortHandler, { once: true });
217
+ try {
218
+ // Drain messages until the socket closes or signal aborts.
219
+ // eslint-disable-next-line no-constant-condition
220
+ while (true) {
221
+ if (input.signal?.aborted)
222
+ return;
223
+ const item = await queue.next();
224
+ if (input.signal?.aborted)
225
+ return;
226
+ if (item && '__end' in item) {
227
+ if (item.reason === 'aborted')
228
+ return;
229
+ break; // close → break inner loop, decide reconnect-or-give-up below
230
+ }
231
+ if (item)
232
+ yield this.redactLine(item);
233
+ }
234
+ }
235
+ finally {
236
+ socket.removeEventListener('message', onMessage);
237
+ socket.removeEventListener('error', onError);
238
+ socket.removeEventListener('close', onClose);
239
+ input.signal?.removeEventListener('abort', abortHandler);
240
+ try {
241
+ socket.close();
242
+ }
243
+ catch { /* ignore */ }
244
+ }
245
+ // Closed — decide whether to retry.
246
+ attempt += 1;
247
+ if (attempt >= maxAttempts) {
248
+ yield this.redactLine({
249
+ timestamp: this.now(),
250
+ level: 'warn',
251
+ text: `log stream lost — see ${buildLogsUrl}`,
252
+ });
253
+ return;
254
+ }
255
+ // Exponential backoff: 1s after first close, 2s after second (won't
256
+ // happen given maxAttempts = 2 today, but kept for future tuning).
257
+ const backoffMs = 1000 * 2 ** (attempt - 1);
258
+ await this.sleep(backoffMs);
259
+ if (input.signal?.aborted)
260
+ return;
261
+ }
262
+ }
263
+ /**
264
+ * Phase 4 of v5.6 — roll back to a previous Fly release.
265
+ *
266
+ * Two modes per spec § "Fly.io adapter → Rollback":
267
+ *
268
+ * 1. Native: try `POST /v1/apps/{app}/releases/{releaseId}/rollback`.
269
+ * This is the historical Fly API; the Machines-era replacement may
270
+ * differ — Phase 7 fixture-capture reconciles. If the endpoint returns
271
+ * 404 / 405 / 410 (removed across the Nomad → Machines transition),
272
+ * fall through to the simulated path. Any other non-OK status
273
+ * (auth, invalid_config, etc.) propagates via `assertOkOrThrow`.
274
+ *
275
+ * 2. Simulated: list prior releases via
276
+ * `GET /v1/apps/{app}/releases?limit=10`, find the most recent one
277
+ * with `status === 'succeeded'` whose `id` differs from the one we'd
278
+ * be rolling back from, and trigger a new deploy with that release's
279
+ * `image`. Re-uses the same POST + poll machinery as `deploy()` via
280
+ * `deployImage()`.
281
+ *
282
+ * When `input.to` is set we treat that as a specific release ID:
283
+ * - Native path uses it as the URL fragment.
284
+ * - Simulated path looks it up in the list to grab its `image`. If the
285
+ * release is not present in the recent-10 window, throw
286
+ * `not_found` — caller almost certainly typo'd the ID.
287
+ *
288
+ * Throws `GuardrailError({ code: 'no_previous_deploy', provider: 'fly' })`
289
+ * when the simulated path runs out of candidates (i.e. no prior release
290
+ * with `status === 'succeeded'` exists).
291
+ */
292
+ async rollback(input) {
293
+ const start = this.now();
294
+ // ── Native path ──
295
+ // When `to` is set, we have a concrete release ID to target. When it's
296
+ // not, we still attempt the native verb on the *previous* release we
297
+ // discover via the list endpoint — same call shape, just one indirection.
298
+ let nativeTargetId = input.to;
299
+ let prevImage;
300
+ if (!nativeTargetId) {
301
+ const prev = await this.findPreviousSucceededRelease(undefined, input.signal);
302
+ if (!prev) {
303
+ throw new GuardrailError(`No previous successful Fly release found for app "${this.app}" to roll back to`, { code: 'no_previous_deploy', provider: 'fly' });
304
+ }
305
+ nativeTargetId = prev.id;
306
+ prevImage = prev.image;
307
+ }
308
+ const nativeUrl = `${FLY_API_BASE}/v1/apps/${encodeURIComponent(this.app)}/releases/${encodeURIComponent(nativeTargetId)}/rollback`;
309
+ let nativeRes;
310
+ try {
311
+ nativeRes = await fetchWithRetry(this.fetchImpl, nativeUrl, {
312
+ method: 'POST',
313
+ headers: this.headers(),
314
+ body: '{}',
315
+ signal: input.signal,
316
+ }, { sleepImpl: this.sleep, provider: 'fly' });
317
+ }
318
+ catch (err) {
319
+ // Network exhaustion is already mapped to GuardrailError(transient_network)
320
+ // by fetchWithRetry — rethrow.
321
+ throw err;
322
+ }
323
+ if (nativeRes.ok) {
324
+ let data;
325
+ try {
326
+ data = (await nativeRes.json());
327
+ }
328
+ catch {
329
+ data = undefined;
330
+ }
331
+ const rawOutput = `Fly release ${nativeTargetId} rolled back natively for app "${this.app}"`;
332
+ return {
333
+ status: 'pass',
334
+ deployId: nativeTargetId,
335
+ rolledBackTo: nativeTargetId,
336
+ deployUrl: data?.hostname ? `https://${data.hostname}` : undefined,
337
+ buildLogsUrl: this.buildLogsUrl(nativeTargetId),
338
+ durationMs: this.now() - start,
339
+ output: redactLogLines(rawOutput, this.redactionPatterns),
340
+ };
341
+ }
342
+ // ── Simulated fallback ──
343
+ // The native rollback verb has been removed from the Machines API in
344
+ // some org/region pairs. 404 (endpoint removed), 405 (method now
345
+ // disallowed), and 410 (gone) all indicate "use the simulated path".
346
+ // Anything else — auth, validation, 5xx exhaustion — propagates.
347
+ if (nativeRes.status !== 404 && nativeRes.status !== 405 && nativeRes.status !== 410) {
348
+ await this.assertOkOrThrow(nativeRes, 'native rollback');
349
+ // assertOkOrThrow always throws for non-OK responses; this is unreachable
350
+ // but keeps the type checker happy.
351
+ throw new GuardrailError(`Fly native rollback returned non-OK ${nativeRes.status} (unreachable)`, { code: 'adapter_bug', provider: 'fly' });
352
+ }
353
+ // Simulated rollback: re-deploy a previous successful image.
354
+ let imageToDeploy;
355
+ let simulatedTargetId;
356
+ if (input.to) {
357
+ // Look up the user-specified release in the recent window to grab its
358
+ // image. We search by id rather than re-using `prevImage` (which is
359
+ // unset when `input.to` was provided).
360
+ const releases = await this.listReleases(10, input.signal);
361
+ const match = releases.find((r) => r.id === input.to);
362
+ if (!match) {
363
+ throw new GuardrailError(`Fly release "${input.to}" not found in the last 10 releases for app "${this.app}" — cannot simulate rollback`, { code: 'not_found', provider: 'fly', step: 'simulated rollback' });
364
+ }
365
+ if (!match.image) {
366
+ throw new GuardrailError(`Fly release "${input.to}" has no recorded image — cannot simulate rollback`, { code: 'invalid_config', provider: 'fly', step: 'simulated rollback' });
367
+ }
368
+ imageToDeploy = match.image;
369
+ simulatedTargetId = match.id;
370
+ }
371
+ else {
372
+ // We already discovered the previous successful release before the
373
+ // native attempt; reuse its image when present, otherwise re-list.
374
+ if (prevImage) {
375
+ imageToDeploy = prevImage;
376
+ simulatedTargetId = nativeTargetId;
377
+ }
378
+ else {
379
+ const prev = await this.findPreviousSucceededRelease(undefined, input.signal);
380
+ if (!prev) {
381
+ throw new GuardrailError(`No previous successful Fly release found for app "${this.app}" to roll back to`, { code: 'no_previous_deploy', provider: 'fly' });
382
+ }
383
+ if (!prev.image) {
384
+ throw new GuardrailError(`Previous Fly release "${prev.id}" has no recorded image — cannot simulate rollback`, { code: 'invalid_config', provider: 'fly', step: 'simulated rollback' });
385
+ }
386
+ imageToDeploy = prev.image;
387
+ simulatedTargetId = prev.id;
388
+ }
389
+ }
390
+ const redeployed = await this.deployImage(imageToDeploy, input.signal);
391
+ const rawOutput = `Fly rollback simulated by re-deploying image "${imageToDeploy}" (prior release ${simulatedTargetId}) → new release ${redeployed.deployId ?? '<unknown>'}`;
392
+ return {
393
+ ...redeployed,
394
+ // Carry the new release id forward as `deployId` (we just deployed it),
395
+ // and flag the prior release as the rollback target so the CLI can
396
+ // surface "rolled back to X (new deploy Y)".
397
+ rolledBackTo: simulatedTargetId,
398
+ durationMs: this.now() - start,
399
+ output: redactLogLines(rawOutput, this.redactionPatterns),
400
+ };
401
+ }
402
+ /**
403
+ * Private helper — re-uses the deploy() POST + poll machinery to deploy a
404
+ * specific image without going through the constructor-stamped image. Used
405
+ * by `rollback()`'s simulated path to redeploy a previous successful image.
406
+ */
407
+ async deployImage(image, signal) {
408
+ const start = this.now();
409
+ const url = `${FLY_API_BASE}/v1/apps/${encodeURIComponent(this.app)}/releases`;
410
+ const body = { image };
411
+ if (this.region)
412
+ body.region = this.region;
413
+ const res = await fetchWithRetry(this.fetchImpl, url, {
414
+ method: 'POST',
415
+ headers: this.headers(),
416
+ body: JSON.stringify(body),
417
+ signal,
418
+ }, { sleepImpl: this.sleep, provider: 'fly' });
419
+ await this.assertOkOrThrow(res, 'create release (rollback)');
420
+ const created = (await res.json());
421
+ if (!created.id) {
422
+ throw new GuardrailError(`Fly returned no release id during rollback (got: ${JSON.stringify(created).slice(0, 200)})`, { code: 'adapter_bug', provider: 'fly' });
423
+ }
424
+ return this.pollUntilTerminal(created.id, start, signal);
425
+ }
426
+ /**
427
+ * List the most recent releases for the configured app. Newest-first.
428
+ * `limit` caps the result set — defaults to 10 (the spec's recommended
429
+ * window for the rollback lookup). 4xx/5xx errors propagate via
430
+ * `assertOkOrThrow`.
431
+ */
432
+ async listReleases(limit = 10, signal) {
433
+ const url = `${FLY_API_BASE}/v1/apps/${encodeURIComponent(this.app)}/releases?limit=${encodeURIComponent(String(limit))}`;
434
+ const res = await fetchWithRetry(this.fetchImpl, url, {
435
+ method: 'GET',
436
+ headers: this.headers(),
437
+ signal,
438
+ }, { sleepImpl: this.sleep, provider: 'fly' });
439
+ await this.assertOkOrThrow(res, 'list releases');
440
+ const data = (await res.json());
441
+ // Be defensive — Fly has shipped both list-envelope and bare-array
442
+ // shapes across API generations.
443
+ const arr = Array.isArray(data) ? data : Array.isArray(data?.releases) ? data.releases : [];
444
+ return arr;
445
+ }
446
+ /**
447
+ * Find the most recent prior release with `status === 'succeeded'`. When
448
+ * `excludeId` is supplied, that release is skipped (used to ensure
449
+ * `rollback()` never returns "rolled back to the deploy I'm rolling back
450
+ * from" when the caller didn't supply `input.to`).
451
+ *
452
+ * Returns `null` when no candidate exists.
453
+ */
454
+ async findPreviousSucceededRelease(excludeId, signal) {
455
+ const releases = await this.listReleases(10, signal);
456
+ // Fly returns newest-first; the first `succeeded` entry is the current
457
+ // prod release. When `excludeId` is unset we still want the *previous*
458
+ // succeeded release — drop the first match and return the next.
459
+ const succeeded = releases.filter((r) => {
460
+ const state = r.state ?? r.status;
461
+ if (state !== 'succeeded')
462
+ return false;
463
+ if (excludeId && r.id === excludeId)
464
+ return false;
465
+ return true;
466
+ });
467
+ if (excludeId) {
468
+ // Caller already filtered out the rollback-from id; return the newest
469
+ // remaining succeeded release.
470
+ return succeeded[0] ?? null;
471
+ }
472
+ // No exclude — drop the head (current prod) and return the next.
473
+ if (succeeded.length < 2)
474
+ return null;
475
+ return succeeded[1] ?? null;
476
+ }
477
+ // ─────────────────────────────────────────────────────────────────────────────
478
+ // private helpers
479
+ // ─────────────────────────────────────────────────────────────────────────────
480
+ /**
481
+ * Apply the adapter's redaction patterns to a log line's `text` field.
482
+ * Pure helper — keeps the streamLogs loop readable.
483
+ */
484
+ redactLine(line) {
485
+ return { ...line, text: redactLogLines(line.text, this.redactionPatterns) };
486
+ }
487
+ async pollUntilTerminal(releaseId, start, signal) {
488
+ const url = `${FLY_API_BASE}/v1/apps/${encodeURIComponent(this.app)}/releases/${encodeURIComponent(releaseId)}`;
489
+ while (true) {
490
+ if (signal?.aborted) {
491
+ return { status: 'in-progress', deployId: releaseId, durationMs: this.now() - start };
492
+ }
493
+ if (this.now() - start > this.maxPollMs) {
494
+ return {
495
+ status: 'in-progress',
496
+ deployId: releaseId,
497
+ durationMs: this.now() - start,
498
+ buildLogsUrl: this.buildLogsUrl(releaseId),
499
+ output: redactLogLines(`Fly release still in progress after ${this.maxPollMs}ms — check ${this.buildLogsUrl(releaseId)}`, this.redactionPatterns),
500
+ };
501
+ }
502
+ const res = await fetchWithRetry(this.fetchImpl, url, {
503
+ method: 'GET',
504
+ headers: this.headers(),
505
+ signal,
506
+ }, { sleepImpl: this.sleep, provider: 'fly' });
507
+ await this.assertOkOrThrow(res, 'poll release');
508
+ const data = (await res.json());
509
+ const state = data.state ?? data.status;
510
+ if (state === 'succeeded' || state === 'failed' || state === 'cancelled') {
511
+ return this.shapeResult(releaseId, data, state, this.now() - start);
512
+ }
513
+ await this.sleep(this.pollIntervalMs);
514
+ }
515
+ }
516
+ shapeResult(releaseId, data, state, durationMs) {
517
+ const status = state === 'succeeded'
518
+ ? 'pass'
519
+ : state === 'failed' || state === 'cancelled'
520
+ ? 'fail'
521
+ : 'in-progress';
522
+ // Apply redaction to the human-readable output line. Real-world Fly
523
+ // logs often echo back env vars and tokens; we never want those landing
524
+ // in PR-comment bodies. (Spec § "Log redaction".)
525
+ const rawOutput = state ? `Fly release ${releaseId}: state=${state}` : undefined;
526
+ return {
527
+ status,
528
+ deployId: releaseId,
529
+ deployUrl: data.hostname ? `https://${data.hostname}` : undefined,
530
+ buildLogsUrl: this.buildLogsUrl(releaseId),
531
+ durationMs,
532
+ output: rawOutput !== undefined ? redactLogLines(rawOutput, this.redactionPatterns) : undefined,
533
+ };
534
+ }
535
+ headers() {
536
+ return {
537
+ Authorization: `Bearer ${this.token}`,
538
+ 'Content-Type': 'application/json',
539
+ };
540
+ }
541
+ buildLogsUrl(releaseId) {
542
+ return `${FLY_DASHBOARD_BASE}/${encodeURIComponent(this.app)}/releases/${encodeURIComponent(releaseId)}`;
543
+ }
544
+ /**
545
+ * HTTP-status-keyed error mapper. Per v5.6 spec:
546
+ *
547
+ * | Status | ErrorCode |
548
+ * |---|---|
549
+ * | 401 / 403 | `auth` |
550
+ * | 404 | `not_found` |
551
+ * | 422 / 400 | `invalid_config` |
552
+ * | 5xx | `transient_network` (retryable) |
553
+ * | other 4xx | `adapter_bug` |
554
+ *
555
+ * The `Fly-Request-Id` response header is captured into `details` whenever
556
+ * present so support tickets can quote it back to Fly.
557
+ */
558
+ async assertOkOrThrow(res, step) {
559
+ if (res.ok)
560
+ return;
561
+ const bodyText = await safeReadBody(res);
562
+ const requestId = readFlyRequestId(res);
563
+ const details = { status: res.status };
564
+ if (requestId)
565
+ details.flyRequestId = requestId;
566
+ if (res.status === 401 || res.status === 403) {
567
+ throw new GuardrailError(`Fly auth failed (${res.status}) on ${step} — check FLY_API_TOKEN scope for app "${this.app}". Regenerate at ${FLY_TOKEN_DOC_URL}: ${bodyText}`, { code: 'auth', provider: 'fly', step, details });
568
+ }
569
+ if (res.status === 404) {
570
+ throw new GuardrailError(`Fly resource not found (${res.status}) on ${step} — app slug "${this.app}" may be wrong, or the release ID belongs to a different app${requestId ? ` (Fly-Request-Id: ${requestId})` : ''}: ${bodyText}`, { code: 'not_found', provider: 'fly', step, details });
571
+ }
572
+ if (res.status === 422 || res.status === 400) {
573
+ throw new GuardrailError(`Fly rejected the request (${res.status}) on ${step} — likely a bad image reference, missing region, or malformed body: ${bodyText}`, { code: 'invalid_config', provider: 'fly', step, details });
574
+ }
575
+ if (res.status >= 500 && res.status < 600) {
576
+ throw new GuardrailError(`Fly API server error (${res.status}) on ${step}: ${bodyText}`, { code: 'transient_network', provider: 'fly', step, details, retryable: true });
577
+ }
578
+ throw new GuardrailError(`Fly API error (${res.status}) on ${step}: ${bodyText}`, { code: 'adapter_bug', provider: 'fly', step, details });
579
+ }
580
+ }
581
+ /**
582
+ * Pull `Fly-Request-Id` (case-insensitive) off the response. Fly echoes this
583
+ * header on every API response and support tickets quote it back, so we
584
+ * stash it in `GuardrailError.details.flyRequestId` for any non-OK status.
585
+ *
586
+ * Falls back to `null` when `headers.get` is unavailable (e.g. a stubbed
587
+ * Response in tests that doesn't implement Headers).
588
+ */
589
+ function readFlyRequestId(res) {
590
+ const headers = res.headers;
591
+ if (!headers || typeof headers.get !== 'function')
592
+ return null;
593
+ return headers.get('Fly-Request-Id') ?? headers.get('fly-request-id') ?? null;
594
+ }
595
+ /**
596
+ * Default WebSocket URL builder for Fly log streaming.
597
+ *
598
+ * The URL shape below is intent-level per the v5.6 spec § "Fly.io adapter →
599
+ * Logs":
600
+ *
601
+ * wss://api.machines.dev/v1/apps/{app}/machines/{machineId}/logs
602
+ *
603
+ * Phase 7 of v5.6 reconciles this against captured fixtures from a real
604
+ * Fly account. If the published path differs (e.g. `/releases/{id}/logs` or
605
+ * a different host), we'll update this builder there. Until then, callers
606
+ * who hit a divergent path can pass `buildLogsWsUrl` to override.
607
+ *
608
+ * Note: we treat the `deployId` (release id) as the machine id for now —
609
+ * Fly's deploy → release → machine mapping is not 1:1 in all cases, and
610
+ * Phase 7 will need to either look up the machine list before subscribing
611
+ * or use a different log endpoint that takes a release id directly.
612
+ */
613
+ function defaultFlyLogsWsUrl(app, releaseId) {
614
+ // wss base mirrors FLY_API_BASE but with a `wss://` scheme.
615
+ return `wss://api.machines.dev/v1/apps/${encodeURIComponent(app)}/machines/${encodeURIComponent(releaseId)}/logs`;
616
+ }
617
+ /**
618
+ * Best-effort decoder for the binary `data` field of a `MessageEvent`.
619
+ * Fly normally sends UTF-8 text; tests may pass a Buffer or Uint8Array.
620
+ */
621
+ function safeBufferToString(data) {
622
+ if (data == null)
623
+ return '';
624
+ if (typeof data === 'string')
625
+ return data;
626
+ if (data instanceof ArrayBuffer)
627
+ return new TextDecoder('utf-8').decode(data);
628
+ if (ArrayBuffer.isView(data)) {
629
+ const view = data;
630
+ return new TextDecoder('utf-8').decode(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
631
+ }
632
+ if (typeof data.toString === 'function') {
633
+ return data.toString();
634
+ }
635
+ return '';
636
+ }
637
+ /**
638
+ * Parse a single NDJSON log line from Fly's WS stream into a `DeployLogLine`.
639
+ *
640
+ * Fly wraps log entries in objects whose canonical shape is roughly
641
+ * `{ timestamp: <epoch_ms>, level: 'info' | 'warn' | 'error', message: '<text>' }`.
642
+ * We accept both `message` and `text` (older Fly clients use the latter).
643
+ * Lines that fail to JSON-parse OR that have no usable text return `null`,
644
+ * which the caller drops silently — never crash a long-running stream.
645
+ */
646
+ function parseFlyLogLine(raw, fallbackTs) {
647
+ const trimmed = raw.trim();
648
+ if (!trimmed)
649
+ return null;
650
+ let parsed;
651
+ try {
652
+ parsed = JSON.parse(trimmed);
653
+ }
654
+ catch {
655
+ // Not all lines are JSON — Fly occasionally emits raw text (e.g. boot
656
+ // banners). Surface those as plain stdout entries.
657
+ return { timestamp: fallbackTs, level: 'info', text: trimmed };
658
+ }
659
+ if (typeof parsed !== 'object' || parsed === null)
660
+ return null;
661
+ const obj = parsed;
662
+ const text = typeof obj.message === 'string' ? obj.message : typeof obj.text === 'string' ? obj.text : '';
663
+ if (!text)
664
+ return null;
665
+ const ts = typeof obj.timestamp === 'number'
666
+ ? obj.timestamp
667
+ : typeof obj.ts === 'number' ? obj.ts : fallbackTs;
668
+ return { timestamp: ts, level: obj.level, text };
669
+ }
670
+ /**
671
+ * Tiny FIFO queue with an awaitable `next()`. Backs the WS event-pump → async
672
+ * generator bridge in `streamLogs`. Resolves promises in push order; if the
673
+ * queue is empty, `next()` returns a promise that resolves on the next push.
674
+ */
675
+ class AsyncMessageQueue {
676
+ buffer = [];
677
+ waiter = null;
678
+ push(item) {
679
+ if (this.waiter) {
680
+ const w = this.waiter;
681
+ this.waiter = null;
682
+ w(item);
683
+ return;
684
+ }
685
+ this.buffer.push(item);
686
+ }
687
+ next() {
688
+ const head = this.buffer.shift();
689
+ if (head !== undefined)
690
+ return Promise.resolve(head);
691
+ return new Promise((resolve) => {
692
+ this.waiter = resolve;
693
+ });
694
+ }
695
+ }
696
+ //# sourceMappingURL=fly.js.map