@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.
Files changed (139) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -0
  4. package/agents/architect.md +108 -0
  5. package/agents/builder.md +97 -0
  6. package/agents/coordinator.md +113 -0
  7. package/agents/deployer.md +117 -0
  8. package/agents/devops.md +114 -0
  9. package/agents/lead.md +107 -0
  10. package/agents/merger.md +103 -0
  11. package/agents/reviewer.md +90 -0
  12. package/agents/scout.md +95 -0
  13. package/agents/verifier.md +106 -0
  14. package/package.json +64 -0
  15. package/src/agents/guard-rules.ts +55 -0
  16. package/src/agents/identity.test.ts +161 -0
  17. package/src/agents/identity.ts +229 -0
  18. package/src/agents/manifest.test.ts +260 -0
  19. package/src/agents/manifest.ts +286 -0
  20. package/src/agents/overlay.test.ts +190 -0
  21. package/src/agents/overlay.ts +212 -0
  22. package/src/agents/system-prompt.test.ts +53 -0
  23. package/src/agents/system-prompt.ts +95 -0
  24. package/src/agents/turn-runner.ts +79 -0
  25. package/src/commands/coordinator.test.ts +75 -0
  26. package/src/commands/coordinator.ts +259 -0
  27. package/src/commands/deploy.test.ts +504 -0
  28. package/src/commands/deploy.ts +874 -0
  29. package/src/commands/doctor.test.ts +106 -0
  30. package/src/commands/doctor.ts +208 -0
  31. package/src/commands/init.ts +71 -0
  32. package/src/commands/log.ts +51 -0
  33. package/src/commands/mail.ts +197 -0
  34. package/src/commands/merge.ts +127 -0
  35. package/src/commands/model.ts +58 -0
  36. package/src/commands/prime.ts +61 -0
  37. package/src/commands/reap.ts +87 -0
  38. package/src/commands/serve.ts +61 -0
  39. package/src/commands/setup.ts +48 -0
  40. package/src/commands/ship.test.ts +106 -0
  41. package/src/commands/ship.ts +202 -0
  42. package/src/commands/skill.test.ts +458 -0
  43. package/src/commands/skill.ts +730 -0
  44. package/src/commands/sling.ts +365 -0
  45. package/src/commands/status.ts +60 -0
  46. package/src/commands/stop.ts +56 -0
  47. package/src/commands/tui.ts +199 -0
  48. package/src/commands/worktree.ts +77 -0
  49. package/src/config.test.ts +92 -0
  50. package/src/config.ts +202 -0
  51. package/src/db/sqlite.test.ts +77 -0
  52. package/src/db/sqlite.ts +102 -0
  53. package/src/deploy/audit.test.ts +233 -0
  54. package/src/deploy/audit.ts +245 -0
  55. package/src/deploy/context.test.ts +243 -0
  56. package/src/deploy/context.ts +72 -0
  57. package/src/deploy/registry.test.ts +101 -0
  58. package/src/deploy/registry.ts +86 -0
  59. package/src/deploy/secrets.test.ts +129 -0
  60. package/src/deploy/secrets.ts +69 -0
  61. package/src/deploy/targets/docker-gha.test.ts +323 -0
  62. package/src/deploy/targets/docker-gha.ts +841 -0
  63. package/src/deploy/types.ts +153 -0
  64. package/src/errors.test.ts +42 -0
  65. package/src/errors.ts +69 -0
  66. package/src/events/store.test.ts +183 -0
  67. package/src/events/store.ts +201 -0
  68. package/src/index.ts +137 -0
  69. package/src/insights/quality-gates.ts +73 -0
  70. package/src/json.test.ts +28 -0
  71. package/src/json.ts +50 -0
  72. package/src/logging/color.ts +62 -0
  73. package/src/logging/logger.ts +60 -0
  74. package/src/logging/sanitizer.test.ts +36 -0
  75. package/src/logging/sanitizer.ts +57 -0
  76. package/src/mail/client.test.ts +192 -0
  77. package/src/mail/client.ts +188 -0
  78. package/src/mail/store.test.ts +279 -0
  79. package/src/mail/store.ts +311 -0
  80. package/src/merge/lock.test.ts +88 -0
  81. package/src/merge/lock.ts +84 -0
  82. package/src/merge/queue.test.ts +136 -0
  83. package/src/merge/queue.ts +177 -0
  84. package/src/merge/resolver.test.ts +219 -0
  85. package/src/merge/resolver.ts +274 -0
  86. package/src/paths.ts +36 -0
  87. package/src/providers/apply.test.ts +90 -0
  88. package/src/providers/apply.ts +66 -0
  89. package/src/providers/registry.test.ts +74 -0
  90. package/src/providers/registry.ts +254 -0
  91. package/src/runtimes/claude.ts +313 -0
  92. package/src/runtimes/codex.ts +280 -0
  93. package/src/runtimes/cursor.ts +247 -0
  94. package/src/runtimes/gemini.ts +173 -0
  95. package/src/runtimes/mock.ts +71 -0
  96. package/src/runtimes/opencode.ts +259 -0
  97. package/src/runtimes/registry.test.ts +924 -0
  98. package/src/runtimes/registry.ts +63 -0
  99. package/src/runtimes/resolve.ts +45 -0
  100. package/src/runtimes/types.ts +97 -0
  101. package/src/scaffold.ts +68 -0
  102. package/src/secrets.test.ts +51 -0
  103. package/src/secrets.ts +78 -0
  104. package/src/serve/api.ts +667 -0
  105. package/src/serve/server.test.ts +433 -0
  106. package/src/serve/server.ts +271 -0
  107. package/src/serve/system.ts +90 -0
  108. package/src/serve/weather.ts +140 -0
  109. package/src/sessions/reaper.test.ts +162 -0
  110. package/src/sessions/reaper.ts +149 -0
  111. package/src/sessions/store.test.ts +351 -0
  112. package/src/sessions/store.ts +350 -0
  113. package/src/skills/distiller.test.ts +498 -0
  114. package/src/skills/distiller.ts +426 -0
  115. package/src/skills/feedback.test.ts +300 -0
  116. package/src/skills/feedback.ts +168 -0
  117. package/src/skills/lifecycle.ts +169 -0
  118. package/src/skills/retrieval.test.ts +421 -0
  119. package/src/skills/retrieval.ts +365 -0
  120. package/src/skills/safety.test.ts +335 -0
  121. package/src/skills/safety.ts +216 -0
  122. package/src/skills/store.test.ts +425 -0
  123. package/src/skills/store.ts +684 -0
  124. package/src/skills/types.ts +107 -0
  125. package/src/types.ts +442 -0
  126. package/src/utils/detect.test.ts +35 -0
  127. package/src/utils/detect.ts +82 -0
  128. package/src/version.test.ts +19 -0
  129. package/src/version.ts +7 -0
  130. package/src/wizard/setup.ts +254 -0
  131. package/src/worktree/manager.test.ts +181 -0
  132. package/src/worktree/manager.ts +229 -0
  133. package/templates/overlay.md.tmpl +102 -0
  134. package/ui/dist/assets/index-C7rXIMER.css +1 -0
  135. package/ui/dist/assets/index-W4kbr4by.js +4526 -0
  136. package/ui/dist/favicon.svg +21 -0
  137. package/ui/dist/index.html +16 -0
  138. package/ui/dist/logo-clay.svg +21 -0
  139. package/ui/dist/logo.svg +18 -0
