@delegance/claude-autopilot 5.5.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 (119) hide show
  1. package/CHANGELOG.md +935 -6
  2. package/README.md +55 -0
  3. package/dist/src/adapters/council/openai.js +12 -6
  4. package/dist/src/adapters/deploy/_http.d.ts +43 -0
  5. package/dist/src/adapters/deploy/_http.js +99 -0
  6. package/dist/src/adapters/deploy/fly.d.ts +206 -0
  7. package/dist/src/adapters/deploy/fly.js +696 -0
  8. package/dist/src/adapters/deploy/index.d.ts +2 -0
  9. package/dist/src/adapters/deploy/index.js +33 -0
  10. package/dist/src/adapters/deploy/render.d.ts +181 -0
  11. package/dist/src/adapters/deploy/render.js +550 -0
  12. package/dist/src/adapters/deploy/types.d.ts +67 -3
  13. package/dist/src/adapters/deploy/vercel.d.ts +17 -1
  14. package/dist/src/adapters/deploy/vercel.js +29 -49
  15. package/dist/src/adapters/pricing.d.ts +36 -0
  16. package/dist/src/adapters/pricing.js +40 -0
  17. package/dist/src/adapters/review-engine/codex.js +10 -7
  18. package/dist/src/cli/autopilot.d.ts +71 -0
  19. package/dist/src/cli/autopilot.js +735 -0
  20. package/dist/src/cli/brainstorm.d.ts +23 -0
  21. package/dist/src/cli/brainstorm.js +131 -0
  22. package/dist/src/cli/costs.d.ts +15 -1
  23. package/dist/src/cli/costs.js +99 -10
  24. package/dist/src/cli/deploy.d.ts +3 -3
  25. package/dist/src/cli/deploy.js +34 -9
  26. package/dist/src/cli/fix.d.ts +18 -0
  27. package/dist/src/cli/fix.js +105 -11
  28. package/dist/src/cli/help-text.d.ts +52 -0
  29. package/dist/src/cli/help-text.js +400 -0
  30. package/dist/src/cli/implement.d.ts +91 -0
  31. package/dist/src/cli/implement.js +196 -0
  32. package/dist/src/cli/index.js +719 -245
  33. package/dist/src/cli/json-envelope.d.ts +187 -0
  34. package/dist/src/cli/json-envelope.js +270 -0
  35. package/dist/src/cli/json-mode.d.ts +33 -0
  36. package/dist/src/cli/json-mode.js +201 -0
  37. package/dist/src/cli/migrate.d.ts +111 -0
  38. package/dist/src/cli/migrate.js +305 -0
  39. package/dist/src/cli/plan.d.ts +81 -0
  40. package/dist/src/cli/plan.js +149 -0
  41. package/dist/src/cli/pr.d.ts +106 -0
  42. package/dist/src/cli/pr.js +191 -19
  43. package/dist/src/cli/preflight.js +26 -0
  44. package/dist/src/cli/review.d.ts +27 -0
  45. package/dist/src/cli/review.js +126 -0
  46. package/dist/src/cli/runs-watch-renderer.d.ts +45 -0
  47. package/dist/src/cli/runs-watch-renderer.js +275 -0
  48. package/dist/src/cli/runs-watch.d.ts +41 -0
  49. package/dist/src/cli/runs-watch.js +395 -0
  50. package/dist/src/cli/runs.d.ts +122 -0
  51. package/dist/src/cli/runs.js +902 -0
  52. package/dist/src/cli/scan.d.ts +93 -0
  53. package/dist/src/cli/scan.js +166 -40
  54. package/dist/src/cli/spec.d.ts +66 -0
  55. package/dist/src/cli/spec.js +132 -0
  56. package/dist/src/cli/validate.d.ts +29 -0
  57. package/dist/src/cli/validate.js +131 -0
  58. package/dist/src/core/config/schema.d.ts +9 -0
  59. package/dist/src/core/config/schema.js +7 -0
  60. package/dist/src/core/config/types.d.ts +11 -0
  61. package/dist/src/core/council/runner.d.ts +10 -1
  62. package/dist/src/core/council/runner.js +25 -3
  63. package/dist/src/core/council/types.d.ts +7 -0
  64. package/dist/src/core/errors.d.ts +1 -1
  65. package/dist/src/core/errors.js +11 -0
  66. package/dist/src/core/logging/redaction.d.ts +13 -0
  67. package/dist/src/core/logging/redaction.js +20 -0
  68. package/dist/src/core/migrate/schema-validator.js +15 -1
  69. package/dist/src/core/phases/static-rules.d.ts +5 -1
  70. package/dist/src/core/phases/static-rules.js +2 -5
  71. package/dist/src/core/run-state/budget.d.ts +88 -0
  72. package/dist/src/core/run-state/budget.js +141 -0
  73. package/dist/src/core/run-state/cli-internal.d.ts +21 -0
  74. package/dist/src/core/run-state/cli-internal.js +174 -0
  75. package/dist/src/core/run-state/events.d.ts +59 -0
  76. package/dist/src/core/run-state/events.js +504 -0
  77. package/dist/src/core/run-state/lock.d.ts +61 -0
  78. package/dist/src/core/run-state/lock.js +206 -0
  79. package/dist/src/core/run-state/phase-context.d.ts +60 -0
  80. package/dist/src/core/run-state/phase-context.js +108 -0
  81. package/dist/src/core/run-state/phase-registry.d.ts +137 -0
  82. package/dist/src/core/run-state/phase-registry.js +162 -0
  83. package/dist/src/core/run-state/phase-runner.d.ts +80 -0
  84. package/dist/src/core/run-state/phase-runner.js +447 -0
  85. package/dist/src/core/run-state/provider-readback.d.ts +130 -0
  86. package/dist/src/core/run-state/provider-readback.js +426 -0
  87. package/dist/src/core/run-state/replay-decision.d.ts +69 -0
  88. package/dist/src/core/run-state/replay-decision.js +144 -0
  89. package/dist/src/core/run-state/resolve-engine.d.ts +100 -0
  90. package/dist/src/core/run-state/resolve-engine.js +190 -0
  91. package/dist/src/core/run-state/resume-preflight.d.ts +66 -0
  92. package/dist/src/core/run-state/resume-preflight.js +116 -0
  93. package/dist/src/core/run-state/run-phase-with-lifecycle.d.ts +73 -0
  94. package/dist/src/core/run-state/run-phase-with-lifecycle.js +186 -0
  95. package/dist/src/core/run-state/runs.d.ts +57 -0
  96. package/dist/src/core/run-state/runs.js +288 -0
  97. package/dist/src/core/run-state/snapshot.d.ts +14 -0
  98. package/dist/src/core/run-state/snapshot.js +114 -0
  99. package/dist/src/core/run-state/state.d.ts +40 -0
  100. package/dist/src/core/run-state/state.js +164 -0
  101. package/dist/src/core/run-state/types.d.ts +278 -0
  102. package/dist/src/core/run-state/types.js +13 -0
  103. package/dist/src/core/run-state/ulid.d.ts +11 -0
  104. package/dist/src/core/run-state/ulid.js +95 -0
  105. package/dist/src/core/schema-alignment/extractor/index.d.ts +1 -1
  106. package/dist/src/core/schema-alignment/extractor/index.js +2 -2
  107. package/dist/src/core/schema-alignment/extractor/prisma.d.ts +13 -1
  108. package/dist/src/core/schema-alignment/extractor/prisma.js +65 -10
  109. package/dist/src/core/schema-alignment/git-history.d.ts +19 -0
  110. package/dist/src/core/schema-alignment/git-history.js +53 -0
  111. package/dist/src/core/static-rules/rules/brand-tokens.js +2 -2
  112. package/dist/src/core/static-rules/rules/schema-alignment.js +14 -4
  113. package/package.json +2 -1
  114. package/scripts/autoregress.ts +1 -1
  115. package/skills/claude-autopilot.md +1 -1
  116. package/skills/make-interfaces-feel-better/SKILL.md +104 -0
  117. package/skills/simplify-ui/SKILL.md +103 -0
  118. package/skills/ui/SKILL.md +117 -0
  119. package/skills/ui-ux-pro-max/SKILL.md +90 -0
