@arcote.tech/arc-cli 0.6.2 → 0.7.1

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 (37) hide show
  1. package/dist/index.js +1696 -1663
  2. package/package.json +7 -7
  3. package/src/builder/access-extractor.ts +64 -46
  4. package/src/builder/build-cache.ts +3 -1
  5. package/src/builder/chunk-planner.ts +107 -0
  6. package/src/builder/dependency-collector.ts +83 -41
  7. package/src/builder/framework-peers.ts +81 -0
  8. package/src/builder/module-builder.ts +322 -106
  9. package/src/commands/platform-build.ts +2 -1
  10. package/src/commands/platform-deploy.ts +121 -64
  11. package/src/commands/platform-dev.ts +11 -100
  12. package/src/commands/platform-start.ts +4 -90
  13. package/src/deploy/ansible.ts +23 -3
  14. package/src/deploy/assets/ansible/site.yml +23 -7
  15. package/src/deploy/assets.ts +23 -7
  16. package/src/deploy/bootstrap.ts +270 -10
  17. package/src/deploy/caddyfile.ts +19 -23
  18. package/src/deploy/compose.ts +44 -27
  19. package/src/deploy/config.ts +67 -3
  20. package/src/deploy/deploy-env.ts +129 -0
  21. package/src/deploy/env-file.ts +103 -0
  22. package/src/deploy/htpasswd.ts +28 -0
  23. package/src/deploy/image-template.ts +74 -0
  24. package/src/deploy/image.ts +243 -0
  25. package/src/deploy/registry.ts +79 -0
  26. package/src/deploy/ssh.ts +52 -122
  27. package/src/deploy/survey.ts +64 -0
  28. package/src/index.ts +20 -13
  29. package/src/platform/server.ts +119 -94
  30. package/src/platform/shared.ts +139 -292
  31. package/src/platform/startup.ts +159 -0
  32. package/runtime/Dockerfile +0 -29
  33. package/runtime/build-and-push.sh +0 -23
  34. package/runtime/entrypoint.sh +0 -58
  35. package/src/commands/build-shell.ts +0 -152
  36. package/src/deploy/remote-sync.ts +0 -321
  37. package/src/platform/deploy-api.ts +0 -400
@@ -1,3 +1,4 @@
1
+ import { spawn } from "bun";
1
2
  import { mkdirSync, writeFileSync } from "fs";
2
3
  import { tmpdir } from "os";
3
4
  import { join } from "path";
@@ -5,12 +6,39 @@ import { runAnsible } from "./ansible";
5
6
  import { generateCaddyfile } from "./caddyfile";
6
7
  import { generateCompose } from "./compose";
7
8
  import type { DeployConfig } from "./config";
9
+ import { generateHtpasswd } from "./htpasswd";
8
10
  import { runTerraform } from "./terraform";
9
11
  import { saveDeployConfig } from "./config";
10
12
  import { ok, log, err } from "../platform/shared";
11
13
  import { writeStateMarker, STATE_MARKER_PATH } from "./remote-state";
12
14
  import type { RemoteState } from "./remote-state";
13
- import { assertExec, canSsh, scpUpload, waitForSsh } from "./ssh";
15
+ import { assertExec, baseSshArgs, canSsh, scpUpload, sshExec, waitForSsh } from "./ssh";
16
+ import type { DeployTarget } from "./config";
17
+
18
+ /**
19
+ * Wait until *any* of the supplied SSH targets accepts a connection. Polls
20
+ * all targets in parallel each round; the first one that succeeds wins.
21
+ * Useful when we don't know whether the host is on its first ansible-less
22
+ * boot (root only) or already hardened (deploy user only).
23
+ */
24
+ async function waitForAnySsh(
25
+ targets: DeployTarget[],
26
+ opts: { timeoutMs?: number; intervalMs?: number } = {},
27
+ ): Promise<void> {
28
+ const timeout = opts.timeoutMs ?? 300_000;
29
+ const interval = opts.intervalMs ?? 5_000;
30
+ const start = Date.now();
31
+ while (Date.now() - start < timeout) {
32
+ const results = await Promise.all(targets.map((t) => canSsh(t)));
33
+ if (results.some(Boolean)) return;
34
+ await Bun.sleep(interval);
35
+ }
36
+ throw new Error(
37
+ `Timed out waiting for SSH on ${targets
38
+ .map((t) => `${t.user}@${t.host}`)
39
+ .join(" or ")}`,
40
+ );
41
+ }
14
42
 