@@ -0,0 +1,874 @@
1
+ /**
2
+ * `agentplate target`, `agentplate deploy`, and `agentplate rollback` — the operator
3
+ * surface for the build → CI/CD → deploy pipeline.
4
+ *
5
+ * These commands are deliberately thin: every consequential decision lives in
6
+ * the deploy core (registry/context/secrets/audit) and the target adapters. The
7
+ * command layer only resolves a target, detects an {@link AppProfile}, assembles
8
+ * a {@link DeployContext}, applies the *gate policy*, drives the
9
+ * generate → write → deploy → verify sequence, and records one append-only audit
10
+ * row. Secrets flow exclusively through `ctx.secretEnv` (env-by-name) and are
11
+ * never printed; captured target output is already redacted by the adapters.
12
+ *
13
+ * The `deploy`/`rollback`/`detect` actions are exported as standalone helpers so
14
+ * tests can drive them directly without the CLI being registered in `index.ts`.
15
+ * Each helper takes a resolved project `root` plus parsed options and returns a
16
+ * structured result; the Commander actions are thin wrappers that resolve the
17
+ * root, print, and (for `--json`) emit an envelope.
18
+ */
19
+
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, isAbsolute, resolve } from "node:path";
22
+ import { Command } from "commander";
23
+ import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
24
+ import { createDeployAudit } from "../deploy/audit.ts";
25
+ import { buildDeployContext } from "../deploy/context.ts";
26
+ import { getAllDeployTargets, getDeployTarget } from "../deploy/registry.ts";
27
+ import { missingSecretKeys } from "../deploy/secrets.ts";
28
+ import type {
29
+ AppProfile,
30
+ DeployContext,
31
+ DeployResult,
32
+ DeployTarget,
33
+ DetectResult,
34
+ GeneratedArtifact,
35
+ VerifyResult,
36
+ } from "../deploy/types.ts";
37
+ import { NotFoundError, ValidationError } from "../errors.ts";
38
+ import { jsonOutput } from "../json.ts";
39
+ import { muted, printError, printInfo, printSuccess, printWarning } from "../logging/color.ts";
40
+ import { currentRunPath, deploysDbPath } from "../paths.ts";
41
+ import type { DeployAuditRow } from "../types.ts";
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Shared helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** Resolve + require an initialized project root (honors `--project`). */
48
+ function requireInit(): string {
49
+ const root = findProjectRoot();
50
+ if (!isInitialized(root)) {
51
+ throw new ValidationError("Not initialized. Run `agentplate setup` first.");
52
+ }
53
+ return root;
54
+ }
55
+
56
+ /** Read the active run id from `.agentplate/current-run.txt`, or null when unset. */
57
+ function readCurrentRun(root: string): string | null {
58
+ const path = currentRunPath(root);
59
+ if (!existsSync(path)) return null;
60
+ try {
61
+ const trimmed = readFileSync(path, "utf8").trim();
62
+ return trimmed === "" ? null : trimmed;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Best-effort current commit sha for the audit row. Never throws: a project may
70
+ * be a non-git directory or have no commits yet, in which case the audit records
71
+ * an empty sha rather than failing the deploy.
72
+ */
73
+ async function readCommitSha(root: string): Promise<string> {
74
+ try {
75
+ const proc = Bun.spawn(["git", "rev-parse", "HEAD"], {
76
+ cwd: root,
77
+ stdout: "pipe",
78
+ stderr: "pipe",
79
+ });
80
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
81
+ if (exitCode !== 0) return "";
82
+ return stdout.trim();
83
+ } catch {
84
+ return "";
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Write a generated artifact under `root`, creating parent directories as
90
+ * needed. The artifact `path` is contract-defined as *relative to the worktree
91
+ * root*; an absolute path is rejected so a target can never escape the project
92
+ * tree. Honors the optional file `mode` (default 0o644).
93
+ */
94
+ function writeArtifact(root: string, artifact: GeneratedArtifact): string {
95
+ if (isAbsolute(artifact.path)) {
96
+ throw new ValidationError(
97
+ `Generated artifact path must be relative to the project root: "${artifact.path}"`,
98
+ );
99
+ }
100
+ const target = resolve(root, artifact.path);
101
+ // Defense in depth: refuse paths that resolve outside the project root.
102
+ const rootResolved = resolve(root);
103
+ if (target !== rootResolved && !target.startsWith(`${rootResolved}/`)) {
104
+ throw new ValidationError(
105
+ `Generated artifact path escapes the project root: "${artifact.path}"`,
106
+ );
107
+ }
108
+ mkdirSync(dirname(target), { recursive: true });
109
+ writeFileSync(target, artifact.content, { mode: artifact.mode ?? 0o644 });
110
+ return target;
111
+ }
112
+
113
+ /**
114
+ * Resolve the gate policy for an environment, mirroring the registry's
115
+ * fail-loud stance: an environment with no explicit policy defaults to "auto".
116
+ */
117
+ function gatePolicyFor(
118
+ config: ReturnType<typeof loadConfig>,
119
+ environment: string,
120
+ ): "confirm" | "auto" {
121
+ return config.deploy.gates[environment] ?? "auto";
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // target list / detect / configure
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /** A flattened, JSON-friendly view of a registered target for `target list`. */
129
+ interface TargetListItem {
130
+ id: string;
131
+ label: string;
132
+ description: string;
133
+ stability: DeployTarget["stability"];
134
+ caps: DeployTarget["caps"];
135
+ }
136
+
137
+ /** Build the `target list` payload from the registry (no project state needed). */
138
+ export function buildTargetList(): TargetListItem[] {
139
+ return getAllDeployTargets().map((t) => ({
140
+ id: t.id,
141
+ label: t.label,
142
+ description: t.description,
143
+ stability: t.stability,
144
+ caps: t.caps,
145
+ }));
146
+ }
147
+
148
+ /** One target's detection outcome, used by `target detect`. */
149
+ interface TargetDetection {
150
+ id: string;
151
+ label: string;
152
+ stability: DeployTarget["stability"];
153
+ detect: DetectResult;
154
+ }
155
+
156
+ /** The full `target detect` result: every target ranked, plus the winner. */
157
+ export interface DetectReport {
158
+ dir: string;
159
+ detections: TargetDetection[];
160
+ /** Highest-confidence *fitting* target id, or null when nothing fits. */
161
+ chosenTarget: string | null;
162
+ /** The chosen target's detected profile, or null when nothing fits. */
163
+ chosenProfile: AppProfile | null;
164
+ }
165
+
166
+ /**
167
+ * Run every registered target's `detect()` against `dir`, rank by descending
168
+ * confidence (fitting targets first), and pick the best fit. Pure read-only.
169
+ */
170
+ export async function detectTargets(dir: string): Promise<DetectReport> {
171
+ const detections: TargetDetection[] = [];
172
+ for (const target of getAllDeployTargets()) {
173
+ const detect = await target.detect(dir);
174
+ detections.push({ id: target.id, label: target.label, stability: target.stability, detect });
175
+ }
176
+ // Sort fitting targets ahead of non-fitting, then by confidence desc.
177
+ detections.sort((a, b) => {
178
+ if (a.detect.fit !== b.detect.fit) return a.detect.fit ? -1 : 1;
179
+ return b.detect.confidence - a.detect.confidence;
180
+ });
181
+ const best = detections.find((d) => d.detect.fit) ?? null;
182
+ return {
183
+ dir,
184
+ detections,
185
+ chosenTarget: best ? best.id : null,
186
+ chosenProfile: best ? best.detect.profile : null,
187
+ };
188
+ }
189
+
190
+ /** The `target configure` payload: which secret env-var names a target needs. */
191
+ export interface ConfigureReport {
192
+ target: string;
193
+ label: string;
194
+ environments: string[];
195
+ /** Env-var NAMES required at deploy time (from a dry generateConfig). */
196
+ requiredSecretKeys: string[];
197
+ /** Subset of `requiredSecretKeys` not yet resolvable from file or env. */
198
+ missingSecretKeys: string[];
199
+ }
200
+
201
+ /**
202
+ * Compute the secret keys a target needs, by running `generateConfig` on a
203
+ * dry-run context (the only side-effect-free way to learn `requiredSecretKeys`
204
+ * without writing anything). Used by `target configure` to tell the operator
205
+ * exactly which env vars to provide.
206
+ */
207
+ export async function configureTarget(
208
+ root: string,
209
+ name: string,
210
+ environment: string,
211
+ ): Promise<ConfigureReport> {
212
+ const config = loadConfig(root);
213
+ const target = getDeployTarget(name, config);
214
+ const detect = await target.detect(root);
215
+ const ctx = buildDeployContext({
216
+ root,
217
+ worktreePath: root,
218
+ target,
219
+ environment,
220
+ profile: detect.profile,
221
+ dryRun: true,
222
+ runId: readCurrentRun(root),
223
+ agentName: "operator",
224
+ config,
225
+ });
226
+ const generated = await target.generateConfig(ctx);
227
+ return {
228
+ target: target.id,
229
+ label: target.label,
230
+ environments: target.caps.environments,
231
+ requiredSecretKeys: generated.requiredSecretKeys,
232
+ missingSecretKeys: missingSecretKeys(root, generated.requiredSecretKeys),
233
+ };
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // deploy
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /** Options accepted by {@link runDeploy} (already parsed from the CLI). */
241
+ export interface DeployOptions {
242
+ target?: string;
243
+ environment: string;
244
+ dryRun: boolean;
245
+ yes: boolean;
246
+ agentName: string;
247
+ }
248
+
249
+ /** Why a deploy was refused, when it was. */
250
+ export type GateOutcome = DeployAuditRow["gateDecision"];
251
+
252
+ /** Structured result of {@link runDeploy}, suitable for `--json` and printing. */
253
+ export interface DeployRunResult {
254
+ target: string;
255
+ environment: string;
256
+ dryRun: boolean;
257
+ /** "auto" | "approved" | "denied" | "n/a" — what the gate decided. */
258
+ gateDecision: GateOutcome;
259
+ /** Set when the gate denied the deploy (no artifacts written, no deploy run). */
260
+ refused: boolean;
261
+ /** Human-readable reason for a refusal (else null). */
262
+ refusalReason: string | null;
263
+ summary: string;
264
+ /** Paths (absolute) of artifacts written to the project root. */
265
+ writtenArtifacts: string[];
266
+ requiredSecretKeys: string[];
267
+ missingSecretKeys: string[];
268
+ /** The deploy execution result (null when refused, or when dry-run skips it). */
269
+ deploy: DeployResult | null;
270
+ /** The verify result (null unless a real deploy succeeded enough to verify). */
271
+ verify: VerifyResult | null;
272
+ /** The audit row written (null for a refusal — refusals are recorded too). */
273
+ audit: DeployAuditRow | null;
274
+ }
275
+
276
+ /**
277
+ * Execute (or plan) a deployment end to end.
278
+ *
279
+ * Sequence:
280
+ * 1. Resolve the target (explicit `--target`, else `config.deploy.default`).
281
+ * 2. Detect the {@link AppProfile} on the project root.
282
+ * 3. Build a {@link DeployContext} (`dryRun` from `--dry-run`).
283
+ * 4. Apply the gate: with policy `gates[env] ?? "auto"`, refuse when
284
+ * `(policy === "confirm" || caps.irreversible)` and neither `--yes` nor a
285
+ * dry-run is in play — recording a "denied" audit row and stopping.
286
+ * 5. `generateConfig` → write each artifact to the project root.
287
+ * 6. Real run only: fail fast when any required secret is missing; then
288
+ * `deploy()` and, on success, `verify()`.
289
+ * 7. Write one audit row (carrying the dryRun flag, gate decision, status).
290
+ *
291
+ * A dry-run writes artifacts and reports the plan but never calls `deploy()` and
292
+ * never records a *success*; it records a `dryRun:true` row so the plan is
293
+ * auditable without implying anything shipped. `ctx.secretEnv` is never printed
294
+ * or persisted.
295
+ */
296
+ export async function runDeploy(root: string, opts: DeployOptions): Promise<DeployRunResult> {
297
+ const config = loadConfig(root);
298
+ const target = getDeployTarget(opts.target, config);
299
+ const environment = opts.environment;
300
+
301
+ const detect = await target.detect(root);
302
+ const ctx: DeployContext = buildDeployContext({
303
+ root,
304
+ worktreePath: root,
305
+ target,
306
+ environment,
307
+ profile: detect.profile,
308
+ dryRun: opts.dryRun,
309
+ runId: readCurrentRun(root),
310
+ agentName: opts.agentName,
311
+ config,
312
+ });
313
+
314
+ const policy = gatePolicyFor(config, environment);
315
+ const gateRequiresConfirm = policy === "confirm" || target.caps.irreversible;
316
+ const commitSha = await readCommitSha(root);
317
+ const audit = createDeployAudit(deploysDbPath(root));
318
+
319
+ try {
320
+ // --- Gate: refuse a confirm-required / irreversible deploy without --yes.
321
+ // Dry runs are exempt (they never mutate the live target).
322
+ if (gateRequiresConfirm && !opts.yes && !opts.dryRun) {
323
+ const reason =
324
+ `Deploy to "${environment}" via "${target.id}" requires confirmation` +
325
+ (target.caps.irreversible ? " (target is irreversible)" : ` (gate policy: ${policy})`) +
326
+ ". Re-run with --yes to approve.";
327
+ const row = audit.record({
328
+ runId: ctx.runId,
329
+ agentName: ctx.agentName,
330
+ target: target.id,
331
+ environment,
332
+ action: "deploy",
333
+ dryRun: false,
334
+ gateDecision: "denied",
335
+ approvedBy: null,
336
+ status: "failed",
337
+ deploymentId: null,
338
+ urls: [],
339
+ outputs: {},
340
+ commitSha,
341
+ });
342
+ return {
343
+ target: target.id,
344
+ environment,
345
+ dryRun: false,
346
+ gateDecision: "denied",
347
+ refused: true,
348
+ refusalReason: reason,
349
+ summary: reason,
350
+ writtenArtifacts: [],
351
+ requiredSecretKeys: [],
352
+ missingSecretKeys: [],
353
+ deploy: null,
354
+ verify: null,
355
+ audit: row,
356
+ };
357
+ }
358
+
359
+ // We are past the refusal gate, so this deploy is allowed to proceed. The
360
+ // decision recorded on the audit row is "approved" when the operator passed
361
+ // --yes (an explicit go-ahead), otherwise "auto" — covering both an auto
362
+ // gate and the dry-run exemption of a confirm gate (a dry-run is never a
363
+ // live approval, so without --yes it stays "auto").
364
+ const gateDecision: GateOutcome = opts.yes ? "approved" : "auto";
365
+
366
+ // --- Generate + write artifacts (both dry-run and real runs write them,
367
+ // so a dry-run can diff exactly what a real run would produce).
368
+ const generated = await target.generateConfig(ctx);
369
+ const writtenArtifacts: string[] = [];
370
+ for (const artifact of generated.artifacts) {
371
+ writtenArtifacts.push(writeArtifact(root, artifact));
372
+ }
373
+
374
+ const missing = missingSecretKeys(root, generated.requiredSecretKeys);
375
+
376
+ // --- Dry-run: plan only. No deploy(), no verify(), no success row.
377
+ if (opts.dryRun) {
378
+ const row = audit.record({
379
+ runId: ctx.runId,
380
+ agentName: ctx.agentName,
381
+ target: target.id,
382
+ environment,
383
+ action: "deploy",
384
+ dryRun: true,
385
+ gateDecision,
386
+ approvedBy: null,
387
+ // A planned dry-run is itself a success (it produced a plan); the
388
+ // dryRun flag makes clear nothing shipped, and latest() excludes it.
389
+ status: "success",
390
+ deploymentId: null,
391
+ urls: [],
392
+ outputs: {},
393
+ commitSha,
394
+ });
395
+ return {
396
+ target: target.id,
397
+ environment,
398
+ dryRun: true,
399
+ gateDecision,
400
+ refused: false,
401
+ refusalReason: null,
402
+ summary: generated.summary,
403
+ writtenArtifacts,
404
+ requiredSecretKeys: generated.requiredSecretKeys,
405
+ missingSecretKeys: missing,
406
+ deploy: null,
407
+ verify: null,
408
+ audit: row,
409
+ };
410
+ }
411
+
412
+ // --- Real run: fail fast on missing secrets before any mutation.
413
+ if (missing.length > 0) {
414
+ throw new ValidationError(
415
+ `Cannot deploy to "${environment}" via "${target.id}": missing required secret(s): ` +
416
+ `${missing.join(", ")}. Add them with \`agentplate setup\` or via the environment.`,
417
+ );
418
+ }
419
+
420
+ // --- Execute + verify.
421
+ const deployResult = await target.deploy(ctx);
422
+ let verifyResult: VerifyResult | null = null;
423
+ if (deployResult.ok) {
424
+ verifyResult = await target.verify(ctx, deployResult);
425
+ }
426
+
427
+ const row = audit.record({
428
+ runId: ctx.runId,
429
+ agentName: ctx.agentName,
430
+ target: target.id,
431
+ environment,
432
+ action: "deploy",
433
+ dryRun: false,
434
+ gateDecision,
435
+ approvedBy: gateDecision === "approved" ? ctx.agentName : null,
436
+ status: deployResult.ok ? "success" : "failed",
437
+ deploymentId: deployResult.deploymentId,
438
+ urls: deployResult.urls,
439
+ outputs: deployResult.outputs,
440
+ commitSha,
441
+ });
442
+
443
+ return {
444
+ target: target.id,
445
+ environment,
446
+ dryRun: false,
447
+ gateDecision,
448
+ refused: false,
449
+ refusalReason: null,
450
+ summary: generated.summary,
451
+ writtenArtifacts,
452
+ requiredSecretKeys: generated.requiredSecretKeys,
453
+ missingSecretKeys: [],
454
+ deploy: deployResult,
455
+ verify: verifyResult,
456
+ audit: row,
457
+ };
458
+ } finally {
459
+ audit.close();
460
+ }
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // rollback
465
+ // ---------------------------------------------------------------------------
466
+
467
+ /** Options accepted by {@link runRollback}. */
468
+ export interface RollbackOptions {
469
+ target?: string;
470
+ environment: string;
471
+ agentName: string;
472
+ }
473
+
474
+ /** Structured result of {@link runRollback}. */
475
+ export interface RollbackRunResult {
476
+ target: string;
477
+ environment: string;
478
+ /** The audit row of the deployment being rolled back from (null if none). */
479
+ previous: DeployAuditRow | null;
480
+ rollback: DeployResult | null;
481
+ audit: DeployAuditRow | null;
482
+ }
483
+
484
+ /**
485
+ * Roll a target+environment back to its previous successful deployment. Loads
486
+ * the most recent successful deploy from the audit store ({@link DeployAudit.latest}),
487
+ * reconstructs a minimal {@link DeployResult} carrying that deployment's id and
488
+ * outputs, calls the target's `rollback`, and records a "rollback" audit row.
489
+ *
490
+ * Throws {@link NotFoundError} when there is no prior successful deploy to revert
491
+ * to, and {@link ValidationError} when the target declares it cannot roll back.
492
+ */
493
+ export async function runRollback(root: string, opts: RollbackOptions): Promise<RollbackRunResult> {
494
+ const config = loadConfig(root);
495
+ const target = getDeployTarget(opts.target, config);
496
+ const environment = opts.environment;
497
+
498
+ if (!target.caps.canRollback) {
499
+ throw new ValidationError(`Target "${target.id}" does not support rollback.`);
500
+ }
501
+
502
+ const audit = createDeployAudit(deploysDbPath(root));
503
+ try {
504
+ const previous = audit.latest(target.id, environment);
505
+ if (previous === null) {
506
+ throw new NotFoundError(
507
+ `No prior successful deploy found for "${target.id}" in "${environment}" to roll back to.`,
508
+ );
509
+ }
510
+
511
+ const detect = await target.detect(root);
512
+ const ctx: DeployContext = buildDeployContext({
513
+ root,
514
+ worktreePath: root,
515
+ target,
516
+ environment,
517
+ profile: detect.profile,
518
+ dryRun: false,
519
+ runId: readCurrentRun(root),
520
+ agentName: opts.agentName,
521
+ config,
522
+ });
523
+
524
+ // Reconstruct the deployment to roll back from, from its audit row.
525
+ const priorDeployment: DeployResult = {
526
+ ok: true,
527
+ urls: previous.urls,
528
+ deploymentId: previous.deploymentId,
529
+ log: "",
530
+ outputs: previous.outputs,
531
+ errorMessage: null,
532
+ };
533
+
534
+ const rollbackResult = await target.rollback(ctx, priorDeployment);
535
+ const commitSha = await readCommitSha(root);
536
+ const row = audit.record({
537
+ runId: ctx.runId,
538
+ agentName: ctx.agentName,
539
+ target: target.id,
540
+ environment,
541
+ action: "rollback",
542
+ dryRun: false,
543
+ gateDecision: "n/a",
544
+ approvedBy: null,
545
+ status: rollbackResult.ok ? "success" : "failed",
546
+ deploymentId: rollbackResult.deploymentId,
547
+ urls: rollbackResult.urls,
548
+ outputs: rollbackResult.outputs,
549
+ commitSha,
550
+ });
551
+
552
+ return { target: target.id, environment, previous, rollback: rollbackResult, audit: row };
553
+ } finally {
554
+ audit.close();
555
+ }
556
+ }
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // deploy status / history (read the audit store)
560
+ // ---------------------------------------------------------------------------
561
+
562
+ /** Read audit rows, newest first, with optional target/env/limit filters. */
563
+ export function readAuditHistory(
564
+ root: string,
565
+ filter: { target?: string; environment?: string; limit?: number },
566
+ ): DeployAuditRow[] {
567
+ const audit = createDeployAudit(deploysDbPath(root));
568
+ try {
569
+ return audit.list(filter);
570
+ } finally {
571
+ audit.close();
572
+ }
573
+ }
574
+
575
+ // ---------------------------------------------------------------------------
576
+ // Printing helpers (human mode)
577
+ // ---------------------------------------------------------------------------
578
+
579
+ function printAuditRow(row: DeployAuditRow): void {
580
+ const flag = row.dryRun ? muted(" [dry-run]") : "";
581
+ const statusMark = row.status === "success" ? "✓" : "✗";
582
+ printInfo(
583
+ `${statusMark} ${row.createdAt} ${row.action} ${row.target} → ${row.environment}` +
584
+ ` (gate: ${row.gateDecision})${flag}`,
585
+ );
586
+ if (row.urls.length > 0) printInfo(muted(` urls: ${row.urls.join(", ")}`));
587
+ if (row.deploymentId) printInfo(muted(` deployment: ${row.deploymentId}`));
588
+ }
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // Command builders
592
+ // ---------------------------------------------------------------------------
593
+
594
+ function targetListCommand(): Command {
595
+ return new Command("list")
596
+ .description("List registered deploy targets")
597
+ .option("--json", "output JSON")
598
+ .action((_opts: { json?: boolean }, command: Command) => {
599
+ const useJson = command.optsWithGlobals().json === true;
600
+ const items = buildTargetList();
601
+ if (useJson) {
602
+ jsonOutput(items);
603
+ return;
604
+ }
605
+ if (items.length === 0) {
606
+ printInfo(muted("(no deploy targets registered)"));
607
+ return;
608
+ }
609
+ for (const item of items) {
610
+ printInfo(`${item.id} ${muted(`(${item.stability})`)} ${item.label}`);
611
+ const capBits = [
612
+ item.caps.canRollback ? "rollback" : "no-rollback",
613
+ item.caps.irreversible ? "irreversible" : "reversible",
614
+ item.caps.requiresCredentials ? "creds-required" : "no-creds",
615
+ `envs: ${item.caps.environments.join("/")}`,
616
+ ];
617
+ printInfo(muted(` ${capBits.join(" · ")}`));
618
+ }
619
+ });
620
+ }
621
+
622
+ function targetDetectCommand(): Command {
623
+ return new Command("detect")
624
+ .description("Detect which deploy targets fit a project directory")
625
+ .argument("[dir]", "directory to inspect (default: project root)")
626
+ .option("--json", "output JSON")
627
+ .action(async (dir: string | undefined, _opts: { json?: boolean }, command: Command) => {
628
+ const useJson = command.optsWithGlobals().json === true;
629
+ const root = requireInit();
630
+ const inspectDir = dir ? resolve(root, dir) : root;
631
+ const report = await detectTargets(inspectDir);
632
+ if (useJson) {
633
+ jsonOutput(report);
634
+ return;
635
+ }
636
+ printInfo(`Detected targets for ${muted(report.dir)}:`);
637
+ for (const d of report.detections) {
638
+ const mark = d.detect.fit ? "✓" : "·";
639
+ printInfo(
640
+ ` ${mark} ${d.id} conf=${d.detect.confidence.toFixed(2)} ${muted(d.detect.reason)}`,
641
+ );
642
+ }
643
+ if (report.chosenTarget && report.chosenProfile) {
644
+ printSuccess(`Chosen: ${report.chosenTarget}`);
645
+ const p = report.chosenProfile;
646
+ printInfo(
647
+ muted(
648
+ ` profile: ${p.language}` +
649
+ `${p.framework ? `/${p.framework}` : ""}` +
650
+ ` kind=${p.kind} port=${p.port ?? "n/a"}`,
651
+ ),
652
+ );
653
+ } else {
654
+ printWarning("No target fits this directory.");
655
+ }
656
+ });
657
+ }
658
+
659
+ function targetConfigureCommand(): Command {
660
+ return new Command("configure")
661
+ .description("Show the secrets a target needs and how to provide them")
662
+ .argument("<name>", "deploy target id")
663
+ .option("--env <e>", "environment to configure for", "production")
664
+ .option("--json", "output JSON")
665
+ .action(async (name: string, opts: { env: string; json?: boolean }, command: Command) => {
666
+ const useJson = command.optsWithGlobals().json === true;
667
+ const root = requireInit();
668
+ const report = await configureTarget(root, name, opts.env);
669
+ if (useJson) {
670
+ jsonOutput(report);
671
+ return;
672
+ }
673
+ printInfo(`Configure ${report.target} (${report.label}) for ${opts.env}:`);
674
+ if (report.requiredSecretKeys.length === 0) {
675
+ printSuccess("This target needs no secrets.");
676
+ } else {
677
+ printInfo("Required secret env var(s):");
678
+ for (const key of report.requiredSecretKeys) {
679
+ const present = !report.missingSecretKeys.includes(key);
680
+ const mark = present ? "✓" : "✗";
681
+ printInfo(` ${mark} ${key}${present ? muted(" (set)") : muted(" (missing)")}`);
682
+ }
683
+ if (report.missingSecretKeys.length > 0) {
684
+ printWarning(
685
+ `Provide the missing secret(s) via the environment or ` +
686
+ `\`agentplate setup\` before deploying.`,
687
+ );
688
+ }
689
+ }
690
+ });
691
+ }
692
+
693
+ /** `agentplate target <list|detect|configure>` — inspect + prep deploy targets. */
694
+ export function createTargetCommand(): Command {
695
+ return new Command("target")
696
+ .description("Inspect and configure deploy targets")
697
+ .addCommand(targetListCommand())
698
+ .addCommand(targetDetectCommand())
699
+ .addCommand(targetConfigureCommand());
700
+ }
701
+
702
+ function deployStatusCommand(): Command {
703
+ return new Command("status")
704
+ .description("Show the most recent deploy/rollback activity")
705
+ .option("--target <name>", "filter by target")
706
+ .option("--env <e>", "filter by environment")
707
+ .option("--json", "output JSON")
708
+ .action((opts: { target?: string; env?: string; json?: boolean }, command: Command) => {
709
+ const useJson = command.optsWithGlobals().json === true;
710
+ const root = requireInit();
711
+ const rows = readAuditHistory(root, {
712
+ target: opts.target,
713
+ environment: opts.env,
714
+ limit: 1,
715
+ });
716
+ if (useJson) {
717
+ jsonOutput({ latest: rows[0] ?? null });
718
+ return;
719
+ }
720
+ if (rows.length === 0) {
721
+ printInfo(muted("(no deploy activity recorded)"));
722
+ return;
723
+ }
724
+ printAuditRow(rows[0] as DeployAuditRow);
725
+ });
726
+ }
727
+
728
+ function deployHistoryCommand(): Command {
729
+ return new Command("history")
730
+ .description("List deploy/rollback audit rows (newest first)")
731
+ .option("--target <name>", "filter by target")
732
+ .option("--env <e>", "filter by environment")
733
+ .option("--limit <n>", "max rows", "20")
734
+ .option("--json", "output JSON")
735
+ .action(
736
+ (
737
+ opts: { target?: string; env?: string; limit: string; json?: boolean },
738
+ command: Command,
739
+ ) => {
740
+ const useJson = command.optsWithGlobals().json === true;
741
+ const root = requireInit();
742
+ const limit = Number.parseInt(opts.limit, 10);
743
+ const rows = readAuditHistory(root, {
744
+ target: opts.target,
745
+ environment: opts.env,
746
+ limit: Number.isFinite(limit) && limit > 0 ? limit : 20,
747
+ });
748
+ if (useJson) {
749
+ jsonOutput(rows);
750
+ return;
751
+ }
752
+ if (rows.length === 0) {
753
+ printInfo(muted("(no deploy activity recorded)"));
754
+ return;
755
+ }
756
+ for (const row of rows) printAuditRow(row);
757
+ },
758
+ );
759
+ }
760
+
761
+ /** `agentplate deploy` — generate config, apply the gate, deploy, verify, audit. */
762
+ export function createDeployCommand(): Command {
763
+ return new Command("deploy")
764
+ .description("Build, gate-check, deploy, and verify the project to a target")
765
+ .option("--target <name>", "deploy target id (default: config.deploy.default)")
766
+ .option("--env <e>", "environment", "production")
767
+ .option("--dry-run", "generate config + plan only; never deploy")
768
+ .option("--yes", "approve a deploy that requires confirmation")
769
+ .option("--json", "output JSON")
770
+ .addCommand(deployStatusCommand())
771
+ .addCommand(deployHistoryCommand())
772
+ .action(
773
+ async (
774
+ opts: {
775
+ target?: string;
776
+ env: string;
777
+ dryRun?: boolean;
778
+ yes?: boolean;
779
+ json?: boolean;
780
+ },
781
+ command: Command,
782
+ ) => {
783
+ const useJson = command.optsWithGlobals().json === true;
784
+ const root = requireInit();
785
+ const result = await runDeploy(root, {
786
+ target: opts.target,
787
+ environment: opts.env,
788
+ dryRun: opts.dryRun === true,
789
+ yes: opts.yes === true,
790
+ agentName: "operator",
791
+ });
792
+
793
+ if (useJson) {
794
+ jsonOutput(result);
795
+ // A refusal is a non-zero exit so scripts can branch on it.
796
+ if (result.refused) process.exitCode = new ValidationError("").exitCode;
797
+ return;
798
+ }
799
+
800
+ if (result.refused) {
801
+ printError(result.refusalReason ?? "Deploy refused by gate.");
802
+ process.exitCode = new ValidationError("").exitCode;
803
+ return;
804
+ }
805
+
806
+ if (result.dryRun) {
807
+ printSuccess(`[dry-run] ${result.summary}`);
808
+ for (const path of result.writtenArtifacts) printInfo(muted(` wrote ${path}`));
809
+ if (result.requiredSecretKeys.length > 0) {
810
+ printInfo(
811
+ muted(
812
+ ` requires secrets: ${result.requiredSecretKeys.join(", ")}` +
813
+ (result.missingSecretKeys.length > 0
814
+ ? ` (missing: ${result.missingSecretKeys.join(", ")})`
815
+ : ""),
816
+ ),
817
+ );
818
+ }
819
+ printInfo(muted(" (dry-run — nothing was deployed)"));
820
+ return;
821
+ }
822
+
823
+ for (const path of result.writtenArtifacts) printInfo(muted(` wrote ${path}`));
824
+ if (result.deploy?.ok) {
825
+ printSuccess(`Deployed ${result.target} → ${result.environment}`);
826
+ for (const url of result.deploy.urls) printInfo(` ${url}`);
827
+ for (const [k, v] of Object.entries(result.deploy.outputs)) {
828
+ printInfo(muted(` ${k}: ${v}`));
829
+ }
830
+ if (result.verify) {
831
+ const vmark = result.verify.healthy ? "✓" : "✗";
832
+ printInfo(` ${vmark} verify: ${result.verify.healthy ? "healthy" : "unhealthy"}`);
833
+ for (const check of result.verify.checks) {
834
+ printInfo(muted(` ${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}`));
835
+ }
836
+ }
837
+ } else {
838
+ printError(`Deploy failed: ${result.deploy?.errorMessage ?? "unknown error"}`);
839
+ process.exitCode = 1;
840
+ }
841
+ },
842
+ );
843
+ }
844
+
845
+ /** `agentplate rollback` — revert a target+environment to its last good deploy. */
846
+ export function createRollbackCommand(): Command {
847
+ return new Command("rollback")
848
+ .description("Roll a target back to its previous successful deployment")
849
+ .option("--target <name>", "deploy target id (default: config.deploy.default)")
850
+ .option("--env <e>", "environment", "production")
851
+ .option("--json", "output JSON")
852
+ .action(async (opts: { target?: string; env: string; json?: boolean }, command: Command) => {
853
+ const useJson = command.optsWithGlobals().json === true;
854
+ const root = requireInit();
855
+ const result = await runRollback(root, {
856
+ target: opts.target,
857
+ environment: opts.env,
858
+ agentName: "operator",
859
+ });
860
+ if (useJson) {
861
+ jsonOutput(result);
862
+ return;
863
+ }
864
+ if (result.rollback?.ok) {
865
+ printSuccess(
866
+ `Rolled back ${result.target} → ${result.environment}` +
867
+ (result.rollback.deploymentId ? ` to ${result.rollback.deploymentId}` : ""),
868
+ );
869
+ } else {
870
+ printError(`Rollback failed: ${result.rollback?.errorMessage ?? "unknown error"}`);
871
+ process.exitCode = 1;
872
+ }
873
+ });
874
+ }