@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,841 @@
1
+ /**
2
+ * Docker + GitHub Actions deploy target.
3
+ *
4
+ * The baseline, most-universal target: containerize the app and ship a GitHub
5
+ * Actions workflow that builds the image, logs in to GHCR, and pushes a tag.
6
+ * It is the least credential-coupled target — a single `GHCR_TOKEN` is the only
7
+ * secret — which makes it a sane default fit for almost any web service or
8
+ * static site.
9
+ *
10
+ * Mechanics only (no AI). The staged pipeline calls these methods; this file
11
+ * never embeds pipeline policy. The split mirrors the runtime adapters:
12
+ *
13
+ * detect() — pure, read-only inference of an {@link AppProfile} from
14
+ * package.json / lockfiles / framework markers.
15
+ * generateConfig() — deterministic, side-effect-free artifact emission
16
+ * (Dockerfile, .dockerignore, deploy workflow). The engine
17
+ * writes the returned content so `--dry-run` can diff.
18
+ * deploy() — the ONE outward-facing mutation. Honors `ctx.dryRun`:
19
+ * a dry run plans (no `docker` invoked) and a real run
20
+ * builds (and pushes when a token is present).
21
+ * verify() — read-only smoke check (HTTP GET, else local image probe).
22
+ * rollback() — best-effort re-tag/redeploy of the previous image ref.
23
+ *
24
+ * Secrets enter exclusively through {@link DockerGhaTarget.buildSecretEnv}
25
+ * (env-by-name), and every captured CLI line is run through {@link sanitize}
26
+ * before it leaves this module.
27
+ */
28
+
29
+ import { existsSync, readFileSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { sanitize } from "../../logging/sanitizer.ts";
32
+ import type {
33
+ AppProfile,
34
+ DeployCaps,
35
+ DeployContext,
36
+ DeployResult,
37
+ DeploySecretStore,
38
+ DeployTarget,
39
+ DetectResult,
40
+ GeneratedArtifact,
41
+ GeneratedConfig,
42
+ VerifyResult,
43
+ } from "../types.ts";
44
+
45
+ /** The single secret this target needs at deploy time (GHCR push auth). */
46
+ const GHCR_TOKEN_KEY = "GHCR_TOKEN";
47
+
48
+ /** Default container port when none can be inferred from the app. */
49
+ const DEFAULT_PORT = 3000;
50
+
51
+ /** Result of running a subprocess (never throws on non-zero exit). */
52
+ interface ProcResult {
53
+ stdout: string;
54
+ stderr: string;
55
+ exitCode: number;
56
+ /** True when the binary itself could not be spawned (e.g. not installed). */
57
+ spawnFailed: boolean;
58
+ }
59
+
60
+ /**
61
+ * Run a command and capture its output. Mirrors the merge resolver's `runGit`:
62
+ * non-zero exits are returned, not thrown, because callers branch on them. A
63
+ * spawn failure (binary missing) is surfaced via `spawnFailed` rather than an
64
+ * exception so deploy/verify can degrade gracefully.
65
+ */
66
+ async function runCommand(
67
+ argv: string[],
68
+ cwd: string,
69
+ env?: Record<string, string>,
70
+ ): Promise<ProcResult> {
71
+ try {
72
+ const proc = Bun.spawn(argv, {
73
+ cwd,
74
+ stdout: "pipe",
75
+ stderr: "pipe",
76
+ env: env ? { ...process.env, ...env } : process.env,
77
+ });
78
+ const [stdout, stderr, exitCode] = await Promise.all([
79
+ new Response(proc.stdout).text(),
80
+ new Response(proc.stderr).text(),
81
+ proc.exited,
82
+ ]);
83
+ return { stdout, stderr, exitCode, spawnFailed: false };
84
+ } catch (error) {
85
+ return { stdout: "", stderr: (error as Error).message, exitCode: -1, spawnFailed: true };
86
+ }
87
+ }
88
+
89
+ /** Minimal shape we read out of a project's package.json (everything optional). */
90
+ interface PackageJsonShape {
91
+ name?: unknown;
92
+ scripts?: unknown;
93
+ dependencies?: unknown;
94
+ devDependencies?: unknown;
95
+ }
96
+
97
+ /** Read + JSON-parse a file, returning null on any failure (missing, malformed). */
98
+ function readJsonFile(path: string): unknown {
99
+ if (!existsSync(path)) return null;
100
+ try {
101
+ return JSON.parse(readFileSync(path, "utf8"));
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /** Treat an unknown value as a string→unknown record (else an empty record). */
108
+ function asRecord(value: unknown): Record<string, unknown> {
109
+ return value !== null && typeof value === "object" && !Array.isArray(value)
110
+ ? (value as Record<string, unknown>)
111
+ : {};
112
+ }
113
+
114
+ /** True if any of `names` is present as a key in the (deps ∪ devDeps) union. */
115
+ function dependsOn(pkg: PackageJsonShape, names: string[]): boolean {
116
+ const deps = { ...asRecord(pkg.dependencies), ...asRecord(pkg.devDependencies) };
117
+ return names.some((name) => name in deps);
118
+ }
119
+
120
+ /** Map a detected lockfile to its package-manager family name (null if none). */
121
+ function detectPackageManager(projectDir: string): string | null {
122
+ const lockfiles = ["bun.lock", "bun.lockb", "pnpm-lock.yaml", "yarn.lock", "package-lock.json"];
123
+ for (const lock of lockfiles) {
124
+ if (existsSync(join(projectDir, lock))) return lock;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Parse the env-var NAMES (never values) declared in a `.env.example`. Accepts
131
+ * `KEY=...`, `KEY:`, and `export KEY=...`; ignores comments and blanks. Returns
132
+ * a de-duplicated list in first-seen order.
133
+ */
134
+ function parseEnvExampleKeys(projectDir: string): string[] {
135
+ const path = join(projectDir, ".env.example");
136
+ if (!existsSync(path)) return [];
137
+ let body: string;
138
+ try {
139
+ body = readFileSync(path, "utf8");
140
+ } catch {
141
+ return [];
142
+ }
143
+ const keys: string[] = [];
144
+ const seen = new Set<string>();
145
+ for (const rawLine of body.split(/\r?\n/)) {
146
+ const line = rawLine.trim();
147
+ if (line === "" || line.startsWith("#")) continue;
148
+ const withoutExport = line.startsWith("export ") ? line.slice("export ".length) : line;
149
+ const match = withoutExport.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*[=:]/);
150
+ const key = match?.[1];
151
+ if (key !== undefined && !seen.has(key)) {
152
+ seen.add(key);
153
+ keys.push(key);
154
+ }
155
+ }
156
+ return keys;
157
+ }
158
+
159
+ /**
160
+ * Infer a build command from package.json scripts. Prefers an explicit `build`
161
+ * script; returns null when none exists (nothing to build).
162
+ */
163
+ function inferBuildCommand(pkg: PackageJsonShape, runner: string): string | null {
164
+ const scripts = asRecord(pkg.scripts);
165
+ if (typeof scripts.build === "string") return `${runner} run build`;
166
+ return null;
167
+ }
168
+
169
+ /**
170
+ * Infer a start command. Prefers a `start` script, then a `main`/`server`
171
+ * convention via the package manager's runner; null when nothing is evident.
172
+ */
173
+ function inferStartCommand(pkg: PackageJsonShape, runner: string): string | null {
174
+ const scripts = asRecord(pkg.scripts);
175
+ if (typeof scripts.start === "string") return `${runner} run start`;
176
+ if (typeof scripts.serve === "string") return `${runner} run serve`;
177
+ return null;
178
+ }
179
+
180
+ /** Map a lockfile family to the CLI used to run scripts inside the image. */
181
+ function runnerForLockfile(lockfile: string | null): string {
182
+ if (lockfile === "bun.lock" || lockfile === "bun.lockb") return "bun";
183
+ if (lockfile === "pnpm-lock.yaml") return "pnpm";
184
+ if (lockfile === "yarn.lock") return "yarn";
185
+ return "npm";
186
+ }
187
+
188
+ /** Detect a framework + its conventional listen port, if recognizable. */
189
+ function detectFramework(pkg: PackageJsonShape): { framework: string | null; port: number | null } {
190
+ if (dependsOn(pkg, ["next"])) return { framework: "next", port: 3000 };
191
+ if (dependsOn(pkg, ["@remix-run/node", "@remix-run/serve"])) {
192
+ return { framework: "remix", port: 3000 };
193
+ }
194
+ if (dependsOn(pkg, ["nuxt"])) return { framework: "nuxt", port: 3000 };
195
+ if (dependsOn(pkg, ["@nestjs/core"])) return { framework: "nest", port: 3000 };
196
+ if (dependsOn(pkg, ["express", "fastify", "koa", "hono"])) {
197
+ return { framework: dependsOn(pkg, ["fastify"]) ? "fastify" : "express", port: DEFAULT_PORT };
198
+ }
199
+ if (dependsOn(pkg, ["vite"])) return { framework: "vite", port: null };
200
+ if (dependsOn(pkg, ["react-scripts"])) return { framework: "cra", port: null };
201
+ return { framework: null, port: null };
202
+ }
203
+
204
+ /**
205
+ * Decide whether the app is a long-running service or a static site. A build
206
+ * tool with no obvious server (vite/CRA and no start/serve script and no
207
+ * server framework) is treated as static; otherwise a service.
208
+ */
209
+ function classifyKind(framework: string | null, startCommand: string | null): "service" | "static" {
210
+ const serverFrameworks = ["next", "remix", "nuxt", "nest", "express", "fastify"];
211
+ if (framework !== null && serverFrameworks.includes(framework)) return "service";
212
+ const staticBuilders = framework === "vite" || framework === "cra";
213
+ if (staticBuilders && startCommand === null) return "static";
214
+ if (startCommand !== null) return "service";
215
+ return "service";
216
+ }
217
+
218
+ /** Build the {@link AppProfile} for a Node/Bun project from its package.json. */
219
+ function profileFromPackageJson(projectDir: string, pkg: PackageJsonShape): AppProfile {
220
+ const packageManager = detectPackageManager(projectDir);
221
+ const runner = runnerForLockfile(packageManager);
222
+ const language: AppProfile["language"] =
223
+ packageManager === "bun.lock" || packageManager === "bun.lockb" ? "bun" : "node";
224
+ const { framework, port } = detectFramework(pkg);
225
+ const buildCommand = inferBuildCommand(pkg, runner);
226
+ const startCommand = inferStartCommand(pkg, runner);
227
+ const kind = classifyKind(framework, startCommand);
228
+ const resolvedPort = kind === "service" ? (port ?? DEFAULT_PORT) : null;
229
+ return {
230
+ language,
231
+ framework,
232
+ kind,
233
+ buildCommand,
234
+ startCommand: kind === "service" ? startCommand : null,
235
+ port: resolvedPort,
236
+ packageManager,
237
+ runtimeEnvKeys: parseEnvExampleKeys(projectDir),
238
+ };
239
+ }
240
+
241
+ /** Profile for a directory with no package.json but a static `index.html`. */
242
+ function staticHtmlProfile(projectDir: string): AppProfile {
243
+ return {
244
+ language: "static",
245
+ framework: null,
246
+ kind: "static",
247
+ buildCommand: null,
248
+ startCommand: null,
249
+ port: null,
250
+ packageManager: null,
251
+ runtimeEnvKeys: parseEnvExampleKeys(projectDir),
252
+ };
253
+ }
254
+
255
+ /** Fallback profile when nothing about the project can be inferred. */
256
+ function unknownProfile(projectDir: string): AppProfile {
257
+ return {
258
+ language: "unknown",
259
+ framework: null,
260
+ kind: "service",
261
+ buildCommand: null,
262
+ startCommand: null,
263
+ port: null,
264
+ packageManager: null,
265
+ runtimeEnvKeys: parseEnvExampleKeys(projectDir),
266
+ };
267
+ }
268
+
269
+ /** Quote the env-var name into the image tag's app slug (lowercased, safe). */
270
+ function slugForApp(name: string): string {
271
+ const slug = name
272
+ .toLowerCase()
273
+ .replace(/[^a-z0-9._-]+/g, "-")
274
+ .replace(/^-+|-+$/g, "");
275
+ return slug === "" ? "app" : slug;
276
+ }
277
+
278
+ /** Short, image-tag-safe slice of a commit sha (or "latest" when absent). */
279
+ function shaTag(sha: string | undefined): string {
280
+ if (sha === undefined || sha.trim() === "") return "latest";
281
+ const cleaned = sha.trim().replace(/[^a-zA-Z0-9._-]+/g, "");
282
+ return cleaned === "" ? "latest" : cleaned.slice(0, 12);
283
+ }
284
+
285
+ /** Coerce a settings value to a string (settings may be string|number|boolean). */
286
+ function settingString(
287
+ settings: Record<string, string | number | boolean>,
288
+ key: string,
289
+ ): string | undefined {
290
+ const value = settings[key];
291
+ if (value === undefined) return undefined;
292
+ return typeof value === "string" ? value : String(value);
293
+ }
294
+
295
+ /**
296
+ * Compute the fully-qualified image reference for a deploy context:
297
+ * <registry>/<app>:<sha-ish>
298
+ * `registry` and `app` come from non-secret settings, with GHCR-shaped
299
+ * fallbacks so a partially-configured target still produces a sane ref.
300
+ */
301
+ function imageRef(ctx: DeployContext): string {
302
+ const registry =
303
+ settingString(ctx.settings, "registry") ?? settingString(ctx.settings, "image") ?? "ghcr.io";
304
+ const appSetting =
305
+ settingString(ctx.settings, "app") ?? settingString(ctx.settings, "appName") ?? "app";
306
+ const app = slugForApp(appSetting);
307
+ const tag = shaTag(settingString(ctx.settings, "sha") ?? settingString(ctx.settings, "commit"));
308
+ // If `registry` already includes a path (a/b), append only the tag; else add app.
309
+ const base = registry.includes("/") ? registry : `${registry}/${app}`;
310
+ return `${base}:${tag}`;
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Artifact templates (deterministic strings)
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /** Multi-stage Dockerfile for a built Node/Bun service. */
318
+ function dockerfileBuiltService(profile: AppProfile): string {
319
+ const isBun = profile.language === "bun";
320
+ const baseImage = isBun ? "oven/bun:1" : "node:22-slim";
321
+ const installCmd = isBun
322
+ ? "bun install --frozen-lockfile"
323
+ : profile.packageManager === "pnpm-lock.yaml"
324
+ ? "corepack enable && pnpm install --frozen-lockfile"
325
+ : profile.packageManager === "yarn.lock"
326
+ ? "corepack enable && yarn install --frozen-lockfile"
327
+ : "npm ci";
328
+ const buildCmd = profile.buildCommand ?? (isBun ? "bun run build" : "npm run build");
329
+ const startCmd = profile.startCommand ?? (isBun ? "bun run start" : "npm run start");
330
+ const port = profile.port ?? DEFAULT_PORT;
331
+ return [
332
+ `# syntax=docker/dockerfile:1`,
333
+ `# Multi-stage build for a ${profile.framework ?? profile.language} service.`,
334
+ `FROM ${baseImage} AS builder`,
335
+ `WORKDIR /app`,
336
+ `COPY . .`,
337
+ `RUN ${installCmd}`,
338
+ `RUN ${buildCmd}`,
339
+ ``,
340
+ `FROM ${baseImage} AS runner`,
341
+ `ENV NODE_ENV=production`,
342
+ `WORKDIR /app`,
343
+ `COPY --from=builder /app /app`,
344
+ `EXPOSE ${port}`,
345
+ `CMD ${dockerExecForm(startCmd)}`,
346
+ ``,
347
+ ].join("\n");
348
+ }
349
+
350
+ /** Single-stage Dockerfile for a Node/Bun service that needs no build step. */
351
+ function dockerfilePlainService(profile: AppProfile): string {
352
+ const isBun = profile.language === "bun";
353
+ const baseImage = isBun ? "oven/bun:1" : "node:22-slim";
354
+ const installCmd = isBun ? "bun install --frozen-lockfile" : "npm ci --omit=dev";
355
+ const startCmd = profile.startCommand ?? (isBun ? "bun run start" : "npm run start");
356
+ const port = profile.port ?? DEFAULT_PORT;
357
+ return [
358
+ `# syntax=docker/dockerfile:1`,
359
+ `# Single-stage image for a ${profile.framework ?? profile.language} service.`,
360
+ `FROM ${baseImage}`,
361
+ `ENV NODE_ENV=production`,
362
+ `WORKDIR /app`,
363
+ `COPY . .`,
364
+ `RUN ${installCmd}`,
365
+ `EXPOSE ${port}`,
366
+ `CMD ${dockerExecForm(startCmd)}`,
367
+ ``,
368
+ ].join("\n");
369
+ }
370
+
371
+ /** Two-stage Dockerfile that builds static assets and serves them via nginx. */
372
+ function dockerfileStatic(profile: AppProfile): string {
373
+ const isBun = profile.language === "bun";
374
+ const baseImage = isBun ? "oven/bun:1" : "node:22-slim";
375
+ const installCmd = isBun ? "bun install --frozen-lockfile" : "npm ci";
376
+ const buildCmd = profile.buildCommand ?? (isBun ? "bun run build" : "npm run build");
377
+ // Vite emits dist/, CRA emits build/. Default to dist for everything else.
378
+ const outDir = profile.framework === "cra" ? "build" : "dist";
379
+ const builderStage =
380
+ profile.language === "static"
381
+ ? // No build tooling: copy the prebuilt site straight into nginx.
382
+ [`FROM nginx:1.27-alpine`, `COPY . /usr/share/nginx/html`]
383
+ : [
384
+ `FROM ${baseImage} AS builder`,
385
+ `WORKDIR /app`,
386
+ `COPY . .`,
387
+ `RUN ${installCmd}`,
388
+ `RUN ${buildCmd}`,
389
+ ``,
390
+ `FROM nginx:1.27-alpine`,
391
+ `COPY --from=builder /app/${outDir} /usr/share/nginx/html`,
392
+ ];
393
+ return [
394
+ `# syntax=docker/dockerfile:1`,
395
+ `# Static site served by nginx${profile.language === "static" ? "" : " (built from source)"}.`,
396
+ ...builderStage,
397
+ `EXPOSE 80`,
398
+ `CMD ["nginx", "-g", "daemon off;"]`,
399
+ ``,
400
+ ].join("\n");
401
+ }
402
+
403
+ /** Render a shell command string as a Docker JSON exec-form CMD array literal. */
404
+ function dockerExecForm(command: string): string {
405
+ const parts = command.split(/\s+/).filter((p) => p !== "");
406
+ return `[${parts.map((p) => JSON.stringify(p)).join(", ")}]`;
407
+ }
408
+
409
+ /** Choose the Dockerfile body that matches the detected profile. */
410
+ function renderDockerfile(profile: AppProfile): string {
411
+ if (profile.kind === "static") return dockerfileStatic(profile);
412
+ if (profile.buildCommand !== null) return dockerfileBuiltService(profile);
413
+ return dockerfilePlainService(profile);
414
+ }
415
+
416
+ /** Standard `.dockerignore` keeping build context small + secret-free. */
417
+ function renderDockerignore(): string {
418
+ return [
419
+ "node_modules",
420
+ "npm-debug.log",
421
+ "bun-debug.log",
422
+ ".git",
423
+ ".gitignore",
424
+ ".github",
425
+ ".agentplate",
426
+ "Dockerfile",
427
+ ".dockerignore",
428
+ "*.md",
429
+ ".env",
430
+ ".env.*",
431
+ "!.env.example",
432
+ "dist",
433
+ "build",
434
+ ".next",
435
+ "coverage",
436
+ ".DS_Store",
437
+ "",
438
+ ].join("\n");
439
+ }
440
+
441
+ /**
442
+ * GitHub Actions workflow: build the image, log in to GHCR with
443
+ * `secrets.GHCR_TOKEN`, and push the tag. Exposes an `environment` input so the
444
+ * same workflow drives preview/staging/production.
445
+ */
446
+ function renderDeployWorkflow(ctx: DeployContext): string {
447
+ const ref = imageRef(ctx);
448
+ const environment = ctx.environment;
449
+ return `name: deploy
450
+
451
+ on:
452
+ workflow_dispatch:
453
+ inputs:
454
+ environment:
455
+ description: Target environment
456
+ required: true
457
+ default: ${environment}
458
+ type: choice
459
+ options:
460
+ - preview
461
+ - staging
462
+ - production
463
+ push:
464
+ branches:
465
+ - main
466
+
467
+ concurrency:
468
+ group: deploy-\${{ github.event.inputs.environment || '${environment}' }}
469
+ cancel-in-progress: false
470
+
471
+ jobs:
472
+ build-and-push:
473
+ runs-on: ubuntu-latest
474
+ environment: \${{ github.event.inputs.environment || '${environment}' }}
475
+ permissions:
476
+ contents: read
477
+ packages: write
478
+ env:
479
+ IMAGE_REF: ${ref}
480
+ steps:
481
+ - name: Checkout
482
+ uses: actions/checkout@v4
483
+
484
+ - name: Set up Docker Buildx
485
+ uses: docker/setup-buildx-action@v3
486
+
487
+ - name: Log in to GHCR
488
+ uses: docker/login-action@v3
489
+ with:
490
+ registry: ghcr.io
491
+ username: \${{ github.actor }}
492
+ password: \${{ secrets.GHCR_TOKEN }}
493
+
494
+ - name: Build and push image
495
+ uses: docker/build-push-action@v6
496
+ with:
497
+ context: .
498
+ push: true
499
+ tags: |
500
+ \${{ env.IMAGE_REF }}
501
+ `;
502
+ }
503
+
504
+ // ---------------------------------------------------------------------------
505
+ // Target implementation
506
+ // ---------------------------------------------------------------------------
507
+
508
+ /**
509
+ * The baseline deploy target: containerize + push via GitHub Actions to GHCR.
510
+ * Stateless; all inputs arrive through {@link DeployContext} / project files,
511
+ * and the only outward mutation lives in {@link DockerGhaTarget.deploy}.
512
+ */
513
+ export class DockerGhaTarget implements DeployTarget {
514
+ readonly id = "docker-gha";
515
+ readonly stability = "beta" as const;
516
+ readonly label = "Docker + GitHub Actions";
517
+ readonly description =
518
+ "Containerize the app and ship a GitHub Actions workflow that builds the image, " +
519
+ "logs in to GHCR, and pushes a tagged image. The most universal, least " +
520
+ "credential-coupled target (a single GHCR_TOKEN).";
521
+ readonly caps: DeployCaps = {
522
+ canRollback: true,
523
+ irreversible: false,
524
+ environments: ["preview", "staging", "production"],
525
+ requiresCredentials: true,
526
+ };
527
+
528
+ async detect(projectDir: string): Promise<DetectResult> {
529
+ const pkgRaw = readJsonFile(join(projectDir, "package.json"));
530
+ if (pkgRaw !== null) {
531
+ const pkg = asRecord(pkgRaw) as PackageJsonShape;
532
+ const profile = profileFromPackageJson(projectDir, pkg);
533
+ // Confidence reflects how much we positively identified.
534
+ let confidence = 0.6;
535
+ if (profile.packageManager !== null) confidence += 0.1;
536
+ if (profile.framework !== null) confidence += 0.15;
537
+ if (profile.startCommand !== null || profile.buildCommand !== null) confidence += 0.1;
538
+ const reasonParts = [
539
+ `package.json detected (${profile.language}`,
540
+ profile.framework !== null ? `, ${profile.framework}` : "",
541
+ `, kind=${profile.kind})`,
542
+ ];
543
+ return {
544
+ fit: true,
545
+ confidence: Math.min(confidence, 0.95),
546
+ profile,
547
+ reason: `${reasonParts.join("")}; containerizable via Docker + GHA.`,
548
+ };
549
+ }
550
+ if (existsSync(join(projectDir, "index.html"))) {
551
+ return {
552
+ fit: true,
553
+ confidence: 0.5,
554
+ profile: staticHtmlProfile(projectDir),
555
+ reason: "Static index.html detected; servable as an nginx container.",
556
+ };
557
+ }
558
+ if (existsSync(join(projectDir, "Dockerfile"))) {
559
+ return {
560
+ fit: true,
561
+ confidence: 0.55,
562
+ profile: unknownProfile(projectDir),
563
+ reason: "Existing Dockerfile detected; image is buildable for GHA push.",
564
+ };
565
+ }
566
+ return {
567
+ fit: false,
568
+ confidence: 0.1,
569
+ profile: unknownProfile(projectDir),
570
+ reason: "No package.json, static entry, or Dockerfile found; cannot infer a build.",
571
+ };
572
+ }
573
+
574
+ async generateConfig(ctx: DeployContext): Promise<GeneratedConfig> {
575
+ const profile = ctx.profile;
576
+ const artifacts: GeneratedArtifact[] = [
577
+ { path: "Dockerfile", content: renderDockerfile(profile), kind: "dockerfile" },
578
+ { path: ".dockerignore", content: renderDockerignore(), kind: "ignore" },
579
+ {
580
+ path: ".github/workflows/deploy.yml",
581
+ content: renderDeployWorkflow(ctx),
582
+ kind: "ci",
583
+ },
584
+ ];
585
+ const summary =
586
+ `Generated a ${profile.kind === "static" ? "static (nginx)" : profile.language} Dockerfile, ` +
587
+ `.dockerignore, and a GitHub Actions deploy workflow (GHCR push, ` +
588
+ `environment=${ctx.environment}). Requires secret GHCR_TOKEN.`;
589
+ return {
590
+ artifacts,
591
+ requiredSecretKeys: [GHCR_TOKEN_KEY],
592
+ summary,
593
+ };
594
+ }
595
+
596
+ async deploy(ctx: DeployContext): Promise<DeployResult> {
597
+ const ref = imageRef(ctx);
598
+ const hasToken =
599
+ typeof ctx.secretEnv[GHCR_TOKEN_KEY] === "string" && ctx.secretEnv[GHCR_TOKEN_KEY] !== "";
600
+
601
+ // Dry-run: plan only. No subprocess, no outward mutation.
602
+ if (ctx.dryRun) {
603
+ const pushNote = hasToken ? "and push" : "(no GHCR_TOKEN present — would skip push)";
604
+ return {
605
+ ok: true,
606
+ urls: [],
607
+ deploymentId: null,
608
+ log: sanitize(
609
+ `[dry-run] would build+push image ${ref} ${pushNote} from ${ctx.worktreePath}`,
610
+ ),
611
+ outputs: { imageRef: ref, environment: ctx.environment, pushed: "false" },
612
+ errorMessage: null,
613
+ };
614
+ }
615
+
616
+ // Real run: build the image. `docker` may be unavailable — degrade, don't throw.
617
+ const logLines: string[] = [];
618
+ const build = await runCommand(
619
+ ["docker", "build", "-t", ref, "."],
620
+ ctx.worktreePath,
621
+ ctx.secretEnv,
622
+ );
623
+ if (build.spawnFailed) {
624
+ return {
625
+ ok: false,
626
+ urls: [],
627
+ deploymentId: null,
628
+ log: sanitize(`docker is unavailable: ${build.stderr}`.trim()),
629
+ outputs: { imageRef: ref, environment: ctx.environment, pushed: "false" },
630
+ errorMessage: "docker CLI not found or could not be spawned",
631
+ };
632
+ }
633
+ logLines.push(`$ docker build -t ${ref} .`, build.stdout, build.stderr);
634
+ if (build.exitCode !== 0) {
635
+ return {
636
+ ok: false,
637
+ urls: [],
638
+ deploymentId: null,
639
+ log: sanitize(logLines.join("\n").trim()),
640
+ outputs: { imageRef: ref, environment: ctx.environment, pushed: "false" },
641
+ errorMessage: `docker build failed (exit ${build.exitCode})`,
642
+ };
643
+ }
644
+
645
+ // Push only when a token is present (GHCR auth is the operator's via env/CI).
646
+ let pushed = false;
647
+ if (hasToken) {
648
+ const push = await runCommand(["docker", "push", ref], ctx.worktreePath, ctx.secretEnv);
649
+ logLines.push(`$ docker push ${ref}`, push.stdout, push.stderr);
650
+ if (push.exitCode !== 0 || push.spawnFailed) {
651
+ return {
652
+ ok: false,
653
+ urls: [],
654
+ deploymentId: ref,
655
+ log: sanitize(logLines.join("\n").trim()),
656
+ outputs: { imageRef: ref, environment: ctx.environment, pushed: "false" },
657
+ errorMessage: `docker push failed (exit ${push.exitCode})`,
658
+ };
659
+ }
660
+ pushed = true;
661
+ }
662
+
663
+ return {
664
+ ok: true,
665
+ urls: [],
666
+ deploymentId: ref,
667
+ log: sanitize(logLines.join("\n").trim()),
668
+ outputs: { imageRef: ref, environment: ctx.environment, pushed: String(pushed) },
669
+ errorMessage: null,
670
+ };
671
+ }
672
+
673
+ async verify(ctx: DeployContext, deployment: DeployResult): Promise<VerifyResult> {
674
+ const checks: VerifyResult["checks"] = [];
675
+ const url = deployment.urls[0];
676
+
677
+ if (url !== undefined && url !== "") {
678
+ // Live URL: an HTTP GET that returns < 500 is "healthy" (4xx is reachable).
679
+ try {
680
+ const response = await fetch(url, { method: "GET", redirect: "manual" });
681
+ const ok = response.status < 500;
682
+ checks.push({
683
+ name: "http-get",
684
+ ok,
685
+ detail: `GET ${url} -> ${response.status}`,
686
+ });
687
+ return { healthy: ok, checks, probedUrl: url };
688
+ } catch (error) {
689
+ checks.push({
690
+ name: "http-get",
691
+ ok: false,
692
+ detail: `GET ${url} failed: ${sanitize((error as Error).message)}`,
693
+ });
694
+ return { healthy: false, checks, probedUrl: url };
695
+ }
696
+ }
697
+
698
+ // No URL: confirm the built image exists locally.
699
+ const ref = deployment.deploymentId ?? imageRef(ctx);
700
+ const inspect = await runCommand(
701
+ ["docker", "image", "inspect", ref],
702
+ ctx.worktreePath,
703
+ ctx.secretEnv,
704
+ );
705
+ if (inspect.spawnFailed) {
706
+ checks.push({
707
+ name: "image-inspect",
708
+ ok: false,
709
+ detail: "docker CLI unavailable; cannot inspect image",
710
+ });
711
+ return { healthy: false, checks, probedUrl: null };
712
+ }
713
+ const ok = inspect.exitCode === 0;
714
+ checks.push({
715
+ name: "image-inspect",
716
+ ok,
717
+ detail: ok ? `image ${ref} present locally` : `image ${ref} not found locally`,
718
+ });
719
+ return { healthy: ok, checks, probedUrl: null };
720
+ }
721
+
722
+ async rollback(ctx: DeployContext, deployment: DeployResult): Promise<DeployResult> {
723
+ // The previous image ref is the rollback artifact; outputs carry it.
724
+ const previousRef =
725
+ deployment.outputs.previousImageRef ?? deployment.outputs.imageRef ?? imageRef(ctx);
726
+ const hasToken =
727
+ typeof ctx.secretEnv[GHCR_TOKEN_KEY] === "string" && ctx.secretEnv[GHCR_TOKEN_KEY] !== "";
728
+
729
+ if (ctx.dryRun) {
730
+ return {
731
+ ok: true,
732
+ urls: [],
733
+ deploymentId: null,
734
+ log: sanitize(`[dry-run] would roll back by redeploying previous image ${previousRef}`),
735
+ outputs: { imageRef: previousRef, environment: ctx.environment, rolledBack: "planned" },
736
+ errorMessage: null,
737
+ };
738
+ }
739
+
740
+ const logLines: string[] = [];
741
+ // Best-effort: confirm the previous image is present, then re-push it.
742
+ const inspect = await runCommand(
743
+ ["docker", "image", "inspect", previousRef],
744
+ ctx.worktreePath,
745
+ ctx.secretEnv,
746
+ );
747
+ if (inspect.spawnFailed) {
748
+ return {
749
+ ok: false,
750
+ urls: [],
751
+ deploymentId: null,
752
+ log: sanitize(`docker is unavailable: ${inspect.stderr}`.trim()),
753
+ outputs: { imageRef: previousRef, environment: ctx.environment, rolledBack: "false" },
754
+ errorMessage: "docker CLI not found or could not be spawned",
755
+ };
756
+ }
757
+ logLines.push(`$ docker image inspect ${previousRef}`, inspect.stdout, inspect.stderr);
758
+ if (inspect.exitCode !== 0) {
759
+ return {
760
+ ok: false,
761
+ urls: [],
762
+ deploymentId: null,
763
+ log: sanitize(logLines.join("\n").trim()),
764
+ outputs: { imageRef: previousRef, environment: ctx.environment, rolledBack: "false" },
765
+ errorMessage: `previous image ${previousRef} not available locally`,
766
+ };
767
+ }
768
+
769
+ if (hasToken) {
770
+ const push = await runCommand(
771
+ ["docker", "push", previousRef],
772
+ ctx.worktreePath,
773
+ ctx.secretEnv,
774
+ );
775
+ logLines.push(`$ docker push ${previousRef}`, push.stdout, push.stderr);
776
+ if (push.exitCode !== 0 || push.spawnFailed) {
777
+ return {
778
+ ok: false,
779
+ urls: [],
780
+ deploymentId: previousRef,
781
+ log: sanitize(logLines.join("\n").trim()),
782
+ outputs: { imageRef: previousRef, environment: ctx.environment, rolledBack: "false" },
783
+ errorMessage: `docker push (rollback) failed (exit ${push.exitCode})`,
784
+ };
785
+ }
786
+ }
787
+
788
+ return {
789
+ ok: true,
790
+ urls: [],
791
+ deploymentId: previousRef,
792
+ log: sanitize(logLines.join("\n").trim()),
793
+ outputs: { imageRef: previousRef, environment: ctx.environment, rolledBack: "true" },
794
+ errorMessage: null,
795
+ };
796
+ }
797
+
798
+ buildSecretEnv(store: DeploySecretStore): Record<string, string> {
799
+ const env: Record<string, string> = {};
800
+ const token = store.get(GHCR_TOKEN_KEY);
801
+ if (token !== undefined && token !== "") env[GHCR_TOKEN_KEY] = token;
802
+ return env;
803
+ }
804
+
805
+ async preflight(
806
+ ctx: DeployContext,
807
+ ): Promise<Array<{ name: string; ok: boolean; detail: string }>> {
808
+ const checks: Array<{ name: string; ok: boolean; detail: string }> = [];
809
+
810
+ const version = await runCommand(["docker", "--version"], ctx.worktreePath);
811
+ if (version.spawnFailed) {
812
+ checks.push({
813
+ name: "docker-cli",
814
+ ok: false,
815
+ detail: "docker CLI not found on PATH",
816
+ });
817
+ } else if (version.exitCode === 0) {
818
+ checks.push({
819
+ name: "docker-cli",
820
+ ok: true,
821
+ detail: sanitize(version.stdout.trim()),
822
+ });
823
+ } else {
824
+ checks.push({
825
+ name: "docker-cli",
826
+ ok: false,
827
+ detail: `docker --version exited ${version.exitCode}`,
828
+ });
829
+ }
830
+
831
+ const hasToken =
832
+ typeof ctx.secretEnv[GHCR_TOKEN_KEY] === "string" && ctx.secretEnv[GHCR_TOKEN_KEY] !== "";
833
+ checks.push({
834
+ name: "ghcr-token",
835
+ ok: hasToken,
836
+ detail: hasToken ? "GHCR_TOKEN is set" : "GHCR_TOKEN is not set (required to push)",
837
+ });
838
+
839
+ return checks;
840
+ }
841
+ }