@@ -0,0 +1,550 @@
1
+ // src/adapters/deploy/render.ts
2
+ //
3
+ // First-class Render deploy adapter. Phase 2 of the v5.6 spec.
4
+ //
5
+ // Implements `deploy()` (POST a new deploy on a configured service, then
6
+ // poll until terminal) and `status()` (one-shot GET). Log streaming is
7
+ // Phase 3 (Render uses REST polling, lands later) and rollback is Phase 4
8
+ // (simulated by re-deploying the previous successful commit — Render has
9
+ // no native rollback verb).
10
+ //
11
+ // All HTTP calls go through an injectable `fetchImpl` so unit tests never
12
+ // hit the real Render API. The endpoint shapes below mirror the Render REST
13
+ // API as documented at https://api-docs.render.com/.
14
+ //
15
+ // Phase 5 (v5.6) consolidated `fetchWithRetry` and `safeReadBody` into the
16
+ // shared `_http.ts` module — see that file's header for the rationale.
17
+ // Error-mapping (`assertOkOrThrow`) stays per-adapter because each one
18
+ // composes a different error message and reads a different request-id
19
+ // header.
20
+ //
21
+ // Spec: docs/specs/v5.6-fly-render-adapters.md
22
+ import { GuardrailError } from "../../core/errors.js";
23
+ import { redactLogLines } from "../../core/logging/redaction.js";
24
+ import { fetchWithRetry, safeReadBody } from "./_http.js";
25
+ const RENDER_API_BASE = 'https://api.render.com';
26
+ const RENDER_DASHBOARD_BASE = 'https://dashboard.render.com';
27
+ const RENDER_TOKEN_DOC_URL = 'https://dashboard.render.com/u/settings#api-keys';
28
+ /**
29
+ * Render deploy adapter.
30
+ *
31
+ * Construct once per pipeline run. The adapter is stateless across calls —
32
+ * all configuration (token, serviceId, clearCache) is captured at
33
+ * construction time. Per the v5.6 spec, only `deploy()` and `status()` are
34
+ * wired in Phase 2; `streamLogs` (REST polling) and `rollback` (simulated
35
+ * via re-deploy) land in Phases 3 and 4 respectively.
36
+ */
37
+ export class RenderDeployAdapter {
38
+ name = 'render';
39
+ capabilities = {
40
+ streamMode: 'polling',
41
+ nativeRollback: false,
42
+ };
43
+ token;
44
+ serviceId;
45
+ clearCache;
46
+ pollIntervalMs;
47
+ maxPollMs;
48
+ fetchImpl;
49
+ sleep;
50
+ now;
51
+ redactionPatterns;
52
+ logPollIntervalMs;
53
+ constructor(opts) {
54
+ const token = opts.token ?? process.env.RENDER_API_KEY;
55
+ if (!token) {
56
+ throw new GuardrailError(`Render deploy adapter requires RENDER_API_KEY. Create one at ${RENDER_TOKEN_DOC_URL}`, { code: 'auth', provider: 'render' });
57
+ }
58
+ if (!opts.serviceId) {
59
+ throw new GuardrailError('Render deploy adapter requires `serviceId` (Render service ID, e.g. srv-abc123)', { code: 'invalid_config', provider: 'render' });
60
+ }
61
+ this.token = token;
62
+ this.serviceId = opts.serviceId;
63
+ this.clearCache = opts.clearCache ?? 'do_not_clear';
64
+ this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
65
+ this.maxPollMs = opts.maxPollMs ?? 15 * 60 * 1000;
66
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
67
+ this.sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
68
+ this.now = opts.nowImpl ?? Date.now;
69
+ this.redactionPatterns = opts.redactionPatterns;
70
+ this.logPollIntervalMs = opts.logPollIntervalMs ?? 2000;
71
+ }
72
+ async deploy(input) {
73
+ const start = this.now();
74
+ const url = `${RENDER_API_BASE}/v1/services/${encodeURIComponent(this.serviceId)}/deploys`;
75
+ const body = {
76
+ clearCache: this.clearCache,
77
+ };
78
+ // Render accepts `commitId` to deploy a specific commit — useful both
79
+ // for normal deploys driven by a SHA and for the eventual Phase 4
80
+ // simulated-rollback path that re-deploys a previous commit.
81
+ if (input.commitSha)
82
+ body.commitId = input.commitSha;
83
+ const res = await fetchWithRetry(this.fetchImpl, url, {
84
+ method: 'POST',
85
+ headers: this.headers(),
86
+ body: JSON.stringify(body),
87
+ signal: input.signal,
88
+ }, { sleepImpl: this.sleep, provider: 'render' });
89
+ await this.assertOkOrThrow(res, 'create deploy');
90
+ const created = (await res.json());
91
+ if (!created.id) {
92
+ throw new GuardrailError(`Render returned no deploy id (got: ${JSON.stringify(created).slice(0, 200)})`, { code: 'adapter_bug', provider: 'render' });
93
+ }
94
+ // Fire onDeployStart so callers can subscribe to side-channel work
95
+ // (log streaming once Phase 3 lands) in parallel with polling. Wrap in
96
+ // try/catch — a buggy callback must not crash the deploy.
97
+ try {
98
+ input.onDeployStart?.(created.id);
99
+ }
100
+ catch {
101
+ /* swallow — observability concern only */
102
+ }
103
+ return this.pollUntilTerminal(created.id, start, input.signal);
104
+ }
105
+ async status(input) {
106
+ const start = this.now();
107
+ // Render's API for fetching a single deploy is service-scoped — the
108
+ // shorthand /v1/deploys/{id} does NOT exist. Caught by Cursor Bugbot
109
+ // on PR #73 (HIGH).
110
+ const url = `${RENDER_API_BASE}/v1/services/${encodeURIComponent(this.serviceId)}/deploys/${encodeURIComponent(input.deployId)}`;
111
+ const res = await fetchWithRetry(this.fetchImpl, url, {
112
+ method: 'GET',
113
+ headers: this.headers(),
114
+ signal: input.signal,
115
+ }, { sleepImpl: this.sleep, provider: 'render' });
116
+ await this.assertOkOrThrow(res, 'get deploy');
117
+ const data = (await res.json());
118
+ const result = this.shapeResult(input.deployId, data, data.status, this.now() - start);
119
+ return { ...result, deployId: input.deployId };
120
+ }
121
+ /**
122
+ * Phase 3 of v5.6 — REST-polling log stream for a Render deploy.
123
+ *
124
+ * Render has no WebSocket log endpoint (cf. v5.6 spec § "Render adapter →
125
+ * Logs" and capability metadata `streamMode: 'polling'`). This generator
126
+ * polls `GET /v1/services/{serviceId}/logs?deployId={id}&direction=forward
127
+ * &limit=100` every 2s while the deploy is `in-progress` and yields any
128
+ * new lines.
129
+ *
130
+ * Cursor invariant — keyed by `(timestamp, logId)`:
131
+ * - We track the most-recently-yielded `(ts, id)` pair as `cursor`.
132
+ * - On each poll, we discard every returned line whose `(ts, id)` is
133
+ * `<= cursor` (lexicographic on the pair, primary key timestamp). This
134
+ * handles two real cases:
135
+ * 1. Pagination overlap — Render's forward-direction list often
136
+ * repeats the last entry of the prior page as the first entry of
137
+ * the next. Without dedup we'd yield duplicates.
138
+ * 2. Same-millisecond entries — multiple log lines can share a `ts`.
139
+ * The secondary `id` ordering keeps them stable.
140
+ * - We never miss a line: `cursor` advances strictly monotonically, and
141
+ * the polling URL uses `direction=forward` so Render returns lines
142
+ * newer than (or equal to) our cursor's timestamp.
143
+ *
144
+ * Termination:
145
+ * - `signal.aborted` — exit immediately at the next await boundary.
146
+ * - Deploy status reaches a terminal state (live / build_failed /
147
+ * update_failed / canceled / deactivated) — drain one final poll for
148
+ * any tail lines, then exit.
149
+ * - Hard cap of `maxPollMs` ticks — same budget as `pollUntilTerminal`
150
+ * to avoid an infinite generator if status is stuck.
151
+ *
152
+ * Every yielded line's `text` is run through `redactLogLines()` before
153
+ * leaving the adapter.
154
+ */
155
+ async *streamLogs(input) {
156
+ const logsUrl = (`${RENDER_API_BASE}/v1/services/${encodeURIComponent(this.serviceId)}/logs`
157
+ + `?deployId=${encodeURIComponent(input.deployId)}`
158
+ + `&direction=forward&limit=100`);
159
+ const statusUrl = `${RENDER_API_BASE}/v1/services/${encodeURIComponent(this.serviceId)}/deploys/${encodeURIComponent(input.deployId)}`;
160
+ const start = this.now();
161
+ let cursorTs = -1;
162
+ let cursorId = '';
163
+ let terminalSeen = false;
164
+ while (true) {
165
+ if (input.signal?.aborted)
166
+ return;
167
+ if (this.now() - start > this.maxPollMs)
168
+ return;
169
+ // 1. Fetch the next batch of log lines.
170
+ let logsRes;
171
+ try {
172
+ logsRes = await fetchWithRetry(this.fetchImpl, logsUrl, {
173
+ method: 'GET',
174
+ headers: this.headers(),
175
+ signal: input.signal,
176
+ }, { sleepImpl: this.sleep, provider: 'render' });
177
+ }
178
+ catch (err) {
179
+ if (err instanceof Error && err.name === 'AbortError')
180
+ return;
181
+ throw err;
182
+ }
183
+ if (input.signal?.aborted)
184
+ return;
185
+ // 404 here = deploy ID typo or wrong service. Surface as a single
186
+ // warn line and stop — same shape as the Fly "lost stream" exit.
187
+ if (!logsRes.ok) {
188
+ // Re-use the assertOkOrThrow surface for a typed GuardrailError.
189
+ await this.assertOkOrThrow(logsRes, 'stream logs');
190
+ }
191
+ const logsData = (await logsRes.json());
192
+ const lines = Array.isArray(logsData?.logs) ? logsData.logs : [];
193
+ // Parse first, then sort by (ts, id) ascending before applying the
194
+ // cursor filter. Render's API does NOT guarantee that same-millisecond
195
+ // entries arrive in lexicographic id order — without this sort, an
196
+ // entry with an alphabetically-earlier id arriving AFTER a same-ts
197
+ // sibling would advance the cursor past it and silently drop it on
198
+ // the next pass. Caught by Cursor Bugbot on PR #75 (MEDIUM).
199
+ const parsedBatch = [];
200
+ for (const entry of lines) {
201
+ if (input.signal?.aborted)
202
+ return;
203
+ const parsed = parseRenderLogEntry(entry, this.now());
204
+ if (parsed)
205
+ parsedBatch.push(parsed);
206
+ }
207
+ parsedBatch.sort((a, b) => {
208
+ if (a.ts !== b.ts)
209
+ return a.ts - b.ts;
210
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
211
+ });
212
+ for (const parsed of parsedBatch) {
213
+ if (input.signal?.aborted)
214
+ return;
215
+ // Cursor compare: primary timestamp, secondary id. Strictly greater
216
+ // than previous cursor → yield + advance.
217
+ if (parsed.ts < cursorTs)
218
+ continue;
219
+ if (parsed.ts === cursorTs && parsed.id <= cursorId)
220
+ continue;
221
+ cursorTs = parsed.ts;
222
+ cursorId = parsed.id;
223
+ yield this.redactLine({ timestamp: parsed.ts, level: parsed.level, text: parsed.text });
224
+ }
225
+ // 2. After we've drained this poll, check if we already saw a terminal
226
+ // status on the previous tick — if so, this was the final tail-drain.
227
+ if (terminalSeen)
228
+ return;
229
+ // 3. Status check — same service-scoped endpoint as `pollUntilTerminal`.
230
+ let statusRes;
231
+ try {
232
+ statusRes = await fetchWithRetry(this.fetchImpl, statusUrl, {
233
+ method: 'GET',
234
+ headers: this.headers(),
235
+ signal: input.signal,
236
+ }, { sleepImpl: this.sleep, provider: 'render' });
237
+ }
238
+ catch (err) {
239
+ if (err instanceof Error && err.name === 'AbortError')
240
+ return;
241
+ throw err;
242
+ }
243
+ if (input.signal?.aborted)
244
+ return;
245
+ if (statusRes.ok) {
246
+ const statusData = (await statusRes.json());
247
+ const s = statusData?.status;
248
+ if (s === 'live'
249
+ || s === 'build_failed'
250
+ || s === 'update_failed'
251
+ || s === 'canceled'
252
+ || s === 'deactivated') {
253
+ // Mark terminal — one more poll iteration drains tail lines, then
254
+ // the `terminalSeen` short-circuit above exits the loop.
255
+ terminalSeen = true;
256
+ }
257
+ }
258
+ // 4. Sleep until the next poll. Honor abort while waiting.
259
+ if (input.signal?.aborted)
260
+ return;
261
+ await this.sleep(this.logPollIntervalMs);
262
+ }
263
+ }
264
+ /**
265
+ * Phase 4 of v5.6 — simulated rollback for Render.
266
+ *
267
+ * Render has no native rollback verb, so we simulate by re-deploying a
268
+ * prior commit per spec § "Render adapter → Rollback":
269
+ *
270
+ * - List recent deploys: `GET /v1/services/{serviceId}/deploys?limit=20`.
271
+ * - Select the rollback target:
272
+ * - When `input.to` is set: look up that specific deploy in the list,
273
+ * read its `commit.id`, and re-deploy that commit. Deploy ID becomes
274
+ * the lookup key.
275
+ * - When `input.to` is unset: walk the list newest-first and pick the
276
+ * most recent deploy with `status === 'live'` *that is not the head*.
277
+ * The intent is "go back one" — the head is the deploy we'd be
278
+ * rolling back from.
279
+ * - Re-deploy via `POST /v1/services/{serviceId}/deploys` with `commitId`,
280
+ * then poll until terminal — re-uses the existing `deploy()`-style
281
+ * machinery via `redeployCommit()`.
282
+ *
283
+ * Throws `GuardrailError({ code: 'no_previous_deploy', provider: 'render' })`
284
+ * when no usable prior `live` deploy exists. Throws `not_found` when
285
+ * `input.to` references a deploy that isn't in the recent-20 window.
286
+ *
287
+ * Returns a `DeployResult` with:
288
+ * - `deployId` — the *new* deploy ID Render returned for the re-deploy.
289
+ * - `rolledBackTo` — the *prior* deploy ID we rolled back to.
290
+ * - `output` — human-readable summary of the swap (commits + IDs), redacted.
291
+ */
292
+ async rollback(input) {
293
+ const start = this.now();
294
+ const deploys = await this.listDeploys(20, input.signal);
295
+ let priorDeployId;
296
+ let priorCommitId;
297
+ if (input.to) {
298
+ const match = deploys.find((d) => d.id === input.to);
299
+ if (!match) {
300
+ throw new GuardrailError(`Render deploy "${input.to}" not found in the last 20 deploys for service "${this.serviceId}" — cannot rollback`, { code: 'not_found', provider: 'render', step: 'rollback lookup' });
301
+ }
302
+ if (!match.commit?.id) {
303
+ throw new GuardrailError(`Render deploy "${input.to}" has no recorded commit id — cannot simulate rollback`, { code: 'invalid_config', provider: 'render', step: 'rollback lookup' });
304
+ }
305
+ priorDeployId = match.id;
306
+ priorCommitId = match.commit.id;
307
+ }
308
+ else {
309
+ // Walk newest-first. The first `live` deploy is the current head — we
310
+ // skip it and return the next. This matches the "go back one"
311
+ // semantics from the spec: the head is the deploy we're rolling
312
+ // BACK FROM, never the rollback target.
313
+ let sawHead = false;
314
+ for (const d of deploys) {
315
+ if (d.status !== 'live')
316
+ continue;
317
+ if (!sawHead) {
318
+ sawHead = true;
319
+ continue;
320
+ }
321
+ if (!d.commit?.id)
322
+ continue;
323
+ priorDeployId = d.id;
324
+ priorCommitId = d.commit.id;
325
+ break;
326
+ }
327
+ if (!priorDeployId || !priorCommitId) {
328
+ throw new GuardrailError(`No previous live Render deploy with a commit id found for service "${this.serviceId}" to roll back to`, { code: 'no_previous_deploy', provider: 'render' });
329
+ }
330
+ }
331
+ const redeployed = await this.redeployCommit(priorCommitId, input.signal);
332
+ const rawOutput = `Render rollback simulated by re-deploying commit "${priorCommitId}" (prior deploy ${priorDeployId}) → new deploy ${redeployed.deployId ?? '<unknown>'}`;
333
+ return {
334
+ ...redeployed,
335
+ // The prior deploy ID is the rollback target; deployId stays the
336
+ // newly-created deploy from the re-deploy POST. The CLI surfaces
337
+ // both in the PR-comment row.
338
+ rolledBackTo: priorDeployId,
339
+ durationMs: this.now() - start,
340
+ output: redactLogLines(rawOutput, this.redactionPatterns),
341
+ };
342
+ }
343
+ /**
344
+ * Private helper — re-uses the deploy() POST + poll machinery to redeploy
345
+ * a specific commit. Used by `rollback()` to reissue a prior commit.
346
+ */
347
+ async redeployCommit(commitId, signal) {
348
+ const start = this.now();
349
+ const url = `${RENDER_API_BASE}/v1/services/${encodeURIComponent(this.serviceId)}/deploys`;
350
+ const body = {
351
+ clearCache: this.clearCache,
352
+ commitId,
353
+ };
354
+ const res = await fetchWithRetry(this.fetchImpl, url, {
355
+ method: 'POST',
356
+ headers: this.headers(),
357
+ body: JSON.stringify(body),
358
+ signal,
359
+ }, { sleepImpl: this.sleep, provider: 'render' });
360
+ await this.assertOkOrThrow(res, 'create deploy (rollback)');
361
+ const created = (await res.json());
362
+ if (!created.id) {
363
+ throw new GuardrailError(`Render returned no deploy id during rollback (got: ${JSON.stringify(created).slice(0, 200)})`, { code: 'adapter_bug', provider: 'render' });
364
+ }
365
+ return this.pollUntilTerminal(created.id, start, signal);
366
+ }
367
+ /**
368
+ * List the most recent deploys for the configured service. Newest-first.
369
+ * `limit` caps the result set — defaults to 20 (the spec's recommended
370
+ * window for the rollback lookup). Each entry includes `id`, `commit.id`,
371
+ * and `status` per the Render REST API.
372
+ */
373
+ async listDeploys(limit = 20, signal) {
374
+ const url = `${RENDER_API_BASE}/v1/services/${encodeURIComponent(this.serviceId)}/deploys`
375
+ + `?limit=${encodeURIComponent(String(limit))}`;
376
+ const res = await fetchWithRetry(this.fetchImpl, url, {
377
+ method: 'GET',
378
+ headers: this.headers(),
379
+ signal,
380
+ }, { sleepImpl: this.sleep, provider: 'render' });
381
+ await this.assertOkOrThrow(res, 'list deploys');
382
+ const data = (await res.json());
383
+ // Render historically returned a top-level array on /deploys; newer
384
+ // versions wrap entries in `[{deploy: {...}, cursor: '...'}]`. Accept
385
+ // both shapes — fall back to a bare-array if neither envelope matches.
386
+ if (Array.isArray(data)) {
387
+ // Could be either RenderDeployResponse[] OR [{deploy: ...}].
388
+ const first = data[0];
389
+ if (first && typeof first === 'object' && 'deploy' in first) {
390
+ const wrapped = data;
391
+ return wrapped.map((entry) => entry.deploy).filter(Boolean);
392
+ }
393
+ return data;
394
+ }
395
+ if (Array.isArray(data?.deploys)) {
396
+ return data.deploys ?? [];
397
+ }
398
+ return [];
399
+ }
400
+ // ─────────────────────────────────────────────────────────────────────────────
401
+ // private helpers
402
+ // ─────────────────────────────────────────────────────────────────────────────
403
+ /** Apply the adapter's redaction patterns to a log line's `text` field. */
404
+ redactLine(line) {
405
+ return { ...line, text: redactLogLines(line.text, this.redactionPatterns) };
406
+ }
407
+ async pollUntilTerminal(deployId, start, signal) {
408
+ // Service-scoped path — see comment in `status()`.
409
+ const url = `${RENDER_API_BASE}/v1/services/${encodeURIComponent(this.serviceId)}/deploys/${encodeURIComponent(deployId)}`;
410
+ while (true) {
411
+ if (signal?.aborted) {
412
+ return { status: 'in-progress', deployId, durationMs: this.now() - start };
413
+ }
414
+ if (this.now() - start > this.maxPollMs) {
415
+ return {
416
+ status: 'in-progress',
417
+ deployId,
418
+ durationMs: this.now() - start,
419
+ buildLogsUrl: this.buildLogsUrl(deployId),
420
+ output: redactLogLines(`Render deploy still in progress after ${this.maxPollMs}ms — check ${this.buildLogsUrl(deployId)}`, this.redactionPatterns),
421
+ };
422
+ }
423
+ const res = await fetchWithRetry(this.fetchImpl, url, {
424
+ method: 'GET',
425
+ headers: this.headers(),
426
+ signal,
427
+ }, { sleepImpl: this.sleep, provider: 'render' });
428
+ await this.assertOkOrThrow(res, 'poll deploy');
429
+ const data = (await res.json());
430
+ const status = data.status;
431
+ if (status === 'live'
432
+ || status === 'build_failed'
433
+ || status === 'update_failed'
434
+ || status === 'canceled'
435
+ || status === 'deactivated') {
436
+ return this.shapeResult(deployId, data, status, this.now() - start);
437
+ }
438
+ await this.sleep(this.pollIntervalMs);
439
+ }
440
+ }
441
+ shapeResult(deployId, data, status, durationMs) {
442
+ // Map Render's eight-state vocabulary onto our pass/fail/in-progress
443
+ // tri-state. `live` is the only success terminal; `deactivated`,
444
+ // `build_failed`, `update_failed`, `canceled` are failure terminals;
445
+ // everything else is interim.
446
+ const resultStatus = status === 'live'
447
+ ? 'pass'
448
+ : status === 'build_failed'
449
+ || status === 'update_failed'
450
+ || status === 'canceled'
451
+ || status === 'deactivated'
452
+ ? 'fail'
453
+ : 'in-progress';
454
+ // Apply redaction to the human-readable output line. Real-world Render
455
+ // logs often echo back env vars and tokens; we never want those landing
456
+ // in PR-comment bodies. (Spec § "Log redaction".)
457
+ const commitInfo = data.commit?.id ? ` commit=${data.commit.id}` : '';
458
+ const rawOutput = status ? `Render deploy ${deployId}: status=${status}${commitInfo}` : undefined;
459
+ return {
460
+ status: resultStatus,
461
+ deployId,
462
+ buildLogsUrl: this.buildLogsUrl(deployId),
463
+ durationMs,
464
+ output: rawOutput !== undefined ? redactLogLines(rawOutput, this.redactionPatterns) : undefined,
465
+ };
466
+ }
467
+ headers() {
468
+ return {
469
+ Authorization: `Bearer ${this.token}`,
470
+ 'Content-Type': 'application/json',
471
+ Accept: 'application/json',
472
+ };
473
+ }
474
+ buildLogsUrl(deployId) {
475
+ return `${RENDER_DASHBOARD_BASE}/web/${encodeURIComponent(this.serviceId)}/deploys/${encodeURIComponent(deployId)}`;
476
+ }
477
+ /**
478
+ * HTTP-status-keyed error mapper. Per v5.6 spec:
479
+ *
480
+ * | Status | ErrorCode |
481
+ * |---|---|
482
+ * | 401 / 403 | `auth` |
483
+ * | 404 | `not_found` |
484
+ * | 422 / 400 | `invalid_config` |
485
+ * | 5xx | `transient_network` (retryable) |
486
+ * | other 4xx | `adapter_bug` |
487
+ *
488
+ * Render echoes a request-id on the `x-request-id` header on most
489
+ * responses. We capture it into `details.renderRequestId` whenever
490
+ * present so support tickets can quote it back to Render.
491
+ */
492
+ async assertOkOrThrow(res, step) {
493
+ if (res.ok)
494
+ return;
495
+ const bodyText = await safeReadBody(res);
496
+ const requestId = readRenderRequestId(res);
497
+ const details = { status: res.status };
498
+ if (requestId)
499
+ details.renderRequestId = requestId;
500
+ if (res.status === 401 || res.status === 403) {
501
+ throw new GuardrailError(`Render auth failed (${res.status}) on ${step} — check RENDER_API_KEY scope for service "${this.serviceId}". Regenerate at ${RENDER_TOKEN_DOC_URL}: ${bodyText}`, { code: 'auth', provider: 'render', step, details });
502
+ }
503
+ if (res.status === 404) {
504
+ throw new GuardrailError(`Render resource not found (${res.status}) on ${step} — service ID "${this.serviceId}" may be wrong, or the deploy ID belongs to a different service${requestId ? ` (x-request-id: ${requestId})` : ''}: ${bodyText}`, { code: 'not_found', provider: 'render', step, details });
505
+ }
506
+ if (res.status === 422 || res.status === 400) {
507
+ throw new GuardrailError(`Render rejected the request (${res.status}) on ${step} — likely a malformed serviceId, invalid clearCache value, or unknown commitId: ${bodyText}`, { code: 'invalid_config', provider: 'render', step, details });
508
+ }
509
+ if (res.status >= 500 && res.status < 600) {
510
+ throw new GuardrailError(`Render API server error (${res.status}) on ${step}: ${bodyText}`, { code: 'transient_network', provider: 'render', step, details, retryable: true });
511
+ }
512
+ throw new GuardrailError(`Render API error (${res.status}) on ${step}: ${bodyText}`, { code: 'adapter_bug', provider: 'render', step, details });
513
+ }
514
+ }
515
+ /**
516
+ * Pull `x-request-id` (case-insensitive) off the response. Render echoes
517
+ * this header on most API responses; capturing it into
518
+ * `GuardrailError.details.renderRequestId` lets users quote it back when
519
+ * filing support tickets.
520
+ *
521
+ * Falls back to `null` when `headers.get` is unavailable (e.g. a stubbed
522
+ * Response in tests that doesn't implement Headers).
523
+ */
524
+ function readRenderRequestId(res) {
525
+ const headers = res.headers;
526
+ if (!headers || typeof headers.get !== 'function')
527
+ return null;
528
+ return headers.get('x-request-id') ?? headers.get('X-Request-Id') ?? null;
529
+ }
530
+ /**
531
+ * Parse a single Render log entry into our cursor-friendly tuple. Returns
532
+ * `null` for entries that have no usable text (we never yield empty lines)
533
+ * or no usable timestamp (the cursor invariant requires `ts`).
534
+ */
535
+ function parseRenderLogEntry(entry, fallbackTs) {
536
+ const text = typeof entry.message === 'string'
537
+ ? entry.message
538
+ : typeof entry.text === 'string' ? entry.text : '';
539
+ if (!text)
540
+ return null;
541
+ let ts = fallbackTs;
542
+ if (typeof entry.timestamp === 'string' && entry.timestamp.length > 0) {
543
+ const parsed = Date.parse(entry.timestamp);
544
+ if (!Number.isNaN(parsed))
545
+ ts = parsed;
546
+ }
547
+ const id = typeof entry.id === 'string' ? entry.id : '';
548
+ return { ts, id, level: entry.level, text };
549
+ }
550
+ //# sourceMappingURL=render.js.map
@@ -33,9 +33,19 @@ export interface DeployInput {
33
33
  * before the platform reached a terminal state — the deploy may still finish
34
34
  * later. The adapter does NOT auto-resume in Phase 1; the caller can re-poll
35
35
  * via `status({ deployId })`.
36
+ *
37
+ * `fail_rolled_back` and `fail_rollback_failed` (Phase 4 of v5.6) describe
38
+ * the bounded auto-rollback outcomes. They both mean "the deploy itself
39
+ * succeeded but the post-deploy health check failed"; the suffix indicates
40
+ * whether the subsequent rollback attempt succeeded (`_rolled_back`) or
41
+ * failed (`_rollback_failed`). Adapters never set these directly — they are
42
+ * stamped onto the `DeployResult` by the CLI orchestration in
43
+ * `src/cli/deploy.ts` after `rollback()` returns. Plain `fail` continues to
44
+ * cover deploy-itself failures and the "no rollback configured / not
45
+ * supported" branches so existing consumers keep working.
36
46
  */
37
47
  export interface DeployResult {
38
- status: 'pass' | 'fail' | 'in-progress';
48
+ status: 'pass' | 'fail' | 'in-progress' | 'fail_rolled_back' | 'fail_rollback_failed';
39
49
  /** Adapter-native deploy ID. Vercel uses `dpl_xxx`. Empty for generic when stdout has no extractable URL. */
40
50
  deployId?: string;
41
51
  /** Public URL of the deploy (e.g. `https://my-app-abc.vercel.app`). */
@@ -105,6 +115,34 @@ export interface DeployLogLine {
105
115
  /** Log text, no trailing newline. */
106
116
  text: string;
107
117
  }
118
+ /**
119
+ * Self-described capability surface for an adapter.
120
+ *
121
+ * Codex review of v5.6 (#4) flagged that Render's REST polling and Fly's
122
+ * WebSocket streaming offer materially different log-streaming experiences,
123
+ * and pretending they're equivalent degrades user trust. CLI surfaces (and
124
+ * downstream consumers) inspect this struct to print a one-line notice when
125
+ * `--watch` is invoked against a `polling`-mode adapter, and to choose between
126
+ * native vs simulated rollback messaging in the PR comment.
127
+ *
128
+ * The field is optional — adapters that don't declare capabilities are
129
+ * treated as `streamMode: 'none'`, `nativeRollback: false` for safety.
130
+ */
131
+ export interface DeployAdapterCapabilities {
132
+ /**
133
+ * How `streamLogs()` (when implemented) delivers lines:
134
+ * - `websocket` — real-time, push-based, near-zero gap between line emit and consumer receive
135
+ * - `polling` — REST-paginated polling cursor, lines may arrive in batches with short gaps
136
+ * - `none` — no log-streaming surface at all
137
+ */
138
+ streamMode?: 'websocket' | 'polling' | 'none';
139
+ /**
140
+ * `true` when the platform exposes a single "promote prior release" verb
141
+ * (Vercel's `/promote`, Fly's `/rollback`); `false` when rollback must be
142
+ * simulated by re-deploying a previous successful image/commit (Render).
143
+ */
144
+ nativeRollback?: boolean;
145
+ }
108
146
  /**
109
147
  * The DeployAdapter contract.
110
148
  *
@@ -115,6 +153,12 @@ export interface DeployLogLine {
115
153
  export interface DeployAdapter {
116
154
  /** Stable identifier — surfaced in CLI output and logs. */
117
155
  readonly name: string;
156
+ /**
157
+ * Optional self-description of the adapter's streaming + rollback shape.
158
+ * See {@link DeployAdapterCapabilities} for semantics. CLI consumers use
159
+ * this to decide whether to print the polling-mode notice on `--watch`.
160
+ */
161
+ readonly capabilities?: DeployAdapterCapabilities;
118
162
  deploy(input: DeployInput): Promise<DeployResult>;
119
163
  status?(input: DeployStatusInput): Promise<DeployStatusResult>;
120
164
  rollback?(input: DeployRollbackInput): Promise<DeployResult>;
@@ -137,14 +181,34 @@ export interface DeployAdapter {
137
181
  * The factory in `./index.ts` enforces these rules at construction time.
138
182
  */
139
183
  export interface DeployConfig {
140
- /** Which adapter to use. Phase 1 ships `vercel` + `generic`. */
141
- adapter: 'vercel' | 'generic';
184
+ /**
185
+ * Which adapter to use. v5.4 shipped `vercel` + `generic`; v5.6 Phase 1
186
+ * adds `fly`; v5.6 Phase 2 adds `render`.
187
+ */
188
+ adapter: 'vercel' | 'fly' | 'render' | 'generic';
142
189
  /** Vercel project ID or slug. Required when `adapter === 'vercel'`. */
143
190
  project?: string;
144
191
  /** Vercel team ID for team accounts. Optional. */
145
192
  team?: string;
146
193
  /** Deploy target. Default: `production`. */
147
194
  target?: 'production' | 'preview';
195
+ /** Fly app slug. Required when `adapter === 'fly'`. */
196
+ app?: string;
197
+ /**
198
+ * Pre-pushed image reference, e.g. `registry.fly.io/my-app:deployment-01`.
199
+ * Required when `adapter === 'fly'` — the Fly adapter does not build the
200
+ * image; pushing is the user's responsibility (`fly deploy --build-only --push`).
201
+ */
202
+ image?: string;
203
+ /** Optional Fly region pin (e.g. `ord`). Falls back to the app's default region. */
204
+ region?: string;
205
+ /** Render service ID (e.g. `srv-abc123`). Required when `adapter === 'render'`. */
206
+ serviceId?: string;
207
+ /**
208
+ * Whether Render should clear the build cache before deploying. Optional,
209
+ * default `'do_not_clear'`. Maps directly to the Render API body field.
210
+ */
211
+ clearCache?: 'do_not_clear' | 'clear';
148
212
  /** Shell command to run for the deploy (e.g. `vercel --prod`). Required when `adapter === 'generic'`. */
149
213
  deployCommand?: string;
150
214
  /** Stream build logs to stderr in real time. Phase 2. */