@agentplate/cli 1.0.0
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 +54 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/agents/architect.md +108 -0
- package/agents/builder.md +97 -0
- package/agents/coordinator.md +113 -0
- package/agents/deployer.md +117 -0
- package/agents/devops.md +114 -0
- package/agents/lead.md +107 -0
- package/agents/merger.md +103 -0
- package/agents/reviewer.md +90 -0
- package/agents/scout.md +95 -0
- package/agents/verifier.md +106 -0
- package/package.json +64 -0
- package/src/agents/guard-rules.ts +55 -0
- package/src/agents/identity.test.ts +161 -0
- package/src/agents/identity.ts +229 -0
- package/src/agents/manifest.test.ts +260 -0
- package/src/agents/manifest.ts +286 -0
- package/src/agents/overlay.test.ts +190 -0
- package/src/agents/overlay.ts +212 -0
- package/src/agents/system-prompt.test.ts +53 -0
- package/src/agents/system-prompt.ts +95 -0
- package/src/agents/turn-runner.ts +79 -0
- package/src/commands/coordinator.test.ts +75 -0
- package/src/commands/coordinator.ts +259 -0
- package/src/commands/deploy.test.ts +504 -0
- package/src/commands/deploy.ts +874 -0
- package/src/commands/doctor.test.ts +106 -0
- package/src/commands/doctor.ts +208 -0
- package/src/commands/init.ts +71 -0
- package/src/commands/log.ts +51 -0
- package/src/commands/mail.ts +197 -0
- package/src/commands/merge.ts +127 -0
- package/src/commands/model.ts +58 -0
- package/src/commands/prime.ts +61 -0
- package/src/commands/reap.ts +87 -0
- package/src/commands/serve.ts +61 -0
- package/src/commands/setup.ts +48 -0
- package/src/commands/ship.test.ts +106 -0
- package/src/commands/ship.ts +202 -0
- package/src/commands/skill.test.ts +458 -0
- package/src/commands/skill.ts +730 -0
- package/src/commands/sling.ts +365 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/stop.ts +56 -0
- package/src/commands/tui.ts +199 -0
- package/src/commands/worktree.ts +77 -0
- package/src/config.test.ts +92 -0
- package/src/config.ts +202 -0
- package/src/db/sqlite.test.ts +77 -0
- package/src/db/sqlite.ts +102 -0
- package/src/deploy/audit.test.ts +233 -0
- package/src/deploy/audit.ts +245 -0
- package/src/deploy/context.test.ts +243 -0
- package/src/deploy/context.ts +72 -0
- package/src/deploy/registry.test.ts +101 -0
- package/src/deploy/registry.ts +86 -0
- package/src/deploy/secrets.test.ts +129 -0
- package/src/deploy/secrets.ts +69 -0
- package/src/deploy/targets/docker-gha.test.ts +323 -0
- package/src/deploy/targets/docker-gha.ts +841 -0
- package/src/deploy/types.ts +153 -0
- package/src/errors.test.ts +42 -0
- package/src/errors.ts +69 -0
- package/src/events/store.test.ts +183 -0
- package/src/events/store.ts +201 -0
- package/src/index.ts +137 -0
- package/src/insights/quality-gates.ts +73 -0
- package/src/json.test.ts +28 -0
- package/src/json.ts +50 -0
- package/src/logging/color.ts +62 -0
- package/src/logging/logger.ts +60 -0
- package/src/logging/sanitizer.test.ts +36 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/client.test.ts +192 -0
- package/src/mail/client.ts +188 -0
- package/src/mail/store.test.ts +279 -0
- package/src/mail/store.ts +311 -0
- package/src/merge/lock.test.ts +88 -0
- package/src/merge/lock.ts +84 -0
- package/src/merge/queue.test.ts +136 -0
- package/src/merge/queue.ts +177 -0
- package/src/merge/resolver.test.ts +219 -0
- package/src/merge/resolver.ts +274 -0
- package/src/paths.ts +36 -0
- package/src/providers/apply.test.ts +90 -0
- package/src/providers/apply.ts +66 -0
- package/src/providers/registry.test.ts +74 -0
- package/src/providers/registry.ts +254 -0
- package/src/runtimes/claude.ts +313 -0
- package/src/runtimes/codex.ts +280 -0
- package/src/runtimes/cursor.ts +247 -0
- package/src/runtimes/gemini.ts +173 -0
- package/src/runtimes/mock.ts +71 -0
- package/src/runtimes/opencode.ts +259 -0
- package/src/runtimes/registry.test.ts +924 -0
- package/src/runtimes/registry.ts +63 -0
- package/src/runtimes/resolve.ts +45 -0
- package/src/runtimes/types.ts +97 -0
- package/src/scaffold.ts +68 -0
- package/src/secrets.test.ts +51 -0
- package/src/secrets.ts +78 -0
- package/src/serve/api.ts +667 -0
- package/src/serve/server.test.ts +433 -0
- package/src/serve/server.ts +271 -0
- package/src/serve/system.ts +90 -0
- package/src/serve/weather.ts +140 -0
- package/src/sessions/reaper.test.ts +162 -0
- package/src/sessions/reaper.ts +149 -0
- package/src/sessions/store.test.ts +351 -0
- package/src/sessions/store.ts +350 -0
- package/src/skills/distiller.test.ts +498 -0
- package/src/skills/distiller.ts +426 -0
- package/src/skills/feedback.test.ts +300 -0
- package/src/skills/feedback.ts +168 -0
- package/src/skills/lifecycle.ts +169 -0
- package/src/skills/retrieval.test.ts +421 -0
- package/src/skills/retrieval.ts +365 -0
- package/src/skills/safety.test.ts +335 -0
- package/src/skills/safety.ts +216 -0
- package/src/skills/store.test.ts +425 -0
- package/src/skills/store.ts +684 -0
- package/src/skills/types.ts +107 -0
- package/src/types.ts +442 -0
- package/src/utils/detect.test.ts +35 -0
- package/src/utils/detect.ts +82 -0
- package/src/version.test.ts +19 -0
- package/src/version.ts +7 -0
- package/src/wizard/setup.ts +254 -0
- package/src/worktree/manager.test.ts +181 -0
- package/src/worktree/manager.ts +229 -0
- package/templates/overlay.md.tmpl +102 -0
- package/ui/dist/assets/index-C7rXIMER.css +1 -0
- package/ui/dist/assets/index-W4kbr4by.js +4526 -0
- package/ui/dist/favicon.svg +21 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/logo-clay.svg +21 -0
- package/ui/dist/logo.svg +18 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentplate target` / `agentplate deploy` / `agentplate rollback` command tests.
|
|
3
|
+
*
|
|
4
|
+
* Real implementations throughout (no mocks): every test runs against a real
|
|
5
|
+
* temp git repo with an initialized `.agentplate/` tree (a real `config.yaml`), so
|
|
6
|
+
* `loadConfig`/`isInitialized` work and `git rev-parse HEAD` yields a real sha
|
|
7
|
+
* for the audit row. The deploy core (registry, context, secrets, audit) and the
|
|
8
|
+
* real `docker-gha` target are exercised; the only thing we never invoke is
|
|
9
|
+
* `docker` itself — a `--dry-run` plan generates + writes artifacts but runs no
|
|
10
|
+
* subprocess, which is exactly the path under test.
|
|
11
|
+
*
|
|
12
|
+
* The Commander actions resolve the project root via `findProjectRoot()` (which
|
|
13
|
+
* honors `setProjectRootOverride`), but the heavy assertions drive the exported
|
|
14
|
+
* action helpers (`runDeploy`, `detectTargets`, `runRollback`, `configureTarget`,
|
|
15
|
+
* `readAuditHistory`) directly with an explicit `root`, since `index.ts` does not
|
|
16
|
+
* register these commands yet.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
20
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
AGENTPLATE_DIR,
|
|
25
|
+
CONFIG_FILE,
|
|
26
|
+
DEFAULT_CONFIG,
|
|
27
|
+
serializeConfig,
|
|
28
|
+
setProjectRootOverride,
|
|
29
|
+
} from "../config.ts";
|
|
30
|
+
import { createDeployAudit } from "../deploy/audit.ts";
|
|
31
|
+
import { deploysDbPath } from "../paths.ts";
|
|
32
|
+
import { SECRETS_FILE } from "../secrets.ts";
|
|
33
|
+
import type { AgentplateConfig } from "../types.ts";
|
|
34
|
+
import {
|
|
35
|
+
buildTargetList,
|
|
36
|
+
configureTarget,
|
|
37
|
+
createDeployCommand,
|
|
38
|
+
createRollbackCommand,
|
|
39
|
+
createTargetCommand,
|
|
40
|
+
detectTargets,
|
|
41
|
+
readAuditHistory,
|
|
42
|
+
runDeploy,
|
|
43
|
+
runRollback,
|
|
44
|
+
} from "./deploy.ts";
|
|
45
|
+
|
|
46
|
+
// --- temp git-repo harness -------------------------------------------------
|
|
47
|
+
|
|
48
|
+
let root: string;
|
|
49
|
+
|
|
50
|
+
/** Run a git command in `cwd`, throwing on non-zero exit (test-local helper). */
|
|
51
|
+
async function git(cwd: string, args: string[]): Promise<void> {
|
|
52
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
53
|
+
const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
|
54
|
+
if (exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${stderr}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a real temp git repo, initialize `.agentplate/` with a committed config,
|
|
59
|
+
* and (optionally) seed a package.json so the docker-gha target detects a Node
|
|
60
|
+
* app. Returns the absolute root. The repo has one commit so `git rev-parse HEAD`
|
|
61
|
+
* resolves for the audit row.
|
|
62
|
+
*/
|
|
63
|
+
async function initRepo(
|
|
64
|
+
opts: { pkg?: Record<string, unknown>; config?: AgentplateConfig } = {},
|
|
65
|
+
): Promise<string> {
|
|
66
|
+
const dir = mkdtempSync(join(tmpdir(), "agentplate-deploy-cmd-"));
|
|
67
|
+
await git(dir, ["init", "-q"]);
|
|
68
|
+
await git(dir, ["config", "user.email", "test@agentplate.dev"]);
|
|
69
|
+
await git(dir, ["config", "user.name", "Agentplate Test"]);
|
|
70
|
+
|
|
71
|
+
mkdirSync(join(dir, AGENTPLATE_DIR), { recursive: true });
|
|
72
|
+
const config = opts.config ?? DEFAULT_CONFIG;
|
|
73
|
+
writeFileSync(join(dir, AGENTPLATE_DIR, CONFIG_FILE), serializeConfig(config), "utf8");
|
|
74
|
+
|
|
75
|
+
if (opts.pkg) {
|
|
76
|
+
writeFileSync(join(dir, "package.json"), `${JSON.stringify(opts.pkg, null, 2)}\n`, "utf8");
|
|
77
|
+
}
|
|
78
|
+
// A real commit so readCommitSha() returns a sha (not the empty fallback).
|
|
79
|
+
await git(dir, ["add", "-A"]);
|
|
80
|
+
await git(dir, ["commit", "-q", "-m", "init"]);
|
|
81
|
+
return dir;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A package.json that detects as a built Bun service. */
|
|
85
|
+
function servicePkg(): Record<string, unknown> {
|
|
86
|
+
return {
|
|
87
|
+
name: "demo-service",
|
|
88
|
+
scripts: { build: "tsc", start: "bun run server.ts" },
|
|
89
|
+
dependencies: { hono: "^4.0.0" },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Clone DEFAULT_CONFIG with a chosen default target + gate policy override. */
|
|
94
|
+
function configWith(overrides: {
|
|
95
|
+
default?: string;
|
|
96
|
+
gates?: Record<string, "confirm" | "auto">;
|
|
97
|
+
}): AgentplateConfig {
|
|
98
|
+
const cfg: AgentplateConfig = structuredClone(DEFAULT_CONFIG);
|
|
99
|
+
if (overrides.default !== undefined) cfg.deploy.default = overrides.default;
|
|
100
|
+
if (overrides.gates) {
|
|
101
|
+
for (const [env, policy] of Object.entries(overrides.gates)) cfg.deploy.gates[env] = policy;
|
|
102
|
+
}
|
|
103
|
+
return cfg;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
setProjectRootOverride(null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
setProjectRootOverride(null);
|
|
112
|
+
if (root) rmSync(root, { recursive: true, force: true });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// --- command builders ------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
describe("command builders", () => {
|
|
118
|
+
test("createTargetCommand builds with list/detect/configure subcommands", () => {
|
|
119
|
+
const cmd = createTargetCommand();
|
|
120
|
+
expect(cmd.name()).toBe("target");
|
|
121
|
+
const subs = cmd.commands.map((c) => c.name()).sort();
|
|
122
|
+
expect(subs).toEqual(["configure", "detect", "list"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("createDeployCommand builds with status/history subcommands and gate flags", () => {
|
|
126
|
+
const cmd = createDeployCommand();
|
|
127
|
+
expect(cmd.name()).toBe("deploy");
|
|
128
|
+
const subs = cmd.commands.map((c) => c.name()).sort();
|
|
129
|
+
expect(subs).toEqual(["history", "status"]);
|
|
130
|
+
const optionNames = cmd.options.map((o) => o.long);
|
|
131
|
+
expect(optionNames).toContain("--target");
|
|
132
|
+
expect(optionNames).toContain("--dry-run");
|
|
133
|
+
expect(optionNames).toContain("--yes");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("createRollbackCommand builds without throwing", () => {
|
|
137
|
+
const cmd = createRollbackCommand();
|
|
138
|
+
expect(cmd.name()).toBe("rollback");
|
|
139
|
+
expect(cmd.options.map((o) => o.long)).toContain("--target");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// --- target list -----------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
describe("target list", () => {
|
|
146
|
+
test("includes the registered docker-gha target with its caps", () => {
|
|
147
|
+
const items = buildTargetList();
|
|
148
|
+
const docker = items.find((i) => i.id === "docker-gha");
|
|
149
|
+
expect(docker).toBeDefined();
|
|
150
|
+
expect(docker?.label).toBe("Docker + GitHub Actions");
|
|
151
|
+
expect(docker?.caps.canRollback).toBe(true);
|
|
152
|
+
expect(docker?.caps.irreversible).toBe(false);
|
|
153
|
+
expect(docker?.caps.environments).toContain("production");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// --- target detect ---------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
describe("target detect", () => {
|
|
160
|
+
test("detects docker-gha as the chosen target for a Node service", async () => {
|
|
161
|
+
root = await initRepo({ pkg: servicePkg() });
|
|
162
|
+
const report = await detectTargets(root);
|
|
163
|
+
expect(report.dir).toBe(root);
|
|
164
|
+
expect(report.chosenTarget).toBe("docker-gha");
|
|
165
|
+
expect(report.chosenProfile).not.toBeNull();
|
|
166
|
+
expect(report.chosenProfile?.kind).toBe("service");
|
|
167
|
+
// Every registered target appears in the ranked list.
|
|
168
|
+
expect(report.detections.some((d) => d.id === "docker-gha")).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("returns no chosen target for an empty directory", async () => {
|
|
172
|
+
root = await initRepo();
|
|
173
|
+
const report = await detectTargets(root);
|
|
174
|
+
// docker-gha does not fit a bare repo (no package.json/index.html/Dockerfile).
|
|
175
|
+
expect(report.chosenTarget).toBeNull();
|
|
176
|
+
expect(report.chosenProfile).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// --- target configure ------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
describe("target configure", () => {
|
|
183
|
+
test("reports docker-gha's required secret (GHCR_TOKEN) as missing by default", async () => {
|
|
184
|
+
root = await initRepo({ pkg: servicePkg() });
|
|
185
|
+
// Make sure the env doesn't accidentally satisfy the secret.
|
|
186
|
+
const saved = process.env.GHCR_TOKEN;
|
|
187
|
+
delete process.env.GHCR_TOKEN;
|
|
188
|
+
try {
|
|
189
|
+
const report = await configureTarget(root, "docker-gha", "production");
|
|
190
|
+
expect(report.target).toBe("docker-gha");
|
|
191
|
+
expect(report.requiredSecretKeys).toContain("GHCR_TOKEN");
|
|
192
|
+
expect(report.missingSecretKeys).toContain("GHCR_TOKEN");
|
|
193
|
+
} finally {
|
|
194
|
+
if (saved !== undefined) process.env.GHCR_TOKEN = saved;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("required secret is no longer missing once present in the secrets file", async () => {
|
|
199
|
+
root = await initRepo({ pkg: servicePkg() });
|
|
200
|
+
const saved = process.env.GHCR_TOKEN;
|
|
201
|
+
delete process.env.GHCR_TOKEN;
|
|
202
|
+
writeFileSync(join(root, AGENTPLATE_DIR, SECRETS_FILE), "GHCR_TOKEN: tok-abc\n", {
|
|
203
|
+
mode: 0o600,
|
|
204
|
+
});
|
|
205
|
+
try {
|
|
206
|
+
const report = await configureTarget(root, "docker-gha", "production");
|
|
207
|
+
expect(report.requiredSecretKeys).toContain("GHCR_TOKEN");
|
|
208
|
+
expect(report.missingSecretKeys).not.toContain("GHCR_TOKEN");
|
|
209
|
+
} finally {
|
|
210
|
+
if (saved !== undefined) process.env.GHCR_TOKEN = saved;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// --- deploy --dry-run (the core scenario) ----------------------------------
|
|
216
|
+
|
|
217
|
+
describe("deploy --dry-run", () => {
|
|
218
|
+
test("writes Dockerfile + workflow, plans, and records a dryRun row but NO success deploy", async () => {
|
|
219
|
+
root = await initRepo({ pkg: servicePkg(), config: configWith({ default: "docker-gha" }) });
|
|
220
|
+
|
|
221
|
+
const result = await runDeploy(root, {
|
|
222
|
+
target: "docker-gha",
|
|
223
|
+
environment: "production",
|
|
224
|
+
dryRun: true,
|
|
225
|
+
yes: false,
|
|
226
|
+
agentName: "operator",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Plan, not a deploy.
|
|
230
|
+
expect(result.dryRun).toBe(true);
|
|
231
|
+
expect(result.refused).toBe(false);
|
|
232
|
+
expect(result.deploy).toBeNull();
|
|
233
|
+
expect(result.verify).toBeNull();
|
|
234
|
+
|
|
235
|
+
// Artifacts written to the project root.
|
|
236
|
+
expect(existsSync(join(root, "Dockerfile"))).toBe(true);
|
|
237
|
+
expect(existsSync(join(root, ".github", "workflows", "deploy.yml"))).toBe(true);
|
|
238
|
+
expect(existsSync(join(root, ".dockerignore"))).toBe(true);
|
|
239
|
+
const dockerfile = readFileSync(join(root, "Dockerfile"), "utf8");
|
|
240
|
+
expect(dockerfile).toContain("FROM");
|
|
241
|
+
const workflow = readFileSync(join(root, ".github", "workflows", "deploy.yml"), "utf8");
|
|
242
|
+
expect(workflow).toContain("name: deploy");
|
|
243
|
+
expect(workflow).toContain("GHCR_TOKEN");
|
|
244
|
+
|
|
245
|
+
// The written-artifact list is absolute and includes the Dockerfile.
|
|
246
|
+
expect(result.writtenArtifacts.some((p) => p.endsWith("/Dockerfile"))).toBe(true);
|
|
247
|
+
|
|
248
|
+
// Required secret surfaced, not deployed.
|
|
249
|
+
expect(result.requiredSecretKeys).toContain("GHCR_TOKEN");
|
|
250
|
+
|
|
251
|
+
// Audit: exactly one row, flagged dryRun, NOT a real deploy success.
|
|
252
|
+
const rows = readAuditHistory(root, {});
|
|
253
|
+
expect(rows.length).toBe(1);
|
|
254
|
+
expect(rows[0]?.dryRun).toBe(true);
|
|
255
|
+
expect(rows[0]?.action).toBe("deploy");
|
|
256
|
+
// latest() (used by rollback) must ignore dry runs entirely.
|
|
257
|
+
const audit = createDeployAudit(deploysDbPath(root));
|
|
258
|
+
try {
|
|
259
|
+
expect(audit.latest("docker-gha", "production")).toBeNull();
|
|
260
|
+
} finally {
|
|
261
|
+
audit.close();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("dry-run records a real commit sha on the audit row", async () => {
|
|
266
|
+
root = await initRepo({ pkg: servicePkg(), config: configWith({ default: "docker-gha" }) });
|
|
267
|
+
const result = await runDeploy(root, {
|
|
268
|
+
target: "docker-gha",
|
|
269
|
+
environment: "preview",
|
|
270
|
+
dryRun: true,
|
|
271
|
+
yes: false,
|
|
272
|
+
agentName: "operator",
|
|
273
|
+
});
|
|
274
|
+
expect(result.audit).not.toBeNull();
|
|
275
|
+
// A real git repo with one commit → a 40-char sha, not the empty fallback.
|
|
276
|
+
expect(result.audit?.commitSha).toMatch(/^[0-9a-f]{40}$/);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("a confirm-gated production deploy is NOT refused under --dry-run", async () => {
|
|
280
|
+
// production defaults to a "confirm" gate; dry runs are exempt.
|
|
281
|
+
root = await initRepo({ pkg: servicePkg(), config: configWith({ default: "docker-gha" }) });
|
|
282
|
+
const result = await runDeploy(root, {
|
|
283
|
+
target: "docker-gha",
|
|
284
|
+
environment: "production",
|
|
285
|
+
dryRun: true,
|
|
286
|
+
yes: false,
|
|
287
|
+
agentName: "operator",
|
|
288
|
+
});
|
|
289
|
+
expect(result.refused).toBe(false);
|
|
290
|
+
expect(result.dryRun).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// --- deploy gate (refusal) -------------------------------------------------
|
|
295
|
+
|
|
296
|
+
describe("deploy gate", () => {
|
|
297
|
+
test("refuses a confirm-gated real deploy without --yes (denied audit row, no artifacts)", async () => {
|
|
298
|
+
root = await initRepo({
|
|
299
|
+
pkg: servicePkg(),
|
|
300
|
+
config: configWith({ default: "docker-gha", gates: { production: "confirm" } }),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const result = await runDeploy(root, {
|
|
304
|
+
target: "docker-gha",
|
|
305
|
+
environment: "production",
|
|
306
|
+
dryRun: false,
|
|
307
|
+
yes: false,
|
|
308
|
+
agentName: "operator",
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(result.refused).toBe(true);
|
|
312
|
+
expect(result.gateDecision).toBe("denied");
|
|
313
|
+
expect(result.refusalReason).toContain("--yes");
|
|
314
|
+
// No artifacts written when the gate denies up front.
|
|
315
|
+
expect(existsSync(join(root, "Dockerfile"))).toBe(false);
|
|
316
|
+
// A denied row IS recorded (failed status), and it is not a deploy target.
|
|
317
|
+
const rows = readAuditHistory(root, {});
|
|
318
|
+
expect(rows.length).toBe(1);
|
|
319
|
+
expect(rows[0]?.gateDecision).toBe("denied");
|
|
320
|
+
expect(rows[0]?.status).toBe("failed");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("an auto-gated environment is not refused (proven via a docker-free dry-run)", async () => {
|
|
324
|
+
// staging defaults to "auto". We assert the gate never refuses; the dry-run
|
|
325
|
+
// path proves this without ever reaching target.deploy() (no docker invoked,
|
|
326
|
+
// per the test rules).
|
|
327
|
+
root = await initRepo({
|
|
328
|
+
pkg: servicePkg(),
|
|
329
|
+
config: configWith({ default: "docker-gha", gates: { staging: "auto" } }),
|
|
330
|
+
});
|
|
331
|
+
const result = await runDeploy(root, {
|
|
332
|
+
target: "docker-gha",
|
|
333
|
+
environment: "staging",
|
|
334
|
+
dryRun: true,
|
|
335
|
+
yes: false,
|
|
336
|
+
agentName: "operator",
|
|
337
|
+
});
|
|
338
|
+
expect(result.refused).toBe(false);
|
|
339
|
+
expect(result.gateDecision).toBe("auto");
|
|
340
|
+
expect(existsSync(join(root, "Dockerfile"))).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("--yes maps a confirm-gated deploy to gateDecision 'approved' (docker-free dry-run)", async () => {
|
|
344
|
+
// The gateDecision is computed before the dry-run/real split, so a dry-run
|
|
345
|
+
// with --yes exercises the same approval mapping a real run would — and it
|
|
346
|
+
// never calls target.deploy(), so no docker is invoked.
|
|
347
|
+
root = await initRepo({
|
|
348
|
+
pkg: servicePkg(),
|
|
349
|
+
config: configWith({ default: "docker-gha", gates: { production: "confirm" } }),
|
|
350
|
+
});
|
|
351
|
+
const result = await runDeploy(root, {
|
|
352
|
+
target: "docker-gha",
|
|
353
|
+
environment: "production",
|
|
354
|
+
dryRun: true,
|
|
355
|
+
yes: true,
|
|
356
|
+
agentName: "operator",
|
|
357
|
+
});
|
|
358
|
+
expect(result.refused).toBe(false);
|
|
359
|
+
expect(result.gateDecision).toBe("approved");
|
|
360
|
+
const rows = readAuditHistory(root, {});
|
|
361
|
+
expect(rows[0]?.gateDecision).toBe("approved");
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// --- deploy fail-fast on missing secrets -----------------------------------
|
|
366
|
+
|
|
367
|
+
describe("deploy missing-secret fail-fast", () => {
|
|
368
|
+
test("a real auto deploy throws when a required secret is absent", async () => {
|
|
369
|
+
root = await initRepo({
|
|
370
|
+
pkg: servicePkg(),
|
|
371
|
+
config: configWith({ default: "docker-gha", gates: { staging: "auto" } }),
|
|
372
|
+
});
|
|
373
|
+
const saved = process.env.GHCR_TOKEN;
|
|
374
|
+
delete process.env.GHCR_TOKEN;
|
|
375
|
+
try {
|
|
376
|
+
await expect(
|
|
377
|
+
runDeploy(root, {
|
|
378
|
+
target: "docker-gha",
|
|
379
|
+
environment: "staging",
|
|
380
|
+
dryRun: false,
|
|
381
|
+
yes: false,
|
|
382
|
+
agentName: "operator",
|
|
383
|
+
}),
|
|
384
|
+
).rejects.toThrow(/missing required secret/i);
|
|
385
|
+
} finally {
|
|
386
|
+
if (saved !== undefined) process.env.GHCR_TOKEN = saved;
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// --- target resolution failures --------------------------------------------
|
|
392
|
+
|
|
393
|
+
describe("target resolution", () => {
|
|
394
|
+
test("runDeploy throws when no target is given and config.deploy.default is unset", async () => {
|
|
395
|
+
root = await initRepo({ pkg: servicePkg() }); // DEFAULT_CONFIG → deploy.default === ""
|
|
396
|
+
await expect(
|
|
397
|
+
runDeploy(root, {
|
|
398
|
+
environment: "preview",
|
|
399
|
+
dryRun: true,
|
|
400
|
+
yes: false,
|
|
401
|
+
agentName: "operator",
|
|
402
|
+
}),
|
|
403
|
+
).rejects.toThrow(/No deploy target specified/i);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("runDeploy throws on an unknown target name", async () => {
|
|
407
|
+
root = await initRepo({ pkg: servicePkg() });
|
|
408
|
+
await expect(
|
|
409
|
+
runDeploy(root, {
|
|
410
|
+
target: "does-not-exist",
|
|
411
|
+
environment: "preview",
|
|
412
|
+
dryRun: true,
|
|
413
|
+
yes: false,
|
|
414
|
+
agentName: "operator",
|
|
415
|
+
}),
|
|
416
|
+
).rejects.toThrow(/Unknown deploy target/i);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// --- rollback --------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
describe("rollback", () => {
|
|
423
|
+
test("throws NotFoundError when there is no prior successful deploy", async () => {
|
|
424
|
+
root = await initRepo({ pkg: servicePkg(), config: configWith({ default: "docker-gha" }) });
|
|
425
|
+
await expect(
|
|
426
|
+
runRollback(root, { target: "docker-gha", environment: "production", agentName: "operator" }),
|
|
427
|
+
).rejects.toThrow(/No prior successful deploy/i);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("rolls back to the latest successful deploy and records a rollback row", async () => {
|
|
431
|
+
root = await initRepo({ pkg: servicePkg(), config: configWith({ default: "docker-gha" }) });
|
|
432
|
+
// Seed a real successful deploy row directly through the audit store so
|
|
433
|
+
// latest() has something to revert to (without invoking docker).
|
|
434
|
+
const seedAudit = createDeployAudit(deploysDbPath(root));
|
|
435
|
+
try {
|
|
436
|
+
seedAudit.record({
|
|
437
|
+
runId: null,
|
|
438
|
+
agentName: "deployer-seed",
|
|
439
|
+
target: "docker-gha",
|
|
440
|
+
environment: "staging",
|
|
441
|
+
action: "deploy",
|
|
442
|
+
dryRun: false,
|
|
443
|
+
gateDecision: "auto",
|
|
444
|
+
approvedBy: null,
|
|
445
|
+
status: "success",
|
|
446
|
+
deploymentId: "ghcr.io/demo/app:abc123",
|
|
447
|
+
urls: [],
|
|
448
|
+
outputs: { imageRef: "ghcr.io/demo/app:abc123" },
|
|
449
|
+
commitSha: "deadbeef",
|
|
450
|
+
});
|
|
451
|
+
} finally {
|
|
452
|
+
seedAudit.close();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const result = await runRollback(root, {
|
|
456
|
+
target: "docker-gha",
|
|
457
|
+
environment: "staging",
|
|
458
|
+
agentName: "operator",
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
expect(result.previous).not.toBeNull();
|
|
462
|
+
expect(result.previous?.deploymentId).toBe("ghcr.io/demo/app:abc123");
|
|
463
|
+
expect(result.rollback).not.toBeNull();
|
|
464
|
+
expect(result.audit?.action).toBe("rollback");
|
|
465
|
+
expect(result.audit?.gateDecision).toBe("n/a");
|
|
466
|
+
|
|
467
|
+
// History now has two rows (the seeded deploy + the rollback), newest first.
|
|
468
|
+
const rows = readAuditHistory(root, { target: "docker-gha", environment: "staging" });
|
|
469
|
+
expect(rows.length).toBe(2);
|
|
470
|
+
expect(rows[0]?.action).toBe("rollback");
|
|
471
|
+
expect(rows[1]?.action).toBe("deploy");
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// --- audit history read ----------------------------------------------------
|
|
476
|
+
|
|
477
|
+
describe("deploy history / status read", () => {
|
|
478
|
+
test("readAuditHistory returns rows newest-first and honors the limit", async () => {
|
|
479
|
+
root = await initRepo({ pkg: servicePkg(), config: configWith({ default: "docker-gha" }) });
|
|
480
|
+
// Two dry-run plans → two audit rows.
|
|
481
|
+
await runDeploy(root, {
|
|
482
|
+
target: "docker-gha",
|
|
483
|
+
environment: "preview",
|
|
484
|
+
dryRun: true,
|
|
485
|
+
yes: false,
|
|
486
|
+
agentName: "operator",
|
|
487
|
+
});
|
|
488
|
+
await runDeploy(root, {
|
|
489
|
+
target: "docker-gha",
|
|
490
|
+
environment: "staging",
|
|
491
|
+
dryRun: true,
|
|
492
|
+
yes: false,
|
|
493
|
+
agentName: "operator",
|
|
494
|
+
});
|
|
495
|
+
const all = readAuditHistory(root, {});
|
|
496
|
+
expect(all.length).toBe(2);
|
|
497
|
+
const limited = readAuditHistory(root, { limit: 1 });
|
|
498
|
+
expect(limited.length).toBe(1);
|
|
499
|
+
// Filter by environment.
|
|
500
|
+
const previewOnly = readAuditHistory(root, { environment: "preview" });
|
|
501
|
+
expect(previewOnly.length).toBe(1);
|
|
502
|
+
expect(previewOnly[0]?.environment).toBe("preview");
|
|
503
|
+
});
|
|
504
|
+
});
|