15
43
  // ---------------------------------------------------------------------------
16
44
  // Bootstrap orchestrator.
@@ -33,6 +61,8 @@ export interface BootstrapInputs {
33
61
  cliVersion: string;
34
62
  /** sha256 of deploy.arc.json — used for the remote state marker. */
35
63
  configHash: string;
64
+ /** Force the ansible run even when the host is already bootstrapped. */
65
+ forceAnsible?: boolean;
36
66
  }
37
67
 
38
68
  export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
@@ -64,16 +94,31 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
64
94
  saveDeployConfig(rootDir, cfg);
65
95
 
66
96
  log("Waiting for SSH to come up...");
67
- await waitForSsh({ ...cfg.target, user: "root" });
97
+ // On a brand-new VM only root exists; on a re-applied (no-op) terraform
98
+ // the deploy user already exists and root login is disabled by ansible
99
+ // hardening. Probe both — succeed on whichever lands first.
100
+ await waitForAnySsh([
101
+ { ...cfg.target, user: "root" },
102
+ { ...cfg.target, user: cfg.target.user },
103
+ ]);
68
104
  ok("SSH reachable");
69
105
  }
70
106
 
71
- if (state.kind === "unreachable" || state.kind === "no-docker") {
107
+ // Ansible only runs on fresh hosts (unreachable / no-docker) by default —
108
+ // it's idempotent but slow (~30–60s) and the host config rarely drifts.
109
+ // `--force-bootstrap` re-runs it on demand (after editing the embedded
110
+ // playbook, or to recover from manual host edits).
111
+ const needAnsible =
112
+ state.kind === "unreachable" ||
113
+ state.kind === "no-docker" ||
114
+ inputs.forceAnsible === true;
115
+
116
+ if (needAnsible) {
72
117
  log("Running Ansible bootstrap (Docker + firewall + SSH hardening)...");
73
118
  // Run as root whenever the configured user can't SSH (covers both freshly
74
119
  // provisioned VMs and second-attempt deploys after ansible failure).
75
120
  const deployUserWorks =
76
- state.kind === "no-docker" && (await canSsh(cfg.target));
121
+ state.kind !== "unreachable" && (await canSsh(cfg.target));
77
122
  const asRoot = !deployUserWorks;
78
123
  await runAnsible({
79
124
  target: cfg.target,
@@ -83,7 +128,21 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
83
128
  ok("Host bootstrapped");
84
129
  }
85
130
 
86
- if (state.kind !== "ready") {
131
+ // Force upStack whenever:
132
+ // - stack isn't fully ready, OR
133
+ // - marker is missing (legacy v0.5 deploy with no .arc-state.json), OR
134
+ // - configHash differs from last bootstrap (deploy.arc.json changed), OR
135
+ // - registry container isn't running (e.g. legacy stack predates v0.7)
136
+ // Without this, an old v0.5 stack (no registry container) is classified as
137
+ // "ready" and bootstrap is skipped — then `docker login` on the next step
138
+ // hits a vhost that doesn't exist and fails with a TLS error.
139
+ const needUpStack =
140
+ state.kind !== "ready" ||
141
+ state.marker === null ||
142
+ state.marker.configHash !== inputs.configHash ||
143
+ !(await isRegistryRunning(cfg));
144
+
145
+ if (needUpStack) {
87
146
  await upStack(inputs);
88
147
  ok("Docker stack up");
89
148
  }
@@ -96,16 +155,50 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
96
155
  });
97
156
  }
98
157
 
158
+ /**
159
+ * Returns true iff `registry` service is up in /opt/arc/docker-compose.yml.
160
+ * Used by bootstrap to detect legacy v0.5 stacks that have no registry
161
+ * container and need a fresh stack write + restart.
162
+ */
163
+ async function isRegistryRunning(cfg: DeployConfig): Promise<boolean> {
164
+ const res = await sshExec(
165
+ cfg.target,
166
+ `cd ${cfg.target.remoteDir} && docker compose ps --status running --format '{{.Service}}' 2>/dev/null || true`,
167
+ { quiet: true },
168
+ );
169
+ return res.stdout
170
+ .split("\n")
171
+ .map((s) => s.trim())
172
+ .includes("registry");
173
+ }
174
+
99
175
  async function upStack(inputs: BootstrapInputs): Promise<void> {
100
176
  const { cfg } = inputs;
101
177
  const workDir = join(tmpdir(), "arc-deploy", `stack-${Date.now()}`);
102
178
  mkdirSync(workDir, { recursive: true });
103
179
 
104
- writeFileSync(join(workDir, "Caddyfile"), generateCaddyfile(cfg));
105
- writeFileSync(
106
- join(workDir, "docker-compose.yml"),
107
- generateCompose({ cfg, cliVersion: inputs.cliVersion }),
180
+ // Pre-flight DNS — without registry.<domain> resolving to the host, Caddy's
181
+ // ACME challenge for the registry vhost will fail. Better to bail with a
182
+ // clear message than let the operator chase TLS retries for 10 minutes.
183
+ await assertRegistryDnsResolves(cfg);
184
+
185
+ // Generate htpasswd locally from the password env var. Never write the
186
+ // plaintext password to disk; only the bcrypt hash leaves this process.
187
+ const password = process.env[cfg.registry.passwordEnv];
188
+ if (!password) {
189
+ throw new Error(
190
+ `Registry password env var ${cfg.registry.passwordEnv} is not set. ` +
191
+ `Set it (e.g. \`export ${cfg.registry.passwordEnv}=...\`) before bootstrap.`,
192
+ );
193
+ }
194
+ const htpasswdLine = await generateHtpasswd(
195
+ cfg.registry.username,
196
+ password,
108
197
  );
198
+ writeFileSync(join(workDir, "htpasswd"), htpasswdLine);
199
+
200
+ writeFileSync(join(workDir, "Caddyfile"), generateCaddyfile(cfg));
201
+ writeFileSync(join(workDir, "docker-compose.yml"), generateCompose({ cfg }));
109
202
 
110
203
  // Ensure remoteDir exists
111
204
  await assertExec(
@@ -118,6 +211,10 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
118
211
  `mkdir -p ${cfg.target.remoteDir}/${name}`,
119
212
  );
120
213
  }
214
+ await assertExec(
215
+ cfg.target,
216
+ `mkdir -p ${cfg.target.remoteDir}/registry-auth`,
217
+ );
121
218
 
122
219
  await scpUpload(
123
220
  cfg.target,
@@ -129,9 +226,172 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
129
226
  join(workDir, "docker-compose.yml"),
130
227
  `${cfg.target.remoteDir}/docker-compose.yml`,
131
228
  );
229
+ await scpUpload(
230
+ cfg.target,
231
+ join(workDir, "htpasswd"),
232
+ `${cfg.target.remoteDir}/registry-auth/htpasswd`,
233
+ );
132
234
 
235
+ // Ensure /opt/arc/.env exists so docker compose doesn't error on var
236
+ // substitution. Per-env ARC_IMAGE_<ENV> lines are written by deploy.
133
237
  await assertExec(
134
238
  cfg.target,
135
- `cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures && docker compose up -d`,
239
+ `touch ${cfg.target.remoteDir}/.env`,
240
+ );
241
+
242
+ // Pre-register the deploy user with the private registry so containers can
243
+ // pull. We do this AFTER `docker compose up -d caddy registry` runs (below)
244
+ // — the registry needs to be reachable for `docker login` to succeed.
245
+ await assertExec(
246
+ cfg.target,
247
+ `cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures caddy registry && docker compose up -d caddy registry`,
248
+ );
249
+
250
+ // Wait for the registry vhost to respond (Caddy ACME issuance takes a few
251
+ // seconds on first start), then docker login on the host. This caches
252
+ // credentials in /home/<user>/.docker/config.json so subsequent `docker
253
+ // compose pull arc-<env>` from the per-deploy step can fetch app images.
254
+ await sshDockerLogin(cfg);
255
+
256
+ // Bring up any arc-<env> services whose images are already published.
257
+ // The :? fallback in compose makes services with no ARC_IMAGE_<ENV> set
258
+ // fail their up step — we filter those out by reading .env first.
259
+ const knownEnvs = await listConfiguredEnvs(cfg);
260
+ if (knownEnvs.length > 0) {
261
+ await assertExec(
262
+ cfg.target,
263
+ `cd ${cfg.target.remoteDir} && docker compose up -d ${knownEnvs
264
+ .map((e) => `arc-${e}`)
265
+ .join(" ")}`,
266
+ );
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Log in to the private registry from the deploy user's account on the host.
272
+ * Required so `docker compose pull arc-<env>` works on subsequent deploys
273
+ * (compose runs docker from the deploy user's perspective; that user's
274
+ * `.docker/config.json` is where the registry token lives).
275
+ */
276
+ async function sshDockerLogin(cfg: DeployConfig): Promise<void> {
277
+ const password = process.env[cfg.registry.passwordEnv];
278
+ if (!password) {
279
+ throw new Error(
280
+ `Registry password env var ${cfg.registry.passwordEnv} is not set on the deploy host (CLI machine).`,
281
+ );
282
+ }
283
+ // Stream password over SSH stdin — never reach the command line (no shell
284
+ // history, no `ps`, no double-shell-escape bugs). The remote shell pipes
285
+ // its own stdin straight into `docker login --password-stdin`.
286
+ const cmd = `docker login ${cfg.registry.domain} -u ${cfg.registry.username} --password-stdin`;
287
+ const proc = spawn({
288
+ cmd: [
289
+ "ssh",
290
+ ...baseSshArgs(cfg.target),
291
+ `${cfg.target.user}@${cfg.target.host}`,
292
+ "--",
293
+ cmd,
294
+ ],
295
+ stdin: "pipe",
296
+ stdout: "pipe",
297
+ stderr: "pipe",
298
+ });
299
+ if (proc.stdin) {
300
+ await (proc.stdin as any).write(new TextEncoder().encode(password));
301
+ await (proc.stdin as any).end?.();
302
+ }
303
+ const exit = await proc.exited;
304
+ if (exit !== 0) {
305
+ const stderr = await new Response(proc.stderr).text();
306
+ throw new Error(
307
+ `Server-side docker login failed (exit ${exit}): ${stderr.trim()}`,
308
+ );
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Read /opt/arc/.env on the host and return env names that have an
314
+ * `ARC_IMAGE_<ENV>=...` line set. Used by bootstrap to decide which arc-<env>
315
+ * services can safely be started (the others would fail compose var
316
+ * substitution with `:?`).
317
+ */
318
+ async function listConfiguredEnvs(cfg: DeployConfig): Promise<string[]> {
319
+ const res = await sshExec(
320
+ cfg.target,
321
+ `cat ${cfg.target.remoteDir}/.env 2>/dev/null || true`,
322
+ { quiet: true },
136
323
  );
324
+ const set = new Set<string>();
325
+ for (const line of res.stdout.split("\n")) {
326
+ const m = line.match(/^ARC_IMAGE_([A-Z0-9_]+)=/);
327
+ if (!m) continue;
328
+ const lowerName = m[1].toLowerCase().replace(/_/g, "-");
329
+ if (lowerName in cfg.envs) set.add(lowerName);
330
+ }
331
+ return [...set];
332
+ }
333
+
334
+ /**
335
+ * Verify that `<registry.domain>` resolves to the target host's IP. If DNS
336
+ * isn't propagated yet, Caddy's ACME challenge for the registry vhost will
337
+ * fail repeatedly. Fail fast with an actionable hint instead.
338
+ */
339
+ async function assertRegistryDnsResolves(cfg: DeployConfig): Promise<void> {
340
+ // Source of truth for "is the DNS update live?" is the authoritative NS for
341
+ // the apex domain — public resolvers (8.8.8.8 / 1.1.1.1) cache for minutes
342
+ // after a record change and disagree among themselves during propagation.
343
+ // Let's Encrypt ACME validates against the authoritative NS too, so this
344
+ // matches what Caddy will see when it tries to issue the cert.
345
+ const apex = apexDomain(cfg.registry.domain);
346
+ let nameservers = await digQuery("8.8.8.8", "NS", apex);
347
+ nameservers = nameservers.map((s) => s.replace(/\.$/, ""));
348
+
349
+ // Sources to query, in order: authoritative NS, then public resolvers.
350
+ // Accept the first source where any answer matches target.host.
351
+ const sources = [...nameservers, "1.1.1.1", "8.8.8.8"];
352
+ let lastAnswers: string[] = [];
353
+
354
+ for (const source of sources) {
355
+ const answers = await digQuery(source, "A", cfg.registry.domain);
356
+ if (answers.length === 0) continue;
357
+ lastAnswers = answers;
358
+ if (answers.includes(cfg.target.host)) return;
359
+ }
360
+
361
+ if (lastAnswers.length === 0) {
362
+ throw new Error(
363
+ `Registry DNS not configured: ${cfg.registry.domain} doesn't resolve. ` +
364
+ `Add an A record pointing to ${cfg.target.host} and re-run deploy.`,
365
+ );
366
+ }
367
+ throw new Error(
368
+ `Registry DNS mismatch: ${cfg.registry.domain} resolves to [${lastAnswers.join(", ")}], ` +
369
+ `but target host is ${cfg.target.host}. Update the A record before continuing.`,
370
+ );
371
+ }
372
+
373
+ function apexDomain(host: string): string {
374
+ // Naive eTLD+1 extraction: last 2 labels. Works for `.pl`, `.com`, etc.
375
+ // For `.co.uk` style TLDs the authoritative NS query still returns the
376
+ // correct NS — dig handles the SOA chase upstream.
377
+ const parts = host.split(".");
378
+ return parts.slice(-2).join(".");
379
+ }
380
+
381
+ async function digQuery(
382
+ server: string,
383
+ type: "A" | "NS",
384
+ name: string,
385
+ ): Promise<string[]> {
386
+ const proc = spawn({
387
+ cmd: ["dig", `@${server}`, "+short", "+time=3", "+tries=1", type, name],
388
+ stdout: "pipe",
389
+ stderr: "ignore",
390
+ });
391
+ const exit = await proc.exited;
392
+ if (exit !== 0) return [];
393
+ return (await new Response(proc.stdout).text())
394
+ .split("\n")
395
+ .map((s) => s.trim())
396
+ .filter(Boolean);
137
397
  }
@@ -3,18 +3,16 @@ import type { DeployConfig } from "./config";
3
3
  // ---------------------------------------------------------------------------
4
4
  // Caddyfile generator
5
5
  //
6
- // Two kinds of blocks are produced:
6
+ // Two kinds of vhosts:
7
7
  //
8
- // 1. Public site blocks (80/443) — one per env, routed by Host header.
9
- // Reverse-proxy to arc-<env>:5005 BUT strip any /api/deploy/* path so
10
- // the hot-swap endpoint never leaks to the internet.
8
+ // 1. Public env blocks (80/443) — one per env, routed by Host header.
9
+ // Plain reverse-proxy to arc-<env>:5005. No /api/deploy/* paths exist
10
+ // in v0.7 (deploy goes through docker push, not HTTP), so there's
11
+ // nothing to block at the Caddy level.
11
12
  //
12
- // 2. Internal management listener on 127.0.0.1:2019 handles
13
- // /env/<name>/api/deploy/* paths by rewriting and proxying to
14
- // arc-<name>:5005/api/deploy/*. This is the ONLY path to the deploy
15
- // API from off-host; the listener is bound to loopback inside the
16
- // Caddy container, and docker-compose publishes 127.0.0.1:2019:2019.
17
- // CLI reaches it via `ssh -L`.
13
+ // 2. Private Docker Registry vhostproxies registry.<domain> to the
14
+ // in-cluster `registry:2` service. htpasswd basic auth is enforced on
15
+ // the registry container side; Caddy just terminates TLS.
18
16
  // ---------------------------------------------------------------------------
19
17
 
20
18
  export function generateCaddyfile(cfg: DeployConfig): string {
@@ -35,24 +33,22 @@ export function generateCaddyfile(cfg: DeployConfig): string {
35
33
  // Public blocks — one per env
36
34
  for (const [name, env] of Object.entries(cfg.envs)) {
37
35
  lines.push(`${env.domain} {${tlsDirective}`);
38
- lines.push(" @deploy path /api/deploy /api/deploy/*");
39
- lines.push(" respond @deploy 404");
40
- lines.push("");
41
36
  lines.push(` reverse_proxy arc-${name}:5005`);
42
37
  lines.push("}");
43
38
  lines.push("");
44
39
  }
45
40
 
46
- // Internal management listener
47
- lines.push("# Loopback-only management listener (SSH tunnel access).");
48
- lines.push("http://127.0.0.1:2019 {");
49
- lines.push(" bind 127.0.0.1");
50
- for (const [name] of Object.entries(cfg.envs)) {
51
- lines.push(` handle_path /env/${name}/* {`);
52
- lines.push(` reverse_proxy arc-${name}:5005`);
53
- lines.push(` }`);
54
- }
55
- lines.push(" respond 404");
41
+ // Private Docker Registry — Caddy proxies HTTPS termination + Let's Encrypt;
42
+ // the registry:2 container handles its own htpasswd basic auth upstream.
43
+ // 5 GiB request body cap fits real app images comfortably (default 100MB
44
+ // triggers 413 on the first layer push).
45
+ lines.push(`${cfg.registry.domain} {${tlsDirective}`);
46
+ lines.push(" reverse_proxy registry:5000 {");
47
+ lines.push(" header_up Host {host}");
48
+ lines.push(" }");
49
+ lines.push(" request_body {");
50
+ lines.push(" max_size 5GB");
51
+ lines.push(" }");
56
52
  lines.push("}");
57
53
 
58
54
  return lines.join("\n") + "\n";
@@ -1,41 +1,32 @@
1
1
  import type { DeployConfig } from "./config";
2
2
 
3
3
  // ---------------------------------------------------------------------------
4
- // docker-compose.yml generator — v0.6
4
+ // docker-compose.yml generator
5
5
  //
6
6
  // Services:
7
7
  // - caddy (public 80/443, loopback 127.0.0.1:2019 for deploy tunnel)
8
- // - arc-<env> per entry in deploy.arc.json envs
8
+ // - arc-<env> per entry in deploy.arc.json envs (bind-mounts project dir)
9
9
  //
10
- // arc-<env> uses `arcote/runtime:1` (generic image). CLI version is passed
11
- // via ARC_CLI_VERSION env var entrypoint installs arc-cli on first boot,
12
- // then `arc platform start` decides between pre-deploy and full mode based
13
- // on volume state. No user code or node_modules on host disk (everything
14
- // lives in named volumes, populated via /api/deploy/* multipart pushes).
10
+ // No custom images: vanilla `caddy:2-alpine` and `oven/bun:1-alpine` from
11
+ // Docker Hub. The Arc CLI and user's built artifacts come via the volume
12
+ // mount at /opt/arc/<env>/ rsynced by the deploy command.
15
13
  // ---------------------------------------------------------------------------
16
14
 
17
15
  export interface ComposeOptions {
18
16
  cfg: DeployConfig;
19
- /** CLI version used by entrypoint.sh to `bun add @arcote.tech/arc-cli@VER`. */
20
- cliVersion: string;
21
17
  }
22
18
 
23
- const RESERVED_ENV = new Set(["PORT", "ARC_DEPLOY_API", "ARC_CLI_VERSION"]);
24
-
25
- export function generateCompose({ cfg, cliVersion }: ComposeOptions): string {
19
+ export function generateCompose({ cfg }: ComposeOptions): string {
26
20
  const lines: string[] = [];
27
21
  lines.push("# Generated by `arc platform deploy` — do not edit by hand.");
28
22
  lines.push("");
29
23
  lines.push("services:");
30
-
31
- // Caddy
32
24
  lines.push(" caddy:");
33
25
  lines.push(" image: caddy:2-alpine");
34
26
  lines.push(" restart: unless-stopped");
35
27
  lines.push(" ports:");
36
28
  lines.push(' - "80:80"');
37
29
  lines.push(' - "443:443"');
38
- lines.push(' - "127.0.0.1:2019:2019"');
39
30
  lines.push(" volumes:");
40
31
  lines.push(" - ./Caddyfile:/etc/caddy/Caddyfile:ro");
41
32
  lines.push(" - caddy_data:/data");
@@ -44,28 +35,57 @@ export function generateCompose({ cfg, cliVersion }: ComposeOptions): string {
44
35
  lines.push(" - arc-net");
45
36
  lines.push("");
46
37
 
47
- // Per-env runtime containers
38
+ // Private Docker Registry — `arc platform deploy` pushes app images here,
39
+ // arc-<env> containers pull from here. htpasswd auth file is uploaded by
40
+ // bootstrap (generated locally from the password env var).
41
+ lines.push(" registry:");
42
+ lines.push(" image: registry:2");
43
+ lines.push(" restart: unless-stopped");
44
+ lines.push(" volumes:");
45
+ lines.push(" - registry_data:/var/lib/registry");
46
+ lines.push(" - ./registry-auth/htpasswd:/auth/htpasswd:ro");
47
+ lines.push(" environment:");
48
+ lines.push(" REGISTRY_AUTH: htpasswd");
49
+ lines.push(' REGISTRY_AUTH_HTPASSWD_REALM: "Arc Registry"');
50
+ lines.push(" REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd");
51
+ // Large image layers (framework peers + arc-cli bundle + chunks) need a
52
+ // generous upload limit. Default 100MB triggers 413 on real apps.
53
+ lines.push(' REGISTRY_HTTP_HOST: "https://' + cfg.registry.domain + '"');
54
+ lines.push(" networks:");
55
+ lines.push(" - arc-net");
56
+ lines.push(" expose:");
57
+ lines.push(' - "5000"');
58
+ lines.push("");
59
+
48
60
  for (const [name, env] of Object.entries(cfg.envs)) {
61
+ const upperName = name.toUpperCase().replace(/-/g, "_");
49
62
  lines.push(` arc-${name}:`);
50
- lines.push(" image: pkrasinski/arc-runtime:1");
63
+ // Image ref comes from /opt/arc/.env, written per-deploy with the content
64
+ // hash of the latest build. Default to a placeholder so `docker compose
65
+ // pull caddy registry` doesn't fail with `:?` interpolation errors on this
66
+ // service before the first deploy ever sets ARC_IMAGE_<ENV>.
67
+ lines.push(
68
+ ` image: \${ARC_IMAGE_${upperName}:-arc-${name}:not-deployed}`,
69
+ );
70
+ lines.push(` container_name: arc-${name}`);
51
71
  lines.push(" restart: unless-stopped");
52
72
  lines.push(" volumes:");
53
- lines.push(` - arc-platform-${name}:/app/.arc/platform`);
73
+ // Only the data volume — user code lives entirely inside the image.
74
+ // SQLite + uploads persist across redeploys.
54
75
  lines.push(` - arc-data-${name}:/app/.arc/data`);
55
- lines.push(" - arc-cli-cache:/app/.arc/cli");
56
- lines.push(" - arc-bun-cache:/root/.bun/install/cache");
57
76
  lines.push(" environment:");
58
77
  lines.push(" PORT: 5005");
59
- lines.push(' ARC_DEPLOY_API: "1"');
60
- lines.push(` ARC_CLI_VERSION: ${JSON.stringify(cliVersion)}`);
61
78
  const userEnv = env.envVars ?? {};
62
79
  if (!("NODE_ENV" in userEnv)) {
63
80
  lines.push(" NODE_ENV: production");
64
81
  }
82
+ // PORT is reserved — user envVars can't override.
83
+ const reserved = new Set(["PORT"]);
65
84
  for (const [k, v] of Object.entries(userEnv)) {
66
- if (RESERVED_ENV.has(k)) continue;
85
+ if (reserved.has(k)) continue;
67
86
  lines.push(` ${k}: ${JSON.stringify(v)}`);
68
87
  }
88
+ // ENTRYPOINT + CMD come from the image — no `command:` override needed.
69
89
  lines.push(" networks:");
70
90
  lines.push(" - arc-net");
71
91
  lines.push(" expose:");
@@ -73,17 +93,14 @@ export function generateCompose({ cfg, cliVersion }: ComposeOptions): string {
73
93
  lines.push("");
74
94
  }
75
95
 
76
- // Networks + volumes
77
96
  lines.push("networks:");
78
97
  lines.push(" arc-net:");
79
98
  lines.push("");
80
99
  lines.push("volumes:");
81
100
  lines.push(" caddy_data:");
82
101
  lines.push(" caddy_config:");
83
- lines.push(" arc-cli-cache:");
84
- lines.push(" arc-bun-cache:");
102
+ lines.push(" registry_data:");
85
103
  for (const [name] of Object.entries(cfg.envs)) {
86
- lines.push(` arc-platform-${name}:`);
87
104
  lines.push(` arc-data-${name}:`);
88
105
  }
89
106
 
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "fs";
2
2
  import { join } from "path";
3
+ import { applyDeployGlobals, loadDeployEnvFiles } from "./env-file";
3
4
 
4
5
  // ---------------------------------------------------------------------------
5
6
  // deploy.arc.json — single source of truth for deployment configuration.
@@ -54,10 +55,20 @@ export interface DeployProvision {
54
55
  ansible?: DeployProvisionAnsible;
55
56
  }
56
57
 
58
+ export interface DeployRegistry {
59
+ /** Full domain — Caddy reverse-proxies this to the registry:5000 container. */
60
+ domain: string;
61
+ /** htpasswd basic-auth username. Default: `deploy`. */
62
+ username: string;
63
+ /** Name of env var holding the htpasswd password. Never inline the secret. */
64
+ passwordEnv: string;
65
+ }
66
+
57
67
  export interface DeployConfig {
58
68
  target: DeployTarget;
59
69
  envs: Record<string, DeployEnv>;
60
70
  caddy: DeployCaddy;
71
+ registry: DeployRegistry;
61
72
  provision?: DeployProvision;
62
73
  }
63
74
 
@@ -76,8 +87,17 @@ export function deployConfigExists(rootDir: string): boolean {
76
87
  }
77
88
 
78
89
  /**
79
- * Load deploy.arc.json, expand `${VAR}` references against process.env,
80
- * and validate shape. Throws with a precise error on any issue.
90
+ * Load deploy.arc.json + side-car env files (deploy.arc.env for globals,
91
+ * deploy.arc.<env>.env for per-env secrets), expand `${VAR}` references
92
+ * against process.env, and validate shape.
93
+ *
94
+ * Resolution order (last wins):
95
+ * 1. deploy.arc.json `envs.<name>.envVars` (declared in config)
96
+ * 2. deploy.arc.<name>.env (sidecar file, gitignored)
97
+ * 3. existing process.env values (CI/CD secret store)
98
+ *
99
+ * Globals from `deploy.arc.env` populate process.env (without overriding
100
+ * existing values), so terraform/dockerLogin/ansible see them naturally.
81
101
  */
82
102
  export function loadDeployConfig(rootDir: string): DeployConfig {
83
103
  const path = deployConfigPath(rootDir);
@@ -91,8 +111,33 @@ export function loadDeployConfig(rootDir: string): DeployConfig {
91
111
  } catch (e) {
92
112
  throw new Error(`Invalid JSON in ${DEPLOY_CONFIG_FILE}: ${(e as Error).message}`);
93
113
  }
114
+
115
+ // Read env names from raw JSON (pre-validation) to know which sidecar
116
+ // files to look for. Validation runs next.
117
+ const envNames = isObject(parsed) && isObject(parsed.envs)
118
+ ? Object.keys(parsed.envs)
119
+ : [];
120
+ const envFiles = loadDeployEnvFiles(rootDir, envNames);
121
+
122
+ // Globals → process.env (without overriding) so downstream code can read
123
+ // HCLOUD_TOKEN, ARC_REGISTRY_PASSWORD etc. as if they were exported in shell.
124
+ applyDeployGlobals(envFiles.globals);
125
+
94
126
  const expanded = expandEnvVars(parsed, process.env);
95
- return validateDeployConfig(expanded);
127
+ const validated = validateDeployConfig(expanded);
128
+
129
+ // Merge sidecar per-env vars into cfg.envs[name].envVars.
130
+ // Existing keys (declared in deploy.arc.json) win over sidecar — config is
131
+ // the source of truth for variable NAMES, sidecar provides VALUES for
132
+ // anything not pinned otherwise.
133
+ for (const [envName, vars] of Object.entries(envFiles.perEnv)) {
134
+ if (!(envName in validated.envs)) continue;
135
+ const env = validated.envs[envName];
136
+ const merged: Record<string, string> = { ...vars, ...(env.envVars ?? {}) };
137
+ validated.envs[envName] = { ...env, envVars: merged };
138
+ }
139
+
140
+ return validated;
96
141
  }
97
142
 
98
143
  export function saveDeployConfig(rootDir: string, cfg: DeployConfig): void {
@@ -139,6 +184,20 @@ export function validateDeployConfig(input: unknown): DeployConfig {
139
184
  const target = requireObject(input, "target");
140
185
  const envs = requireObject(input, "envs");
141
186
  const caddy = requireObject(input, "caddy");
187
+ const registry = requireObject(input, "registry");
188
+
189
+ const registryDomain = requireString(registry, "registry.domain");
190
+ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(registryDomain)) {
191
+ throw new Error(
192
+ `deploy.arc.json: registry.domain "${registryDomain}" doesn't look like a domain`,
193
+ );
194
+ }
195
+ const passwordEnv = requireString(registry, "registry.passwordEnv");
196
+ if (!/^[A-Z][A-Z0-9_]*$/.test(passwordEnv)) {
197
+ throw new Error(
198
+ `deploy.arc.json: registry.passwordEnv "${passwordEnv}" must be an UPPER_SNAKE_CASE env var name`,
199
+ );
200
+ }
142
201
 
143
202
  const validated: DeployConfig = {
144
203
  target: {
@@ -152,6 +211,11 @@ export function validateDeployConfig(input: unknown): DeployConfig {
152
211
  caddy: {
153
212
  email: requireString(caddy, "caddy.email"),
154
213
  },
214
+ registry: {
215
+ domain: registryDomain,
216
+ username: optionalString(registry, "registry.username") ?? "deploy",
217
+ passwordEnv,
218
+ },
155
219
  };
156
220
 
157
221
  const envKeys = Object.keys(envs);