@delegance/claude-autopilot 5.2.2 → 5.5.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.
- package/CHANGELOG.md +97 -0
- package/README.md +49 -17
- package/dist/src/adapters/council/claude.js +2 -1
- package/dist/src/adapters/council/openai.js +2 -1
- package/dist/src/adapters/deploy/generic.d.ts +39 -0
- package/dist/src/adapters/deploy/generic.js +98 -0
- package/dist/src/adapters/deploy/index.d.ts +13 -0
- package/dist/src/adapters/deploy/index.js +45 -0
- package/dist/src/adapters/deploy/types.d.ts +157 -0
- package/dist/src/adapters/deploy/types.js +15 -0
- package/dist/src/adapters/deploy/vercel.d.ts +127 -0
- package/dist/src/adapters/deploy/vercel.js +446 -0
- package/dist/src/adapters/review-engine/claude.js +2 -1
- package/dist/src/adapters/review-engine/codex.js +2 -1
- package/dist/src/adapters/review-engine/gemini.js +2 -1
- package/dist/src/adapters/review-engine/openai-compatible.js +2 -1
- package/dist/src/adapters/sdk-loader.d.ts +15 -0
- package/dist/src/adapters/sdk-loader.js +77 -0
- package/dist/src/cli/deploy.d.ts +71 -0
- package/dist/src/cli/deploy.js +514 -0
- package/dist/src/cli/index.js +91 -3
- package/dist/src/cli/preflight.js +76 -1
- package/dist/src/core/config/schema.d.ts +34 -0
- package/dist/src/core/config/schema.js +18 -0
- package/dist/src/core/config/types.d.ts +6 -0
- package/dist/src/core/errors.d.ts +1 -1
- package/dist/src/core/errors.js +1 -0
- package/dist/src/core/migrate/detector-rules.js +6 -0
- package/dist/src/core/migrate/schema-validator.js +7 -0
- package/package.json +8 -5
- package/scripts/autoregress.ts +2 -1
- package/skills/migrate/SKILL.md +193 -47
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// src/adapters/sdk-loader.ts
|
|
2
|
+
//
|
|
3
|
+
// Lazy-load LLM SDKs via dynamic import so the package doesn't pay the boot
|
|
4
|
+
// cost (or install footprint) for providers the user isn't using. The four
|
|
5
|
+
// LLM SDKs together account for ~26 MB of node_modules; users with only one
|
|
6
|
+
// API key need only one of them.
|
|
7
|
+
//
|
|
8
|
+
// On a missing SDK, throw a `GuardrailError` with the exact `npm install`
|
|
9
|
+
// command — same UX as a missing API key.
|
|
10
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
11
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
12
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
13
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return path;
|
|
17
|
+
};
|
|
18
|
+
import { GuardrailError } from "../core/errors.js";
|
|
19
|
+
function isModuleNotFound(err) {
|
|
20
|
+
if (!err || typeof err !== 'object')
|
|
21
|
+
return false;
|
|
22
|
+
const code = err.code;
|
|
23
|
+
return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND';
|
|
24
|
+
}
|
|
25
|
+
function missingSdkError(pkg, provider) {
|
|
26
|
+
return new GuardrailError(`${pkg} not installed — run: npm install ${pkg}`, { code: 'auth', provider });
|
|
27
|
+
}
|
|
28
|
+
export async function loadAnthropic() {
|
|
29
|
+
try {
|
|
30
|
+
const mod = await import('@anthropic-ai/sdk');
|
|
31
|
+
return (mod.default ?? mod);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (isModuleNotFound(err))
|
|
35
|
+
throw missingSdkError('@anthropic-ai/sdk', 'claude');
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function loadOpenAI() {
|
|
40
|
+
try {
|
|
41
|
+
const mod = await import('openai');
|
|
42
|
+
return (mod.default ?? mod);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (isModuleNotFound(err))
|
|
46
|
+
throw missingSdkError('openai', 'openai');
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function loadGoogleGenerativeAI() {
|
|
51
|
+
try {
|
|
52
|
+
const mod = await import('@google/generative-ai');
|
|
53
|
+
return mod.GoogleGenerativeAI;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (isModuleNotFound(err))
|
|
57
|
+
throw missingSdkError('@google/generative-ai', 'gemini');
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Quick non-throwing check — used by `doctor` to report install state.
|
|
63
|
+
*/
|
|
64
|
+
export async function isSdkInstalled(pkg) {
|
|
65
|
+
try {
|
|
66
|
+
await import(__rewriteRelativeImportExtension(pkg));
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
if (isModuleNotFound(err))
|
|
71
|
+
return false;
|
|
72
|
+
// Other errors (e.g., the SDK itself failed to load) — count as installed
|
|
73
|
+
// but broken; doctor will surface that separately if needed.
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=sdk-loader.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { DeployAdapter, DeployConfig } from '../adapters/deploy/types.ts';
|
|
2
|
+
export interface RunDeployOptions {
|
|
3
|
+
configPath?: string;
|
|
4
|
+
/** When set, overrides `deploy.adapter` from config. */
|
|
5
|
+
adapterOverride?: 'vercel' | 'generic';
|
|
6
|
+
ref?: string;
|
|
7
|
+
commitSha?: string;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
/** Phase 2 — when true, subscribe to streamLogs() and pipe to stderr. */
|
|
10
|
+
watch?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Test seam — allows injecting a fake DeployAdapter without going through
|
|
13
|
+
* the real factory (which requires VERCEL_TOKEN etc.). Production callers
|
|
14
|
+
* MUST NOT set this.
|
|
15
|
+
*/
|
|
16
|
+
adapterFactory?: (config: DeployConfig) => DeployAdapter;
|
|
17
|
+
/**
|
|
18
|
+
* Test seam — injected `fetch` implementation for the post-deploy health
|
|
19
|
+
* check. Defaults to `globalThis.fetch`.
|
|
20
|
+
*/
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
/**
|
|
23
|
+
* Test seam — injected sleep function used between health-check retries.
|
|
24
|
+
* Defaults to `setTimeout`-based sleep. Pass `async () => {}` from tests.
|
|
25
|
+
*/
|
|
26
|
+
sleepImpl?: (ms: number) => Promise<void>;
|
|
27
|
+
/** GitHub PR number — when set, post upserting deploy summary comment. */
|
|
28
|
+
pr?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Test seam — injected `gh` CLI runner. Receives argv plus an optional
|
|
31
|
+
* stdin `body` (passed via `gh ... --body-file -`). Returns stdout.
|
|
32
|
+
* Defaults to `core/shell.runSafe`.
|
|
33
|
+
*/
|
|
34
|
+
ghImpl?: (args: string[], opts?: {
|
|
35
|
+
body?: string;
|
|
36
|
+
cwd?: string;
|
|
37
|
+
}) => string;
|
|
38
|
+
}
|
|
39
|
+
export declare function runDeploy(opts: RunDeployOptions): Promise<number>;
|
|
40
|
+
export interface RunDeployRollbackOptions {
|
|
41
|
+
configPath?: string;
|
|
42
|
+
adapterOverride?: 'vercel' | 'generic';
|
|
43
|
+
/** Specific deploy ID to roll back to. When omitted, the previous prod deploy is used. */
|
|
44
|
+
to?: string;
|
|
45
|
+
cwd?: string;
|
|
46
|
+
/** Test seam — same contract as `RunDeployOptions.adapterFactory`. */
|
|
47
|
+
adapterFactory?: (config: DeployConfig) => DeployAdapter;
|
|
48
|
+
}
|
|
49
|
+
export interface RunDeployStatusOptions {
|
|
50
|
+
configPath?: string;
|
|
51
|
+
adapterOverride?: 'vercel' | 'generic';
|
|
52
|
+
cwd?: string;
|
|
53
|
+
adapterFactory?: (config: DeployConfig) => DeployAdapter;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Handle `claude-autopilot deploy rollback [--to <id>]`.
|
|
57
|
+
*
|
|
58
|
+
* Returns process exit code (0 on success, 1 on failure). Failure modes:
|
|
59
|
+
* - Adapter doesn't implement rollback (e.g. generic adapter).
|
|
60
|
+
* - No previous prod deploy exists when `--to` is omitted.
|
|
61
|
+
* - Auth / network / API error (surfaced via formatErr).
|
|
62
|
+
*/
|
|
63
|
+
export declare function runDeployRollback(opts: RunDeployRollbackOptions): Promise<number>;
|
|
64
|
+
/**
|
|
65
|
+
* Handle `claude-autopilot deploy status`. Lists the current production
|
|
66
|
+
* deploy plus the last 5 builds (newest-first), pulling from the adapter's
|
|
67
|
+
* `listDeployments` capability. Adapters that don't expose this method
|
|
68
|
+
* (e.g. the generic shell adapter) get a clear error and exit 1.
|
|
69
|
+
*/
|
|
70
|
+
export declare function runDeployStatus(opts: RunDeployStatusOptions): Promise<number>;
|
|
71
|
+
//# sourceMappingURL=deploy.d.ts.map
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// src/cli/deploy.ts
|
|
2
|
+
//
|
|
3
|
+
// `claude-autopilot deploy` — Phase 1 of the v5.4 Vercel adapter spec.
|
|
4
|
+
//
|
|
5
|
+
// Loads guardrail.config.yaml, picks a deploy adapter (config or `--adapter`
|
|
6
|
+
// override), runs the deploy, prints a one-line status, returns an exit code.
|
|
7
|
+
//
|
|
8
|
+
// Phase 1 wires only the deploy verb. `deploy status` and `deploy rollback`
|
|
9
|
+
// are scaffolded in the spec for Phase 5 (CLI subcommands wrapping
|
|
10
|
+
// adapter.status/rollback) and not implemented here.
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as os from 'node:os';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { GuardrailError } from "../core/errors.js";
|
|
15
|
+
import { loadConfig } from "../core/config/loader.js";
|
|
16
|
+
import { runSafe } from "../core/shell.js";
|
|
17
|
+
import { createDeployAdapter } from "../adapters/deploy/index.js";
|
|
18
|
+
function hasListDeployments(a) {
|
|
19
|
+
return typeof a.listDeployments === 'function';
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Returns process exit code.
|
|
23
|
+
* 0 — deploy passed
|
|
24
|
+
* 1 — deploy failed (build error, auth error, missing config)
|
|
25
|
+
* 2 — still in progress at poll timeout (caller may retry via deploy status)
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Shared config-loading + adapter-merge logic used by all `deploy` runners
|
|
29
|
+
* (`runDeploy`, `runDeployRollback`, `runDeployStatus`). Returns either the
|
|
30
|
+
* merged `DeployConfig` or an exit code that the caller should propagate.
|
|
31
|
+
*
|
|
32
|
+
* Error-vs-success split mirrors the original inline behavior in `runDeploy`:
|
|
33
|
+
* explicit `--config <missing>` is loud; default-path missing is silent and
|
|
34
|
+
* falls through to the "no adapter configured" check (Bugbot HIGH on PR #59).
|
|
35
|
+
*/
|
|
36
|
+
async function loadDeployConfigAsync(opts) {
|
|
37
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
38
|
+
const explicitConfig = opts.configPath !== undefined;
|
|
39
|
+
const configPath = opts.configPath ?? path.join(cwd, 'guardrail.config.yaml');
|
|
40
|
+
let configBlock;
|
|
41
|
+
if (fs.existsSync(configPath)) {
|
|
42
|
+
try {
|
|
43
|
+
const config = await loadConfig(configPath);
|
|
44
|
+
configBlock = config.deploy;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
console.error(formatErr('failed to load config', err));
|
|
48
|
+
return { errorCode: 1 };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else if (explicitConfig) {
|
|
52
|
+
console.error(`\x1b[31m[deploy] config file not found: ${configPath}\x1b[0m`);
|
|
53
|
+
return { errorCode: 1 };
|
|
54
|
+
}
|
|
55
|
+
const adapter = opts.adapterOverride ?? configBlock?.adapter;
|
|
56
|
+
if (!adapter) {
|
|
57
|
+
console.error('\x1b[31m[deploy] no deploy adapter configured\x1b[0m\n' +
|
|
58
|
+
' hint: set `deploy.adapter` in guardrail.config.yaml, or pass --adapter <vercel|generic>');
|
|
59
|
+
return { errorCode: 1 };
|
|
60
|
+
}
|
|
61
|
+
const merged = {
|
|
62
|
+
...(configBlock ?? { adapter }),
|
|
63
|
+
adapter,
|
|
64
|
+
};
|
|
65
|
+
return { merged };
|
|
66
|
+
}
|
|
67
|
+
export async function runDeploy(opts) {
|
|
68
|
+
const loaded = await loadDeployConfigAsync(opts);
|
|
69
|
+
if ('errorCode' in loaded)
|
|
70
|
+
return loaded.errorCode;
|
|
71
|
+
const { merged } = loaded;
|
|
72
|
+
const adapter = merged.adapter;
|
|
73
|
+
let result;
|
|
74
|
+
let healthOutcome = { status: 'skipped' };
|
|
75
|
+
let streamController;
|
|
76
|
+
let streamPromise;
|
|
77
|
+
let deployAdapterRef;
|
|
78
|
+
try {
|
|
79
|
+
const factory = opts.adapterFactory ?? createDeployAdapter;
|
|
80
|
+
const deployAdapter = factory(merged);
|
|
81
|
+
deployAdapterRef = deployAdapter;
|
|
82
|
+
// --watch: opt into log streaming. We start the stream from inside an
|
|
83
|
+
// onDeployStart callback so it begins as soon as the platform returns
|
|
84
|
+
// an ID, in parallel with the (still-running) deploy.
|
|
85
|
+
let onDeployStart;
|
|
86
|
+
if (opts.watch) {
|
|
87
|
+
if (typeof deployAdapter.streamLogs === 'function') {
|
|
88
|
+
streamController = new AbortController();
|
|
89
|
+
const streamFn = deployAdapter.streamLogs.bind(deployAdapter);
|
|
90
|
+
const ctrlSignal = streamController.signal;
|
|
91
|
+
const adapterName = deployAdapter.name;
|
|
92
|
+
onDeployStart = (deployId) => {
|
|
93
|
+
streamPromise = (async () => {
|
|
94
|
+
try {
|
|
95
|
+
for await (const line of streamFn({ deployId, signal: ctrlSignal })) {
|
|
96
|
+
process.stderr.write(`[deploy:${adapterName}] ${line.text}\n`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (!(err instanceof Error && err.name === 'AbortError')) {
|
|
101
|
+
console.error(`\x1b[2m[deploy] log stream ended: ${err?.message ?? String(err)}\x1b[0m`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
console.error(`\x1b[33m[deploy] --watch ignored — adapter "${deployAdapter.name}" does not support log streaming\x1b[0m`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
result = await deployAdapter.deploy({
|
|
112
|
+
ref: opts.ref,
|
|
113
|
+
commitSha: opts.commitSha,
|
|
114
|
+
onDeployStart,
|
|
115
|
+
});
|
|
116
|
+
// Stop the stream now that the deploy is settled. Wait briefly so any
|
|
117
|
+
// in-flight log lines flush before we report.
|
|
118
|
+
streamController?.abort();
|
|
119
|
+
if (streamPromise) {
|
|
120
|
+
try {
|
|
121
|
+
await streamPromise;
|
|
122
|
+
}
|
|
123
|
+
catch { /* already logged */ }
|
|
124
|
+
}
|
|
125
|
+
// Phase 4 — post-deploy health check. Skipped when deploy itself failed
|
|
126
|
+
// OR when no explicit `healthCheckUrl` is configured. We deliberately do
|
|
127
|
+
// NOT fall back to `result.deployUrl`: silently probing the deploy URL
|
|
128
|
+
// would change behavior for everyone upgrading to Phase 4 (their deploys
|
|
129
|
+
// would suddenly fail if the URL is preview-only or rate-limited). Health
|
|
130
|
+
// checks are opt-in via config. The spec explicitly leaves room for a
|
|
131
|
+
// future `healthCheckUrl: auto` mode that interpolates from `deployUrl`.
|
|
132
|
+
if (result.status === 'pass') {
|
|
133
|
+
const healthUrl = merged.healthCheckUrl;
|
|
134
|
+
if (healthUrl) {
|
|
135
|
+
healthOutcome = await runHealthCheck({
|
|
136
|
+
url: healthUrl,
|
|
137
|
+
fetchImpl: opts.fetchImpl ?? globalThis.fetch,
|
|
138
|
+
sleepImpl: opts.sleepImpl ?? defaultSleep,
|
|
139
|
+
});
|
|
140
|
+
if (healthOutcome.status === 'fail') {
|
|
141
|
+
const triggers = merged.rollbackOn ?? [];
|
|
142
|
+
const wantRollback = triggers.includes('healthCheckFailure');
|
|
143
|
+
if (wantRollback) {
|
|
144
|
+
if (typeof deployAdapter.rollback === 'function') {
|
|
145
|
+
try {
|
|
146
|
+
const rb = await deployAdapter.rollback({});
|
|
147
|
+
if (rb.status === 'pass') {
|
|
148
|
+
result = {
|
|
149
|
+
...result,
|
|
150
|
+
status: 'fail',
|
|
151
|
+
rolledBackTo: rb.rolledBackTo ?? rb.deployId,
|
|
152
|
+
output: `Deploy passed; health check failed (${healthOutcome.lastError}); auto-rolled back to ${rb.rolledBackTo ?? rb.deployId ?? '<unknown>'}.`,
|
|
153
|
+
};
|
|
154
|
+
printAutoRollback(deployAdapter.name, healthOutcome, rb);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
result = {
|
|
158
|
+
...result,
|
|
159
|
+
status: 'fail',
|
|
160
|
+
output: `Deploy passed; health check failed; auto-rollback ALSO failed: ${rb.output ?? '<no output>'}`,
|
|
161
|
+
};
|
|
162
|
+
printAutoRollbackFailed(rb.output ?? 'rollback returned non-pass');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const msg = err?.message ?? String(err);
|
|
167
|
+
result = {
|
|
168
|
+
...result,
|
|
169
|
+
status: 'fail',
|
|
170
|
+
output: `Deploy passed; health check failed; auto-rollback ERRORED: ${msg}`,
|
|
171
|
+
};
|
|
172
|
+
printAutoRollbackFailed(msg);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
console.error(`\x1b[33m[deploy] rollbackOn=[healthCheckFailure] configured but adapter "${deployAdapter.name}" does not support rollback\x1b[0m`);
|
|
177
|
+
result = {
|
|
178
|
+
...result,
|
|
179
|
+
status: 'fail',
|
|
180
|
+
output: `Deploy passed but health check failed: ${healthOutcome.lastError} at ${healthOutcome.url} (adapter does not support rollback)`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
result = {
|
|
186
|
+
...result,
|
|
187
|
+
status: 'fail',
|
|
188
|
+
output: `Deploy passed but health check failed: ${healthOutcome.lastError} at ${healthOutcome.url}`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
streamController?.abort();
|
|
197
|
+
if (streamPromise) {
|
|
198
|
+
try {
|
|
199
|
+
await streamPromise;
|
|
200
|
+
}
|
|
201
|
+
catch { /* already logged */ }
|
|
202
|
+
}
|
|
203
|
+
console.error(formatErr(`deploy via ${adapter} failed`, err));
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
printResult(adapter, result);
|
|
207
|
+
if (opts.pr !== undefined) {
|
|
208
|
+
try {
|
|
209
|
+
postDeployPrComment({
|
|
210
|
+
pr: opts.pr,
|
|
211
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
212
|
+
adapterName: deployAdapterRef?.name ?? adapter,
|
|
213
|
+
result,
|
|
214
|
+
healthOutcome,
|
|
215
|
+
ghImpl: opts.ghImpl ?? defaultGhImpl,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
console.error(`\x1b[33m[deploy] failed to post PR comment: ${err?.message ?? String(err)}\x1b[0m`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (result.status === 'pass')
|
|
223
|
+
return 0;
|
|
224
|
+
if (result.status === 'in-progress')
|
|
225
|
+
return 2;
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
function printResult(adapter, r) {
|
|
229
|
+
const color = r.status === 'pass' ? '\x1b[32m' : r.status === 'in-progress' ? '\x1b[33m' : '\x1b[31m';
|
|
230
|
+
const seconds = (r.durationMs / 1000).toFixed(1);
|
|
231
|
+
const parts = [`status=${r.status}`, `adapter=${adapter}`];
|
|
232
|
+
if (r.deployId)
|
|
233
|
+
parts.push(`deployId=${r.deployId}`);
|
|
234
|
+
if (r.deployUrl)
|
|
235
|
+
parts.push(`url=${r.deployUrl}`);
|
|
236
|
+
parts.push(`duration=${seconds}s`);
|
|
237
|
+
console.log(`${color}[deploy] ${parts.join(' ')}\x1b[0m`);
|
|
238
|
+
if (r.buildLogsUrl)
|
|
239
|
+
console.log(`\x1b[2m logs: ${r.buildLogsUrl}\x1b[0m`);
|
|
240
|
+
if (r.output && r.status !== 'pass') {
|
|
241
|
+
console.log(`\x1b[2m${r.output}\x1b[0m`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function formatErr(prefix, err) {
|
|
245
|
+
if (err instanceof GuardrailError) {
|
|
246
|
+
const provider = err.provider ? ` [${err.provider}]` : '';
|
|
247
|
+
const code = `[${err.code}]`;
|
|
248
|
+
const hint = err.code === 'auth' ? '\n hint: check VERCEL_TOKEN at https://vercel.com/account/tokens' : '';
|
|
249
|
+
return `\x1b[31m[deploy] ${prefix}${provider} ${code} ${err.message}\x1b[0m${hint}`;
|
|
250
|
+
}
|
|
251
|
+
return `\x1b[31m[deploy] ${prefix}: ${err?.message ?? String(err)}\x1b[0m`;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Handle `claude-autopilot deploy rollback [--to <id>]`.
|
|
255
|
+
*
|
|
256
|
+
* Returns process exit code (0 on success, 1 on failure). Failure modes:
|
|
257
|
+
* - Adapter doesn't implement rollback (e.g. generic adapter).
|
|
258
|
+
* - No previous prod deploy exists when `--to` is omitted.
|
|
259
|
+
* - Auth / network / API error (surfaced via formatErr).
|
|
260
|
+
*/
|
|
261
|
+
export async function runDeployRollback(opts) {
|
|
262
|
+
const loaded = await loadDeployConfigAsync(opts);
|
|
263
|
+
if ('errorCode' in loaded)
|
|
264
|
+
return loaded.errorCode;
|
|
265
|
+
const { merged } = loaded;
|
|
266
|
+
const adapter = merged.adapter;
|
|
267
|
+
let result;
|
|
268
|
+
try {
|
|
269
|
+
const factory = opts.adapterFactory ?? createDeployAdapter;
|
|
270
|
+
const deployAdapter = factory(merged);
|
|
271
|
+
if (typeof deployAdapter.rollback !== 'function') {
|
|
272
|
+
console.error(`\x1b[31m[deploy] adapter "${deployAdapter.name}" does not support rollback\x1b[0m`);
|
|
273
|
+
return 1;
|
|
274
|
+
}
|
|
275
|
+
result = await deployAdapter.rollback({ to: opts.to });
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
console.error(formatErr(`rollback via ${adapter} failed`, err));
|
|
279
|
+
return 1;
|
|
280
|
+
}
|
|
281
|
+
printRollbackResult(adapter, result);
|
|
282
|
+
return result.status === 'pass' ? 0 : 1;
|
|
283
|
+
}
|
|
284
|
+
function printRollbackResult(adapter, r) {
|
|
285
|
+
const color = r.status === 'pass' ? '\x1b[32m' : '\x1b[31m';
|
|
286
|
+
const seconds = (r.durationMs / 1000).toFixed(1);
|
|
287
|
+
const parts = [`status=${r.status}`, `adapter=${adapter}`];
|
|
288
|
+
if (r.rolledBackTo)
|
|
289
|
+
parts.push(`rolledBackTo=${r.rolledBackTo}`);
|
|
290
|
+
if (r.deployUrl)
|
|
291
|
+
parts.push(`url=${r.deployUrl}`);
|
|
292
|
+
parts.push(`duration=${seconds}s`);
|
|
293
|
+
console.log(`${color}[deploy] rollback ${parts.join(' ')}\x1b[0m`);
|
|
294
|
+
if (r.buildLogsUrl)
|
|
295
|
+
console.log(`\x1b[2m logs: ${r.buildLogsUrl}\x1b[0m`);
|
|
296
|
+
if (r.output && r.status !== 'pass') {
|
|
297
|
+
console.log(`\x1b[2m${r.output}\x1b[0m`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Handle `claude-autopilot deploy status`. Lists the current production
|
|
302
|
+
* deploy plus the last 5 builds (newest-first), pulling from the adapter's
|
|
303
|
+
* `listDeployments` capability. Adapters that don't expose this method
|
|
304
|
+
* (e.g. the generic shell adapter) get a clear error and exit 1.
|
|
305
|
+
*/
|
|
306
|
+
export async function runDeployStatus(opts) {
|
|
307
|
+
const loaded = await loadDeployConfigAsync(opts);
|
|
308
|
+
if ('errorCode' in loaded)
|
|
309
|
+
return loaded.errorCode;
|
|
310
|
+
const { merged } = loaded;
|
|
311
|
+
const adapter = merged.adapter;
|
|
312
|
+
try {
|
|
313
|
+
const factory = opts.adapterFactory ?? createDeployAdapter;
|
|
314
|
+
const deployAdapter = factory(merged);
|
|
315
|
+
if (!hasListDeployments(deployAdapter)) {
|
|
316
|
+
console.error(`\x1b[31m[deploy] adapter "${deployAdapter.name}" does not support status listing\x1b[0m`);
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
const items = await deployAdapter.listDeployments(5);
|
|
320
|
+
const sorted = [...items].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
|
|
321
|
+
printStatus(adapter, sorted);
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
console.error(formatErr(`status via ${adapter} failed`, err));
|
|
326
|
+
return 1;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function printStatus(adapter, items) {
|
|
330
|
+
console.log(`\x1b[1m[deploy] status — adapter=${adapter}\x1b[0m`);
|
|
331
|
+
if (items.length === 0) {
|
|
332
|
+
console.log('\x1b[2m (no deployments found)\x1b[0m');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const current = items[0];
|
|
336
|
+
const rest = items.slice(1);
|
|
337
|
+
console.log(` current: ${current.id}` +
|
|
338
|
+
(current.state ? ` state=${current.state}` : '') +
|
|
339
|
+
(current.url ? ` url=https://${current.url}` : '') +
|
|
340
|
+
(typeof current.createdAt === 'number' ? ` age=${formatAge(current.createdAt)}` : ''));
|
|
341
|
+
if (rest.length > 0) {
|
|
342
|
+
console.log(' recent builds:');
|
|
343
|
+
for (const d of rest) {
|
|
344
|
+
console.log(` ${d.id}` +
|
|
345
|
+
(d.state ? ` state=${d.state}` : '') +
|
|
346
|
+
(typeof d.createdAt === 'number' ? ` age=${formatAge(d.createdAt)}` : '') +
|
|
347
|
+
(d.url ? ` url=https://${d.url}` : ''));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function formatAge(createdAtMs) {
|
|
352
|
+
const deltaMs = Date.now() - createdAtMs;
|
|
353
|
+
if (deltaMs < 0)
|
|
354
|
+
return '0s';
|
|
355
|
+
const seconds = Math.floor(deltaMs / 1000);
|
|
356
|
+
if (seconds < 60)
|
|
357
|
+
return `${seconds}s`;
|
|
358
|
+
const minutes = Math.floor(seconds / 60);
|
|
359
|
+
if (minutes < 60)
|
|
360
|
+
return `${minutes}m`;
|
|
361
|
+
const hours = Math.floor(minutes / 60);
|
|
362
|
+
if (hours < 24)
|
|
363
|
+
return `${hours}h`;
|
|
364
|
+
const days = Math.floor(hours / 24);
|
|
365
|
+
return `${days}d`;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Probe a URL up to 3 times with 2s backoff between attempts. 2xx → pass.
|
|
369
|
+
* Per-attempt timeout is 10s. Network errors are treated as failures and
|
|
370
|
+
* retried.
|
|
371
|
+
*/
|
|
372
|
+
async function runHealthCheck(opts) {
|
|
373
|
+
const { url, fetchImpl, sleepImpl } = opts;
|
|
374
|
+
let lastError = '';
|
|
375
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
376
|
+
const ctrl = new AbortController();
|
|
377
|
+
const timer = setTimeout(() => ctrl.abort(), 10_000);
|
|
378
|
+
try {
|
|
379
|
+
const res = await fetchImpl(url, { signal: ctrl.signal });
|
|
380
|
+
clearTimeout(timer);
|
|
381
|
+
if (res.status >= 200 && res.status < 300) {
|
|
382
|
+
return { status: 'pass', url };
|
|
383
|
+
}
|
|
384
|
+
lastError = `HTTP ${res.status}`;
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
lastError = err?.message ?? String(err);
|
|
389
|
+
}
|
|
390
|
+
if (attempt < 3)
|
|
391
|
+
await sleepImpl(2000);
|
|
392
|
+
}
|
|
393
|
+
return { status: 'fail', url, lastError };
|
|
394
|
+
}
|
|
395
|
+
function defaultSleep(ms) {
|
|
396
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Print the distinct yellow auto-rollback marker. Called only after the
|
|
400
|
+
* adapter's `rollback({})` returned `pass` — i.e. the previous prod deploy
|
|
401
|
+
* has been promoted and is now serving traffic.
|
|
402
|
+
*/
|
|
403
|
+
function printAutoRollback(adapter, hc, rb) {
|
|
404
|
+
const yellow = '\x1b[33m';
|
|
405
|
+
const dim = '\x1b[2m';
|
|
406
|
+
const reset = '\x1b[0m';
|
|
407
|
+
const target = rb.rolledBackTo ?? rb.deployId ?? '<unknown>';
|
|
408
|
+
console.log(`${yellow}🔄 [deploy] auto-rolled-back-to=${target} via=${adapter} health-check-url=${hc.url}${reset}`);
|
|
409
|
+
console.log(`${dim} reason: health check failed 3x against ${hc.url} (${hc.lastError})${reset}`);
|
|
410
|
+
if (rb.deployUrl) {
|
|
411
|
+
console.log(`${dim} current: ${rb.deployUrl}${reset}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Print the auto-rollback failure marker. Called when the rollback attempt
|
|
416
|
+
* itself errors or returns non-pass — the original (failing) deploy is
|
|
417
|
+
* still in place and the operator must intervene.
|
|
418
|
+
*/
|
|
419
|
+
function printAutoRollbackFailed(reason) {
|
|
420
|
+
const yellow = '\x1b[33m';
|
|
421
|
+
const dim = '\x1b[2m';
|
|
422
|
+
const reset = '\x1b[0m';
|
|
423
|
+
console.log(`${yellow}🔄 [deploy] auto-rollback FAILED — original deploy left in place${reset}`);
|
|
424
|
+
console.log(`${dim} reason: ${reason}${reset}`);
|
|
425
|
+
}
|
|
426
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
427
|
+
// Phase 4 — `--pr <n>` deploy summary comment.
|
|
428
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
429
|
+
const DEPLOY_COMMENT_MARKER = '<!-- claude-autopilot-deploy -->';
|
|
430
|
+
/** Build the markdown body for the deploy summary comment. Pure, side-effect-free. */
|
|
431
|
+
function buildDeployCommentBody(input) {
|
|
432
|
+
const { adapterName, result, healthOutcome } = input;
|
|
433
|
+
const lines = [DEPLOY_COMMENT_MARKER];
|
|
434
|
+
if (result.rolledBackTo) {
|
|
435
|
+
lines.push('## ❌ Deploy auto-rolled back', '');
|
|
436
|
+
lines.push('| Step | Status | URL / ID |');
|
|
437
|
+
lines.push('|---|:---:|---|');
|
|
438
|
+
lines.push(`| New deploy \`${result.deployId ?? 'unknown'}\` | ✅ built | ${result.deployUrl ?? '—'} |`);
|
|
439
|
+
if (healthOutcome.status === 'fail') {
|
|
440
|
+
lines.push(`| Health check | ❌ failed | ${healthOutcome.url} |`);
|
|
441
|
+
}
|
|
442
|
+
lines.push(`| Auto-rollback to \`${result.rolledBackTo}\` | ✅ promoted | (current production) |`);
|
|
443
|
+
}
|
|
444
|
+
else if (result.status === 'pass') {
|
|
445
|
+
lines.push('## ✅ Deploy succeeded', '');
|
|
446
|
+
lines.push('| Field | Value |');
|
|
447
|
+
lines.push('|---|---|');
|
|
448
|
+
lines.push(`| Deploy ID | \`${result.deployId ?? 'unknown'}\` |`);
|
|
449
|
+
if (result.deployUrl)
|
|
450
|
+
lines.push(`| URL | ${result.deployUrl} |`);
|
|
451
|
+
if (healthOutcome.status === 'pass') {
|
|
452
|
+
lines.push(`| Health check | ✅ ${healthOutcome.url} |`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
lines.push('## ❌ Deploy failed', '');
|
|
457
|
+
lines.push('| Field | Value |');
|
|
458
|
+
lines.push('|---|---|');
|
|
459
|
+
if (result.deployId)
|
|
460
|
+
lines.push(`| Deploy ID | \`${result.deployId}\` |`);
|
|
461
|
+
if (result.deployUrl)
|
|
462
|
+
lines.push(`| URL | ${result.deployUrl} |`);
|
|
463
|
+
if (result.output)
|
|
464
|
+
lines.push(`| Reason | ${result.output.replace(/\n/g, ' ')} |`);
|
|
465
|
+
}
|
|
466
|
+
lines.push('', `*adapter=${adapterName} · duration=${(result.durationMs / 1000).toFixed(1)}s*`);
|
|
467
|
+
return lines.join('\n');
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Upsert the deploy summary comment on a PR. Looks up an existing comment
|
|
471
|
+
* anchored on `DEPLOY_COMMENT_MARKER` and PATCHes it; otherwise creates
|
|
472
|
+
* a new one. The marker is distinct from `<!-- guardrail-review -->` so
|
|
473
|
+
* deploy and review comments coexist.
|
|
474
|
+
*/
|
|
475
|
+
function postDeployPrComment(input) {
|
|
476
|
+
const { pr, cwd, ghImpl } = input;
|
|
477
|
+
const body = buildDeployCommentBody(input);
|
|
478
|
+
const lookup = ghImpl([
|
|
479
|
+
'api',
|
|
480
|
+
`repos/{owner}/{repo}/issues/${pr}/comments`,
|
|
481
|
+
'--jq',
|
|
482
|
+
`[.[] | select(.body | startswith("${DEPLOY_COMMENT_MARKER}")) | .id] | first`,
|
|
483
|
+
], { cwd });
|
|
484
|
+
const existingId = lookup.trim();
|
|
485
|
+
if (existingId && /^\d+$/.test(existingId)) {
|
|
486
|
+
ghImpl([
|
|
487
|
+
'api',
|
|
488
|
+
`repos/{owner}/{repo}/issues/comments/${existingId}`,
|
|
489
|
+
'--method',
|
|
490
|
+
'PATCH',
|
|
491
|
+
'--field',
|
|
492
|
+
'body=@-',
|
|
493
|
+
], { cwd, body });
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
ghImpl(['pr', 'comment', String(pr), '--body-file', '-'], { cwd, body });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Default `gh` runner — wraps `core/shell.runSafe` and passes `body` (when
|
|
501
|
+
* present) via stdin. We translate the placeholder argv tokens `@-` and `-`
|
|
502
|
+
* into `--body-file <tmp>` style is unnecessary because `runSafe` already
|
|
503
|
+
* supports `input: string` which `gh` consumes when given `--body-file -`
|
|
504
|
+
* or `--field body=@-`.
|
|
505
|
+
*/
|
|
506
|
+
function defaultGhImpl(args, opts) {
|
|
507
|
+
const result = runSafe('gh', args, { cwd: opts?.cwd, input: opts?.body });
|
|
508
|
+
return result ?? '';
|
|
509
|
+
}
|
|
510
|
+
// `os` is used by future temp-file fallbacks; kept imported so adding one
|
|
511
|
+
// later doesn't perturb the import block. Reference it once to avoid
|
|
512
|
+
// "unused import" warnings under strict linters.
|
|
513
|
+
void os;
|
|
514
|
+
//# sourceMappingURL=deploy.js.map
|