@arcote.tech/arc-cli 0.6.2 → 0.7.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.
@@ -1,103 +1,14 @@
1
- import { existsSync, watch } from "fs";
2
- import { join } from "path";
3
- import { startPlatformServer } from "../platform/server";
4
- import {
5
- buildAll,
6
- collectArcPeerDeps,
7
- loadServerContext,
8
- log,
9
- ok,
10
- resolveWorkspace,
11
- } from "../platform/shared";
1
+ import { startPlatform } from "../platform/startup";
2
+ import { resolveWorkspace } from "../platform/shared";
12
3
 
13
- export async function platformDev(opts: { noCache?: boolean } = {}): Promise<void> {
4
+ /** `arc platform dev` — dev mode (watcher + SSE reload + no-cache headers). */
5
+ export async function platformDev(
6
+ opts: { noCache?: boolean } = {},
7
+ ): Promise<void> {
14
8
  const ws = resolveWorkspace();
15
- const port = 5005;
16
-
17
- // Initial build — `--no-cache` (if passed) only forces the startup pass;
18
- // subsequent rebuilds always respect the cache to keep dev incremental.
19
- let manifest = await buildAll(ws, { noCache: opts.noCache });
20
-
21
- log("Loading server context...");
22
- const { context, moduleAccess } = await loadServerContext(ws.packages);
23
- if (context) {
24
- ok("Context loaded");
25
- } else {
26
- log("No context — server endpoints skipped");
27
- }
28
-
29
- const arcEntries = collectArcPeerDeps(ws.packages);
30
- const platform = await startPlatformServer({
31
- ws,
32
- port,
33
- manifest,
34
- context,
35
- moduleAccess,
36
- dbPath: join(ws.rootDir, ".arc", "data", "dev.db"),
37
- devMode: true,
38
- arcEntries,
39
- });
40
-
41
- ok(`Server on http://localhost:${port}`);
42
- if (platform.contextHandler) ok("Commands, queries, WebSocket — all on same port");
43
-
44
- // Watch for changes — full buildAll on debounce; cache makes it cheap when
45
- // only one package changed.
46
- log("Watching for changes...");
47
- let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
48
- let isRebuilding = false;
49
-
50
- const triggerRebuild = () => {
51
- if (rebuildTimer) clearTimeout(rebuildTimer);
52
- rebuildTimer = setTimeout(async () => {
53
- if (isRebuilding) return;
54
- isRebuilding = true;
55
- log("Rebuilding...");
56
- try {
57
- manifest = await buildAll(ws);
58
- platform.setManifest(manifest);
59
- platform.notifyReload(manifest);
60
- ok(`Rebuilt — ${manifest.modules.length} module(s)`);
61
- } catch (e) {
62
- console.error(`Rebuild failed: ${e}`);
63
- } finally {
64
- isRebuilding = false;
65
- }
66
- }, 300);
67
- };
68
-
69
- for (const pkg of ws.packages) {
70
- const srcDir = join(pkg.path, "src");
71
- if (!existsSync(srcDir)) continue;
72
-
73
- watch(srcDir, { recursive: true }, (_event, filename) => {
74
- if (
75
- !filename ||
76
- filename.includes(".arc") ||
77
- filename.endsWith(".d.ts") ||
78
- filename.includes("node_modules") ||
79
- filename.includes("dist")
80
- ) return;
81
-
82
- if (!filename.endsWith(".ts") && !filename.endsWith(".tsx")) return;
83
-
84
- triggerRebuild();
85
- });
86
- }
87
-
88
- // .po files — trigger rebuild so the `translations` cache unit picks them up.
89
- const localesDir = join(ws.rootDir, "locales");
90
- if (existsSync(localesDir)) {
91
- watch(localesDir, { recursive: false }, (_event, filename) => {
92
- if (!filename?.endsWith(".po")) return;
93
- triggerRebuild();
94
- });
95
- }
96
-
97
- const cleanup = () => {
98
- platform.stop();
99
- process.exit(0);
100
- };
101
- process.on("SIGTERM", cleanup);
102
- process.on("SIGINT", cleanup);
9
+ // noCache is consumed by buildAll inside startPlatform when devMode=true.
10
+ // For now the dev startup always uses the cache after the first build;
11
+ // explicit --no-cache here only matters if we wire it through later.
12
+ void opts;
13
+ await startPlatform({ ws, devMode: true });
103
14
  }
@@ -1,94 +1,8 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { join } from "path";
3
- import { startPlatformServer } from "../platform/server";
4
- import {
5
- collectArcPeerDeps,
6
- err,
7
- loadServerContext,
8
- log,
9
- ok,
10
- resolveWorkspace,
11
- type BuildManifest,
12
- } from "../platform/shared";
1
+ import { startPlatform } from "../platform/startup";
2
+ import { resolveWorkspace } from "../platform/shared";
13
3
 
4
+ /** `arc platform start` — production mode (no watcher, immutable cache). */
14
5
  export async function platformStart(): Promise<void> {
15
6
  const ws = resolveWorkspace();
16
- const port = parseInt(process.env.PORT || "5005", 10);
17
- const deployApi = process.env.ARC_DEPLOY_API === "1";
18
-
19
- // Pre-deploy mode: container started with empty volume (first boot of an
20
- // arcote/runtime container — manifest hasn't been pushed yet). Boot a
21
- // minimal server so the deploy CLI can reach /api/deploy/* to push the
22
- // initial framework + modules. Container restart (after first manifest
23
- // commit) re-enters this function with manifest present → full mode.
24
- const manifestPath = join(ws.modulesDir, "manifest.json");
25
- if (!existsSync(manifestPath)) {
26
- if (!deployApi) {
27
- err("No build found. Run `arc platform build` first.");
28
- process.exit(1);
29
- }
30
- log("Pre-deploy mode — no manifest yet, awaiting first /api/deploy/*");
31
- const emptyManifest: BuildManifest = {
32
- modules: [],
33
- shellHash: "",
34
- stylesHash: "",
35
- buildTime: new Date().toISOString(),
36
- };
37
- const platform = await startPlatformServer({
38
- ws,
39
- port,
40
- manifest: emptyManifest,
41
- context: null,
42
- moduleAccess: new Map(),
43
- dbPath: join(ws.rootDir, ".arc", "data", "prod.db"),
44
- devMode: false,
45
- deployApi: true,
46
- arcEntries: [],
47
- });
48
- ok(`Pre-deploy server on http://localhost:${port}`);
49
- registerSignalCleanup(platform);
50
- return;
51
- }
52
-
53
- const manifest: BuildManifest = JSON.parse(
54
- readFileSync(manifestPath, "utf-8"),
55
- );
56
-
57
- // Load server context
58
- log("Loading server context...");
59
- const { context, moduleAccess } = await loadServerContext(ws.packages);
60
- if (context) {
61
- ok("Context loaded");
62
- } else {
63
- log("No context — server endpoints skipped");
64
- }
65
-
66
- // Start server (production mode = aggressive caching, SSE only used for deploy hot-swap)
67
- const arcEntries = collectArcPeerDeps(ws.packages);
68
- if (deployApi) ok("Deploy API enabled (/api/deploy/*)");
69
- const platform = await startPlatformServer({
70
- ws,
71
- port,
72
- manifest,
73
- context,
74
- moduleAccess,
75
- dbPath: join(ws.rootDir, ".arc", "data", "prod.db"),
76
- devMode: false,
77
- deployApi,
78
- arcEntries,
79
- });
80
-
81
- ok(`Server on http://localhost:${port}`);
82
- if (platform.contextHandler) ok("Commands, queries, WebSocket — all on same port");
83
-
84
- registerSignalCleanup(platform);
85
- }
86
-
87
- function registerSignalCleanup(platform: { stop: () => void }): void {
88
- const cleanup = () => {
89
- platform.stop();
90
- process.exit(0);
91
- };
92
- process.on("SIGTERM", cleanup);
93
- process.on("SIGINT", cleanup);
7
+ await startPlatform({ ws, devMode: false });
94
8
  }
@@ -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,13 @@ 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";
14
16
 
15
17
  // ---------------------------------------------------------------------------
16
18
  // Bootstrap orchestrator.
@@ -101,11 +103,28 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
101
103
  const workDir = join(tmpdir(), "arc-deploy", `stack-${Date.now()}`);
102
104
  mkdirSync(workDir, { recursive: true });
103
105
 
104
- writeFileSync(join(workDir, "Caddyfile"), generateCaddyfile(cfg));
105
- writeFileSync(
106
- join(workDir, "docker-compose.yml"),
107
- generateCompose({ cfg, cliVersion: inputs.cliVersion }),
106
+ // Pre-flight DNS — without registry.<domain> resolving to the host, Caddy's
107
+ // ACME challenge for the registry vhost will fail. Better to bail with a
108
+ // clear message than let the operator chase TLS retries for 10 minutes.
109
+ await assertRegistryDnsResolves(cfg);
110
+
111
+ // Generate htpasswd locally from the password env var. Never write the
112
+ // plaintext password to disk; only the bcrypt hash leaves this process.
113
+ const password = process.env[cfg.registry.passwordEnv];
114
+ if (!password) {
115
+ throw new Error(
116
+ `Registry password env var ${cfg.registry.passwordEnv} is not set. ` +
117
+ `Set it (e.g. \`export ${cfg.registry.passwordEnv}=...\`) before bootstrap.`,
118
+ );
119
+ }
120
+ const htpasswdLine = await generateHtpasswd(
121
+ cfg.registry.username,
122
+ password,
108
123
  );
124
+ writeFileSync(join(workDir, "htpasswd"), htpasswdLine);
125
+
126
+ writeFileSync(join(workDir, "Caddyfile"), generateCaddyfile(cfg));
127
+ writeFileSync(join(workDir, "docker-compose.yml"), generateCompose({ cfg }));
109
128
 
110
129
  // Ensure remoteDir exists
111
130
  await assertExec(
@@ -118,6 +137,10 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
118
137
  `mkdir -p ${cfg.target.remoteDir}/${name}`,
119
138
  );
120
139
  }
140
+ await assertExec(
141
+ cfg.target,
142
+ `mkdir -p ${cfg.target.remoteDir}/registry-auth`,
143
+ );
121
144
 
122
145
  await scpUpload(
123
146
  cfg.target,
@@ -129,9 +152,137 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
129
152
  join(workDir, "docker-compose.yml"),
130
153
  `${cfg.target.remoteDir}/docker-compose.yml`,
131
154
  );
155
+ await scpUpload(
156
+ cfg.target,
157
+ join(workDir, "htpasswd"),
158
+ `${cfg.target.remoteDir}/registry-auth/htpasswd`,
159
+ );
160
+
161
+ // Ensure /opt/arc/.env exists so docker compose doesn't error on var
162
+ // substitution. Per-env ARC_IMAGE_<ENV> lines are written by deploy.
163
+ await assertExec(
164
+ cfg.target,
165
+ `touch ${cfg.target.remoteDir}/.env`,
166
+ );
132
167
 
168
+ // Pre-register the deploy user with the private registry so containers can
169
+ // pull. We do this AFTER `docker compose up -d caddy registry` runs (below)
170
+ // — the registry needs to be reachable for `docker login` to succeed.
133
171
  await assertExec(
134
172
  cfg.target,
135
- `cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures && docker compose up -d`,
173
+ `cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures caddy registry && docker compose up -d caddy registry`,
136
174
  );
175
+
176
+ // Wait for the registry vhost to respond (Caddy ACME issuance takes a few
177
+ // seconds on first start), then docker login on the host. This caches
178
+ // credentials in /home/<user>/.docker/config.json so subsequent `docker
179
+ // compose pull arc-<env>` from the per-deploy step can fetch app images.
180
+ await sshDockerLogin(cfg);
181
+
182
+ // Bring up any arc-<env> services whose images are already published.
183
+ // The :? fallback in compose makes services with no ARC_IMAGE_<ENV> set
184
+ // fail their up step — we filter those out by reading .env first.
185
+ const knownEnvs = await listConfiguredEnvs(cfg);
186
+ if (knownEnvs.length > 0) {
187
+ await assertExec(
188
+ cfg.target,
189
+ `cd ${cfg.target.remoteDir} && docker compose up -d ${knownEnvs
190
+ .map((e) => `arc-${e}`)
191
+ .join(" ")}`,
192
+ );
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Log in to the private registry from the deploy user's account on the host.
198
+ * Required so `docker compose pull arc-<env>` works on subsequent deploys
199
+ * (compose runs docker from the deploy user's perspective; that user's
200
+ * `.docker/config.json` is where the registry token lives).
201
+ */
202
+ async function sshDockerLogin(cfg: DeployConfig): Promise<void> {
203
+ const password = process.env[cfg.registry.passwordEnv];
204
+ if (!password) {
205
+ throw new Error(
206
+ `Registry password env var ${cfg.registry.passwordEnv} is not set on the deploy host (CLI machine).`,
207
+ );
208
+ }
209
+ // Pipe via stdin — keeps password off the command line and shell history.
210
+ const cmd = `echo "$ARC_REGISTRY_PASSWORD_FORWARDED" | docker login ${cfg.registry.domain} -u ${cfg.registry.username} --password-stdin`;
211
+ const proc = spawn({
212
+ cmd: [
213
+ "ssh",
214
+ ...baseSshArgs(cfg.target),
215
+ `${cfg.target.user}@${cfg.target.host}`,
216
+ "--",
217
+ `ARC_REGISTRY_PASSWORD_FORWARDED='${password.replace(/'/g, "'\\''")}' bash -c ${JSON.stringify(cmd)}`,
218
+ ],
219
+ stdout: "pipe",
220
+ stderr: "pipe",
221
+ });
222
+ const exit = await proc.exited;
223
+ if (exit !== 0) {
224
+ const stderr = await new Response(proc.stderr).text();
225
+ throw new Error(
226
+ `Server-side docker login failed (exit ${exit}): ${stderr.trim()}`,
227
+ );
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Read /opt/arc/.env on the host and return env names that have an
233
+ * `ARC_IMAGE_<ENV>=...` line set. Used by bootstrap to decide which arc-<env>
234
+ * services can safely be started (the others would fail compose var
235
+ * substitution with `:?`).
236
+ */
237
+ async function listConfiguredEnvs(cfg: DeployConfig): Promise<string[]> {
238
+ const res = await sshExec(
239
+ cfg.target,
240
+ `cat ${cfg.target.remoteDir}/.env 2>/dev/null || true`,
241
+ { quiet: true },
242
+ );
243
+ const set = new Set<string>();
244
+ for (const line of res.stdout.split("\n")) {
245
+ const m = line.match(/^ARC_IMAGE_([A-Z0-9_]+)=/);
246
+ if (!m) continue;
247
+ const lowerName = m[1].toLowerCase().replace(/_/g, "-");
248
+ if (lowerName in cfg.envs) set.add(lowerName);
249
+ }
250
+ return [...set];
251
+ }
252
+
253
+ /**
254
+ * Verify that `<registry.domain>` resolves to the target host's IP. If DNS
255
+ * isn't propagated yet, Caddy's ACME challenge for the registry vhost will
256
+ * fail repeatedly. Fail fast with an actionable hint instead.
257
+ */
258
+ async function assertRegistryDnsResolves(cfg: DeployConfig): Promise<void> {
259
+ const proc = spawn({
260
+ cmd: ["dig", "+short", "+time=3", "+tries=1", cfg.registry.domain],
261
+ stdout: "pipe",
262
+ stderr: "ignore",
263
+ });
264
+ const exit = await proc.exited;
265
+ if (exit !== 0) {
266
+ err(
267
+ `\`dig\` is not available — skipping DNS pre-flight for ${cfg.registry.domain}.`,
268
+ );
269
+ return;
270
+ }
271
+ const resolved = (await new Response(proc.stdout).text())
272
+ .split("\n")
273
+ .map((s) => s.trim())
274
+ .filter(Boolean);
275
+
276
+ if (resolved.length === 0) {
277
+ throw new Error(
278
+ `Registry DNS not configured: ${cfg.registry.domain} doesn't resolve. ` +
279
+ `Add an A record pointing to ${cfg.target.host} and re-run deploy.`,
280
+ );
281
+ }
282
+ if (!resolved.includes(cfg.target.host)) {
283
+ throw new Error(
284
+ `Registry DNS mismatch: ${cfg.registry.domain} resolves to [${resolved.join(", ")}], ` +
285
+ `but target host is ${cfg.target.host}. Update the A record before continuing.`,
286
+ );
287
+ }
137
288
  }
@@ -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,56 @@ 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. The `:?` fallback fails compose with a clear
65
+ // error if the env var isn't set — that means "deploy hasn't run yet".
66
+ lines.push(
67
+ ` image: \${ARC_IMAGE_${upperName}:?Run \\\`arc platform deploy ${name}\\\` to publish an image first}`,
68
+ );
69
+ lines.push(` container_name: arc-${name}`);
51
70
  lines.push(" restart: unless-stopped");
52
71
  lines.push(" volumes:");
53
- lines.push(` - arc-platform-${name}:/app/.arc/platform`);
72
+ // Only the data volume — user code lives entirely inside the image.
73
+ // SQLite + uploads persist across redeploys.
54
74
  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
75
  lines.push(" environment:");
58
76
  lines.push(" PORT: 5005");
59
- lines.push(' ARC_DEPLOY_API: "1"');
60
- lines.push(` ARC_CLI_VERSION: ${JSON.stringify(cliVersion)}`);
61
77
  const userEnv = env.envVars ?? {};
62
78
  if (!("NODE_ENV" in userEnv)) {
63
79
  lines.push(" NODE_ENV: production");
64
80
  }
81
+ // PORT is reserved — user envVars can't override.
82
+ const reserved = new Set(["PORT"]);
65
83
  for (const [k, v] of Object.entries(userEnv)) {
66
- if (RESERVED_ENV.has(k)) continue;
84
+ if (reserved.has(k)) continue;
67
85
  lines.push(` ${k}: ${JSON.stringify(v)}`);
68
86
  }
87
+ // ENTRYPOINT + CMD come from the image — no `command:` override needed.
69
88
  lines.push(" networks:");
70
89
  lines.push(" - arc-net");
71
90
  lines.push(" expose:");
@@ -73,17 +92,14 @@ export function generateCompose({ cfg, cliVersion }: ComposeOptions): string {
73
92
  lines.push("");
74
93
  }
75
94
 
76
- // Networks + volumes
77
95
  lines.push("networks:");
78
96
  lines.push(" arc-net:");
79
97
  lines.push("");
80
98
  lines.push("volumes:");
81
99
  lines.push(" caddy_data:");
82
100
  lines.push(" caddy_config:");
83
- lines.push(" arc-cli-cache:");
84
- lines.push(" arc-bun-cache:");
101
+ lines.push(" registry_data:");
85
102
  for (const [name] of Object.entries(cfg.envs)) {
86
- lines.push(` arc-platform-${name}:`);
87
103
  lines.push(` arc-data-${name}:`);
88
104
  }
89
105
 
@@ -54,10 +54,20 @@ export interface DeployProvision {
54
54
  ansible?: DeployProvisionAnsible;
55
55
  }
56
56
 
57
+ export interface DeployRegistry {
58
+ /** Full domain — Caddy reverse-proxies this to the registry:5000 container. */
59
+ domain: string;
60
+ /** htpasswd basic-auth username. Default: `deploy`. */
61
+ username: string;
62
+ /** Name of env var holding the htpasswd password. Never inline the secret. */
63
+ passwordEnv: string;
64
+ }
65
+
57
66
  export interface DeployConfig {
58
67
  target: DeployTarget;
59
68
  envs: Record<string, DeployEnv>;
60
69
  caddy: DeployCaddy;
70
+ registry: DeployRegistry;
61
71
  provision?: DeployProvision;
62
72
  }
63
73
 
@@ -139,6 +149,20 @@ export function validateDeployConfig(input: unknown): DeployConfig {
139
149
  const target = requireObject(input, "target");
140
150
  const envs = requireObject(input, "envs");
141
151
  const caddy = requireObject(input, "caddy");
152
+ const registry = requireObject(input, "registry");
153
+
154
+ const registryDomain = requireString(registry, "registry.domain");
155
+ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(registryDomain)) {
156
+ throw new Error(
157
+ `deploy.arc.json: registry.domain "${registryDomain}" doesn't look like a domain`,
158
+ );
159
+ }
160
+ const passwordEnv = requireString(registry, "registry.passwordEnv");
161
+ if (!/^[A-Z][A-Z0-9_]*$/.test(passwordEnv)) {
162
+ throw new Error(
163
+ `deploy.arc.json: registry.passwordEnv "${passwordEnv}" must be an UPPER_SNAKE_CASE env var name`,
164
+ );
165
+ }
142
166
 
143
167
  const validated: DeployConfig = {
144
168
  target: {
@@ -152,6 +176,11 @@ export function validateDeployConfig(input: unknown): DeployConfig {
152
176
  caddy: {
153
177
  email: requireString(caddy, "caddy.email"),
154
178
  },
179
+ registry: {
180
+ domain: registryDomain,
181
+ username: optionalString(registry, "registry.username") ?? "deploy",
182
+ passwordEnv,
183
+ },
155
184
  };
156
185
 
157
186
  const envKeys = Object.keys(envs);