@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,69 @@
1
+ /**
2
+ * Deploy secret store adapter.
3
+ *
4
+ * Bridges the project-wide secret store ({@link getSecret}/{@link hasSecret},
5
+ * file-then-`process.env`) to the deploy pipeline's contracts. Deploy targets
6
+ * only ever see env-var *names*; values are resolved here at the moment a
7
+ * {@link DeployContext.secretEnv} map is built and injected into a child
8
+ * process. Nothing in this module persists or logs secret values.
9
+ *
10
+ * Three entry points:
11
+ * - {@link createDeploySecretStore} wraps the union store as a
12
+ * {@link DeploySecretStore} handed to a target's `buildSecretEnv`.
13
+ * - {@link resolveTargetSecretEnv} turns a target's `secretEnv` bindings
14
+ * (logicalKey → { fromEnv: ENV_NAME }) into a concrete KEY→value map,
15
+ * including only the bindings whose env var is present.
16
+ * - {@link missingSecretKeys} reports which required env-var names are absent,
17
+ * for preflight / gate fail-fast.
18
+ */
19
+
20
+ import { getSecret, hasSecret } from "../secrets.ts";
21
+ import type { DeployTargetConfig } from "../types.ts";
22
+ import type { DeploySecretStore } from "./types.ts";
23
+
24
+ /**
25
+ * Build the union secret store ({@link DeploySecretStore}) for a project root.
26
+ * `get`/`has` delegate to {@link getSecret}/{@link hasSecret}, which check the
27
+ * gitignored secrets file first, then `process.env`. This is the store passed
28
+ * to a target's `buildSecretEnv`.
29
+ */
30
+ export function createDeploySecretStore(root: string): DeploySecretStore {
31
+ return {
32
+ get(key: string): string | undefined {
33
+ return getSecret(root, key);
34
+ },
35
+ has(key: string): boolean {
36
+ return hasSecret(root, key);
37
+ },
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Resolve a target's secret bindings into a concrete env map for
43
+ * {@link DeployContext.secretEnv}. For each `secretEnv[logicalKey] = { fromEnv:
44
+ * ENV_NAME }`, read the value for `ENV_NAME` and, when present, map
45
+ * `logicalKey → value`. Bindings whose env var is absent are silently omitted
46
+ * (callers use {@link missingSecretKeys} to detect required gaps).
47
+ */
48
+ export function resolveTargetSecretEnv(
49
+ root: string,
50
+ targetConfig: DeployTargetConfig,
51
+ ): Record<string, string> {
52
+ const resolved: Record<string, string> = {};
53
+ for (const [logicalKey, binding] of Object.entries(targetConfig.secretEnv)) {
54
+ const value = getSecret(root, binding.fromEnv);
55
+ if (value !== undefined) resolved[logicalKey] = value;
56
+ }
57
+ return resolved;
58
+ }
59
+
60
+ /**
61
+ * Return the subset of `requiredSecretKeys` (env-var NAMES) that have no value
62
+ * available from the file store or `process.env`. Order and duplicates from the
63
+ * input are preserved as given; an empty result means every required secret is
64
+ * present. Used for preflight and deploy-gate fail-fast — names only, never
65
+ * values.
66
+ */
67
+ export function missingSecretKeys(root: string, requiredSecretKeys: string[]): string[] {
68
+ return requiredSecretKeys.filter((key) => !hasSecret(root, key));
69
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Tests for the Docker + GitHub Actions deploy target.
3
+ *
4
+ * Real implementations only: detect() reads real files in a real temp dir, and
5
+ * the deploy/rollback assertions exercise ONLY the dry-run path so no `docker`
6
+ * binary is ever invoked. generateConfig() is a pure function of the context,
7
+ * so it is asserted directly with no environment setup.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import type { AppProfile, DeployContext, DeployResult, DeploySecretStore } from "../types.ts";
15
+ import { DockerGhaTarget } from "./docker-gha.ts";
16
+
17
+ /** In-memory secret store backed by a plain map (no file, no env mutation). */
18
+ function memStore(values: Record<string, string>): DeploySecretStore {
19
+ return {
20
+ get: (key) => (key in values ? values[key] : undefined),
21
+ has: (key) => key in values,
22
+ };
23
+ }
24
+
25
+ /** Build a DeployContext over a given profile + overrides for tests. */
26
+ function makeContext(overrides: Partial<DeployContext> & { profile: AppProfile }): DeployContext {
27
+ return {
28
+ target: "docker-gha",
29
+ environment: "staging",
30
+ worktreePath: "/tmp/does-not-need-to-exist",
31
+ projectRoot: "/tmp/does-not-need-to-exist",
32
+ secretEnv: {},
33
+ settings: {},
34
+ dryRun: false,
35
+ runId: null,
36
+ agentName: "deployer-1",
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ /** A minimal service profile for config/deploy assertions. */
42
+ const serviceProfile: AppProfile = {
43
+ language: "node",
44
+ framework: "express",
45
+ kind: "service",
46
+ buildCommand: null,
47
+ startCommand: "npm run start",
48
+ port: 3000,
49
+ packageManager: "package-lock.json",
50
+ runtimeEnvKeys: ["DATABASE_URL"],
51
+ };
52
+
53
+ describe("DockerGhaTarget metadata", () => {
54
+ test("declares stable identity + caps", () => {
55
+ const target = new DockerGhaTarget();
56
+ expect(target.id).toBe("docker-gha");
57
+ expect(target.stability).toBe("beta");
58
+ expect(target.label).toBe("Docker + GitHub Actions");
59
+ expect(target.description.length).toBeGreaterThan(0);
60
+ expect(target.caps.canRollback).toBe(true);
61
+ expect(target.caps.irreversible).toBe(false);
62
+ expect(target.caps.requiresCredentials).toBe(true);
63
+ expect(target.caps.environments).toEqual(["preview", "staging", "production"]);
64
+ });
65
+ });
66
+
67
+ describe("DockerGhaTarget.detect", () => {
68
+ let dir: string;
69
+
70
+ beforeEach(async () => {
71
+ dir = await mkdtemp(join(tmpdir(), "fg-docker-gha-"));
72
+ });
73
+
74
+ afterEach(async () => {
75
+ await rm(dir, { recursive: true, force: true });
76
+ });
77
+
78
+ test("detects a built Node service (build + start scripts, framework, port)", async () => {
79
+ await writeFile(
80
+ join(dir, "package.json"),
81
+ JSON.stringify({
82
+ name: "my-api",
83
+ scripts: { build: "tsc", start: "node dist/index.js" },
84
+ dependencies: { express: "^4.0.0" },
85
+ }),
86
+ );
87
+ await writeFile(join(dir, "package-lock.json"), "{}");
88
+ await writeFile(
89
+ join(dir, ".env.example"),
90
+ "# secrets\nDATABASE_URL=\nexport API_KEY=foo\nPORT: 8080\n",
91
+ );
92
+
93
+ const result = await new DockerGhaTarget().detect(dir);
94
+
95
+ expect(result.fit).toBe(true);
96
+ expect(result.confidence).toBeGreaterThan(0.6);
97
+ expect(result.profile.language).toBe("node");
98
+ expect(result.profile.kind).toBe("service");
99
+ expect(result.profile.framework).toBe("express");
100
+ expect(result.profile.buildCommand).toBe("npm run build");
101
+ expect(result.profile.startCommand).toBe("npm run start");
102
+ expect(result.profile.packageManager).toBe("package-lock.json");
103
+ expect(result.profile.port).toBe(3000);
104
+ // Env-var NAMES parsed from .env.example, values never captured.
105
+ expect(result.profile.runtimeEnvKeys).toEqual(["DATABASE_URL", "API_KEY", "PORT"]);
106
+ });
107
+
108
+ test("detects a Bun service from bun.lock", async () => {
109
+ await writeFile(
110
+ join(dir, "package.json"),
111
+ JSON.stringify({ name: "svc", scripts: { start: "bun run server.ts" } }),
112
+ );
113
+ await writeFile(join(dir, "bun.lock"), "");
114
+
115
+ const result = await new DockerGhaTarget().detect(dir);
116
+
117
+ expect(result.profile.language).toBe("bun");
118
+ expect(result.profile.packageManager).toBe("bun.lock");
119
+ expect(result.profile.startCommand).toBe("bun run start");
120
+ expect(result.profile.kind).toBe("service");
121
+ });
122
+
123
+ test("classifies a Vite app with no start script as static", async () => {
124
+ await writeFile(
125
+ join(dir, "package.json"),
126
+ JSON.stringify({
127
+ name: "site",
128
+ scripts: { build: "vite build" },
129
+ devDependencies: { vite: "^5" },
130
+ }),
131
+ );
132
+ await writeFile(join(dir, "package-lock.json"), "{}");
133
+
134
+ const result = await new DockerGhaTarget().detect(dir);
135
+
136
+ expect(result.profile.kind).toBe("static");
137
+ expect(result.profile.framework).toBe("vite");
138
+ expect(result.profile.startCommand).toBeNull();
139
+ expect(result.profile.port).toBeNull();
140
+ expect(result.fit).toBe(true);
141
+ });
142
+
143
+ test("detects a bare static site via index.html (no package.json)", async () => {
144
+ await writeFile(join(dir, "index.html"), "<!doctype html><title>hi</title>");
145
+
146
+ const result = await new DockerGhaTarget().detect(dir);
147
+
148
+ expect(result.fit).toBe(true);
149
+ expect(result.profile.language).toBe("static");
150
+ expect(result.profile.kind).toBe("static");
151
+ });
152
+
153
+ test("returns no-fit for an empty directory", async () => {
154
+ const result = await new DockerGhaTarget().detect(dir);
155
+ expect(result.fit).toBe(false);
156
+ expect(result.confidence).toBeLessThan(0.5);
157
+ expect(result.profile.language).toBe("unknown");
158
+ });
159
+
160
+ test("tolerates a malformed package.json", async () => {
161
+ await writeFile(join(dir, "package.json"), "{ this is not json");
162
+ // No package.json parsed -> falls through; no index.html/Dockerfile -> no fit.
163
+ const result = await new DockerGhaTarget().detect(dir);
164
+ expect(result.fit).toBe(false);
165
+ });
166
+ });
167
+
168
+ describe("DockerGhaTarget.generateConfig", () => {
169
+ test("emits Dockerfile + .dockerignore + deploy.yml with GHCR_TOKEN required", async () => {
170
+ const ctx = makeContext({ profile: serviceProfile, environment: "production" });
171
+ const config = await new DockerGhaTarget().generateConfig(ctx);
172
+
173
+ expect(config.requiredSecretKeys).toEqual(["GHCR_TOKEN"]);
174
+ expect(config.summary.length).toBeGreaterThan(0);
175
+
176
+ const paths = config.artifacts.map((a) => a.path).sort();
177
+ expect(paths).toEqual([".dockerignore", ".github/workflows/deploy.yml", "Dockerfile"]);
178
+
179
+ const dockerfile = config.artifacts.find((a) => a.path === "Dockerfile");
180
+ expect(dockerfile?.kind).toBe("dockerfile");
181
+ expect(dockerfile?.content).toContain("FROM node:22-slim");
182
+ expect(dockerfile?.content).toContain("EXPOSE 3000");
183
+
184
+ const ignore = config.artifacts.find((a) => a.path === ".dockerignore");
185
+ expect(ignore?.kind).toBe("ignore");
186
+ expect(ignore?.content).toContain("node_modules");
187
+ expect(ignore?.content).toContain(".env");
188
+
189
+ const workflow = config.artifacts.find((a) => a.path === ".github/workflows/deploy.yml");
190
+ expect(workflow?.kind).toBe("ci");
191
+ expect(workflow?.content).toContain("secrets.GHCR_TOKEN");
192
+ expect(workflow?.content).toContain("registry: ghcr.io");
193
+ expect(workflow?.content).toContain("docker/build-push-action");
194
+ // Environment input wired through.
195
+ expect(workflow?.content).toContain("production");
196
+ });
197
+
198
+ test("is deterministic for the same context", async () => {
199
+ const ctx = makeContext({ profile: serviceProfile });
200
+ const a = await new DockerGhaTarget().generateConfig(ctx);
201
+ const b = await new DockerGhaTarget().generateConfig(ctx);
202
+ expect(a.artifacts.map((x) => x.content)).toEqual(b.artifacts.map((x) => x.content));
203
+ });
204
+
205
+ test("renders a multi-stage Dockerfile when a build command is present", async () => {
206
+ const built: AppProfile = { ...serviceProfile, buildCommand: "npm run build" };
207
+ const config = await new DockerGhaTarget().generateConfig(makeContext({ profile: built }));
208
+ const dockerfile = config.artifacts.find((a) => a.path === "Dockerfile");
209
+ expect(dockerfile?.content).toContain("AS builder");
210
+ expect(dockerfile?.content).toContain("AS runner");
211
+ expect(dockerfile?.content).toContain("RUN npm run build");
212
+ });
213
+
214
+ test("renders an nginx static Dockerfile for a static profile", async () => {
215
+ const staticProfile: AppProfile = {
216
+ language: "node",
217
+ framework: "vite",
218
+ kind: "static",
219
+ buildCommand: "npm run build",
220
+ startCommand: null,
221
+ port: null,
222
+ packageManager: "package-lock.json",
223
+ runtimeEnvKeys: [],
224
+ };
225
+ const config = await new DockerGhaTarget().generateConfig(
226
+ makeContext({ profile: staticProfile }),
227
+ );
228
+ const dockerfile = config.artifacts.find((a) => a.path === "Dockerfile");
229
+ expect(dockerfile?.content).toContain("nginx:1.27-alpine");
230
+ expect(dockerfile?.content).toContain("/usr/share/nginx/html");
231
+ expect(dockerfile?.content).toContain('CMD ["nginx", "-g", "daemon off;"]');
232
+ });
233
+ });
234
+
235
+ describe("DockerGhaTarget.deploy (dry-run only)", () => {
236
+ test("dry-run returns a planned ok result with no side effects", async () => {
237
+ const ctx = makeContext({
238
+ profile: serviceProfile,
239
+ dryRun: true,
240
+ settings: { registry: "ghcr.io/acme", app: "my-api", sha: "abcdef1234567890" },
241
+ });
242
+ const result = await new DockerGhaTarget().deploy(ctx);
243
+
244
+ expect(result.ok).toBe(true);
245
+ expect(result.deploymentId).toBeNull();
246
+ expect(result.urls).toEqual([]);
247
+ expect(result.errorMessage).toBeNull();
248
+ expect(result.log).toContain("[dry-run]");
249
+ expect(result.log).toContain("would build");
250
+ // Image ref: registry already has a path, so app is not re-appended; sha truncated.
251
+ expect(result.outputs.imageRef).toBe("ghcr.io/acme:abcdef123456");
252
+ expect(result.outputs.pushed).toBe("false");
253
+ });
254
+
255
+ test("dry-run notes a missing token without attempting a push", async () => {
256
+ const ctx = makeContext({ profile: serviceProfile, dryRun: true, secretEnv: {} });
257
+ const result = await new DockerGhaTarget().deploy(ctx);
258
+ expect(result.ok).toBe(true);
259
+ expect(result.log).toContain("no GHCR_TOKEN");
260
+ });
261
+
262
+ test("dry-run with a token plans a push", async () => {
263
+ const ctx = makeContext({
264
+ profile: serviceProfile,
265
+ dryRun: true,
266
+ secretEnv: { GHCR_TOKEN: "ghp_exampletokenvalue000000000000" },
267
+ settings: { app: "svc" },
268
+ });
269
+ const result = await new DockerGhaTarget().deploy(ctx);
270
+ expect(result.ok).toBe(true);
271
+ expect(result.log).toContain("and push");
272
+ // The token value must never leak into the captured log.
273
+ expect(result.log).not.toContain("ghp_exampletokenvalue000000000000");
274
+ // Default registry + slugged app + "latest" when no sha.
275
+ expect(result.outputs.imageRef).toBe("ghcr.io/svc:latest");
276
+ });
277
+
278
+ test("dry-run derives a sane default image ref when settings are empty", async () => {
279
+ const ctx = makeContext({ profile: serviceProfile, dryRun: true });
280
+ const result = await new DockerGhaTarget().deploy(ctx);
281
+ expect(result.outputs.imageRef).toBe("ghcr.io/app:latest");
282
+ });
283
+ });
284
+
285
+ describe("DockerGhaTarget.rollback (dry-run only)", () => {
286
+ test("dry-run plans a redeploy of the previous image ref", async () => {
287
+ const prior: DeployResult = {
288
+ ok: true,
289
+ urls: [],
290
+ deploymentId: "ghcr.io/acme/my-api:oldsha123456",
291
+ log: "",
292
+ outputs: { previousImageRef: "ghcr.io/acme/my-api:oldsha123456" },
293
+ errorMessage: null,
294
+ };
295
+ const ctx = makeContext({ profile: serviceProfile, dryRun: true });
296
+ const result = await new DockerGhaTarget().rollback(ctx, prior);
297
+
298
+ expect(result.ok).toBe(true);
299
+ expect(result.deploymentId).toBeNull();
300
+ expect(result.log).toContain("[dry-run]");
301
+ expect(result.log).toContain("ghcr.io/acme/my-api:oldsha123456");
302
+ expect(result.outputs.imageRef).toBe("ghcr.io/acme/my-api:oldsha123456");
303
+ expect(result.outputs.rolledBack).toBe("planned");
304
+ });
305
+ });
306
+
307
+ describe("DockerGhaTarget.buildSecretEnv", () => {
308
+ test("maps a present GHCR_TOKEN", () => {
309
+ const env = new DockerGhaTarget().buildSecretEnv(memStore({ GHCR_TOKEN: "tok-123" }));
310
+ expect(env).toEqual({ GHCR_TOKEN: "tok-123" });
311
+ });
312
+
313
+ test("omits GHCR_TOKEN when absent or empty", () => {
314
+ const target = new DockerGhaTarget();
315
+ expect(target.buildSecretEnv(memStore({}))).toEqual({});
316
+ expect(target.buildSecretEnv(memStore({ GHCR_TOKEN: "" }))).toEqual({});
317
+ });
318
+
319
+ test("ignores unrelated secrets", () => {
320
+ const env = new DockerGhaTarget().buildSecretEnv(memStore({ GHCR_TOKEN: "tok", OTHER: "x" }));
321
+ expect(env).toEqual({ GHCR_TOKEN: "tok" });
322
+ });
323
+ });