@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,243 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { AGENTPLATE_DIR, DEFAULT_CONFIG } from "../config.ts";
6
+ import { SECRETS_FILE } from "../secrets.ts";
7
+ import type { AgentplateConfig, DeployConfig } from "../types.ts";
8
+ import { buildDeployContext } from "./context.ts";
9
+ import type {
10
+ AppProfile,
11
+ DeployContext,
12
+ DeployResult,
13
+ DeploySecretStore,
14
+ DeployTarget,
15
+ DetectResult,
16
+ GeneratedConfig,
17
+ VerifyResult,
18
+ } from "./types.ts";
19
+
20
+ // A minimal, neutral app profile — context never inspects its contents, it just
21
+ // carries it through, so the exact shape only needs to satisfy the type.
22
+ function profile(): AppProfile {
23
+ return {
24
+ language: "bun",
25
+ framework: null,
26
+ kind: "service",
27
+ buildCommand: null,
28
+ startCommand: "bun start",
29
+ port: 3000,
30
+ packageManager: "bun.lock",
31
+ runtimeEnvKeys: [],
32
+ };
33
+ }
34
+
35
+ // Test-local DeployTarget implementing the interface directly (the canonical
36
+ // Agentplate test style — no module mocks). `buildSecretEnv` is parameterized so a
37
+ // single fake can model both "needs a secret" and "needs none". Every other
38
+ // method is a stub that throws if ever called: buildDeployContext must touch
39
+ // only `id` and `buildSecretEnv`.
40
+ function fakeTarget(
41
+ id: string,
42
+ build: (store: DeploySecretStore) => Record<string, string>,
43
+ ): DeployTarget {
44
+ const unreached = (m: string) => (): never => {
45
+ throw new Error(`fakeTarget.${m} should not be called by buildDeployContext`);
46
+ };
47
+ return {
48
+ id,
49
+ stability: "stable",
50
+ label: "Fake",
51
+ description: "test target",
52
+ caps: {
53
+ canRollback: true,
54
+ irreversible: false,
55
+ environments: ["staging"],
56
+ requiresCredentials: false,
57
+ },
58
+ detect: unreached("detect") as () => Promise<DetectResult>,
59
+ generateConfig: unreached("generateConfig") as () => Promise<GeneratedConfig>,
60
+ deploy: unreached("deploy") as () => Promise<DeployResult>,
61
+ verify: unreached("verify") as () => Promise<VerifyResult>,
62
+ rollback: unreached("rollback") as () => Promise<DeployResult>,
63
+ buildSecretEnv: build,
64
+ };
65
+ }
66
+
67
+ // Clone DEFAULT_CONFIG and layer in deploy settings for one target id.
68
+ function configWith(root: string, deploy: Partial<DeployConfig>): AgentplateConfig {
69
+ const base: AgentplateConfig = structuredClone(DEFAULT_CONFIG);
70
+ base.project.root = root;
71
+ base.deploy = { ...base.deploy, ...deploy };
72
+ return base;
73
+ }
74
+
75
+ describe("buildDeployContext", () => {
76
+ let root: string;
77
+
78
+ beforeEach(async () => {
79
+ root = await mkdtemp(join(tmpdir(), "agentplate-ctx-"));
80
+ // Ensure .agentplate exists so the secret store can read the file there.
81
+ await mkdir(join(root, AGENTPLATE_DIR), { recursive: true });
82
+ });
83
+
84
+ afterEach(async () => {
85
+ await rm(root, { recursive: true, force: true });
86
+ });
87
+
88
+ function baseArgs(
89
+ target: DeployTarget,
90
+ config: AgentplateConfig,
91
+ ): Parameters<typeof buildDeployContext>[0] {
92
+ return {
93
+ root,
94
+ worktreePath: join(root, "wt"),
95
+ target,
96
+ environment: "staging",
97
+ profile: profile(),
98
+ dryRun: false,
99
+ runId: "run-123",
100
+ agentName: "deployer-1",
101
+ config,
102
+ };
103
+ }
104
+
105
+ test("populates settings, environment, dryRun, and identity fields", () => {
106
+ const target = fakeTarget("docker-gha", () => ({}));
107
+ const config = configWith(root, {
108
+ default: "docker-gha",
109
+ targets: {
110
+ "docker-gha": {
111
+ settings: { region: "us-east-1", replicas: 2, public: true },
112
+ secretEnv: {},
113
+ environments: ["staging", "production"],
114
+ },
115
+ },
116
+ });
117
+
118
+ const ctx: DeployContext = buildDeployContext({
119
+ ...baseArgs(target, config),
120
+ environment: "production",
121
+ dryRun: true,
122
+ });
123
+
124
+ expect(ctx.target).toBe("docker-gha");
125
+ expect(ctx.environment).toBe("production");
126
+ expect(ctx.dryRun).toBe(true);
127
+ expect(ctx.projectRoot).toBe(root);
128
+ expect(ctx.worktreePath).toBe(join(root, "wt"));
129
+ expect(ctx.runId).toBe("run-123");
130
+ expect(ctx.agentName).toBe("deployer-1");
131
+ // Non-secret settings come straight from config for this target id.
132
+ expect(ctx.settings).toEqual({ region: "us-east-1", replicas: 2, public: true });
133
+ });
134
+
135
+ test("settings default to an empty object when the target has no config entry", () => {
136
+ const target = fakeTarget("docker-gha", () => ({}));
137
+ // config.deploy.targets is empty → no entry for "docker-gha".
138
+ const config = configWith(root, { default: "docker-gha", targets: {} });
139
+
140
+ const ctx = buildDeployContext(baseArgs(target, config));
141
+
142
+ expect(ctx.settings).toEqual({});
143
+ expect(ctx.target).toBe("docker-gha");
144
+ });
145
+
146
+ test("keys settings by the target's own id, not the config default", () => {
147
+ const target = fakeTarget("docker-gha", () => ({}));
148
+ // A different target ("vercel") is the config default and has settings, but
149
+ // the passed target is "docker-gha" → its (absent) settings win → {}.
150
+ const config = configWith(root, {
151
+ default: "vercel",
152
+ targets: {
153
+ vercel: { settings: { region: "iad1" }, secretEnv: {}, environments: ["production"] },
154
+ },
155
+ });
156
+
157
+ const ctx = buildDeployContext(baseArgs(target, config));
158
+
159
+ expect(ctx.settings).toEqual({});
160
+ });
161
+
162
+ test("secretEnv is empty when the target requests no secrets", () => {
163
+ const target = fakeTarget("docker-gha", () => ({}));
164
+ const config = configWith(root, { default: "docker-gha", targets: {} });
165
+
166
+ const ctx = buildDeployContext(baseArgs(target, config));
167
+
168
+ expect(ctx.secretEnv).toEqual({});
169
+ });
170
+
171
+ test("secretEnv is empty when a requested secret is absent from file and env", () => {
172
+ // Target asks for REGISTRY_TOKEN, but neither the secrets file nor env has
173
+ // it → store.has is false → the target maps nothing.
174
+ const KEY = "AGENTPLATE_TEST_ABSENT_TOKEN_XYZ";
175
+ delete process.env[KEY];
176
+ const target = fakeTarget("docker-gha", (store) => {
177
+ const env: Record<string, string> = {};
178
+ if (store.has(KEY)) env[KEY] = store.get(KEY) ?? "";
179
+ return env;
180
+ });
181
+ const config = configWith(root, { default: "docker-gha", targets: {} });
182
+
183
+ const ctx = buildDeployContext(baseArgs(target, config));
184
+
185
+ expect(ctx.secretEnv).toEqual({});
186
+ });
187
+
188
+ test("secretEnv resolves a value the target asks for from the secrets file", async () => {
189
+ // Write a real gitignored secrets file; the store reads file-then-env.
190
+ const KEY = "REGISTRY_TOKEN";
191
+ await writeFile(join(root, AGENTPLATE_DIR, SECRETS_FILE), `${KEY}: tok-from-file\n`);
192
+ const target = fakeTarget("docker-gha", (store) => {
193
+ const v = store.get(KEY);
194
+ const env: Record<string, string> = {};
195
+ if (v !== undefined) env[KEY] = v;
196
+ return env;
197
+ });
198
+ const config = configWith(root, { default: "docker-gha", targets: {} });
199
+
200
+ const ctx = buildDeployContext(baseArgs(target, config));
201
+
202
+ expect(ctx.secretEnv).toEqual({ REGISTRY_TOKEN: "tok-from-file" });
203
+ });
204
+
205
+ test("secretEnv resolves a value from process.env when no file entry exists", () => {
206
+ const KEY = "AGENTPLATE_TEST_ENV_ONLY_TOKEN";
207
+ process.env[KEY] = "tok-from-env";
208
+ try {
209
+ const target = fakeTarget("docker-gha", (store) => {
210
+ const v = store.get(KEY);
211
+ const env: Record<string, string> = {};
212
+ if (v !== undefined) env[KEY] = v;
213
+ return env;
214
+ });
215
+ const config = configWith(root, { default: "docker-gha", targets: {} });
216
+
217
+ const ctx = buildDeployContext(baseArgs(target, config));
218
+
219
+ expect(ctx.secretEnv).toEqual({ [KEY]: "tok-from-env" });
220
+ } finally {
221
+ delete process.env[KEY];
222
+ }
223
+ });
224
+
225
+ test("profile is carried through unchanged", () => {
226
+ const p = profile();
227
+ const target = fakeTarget("docker-gha", () => ({}));
228
+ const config = configWith(root, { default: "docker-gha", targets: {} });
229
+
230
+ const ctx = buildDeployContext({ ...baseArgs(target, config), profile: p });
231
+
232
+ expect(ctx.profile).toBe(p);
233
+ });
234
+
235
+ test("runId may be null", () => {
236
+ const target = fakeTarget("docker-gha", () => ({}));
237
+ const config = configWith(root, { default: "docker-gha", targets: {} });
238
+
239
+ const ctx = buildDeployContext({ ...baseArgs(target, config), runId: null });
240
+
241
+ expect(ctx.runId).toBeNull();
242
+ });
243
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Deploy context builder.
3
+ *
4
+ * Assembles the {@link DeployContext} every target method
5
+ * (`generateConfig`/`deploy`/`verify`/`rollback`) receives. It is the single
6
+ * seam where non-secret settings (from `config.deploy.targets[id].settings`)
7
+ * meet resolved secret values (from the target's own `buildSecretEnv`, fed the
8
+ * project secret store). Settings come from config; secret *values* are read
9
+ * here at the moment of use and never persisted or logged — the context object
10
+ * lives only for the duration of one pipeline step.
11
+ */
12
+
13
+ import type { AgentplateConfig } from "../types.ts";
14
+ import { createDeploySecretStore } from "./secrets.ts";
15
+ import type { AppProfile, DeployContext, DeployTarget } from "./types.ts";
16
+
17
+ /** Inputs for {@link buildDeployContext}. */
18
+ export interface BuildDeployContextArgs {
19
+ /** Absolute project root (used to resolve the secret store and as `projectRoot`). */
20
+ root: string;
21
+ /** Absolute path to the worktree the target operates on. */
22
+ worktreePath: string;
23
+ /** Resolved deploy target (its `id` keys into config settings; provides `buildSecretEnv`). */
24
+ target: DeployTarget;
25
+ /** Target environment ("preview" | "staging" | "production" | …). */
26
+ environment: string;
27
+ /** App profile detected (or supplied) for this deploy. */
28
+ profile: AppProfile;
29
+ /** When true: generate + plan only; no outward-facing mutation. */
30
+ dryRun: boolean;
31
+ /** Owning run id, or null when run outside a coordinator session. */
32
+ runId: string | null;
33
+ /** Name of the agent (or operator) performing the deploy. */
34
+ agentName: string;
35
+ /** Loaded Agentplate config (source of per-target non-secret settings). */
36
+ config: AgentplateConfig;
37
+ }
38
+
39
+ /**
40
+ * Build a {@link DeployContext} from a resolved target, environment, profile,
41
+ * and config.
42
+ *
43
+ * Non-secret settings are read from `config.deploy.targets[target.id].settings`
44
+ * (an empty object when the target has no config entry). Secret env is produced
45
+ * by `target.buildSecretEnv(...)` given a {@link createDeploySecretStore} bound
46
+ * to `root`, so each target decides exactly which named env vars it needs and
47
+ * the values resolve from the gitignored secrets file or `process.env`. When no
48
+ * secrets are configured the resulting `secretEnv` is simply empty.
49
+ *
50
+ * `projectRoot` is set to `root`; the returned object is plain data with no
51
+ * retained references to the config beyond the shallow `settings` map.
52
+ */
53
+ export function buildDeployContext(args: BuildDeployContextArgs): DeployContext {
54
+ const { root, worktreePath, target, environment, profile, dryRun, runId, agentName, config } =
55
+ args;
56
+
57
+ const settings = config.deploy.targets[target.id]?.settings ?? {};
58
+ const secretEnv = target.buildSecretEnv(createDeploySecretStore(root));
59
+
60
+ return {
61
+ target: target.id,
62
+ environment,
63
+ worktreePath,
64
+ projectRoot: root,
65
+ profile,
66
+ secretEnv,
67
+ settings,
68
+ dryRun,
69
+ runId,
70
+ agentName,
71
+ };
72
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DEFAULT_CONFIG } from "../config.ts";
3
+ import { ValidationError } from "../errors.ts";
4
+ import type { AgentplateConfig } from "../types.ts";
5
+ import { getAllDeployTargets, getDeployTarget, getDeployTargetNames } from "./registry.ts";
6
+ import { DockerGhaTarget } from "./targets/docker-gha.ts";
7
+
8
+ // Clone DEFAULT_CONFIG and set deploy.default, so tests never mutate the
9
+ // shared default object.
10
+ function configWithDefault(target: string): AgentplateConfig {
11
+ const config: AgentplateConfig = structuredClone(DEFAULT_CONFIG);
12
+ config.deploy = { ...config.deploy, default: target };
13
+ return config;
14
+ }
15
+
16
+ describe("getDeployTargetNames", () => {
17
+ test("lists the registered targets in registration order", () => {
18
+ expect(getDeployTargetNames()).toEqual(["docker-gha"]);
19
+ });
20
+ });
21
+
22
+ describe("getAllDeployTargets", () => {
23
+ test("returns one fresh instance per registered target", () => {
24
+ const all = getAllDeployTargets();
25
+ expect(all).toHaveLength(1);
26
+ expect(all[0]).toBeInstanceOf(DockerGhaTarget);
27
+ });
28
+
29
+ test("returns distinct instances across calls (no shared mutable state)", () => {
30
+ const first = getAllDeployTargets()[0];
31
+ const second = getAllDeployTargets()[0];
32
+ expect(first).not.toBe(second);
33
+ });
34
+ });
35
+
36
+ describe("getDeployTarget", () => {
37
+ test("resolves docker-gha by explicit name", () => {
38
+ const target = getDeployTarget("docker-gha");
39
+ expect(target).toBeInstanceOf(DockerGhaTarget);
40
+ expect(target.id).toBe("docker-gha");
41
+ });
42
+
43
+ test("returns a fresh instance on each call", () => {
44
+ expect(getDeployTarget("docker-gha")).not.toBe(getDeployTarget("docker-gha"));
45
+ });
46
+
47
+ test("resolves from config.deploy.default when no name is given", () => {
48
+ const target = getDeployTarget(undefined, configWithDefault("docker-gha"));
49
+ expect(target).toBeInstanceOf(DockerGhaTarget);
50
+ expect(target.id).toBe("docker-gha");
51
+ });
52
+
53
+ test("an explicit name takes precedence over config.deploy.default", () => {
54
+ // Default is an unknown target, but the explicit valid name wins, so this
55
+ // must resolve rather than throw.
56
+ const target = getDeployTarget("docker-gha", configWithDefault("vercel"));
57
+ expect(target).toBeInstanceOf(DockerGhaTarget);
58
+ });
59
+
60
+ test("throws ValidationError listing names when no name and no default are set", () => {
61
+ // DEFAULT_CONFIG.deploy.default is "" → treated as unset.
62
+ expect(() => getDeployTarget(undefined, structuredClone(DEFAULT_CONFIG))).toThrow(
63
+ ValidationError,
64
+ );
65
+ try {
66
+ getDeployTarget(undefined, structuredClone(DEFAULT_CONFIG));
67
+ throw new Error("expected getDeployTarget to throw");
68
+ } catch (error) {
69
+ expect(error).toBeInstanceOf(ValidationError);
70
+ expect((error as ValidationError).message).toContain("docker-gha");
71
+ }
72
+ });
73
+
74
+ test("throws ValidationError when called with no name and no config at all", () => {
75
+ expect(() => getDeployTarget()).toThrow(ValidationError);
76
+ });
77
+
78
+ test("throws ValidationError listing names on an unknown explicit name", () => {
79
+ expect(() => getDeployTarget("nope")).toThrow(ValidationError);
80
+ try {
81
+ getDeployTarget("nope");
82
+ throw new Error("expected getDeployTarget to throw");
83
+ } catch (error) {
84
+ expect(error).toBeInstanceOf(ValidationError);
85
+ expect((error as ValidationError).message).toContain("nope");
86
+ expect((error as ValidationError).message).toContain("docker-gha");
87
+ }
88
+ });
89
+
90
+ test("throws ValidationError on an unknown config default with no explicit name", () => {
91
+ expect(() => getDeployTarget(undefined, configWithDefault("bogus-target"))).toThrow(
92
+ ValidationError,
93
+ );
94
+ try {
95
+ getDeployTarget(undefined, configWithDefault("bogus-target"));
96
+ throw new Error("expected getDeployTarget to throw");
97
+ } catch (error) {
98
+ expect((error as ValidationError).message).toContain("bogus-target");
99
+ }
100
+ });
101
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Deploy target registry — the single place that knows about concrete target
3
+ * classes.
4
+ *
5
+ * Everything else resolves a target by name through {@link getDeployTarget} and
6
+ * then talks only to the {@link DeployTarget} interface, so adding a new target
7
+ * is a one-line registration here plus its adapter file under `./targets/`.
8
+ *
9
+ * Unlike the runtime registry, resolution here has **no silent default
10
+ * fallback**: a deploy is consequential, so an unset target or a typo must fail
11
+ * loudly rather than ship somewhere unintended. The name is resolved from the
12
+ * explicit argument, then `config.deploy.default`; if neither is set, or the
13
+ * resolved name is unknown, a {@link ValidationError} is thrown listing the
14
+ * registered names.
15
+ */
16
+
17
+ import { ValidationError } from "../errors.ts";
18
+ import type { AgentplateConfig } from "../types.ts";
19
+ import { DockerGhaTarget } from "./targets/docker-gha.ts";
20
+ import type { DeployTarget } from "./types.ts";
21
+
22
+ /**
23
+ * Name → factory map. Factories return a *fresh* instance per call so targets
24
+ * can never accidentally share mutable state between resolutions. Insertion
25
+ * order here defines the order reported by {@link getDeployTargetNames} and in
26
+ * error messages.
27
+ *
28
+ * Future targets land here as one-liners once their adapter file exists:
29
+ * ["vercel", () => new VercelTarget()], // ./targets/vercel.ts
30
+ * ["aws", () => new AwsTarget()], // ./targets/aws.ts
31
+ * ["k8s-helm", () => new K8sHelmTarget()], // ./targets/k8s-helm.ts
32
+ * ["onprem", () => new OnpremTarget()], // ./targets/onprem.ts
33
+ */
34
+ const targets = new Map<string, () => DeployTarget>([["docker-gha", () => new DockerGhaTarget()]]);
35
+
36
+ /**
37
+ * Resolve a deploy target by name.
38
+ *
39
+ * Lookup order:
40
+ * 1. explicit `name` (e.g. from `--target`),
41
+ * 2. `config.deploy.default`.
42
+ *
43
+ * There is intentionally **no** built-in default: if neither source yields a
44
+ * name, a {@link ValidationError} is thrown listing the registered targets so
45
+ * the operator must choose explicitly. An unknown name throws the same way.
46
+ *
47
+ * @param name - Target id to resolve (e.g. "docker-gha"). Omit to use the config default.
48
+ * @param config - Agentplate config; `config.deploy.default` is the fallback source.
49
+ * @throws {ValidationError} When no name resolves, or the resolved name is unknown.
50
+ * @returns A fresh {@link DeployTarget} instance.
51
+ */
52
+ export function getDeployTarget(name?: string, config?: AgentplateConfig): DeployTarget {
53
+ const configDefault = config?.deploy?.default;
54
+ const resolved = name ?? (configDefault !== "" ? configDefault : undefined);
55
+ if (resolved === undefined || resolved === "") {
56
+ throw new ValidationError(
57
+ `No deploy target specified and config.deploy.default is unset. ` +
58
+ `Pass a target name. Available targets: ${getDeployTargetNames().join(", ")}`,
59
+ );
60
+ }
61
+ const factory = targets.get(resolved);
62
+ if (!factory) {
63
+ throw new ValidationError(
64
+ `Unknown deploy target: "${resolved}". Available targets: ${getDeployTargetNames().join(", ")}`,
65
+ );
66
+ }
67
+ return factory();
68
+ }
69
+
70
+ /**
71
+ * Names of all registered deploy targets, in registration order. Used to
72
+ * validate a user-supplied target name and to render the choices in help /
73
+ * errors.
74
+ */
75
+ export function getDeployTargetNames(): string[] {
76
+ return [...targets.keys()];
77
+ }
78
+
79
+ /**
80
+ * Return one fresh instance of every registered deploy target. Used by callers
81
+ * that need to enumerate all targets (e.g. doctor preflight, auto-selection by
82
+ * `detect()` confidence).
83
+ */
84
+ export function getAllDeployTargets(): DeployTarget[] {
85
+ return [...targets.values()].map((factory) => factory());
86
+ }
@@ -0,0 +1,129 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { AGENTPLATE_DIR } from "../config.ts";
6
+ import { setSecret } from "../secrets.ts";
7
+ import type { DeployTargetConfig } from "../types.ts";
8
+ import { createDeploySecretStore, missingSecretKeys, resolveTargetSecretEnv } from "./secrets.ts";
9
+
10
+ let root: string;
11
+ /** Env vars to restore after tests that exercise the process.env fallback. */
12
+ const SAVED_ENV: Record<string, string | undefined> = {};
13
+
14
+ function saveEnv(key: string): void {
15
+ if (!(key in SAVED_ENV)) SAVED_ENV[key] = process.env[key];
16
+ }
17
+
18
+ /** Build a minimal DeployTargetConfig with the given secret bindings. */
19
+ function targetConfig(secretEnv: DeployTargetConfig["secretEnv"]): DeployTargetConfig {
20
+ return { settings: {}, secretEnv, environments: ["production"] };
21
+ }
22
+
23
+ beforeEach(() => {
24
+ root = mkdtempSync(join(tmpdir(), "agentplate-deploy-secrets-"));
25
+ // setSecret writes <root>/.agentplate/secrets.local.yaml; the dir must exist.
26
+ mkdirSync(join(root, AGENTPLATE_DIR), { recursive: true });
27
+ });
28
+
29
+ afterEach(() => {
30
+ rmSync(root, { recursive: true, force: true });
31
+ for (const [key, value] of Object.entries(SAVED_ENV)) {
32
+ if (value === undefined) delete process.env[key];
33
+ else process.env[key] = value;
34
+ delete SAVED_ENV[key];
35
+ }
36
+ });
37
+
38
+ describe("createDeploySecretStore", () => {
39
+ test("get/has resolve a value from the file store", () => {
40
+ setSecret(root, "FLY_API_TOKEN", "tok-abc");
41
+ const store = createDeploySecretStore(root);
42
+ expect(store.get("FLY_API_TOKEN")).toBe("tok-abc");
43
+ expect(store.has("FLY_API_TOKEN")).toBe(true);
44
+ });
45
+
46
+ test("get returns undefined and has is false for an unknown key", () => {
47
+ const store = createDeploySecretStore(root);
48
+ expect(store.get("NOPE")).toBeUndefined();
49
+ expect(store.has("NOPE")).toBe(false);
50
+ });
51
+
52
+ test("falls back to process.env when the file store lacks the key", () => {
53
+ saveEnv("DEPLOY_ENV_ONLY");
54
+ process.env.DEPLOY_ENV_ONLY = "from-env";
55
+ const store = createDeploySecretStore(root);
56
+ expect(store.get("DEPLOY_ENV_ONLY")).toBe("from-env");
57
+ expect(store.has("DEPLOY_ENV_ONLY")).toBe(true);
58
+ });
59
+ });
60
+
61
+ describe("resolveTargetSecretEnv", () => {
62
+ test("maps logical keys to values via fromEnv bindings", () => {
63
+ setSecret(root, "FLY_API_TOKEN", "tok-abc");
64
+ setSecret(root, "REGISTRY_PASSWORD", "pw-123");
65
+ const cfg = targetConfig({
66
+ apiToken: { fromEnv: "FLY_API_TOKEN" },
67
+ registryPassword: { fromEnv: "REGISTRY_PASSWORD" },
68
+ });
69
+ expect(resolveTargetSecretEnv(root, cfg)).toEqual({
70
+ apiToken: "tok-abc",
71
+ registryPassword: "pw-123",
72
+ });
73
+ });
74
+
75
+ test("omits bindings whose env var is absent", () => {
76
+ setSecret(root, "PRESENT", "yes");
77
+ const cfg = targetConfig({
78
+ present: { fromEnv: "PRESENT" },
79
+ absent: { fromEnv: "MISSING_ONE" },
80
+ });
81
+ const resolved = resolveTargetSecretEnv(root, cfg);
82
+ expect(resolved).toEqual({ present: "yes" });
83
+ expect("absent" in resolved).toBe(false);
84
+ });
85
+
86
+ test("returns an empty map when there are no bindings", () => {
87
+ expect(resolveTargetSecretEnv(root, targetConfig({}))).toEqual({});
88
+ });
89
+
90
+ test("resolves a binding from process.env via the union store", () => {
91
+ saveEnv("CI_DEPLOY_TOKEN");
92
+ process.env.CI_DEPLOY_TOKEN = "ci-tok";
93
+ const cfg = targetConfig({ token: { fromEnv: "CI_DEPLOY_TOKEN" } });
94
+ expect(resolveTargetSecretEnv(root, cfg)).toEqual({ token: "ci-tok" });
95
+ });
96
+
97
+ test("logical key differs from env-var name (mapping, not pass-through)", () => {
98
+ setSecret(root, "AWS_SECRET_ACCESS_KEY", "secret-val");
99
+ const cfg = targetConfig({ secretKey: { fromEnv: "AWS_SECRET_ACCESS_KEY" } });
100
+ const resolved = resolveTargetSecretEnv(root, cfg);
101
+ expect(resolved.secretKey).toBe("secret-val");
102
+ expect("AWS_SECRET_ACCESS_KEY" in resolved).toBe(false);
103
+ });
104
+ });
105
+
106
+ describe("missingSecretKeys", () => {
107
+ test("reports absent env-var names only", () => {
108
+ setSecret(root, "HAVE_THIS", "v");
109
+ const missing = missingSecretKeys(root, ["HAVE_THIS", "MISSING_A", "MISSING_B"]);
110
+ expect(missing).toEqual(["MISSING_A", "MISSING_B"]);
111
+ });
112
+
113
+ test("returns an empty array when every required key is present", () => {
114
+ setSecret(root, "A", "1");
115
+ setSecret(root, "B", "2");
116
+ expect(missingSecretKeys(root, ["A", "B"])).toEqual([]);
117
+ });
118
+
119
+ test("counts a process.env-provided key as present", () => {
120
+ saveEnv("ENV_PROVIDED");
121
+ process.env.ENV_PROVIDED = "x";
122
+ setSecret(root, "FILE_PROVIDED", "y");
123
+ expect(missingSecretKeys(root, ["FILE_PROVIDED", "ENV_PROVIDED"])).toEqual([]);
124
+ });
125
+
126
+ test("returns an empty array for an empty required list", () => {
127
+ expect(missingSecretKeys(root, [])).toEqual([]);
128
+ });
129
+ });