@arcote.tech/arc-cli 0.5.7 → 0.6.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,4 +1,4 @@
1
- import { spawn } from "bun";
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
2
  import { mkdirSync, writeFileSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
@@ -38,31 +38,34 @@ export async function runAnsible(inputs: AnsibleInputs): Promise<void> {
38
38
  ].join("\n");
39
39
  writeFileSync(join(workDir, "inventory.ini"), inventory);
40
40
 
41
- const extraVars = [
42
- `username=${inputs.target.user}`,
43
- `ssh_port=${port}`,
44
- ];
45
- if (inputs.ansible?.extraAllowedIps?.length) {
46
- extraVars.push(
47
- `extra_allowed_ips=${JSON.stringify(inputs.ansible.extraAllowedIps)}`,
48
- );
49
- }
41
+ // JSON dict so ansible parses types correctly (key=value form makes empty
42
+ // arrays look like the string "[]" and trips recursive templating).
43
+ const extraVarsJson = JSON.stringify({
44
+ username: inputs.target.user,
45
+ ssh_port: port,
46
+ extra_allowed_ips: inputs.ansible?.extraAllowedIps ?? [],
47
+ });
50
48
 
51
- const proc = spawn({
52
- cmd: [
49
+ // Ansible aborts if its stdout/stderr fds have O_NONBLOCK set — which
50
+ // happens when our own process is invoked with non-blocking stdio (e.g.
51
+ // backgrounded by Claude harness, piped through tail). Solution: pipe
52
+ // stdio (always blocking, Node-managed) and forward chunks manually so the
53
+ // user still sees ansible output in real time.
54
+ const exit = await new Promise<number>((resolve, reject) => {
55
+ const proc = nodeSpawn(
53
56
  "ansible-playbook",
54
- "-i",
55
- "inventory.ini",
56
- "site.yml",
57
- "-e",
58
- extraVars.join(" "),
59
- ],
60
- cwd: workDir,
61
- stdout: "inherit",
62
- stderr: "inherit",
63
- env: { ...process.env, ANSIBLE_HOST_KEY_CHECKING: "False" },
57
+ ["-i", "inventory.ini", "site.yml", "-e", extraVarsJson],
58
+ {
59
+ cwd: workDir,
60
+ stdio: ["ignore", "pipe", "pipe"],
61
+ env: { ...process.env, ANSIBLE_HOST_KEY_CHECKING: "False" },
62
+ },
63
+ );
64
+ proc.stdout?.on("data", (chunk: Buffer) => process.stdout.write(chunk));
65
+ proc.stderr?.on("data", (chunk: Buffer) => process.stderr.write(chunk));
66
+ proc.on("error", reject);
67
+ proc.on("exit", (code) => resolve(code ?? 1));
64
68
  });
65
- const exit = await proc.exited;
66
69
  if (exit !== 0) {
67
70
  throw new Error(`ansible-playbook failed (exit ${exit})`);
68
71
  }
@@ -10,7 +10,7 @@ import { saveDeployConfig } from "./config";
10
10
  import { ok, log, err } from "../platform/shared";
11
11
  import { writeStateMarker, STATE_MARKER_PATH } from "./remote-state";
12
12
  import type { RemoteState } from "./remote-state";
13
- import { assertExec, scpUpload, waitForSsh } from "./ssh";
13
+ import { assertExec, canSsh, scpUpload, waitForSsh } from "./ssh";
14
14
 
15
15
  // ---------------------------------------------------------------------------
16
16
  // Bootstrap orchestrator.
@@ -55,6 +55,7 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
55
55
  tf: cfg.provision.terraform,
56
56
  token,
57
57
  serverName: `arc-${Object.keys(cfg.envs)[0] ?? "host"}`,
58
+ workspaceDir: rootDir,
58
59
  });
59
60
  ok(`Server provisioned: ${tfOut.serverIp}`);
60
61
 
@@ -69,9 +70,11 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
69
70
 
70
71
  if (state.kind === "unreachable" || state.kind === "no-docker") {
71
72
  log("Running Ansible bootstrap (Docker + firewall + SSH hardening)...");
72
- // On a freshly provisioned Hetzner VM, only root exists before the playbook
73
- // creates the deploy user; re-use `asRoot: true` for that first shot.
74
- const asRoot = state.kind === "unreachable";
73
+ // Run as root whenever the configured user can't SSH (covers both freshly
74
+ // provisioned VMs and second-attempt deploys after ansible failure).
75
+ const deployUserWorks =
76
+ state.kind === "no-docker" && (await canSsh(cfg.target));
77
+ const asRoot = !deployUserWorks;
75
78
  await runAnsible({
76
79
  target: cfg.target,
77
80
  ansible: cfg.provision?.ansible,
@@ -99,7 +102,10 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
99
102
  mkdirSync(workDir, { recursive: true });
100
103
 
101
104
  writeFileSync(join(workDir, "Caddyfile"), generateCaddyfile(cfg));
102
- writeFileSync(join(workDir, "docker-compose.yml"), generateCompose({ cfg }));
105
+ writeFileSync(
106
+ join(workDir, "docker-compose.yml"),
107
+ generateCompose({ cfg, cliVersion: inputs.cliVersion }),
108
+ );
103
109
 
104
110
  // Ensure remoteDir exists
105
111
  await assertExec(
@@ -1,26 +1,34 @@
1
1
  import type { DeployConfig } from "./config";
2
2
 
3
3
  // ---------------------------------------------------------------------------
4
- // docker-compose.yml generator
4
+ // docker-compose.yml generator — v0.6
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 (bind-mounts project dir)
8
+ // - arc-<env> per entry in deploy.arc.json envs
9
9
  //
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.
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).
13
15
  // ---------------------------------------------------------------------------
14
16
 
15
17
  export interface ComposeOptions {
16
18
  cfg: DeployConfig;
19
+ /** CLI version used by entrypoint.sh to `bun add @arcote.tech/arc-cli@VER`. */
20
+ cliVersion: string;
17
21
  }
18
22
 
19
- export function generateCompose({ cfg }: ComposeOptions): string {
23
+ const RESERVED_ENV = new Set(["PORT", "ARC_DEPLOY_API", "ARC_CLI_VERSION"]);
24
+
25
+ export function generateCompose({ cfg, cliVersion }: ComposeOptions): string {
20
26
  const lines: string[] = [];
21
27
  lines.push("# Generated by `arc platform deploy` — do not edit by hand.");
22
28
  lines.push("");
23
29
  lines.push("services:");
30
+
31
+ // Caddy
24
32
  lines.push(" caddy:");
25
33
  lines.push(" image: caddy:2-alpine");
26
34
  lines.push(" restart: unless-stopped");
@@ -36,22 +44,28 @@ export function generateCompose({ cfg }: ComposeOptions): string {
36
44
  lines.push(" - arc-net");
37
45
  lines.push("");
38
46
 
47
+ // Per-env runtime containers
39
48
  for (const [name, env] of Object.entries(cfg.envs)) {
40
49
  lines.push(` arc-${name}:`);
41
- lines.push(" image: oven/bun:1-alpine");
50
+ lines.push(" image: pkrasinski/arc-runtime:1");
42
51
  lines.push(" restart: unless-stopped");
43
- lines.push(` working_dir: /app`);
44
52
  lines.push(" volumes:");
45
- lines.push(` - ${cfg.target.remoteDir}/${name}:/app`);
53
+ lines.push(` - arc-platform-${name}:/app/.arc/platform`);
46
54
  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");
47
57
  lines.push(" environment:");
48
58
  lines.push(" PORT: 5005");
49
- lines.push(" ARC_DEPLOY_API: \"1\"");
50
- lines.push(" NODE_ENV: production");
51
- for (const [k, v] of Object.entries(env.envVars ?? {})) {
59
+ lines.push(' ARC_DEPLOY_API: "1"');
60
+ lines.push(` ARC_CLI_VERSION: ${JSON.stringify(cliVersion)}`);
61
+ const userEnv = env.envVars ?? {};
62
+ if (!("NODE_ENV" in userEnv)) {
63
+ lines.push(" NODE_ENV: production");
64
+ }
65
+ for (const [k, v] of Object.entries(userEnv)) {
66
+ if (RESERVED_ENV.has(k)) continue;
52
67
  lines.push(` ${k}: ${JSON.stringify(v)}`);
53
68
  }
54
- lines.push(" command: [\"node_modules/.bin/arc\", \"platform\", \"start\"]");
55
69
  lines.push(" networks:");
56
70
  lines.push(" - arc-net");
57
71
  lines.push(" expose:");
@@ -59,13 +73,17 @@ export function generateCompose({ cfg }: ComposeOptions): string {
59
73
  lines.push("");
60
74
  }
61
75
 
76
+ // Networks + volumes
62
77
  lines.push("networks:");
63
78
  lines.push(" arc-net:");
64
79
  lines.push("");
65
80
  lines.push("volumes:");
66
81
  lines.push(" caddy_data:");
67
82
  lines.push(" caddy_config:");
83
+ lines.push(" arc-cli-cache:");
84
+ lines.push(" arc-bun-cache:");
68
85
  for (const [name] of Object.entries(cfg.envs)) {
86
+ lines.push(` arc-platform-${name}:`);
69
87
  lines.push(` arc-data-${name}:`);
70
88
  }
71
89
 
@@ -96,10 +96,15 @@ export function loadDeployConfig(rootDir: string): DeployConfig {
96
96
  }
97
97
 
98
98
  export function saveDeployConfig(rootDir: string, cfg: DeployConfig): void {
99
- writeFileSync(
100
- deployConfigPath(rootDir),
101
- JSON.stringify(cfg, null, 2) + "\n",
102
- );
99
+ const path = deployConfigPath(rootDir);
100
+ // Re-read raw JSON so we preserve `${VAR}` placeholders and don't accidentally
101
+ // serialize expanded secrets back to disk. We only patch target (the only
102
+ // field bootstrap ever mutates — terraform-assigned host IP).
103
+ const raw = existsSync(path)
104
+ ? (JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>)
105
+ : {};
106
+ raw.target = { ...(raw.target as object | undefined), ...cfg.target };
107
+ writeFileSync(path, JSON.stringify(raw, null, 2) + "\n");
103
108
  }
104
109
 
105
110
  // ---------------------------------------------------------------------------
@@ -37,6 +37,13 @@ export async function detectRemoteState(
37
37
  }
38
38
 
39
39
  if (!(await canSsh(cfg.target))) {
40
+ // On a freshly provisioned VM, only root exists — ansible creates `deploy`
41
+ // later. If root SSH works but the configured user doesn't, treat as
42
+ // no-docker so bootstrap re-runs ansible (idempotent) instead of spinning
43
+ // up a duplicate server via terraform.
44
+ if (await canSsh({ ...cfg.target, user: "root" })) {
45
+ return { kind: "no-docker" };
46
+ }
40
47
  return { kind: "unreachable", reason: "ssh connection failed" };
41
48
  }
42
49
 
@@ -1,27 +1,27 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { join, relative } from "path";
1
+ import { existsSync, readFileSync, readdirSync } from "fs";
2
+ import { basename, join } from "path";
3
3
  import type { BuildManifest, ModuleDescriptor } from "@arcote.tech/platform";
4
4
  import type { DeployConfig } from "./config";
5
- import { openTunnel, rsyncDir, scpUpload } from "./ssh";
5
+ import { assertExec, openTunnel } from "./ssh";
6
+ import { isContextPackage } from "../builder/module-builder";
6
7
  import type { WorkspaceInfo } from "../platform/shared";
7
8
 
8
9
  // ---------------------------------------------------------------------------
9
- // Hot-swap logic.
10
+ // v0.6 sync driver — API-only, per-module. No rsync of user code.
10
11
  //
11
12
  // Flow per env:
12
- // 1. rsync the whole project directory to /opt/arc/<env>/ so the running
13
- // container's /app volume sees new code. (This is the authoritative
14
- // source node_modules, package.json, .arc/platform/ all live here.)
15
- // 2. Open an SSH tunnel to the Caddy loopback listener (127.0.0.1:2019).
16
- // 3. GET /env/<env>/api/deploy/manifest remote BuildManifest.
17
- // 4. diffManifests(local, remote)list of changed module files + shell/styles flags.
18
- // 5. POST each changed module (+ shell + styles) as multipart uploads.
19
- // 6. POST the new manifest to /api/deploy/manifest — server writes
20
- // manifest.json and SSE-notifies clients to reimport.
21
- //
22
- // Because rsync in step 1 already moved the bytes, the POSTs are redundant
23
- // for disk writes — but they force the running server to pick up new hashes
24
- // and push an SSE event so connected browser clients reload without F5.
13
+ // 1. Open SSH tunnel to Caddy 127.0.0.1:2019 (admin listener)
14
+ // 2. GET /api/deploy/framework remote framework depsHash
15
+ // 3. If local hash differs → POST /api/deploy/framework (multipart
16
+ // package.json + bun.lock). Response needsRestart=true close tunnel,
17
+ // `docker restart arc-${env}`, reopen tunnel, wait for /health.
18
+ // 4. GET /api/deploy/manifestremote manifest
19
+ // 5. diffManifests per-module list of changes
20
+ // 6. For each changed module: POST /api/deploy/modules/<name>
21
+ // (browser.js + server.js? + package.json + access.json?)
22
+ // 7. If styles changed: POST /api/deploy/styles
23
+ // 8. POST /api/deploy/manifest (commit). Response needsRestart=true when
24
+ // any module had server.js change second restart.
25
25
  // ---------------------------------------------------------------------------
26
26
 
27
27
  export interface SyncInputs {
@@ -34,9 +34,10 @@ export interface SyncInputs {
34
34
 
35
35
  export interface SyncOutcome {
36
36
  env: string;
37
+ frameworkChanged: boolean;
37
38
  changedModules: readonly string[];
38
- shellChanged: boolean;
39
39
  stylesChanged: boolean;
40
+ restarts: number;
40
41
  }
41
42
 
42
43
  // ---------------------------------------------------------------------------
@@ -45,7 +46,6 @@ export interface SyncOutcome {
45
46
 
46
47
  export interface ManifestDiff {
47
48
  changedModules: ModuleDescriptor[];
48
- shellChanged: boolean;
49
49
  stylesChanged: boolean;
50
50
  }
51
51
 
@@ -59,7 +59,6 @@ export function diffManifests(
59
59
  );
60
60
  return {
61
61
  changedModules: [...changedModules],
62
- shellChanged: local.shellHash !== remote.shellHash,
63
62
  stylesChanged: local.stylesHash !== remote.stylesHash,
64
63
  };
65
64
  }
@@ -69,15 +68,11 @@ export function diffManifests(
69
68
  // ---------------------------------------------------------------------------
70
69
 
71
70
  export async function syncEnv(inputs: SyncInputs): Promise<SyncOutcome> {
72
- const { cfg, env, ws, projectDir } = inputs;
71
+ const { cfg, env, ws } = inputs;
73
72
  const envConfig = cfg.envs[env];
74
73
  if (!envConfig) throw new Error(`Unknown env: ${env}`);
75
74
 
76
- // 1. Rsync the project to the host. Excludes dev-only dirs.
77
- const remotePath = `${cfg.target.remoteDir}/${env}`;
78
- await rsyncDir(cfg.target, projectDir, remotePath);
79
-
80
- // 2. Read local manifest
75
+ // Local artifacts must exist (arc platform build was run)
81
76
  const localManifestPath = join(ws.modulesDir, "manifest.json");
82
77
  if (!existsSync(localManifestPath)) {
83
78
  throw new Error(
@@ -88,42 +83,139 @@ export async function syncEnv(inputs: SyncInputs): Promise<SyncOutcome> {
88
83
  readFileSync(localManifestPath, "utf-8"),
89
84
  ) as BuildManifest;
90
85
 
91
- // 3. Open SSH tunnel to Caddy's loopback admin listener
92
- const localPort = 15500 + hashEnvToOffset(env);
93
- const tunnel = await openTunnel(cfg.target, localPort, "127.0.0.1", 2019);
86
+ // Map module name workspace package (needed to decide if server.js exists)
87
+ const pkgByName = new Map(
88
+ ws.packages.map((p) => [p.name.includes("/") ? p.name.split("/").pop()! : p.name, p]),
89
+ );
90
+
91
+ let tunnel = await openTunnel(
92
+ cfg.target,
93
+ 15500 + hashEnvToOffset(env),
94
+ "127.0.0.1",
95
+ 2019,
96
+ );
97
+ let restarts = 0;
98
+ let frameworkChanged = false;
94
99
 
95
100
  try {
96
- const base = `http://127.0.0.1:${localPort}/env/${env}`;
101
+ const base = () =>
102
+ `http://127.0.0.1:${tunnel.localPort}/env/${env}`;
97
103
 
98
- // 4. Fetch remote manifest
99
- const remoteManifestRes = await fetch(`${base}/api/deploy/manifest`);
100
- if (!remoteManifestRes.ok) {
101
- throw new Error(
102
- `Failed to fetch remote manifest: ${remoteManifestRes.status}`,
104
+ // 1. Framework deps — diff and push if changed
105
+ const localFrameworkHash = readDepsHash(join(ws.arcDir, ".deps-hash"));
106
+ const remoteFwRes = await fetch(`${base()}/api/deploy/framework`);
107
+ const remoteFw = remoteFwRes.ok
108
+ ? ((await remoteFwRes.json()) as { depsHash: string | null })
109
+ : { depsHash: null };
110
+
111
+ if (localFrameworkHash && localFrameworkHash !== remoteFw.depsHash) {
112
+ console.log("[arc] Pushing framework deps...");
113
+ const form = new FormData();
114
+ form.append(
115
+ "package.json",
116
+ new Blob([readFileSync(join(ws.arcDir, "package.json"))]),
117
+ "package.json",
103
118
  );
119
+ const lockPath = join(ws.arcDir, "bun.lock");
120
+ if (existsSync(lockPath)) {
121
+ form.append(
122
+ "bun.lock",
123
+ new Blob([readFileSync(lockPath)]),
124
+ "bun.lock",
125
+ );
126
+ }
127
+ const res = await fetch(`${base()}/api/deploy/framework`, {
128
+ method: "POST",
129
+ body: form,
130
+ });
131
+ if (!res.ok) {
132
+ throw new Error(
133
+ `framework push failed: ${res.status} ${await res.text()}`,
134
+ );
135
+ }
136
+ frameworkChanged = true;
137
+ const result = (await res.json()) as { needsRestart?: boolean };
138
+ if (result.needsRestart) {
139
+ tunnel = await restartAndReopen(cfg, env, tunnel);
140
+ restarts += 1;
141
+ }
104
142
  }
105
- const remoteManifest = (await remoteManifestRes.json()) as BuildManifest;
106
143
 
144
+ // 2. Remote manifest + diff
145
+ const remoteManifestRes = await fetch(`${base()}/api/deploy/manifest`);
146
+ const remoteManifest: BuildManifest = remoteManifestRes.ok
147
+ ? await remoteManifestRes.json()
148
+ : ({
149
+ modules: [],
150
+ shellHash: "",
151
+ stylesHash: "",
152
+ buildTime: "",
153
+ } satisfies BuildManifest);
107
154
  const diff = diffManifests(localManifest, remoteManifest);
108
155
 
109
- // 5. Upload shell (if changed) — contains every file under .arc/platform/shell
110
- if (diff.shellChanged) {
111
- const shellFiles = collectFiles(ws.shellDir);
156
+ // 3. Per-module push
157
+ for (const mod of diff.changedModules) {
158
+ const safeName = sanitizeName(mod.name);
159
+ const moduleDir = join(ws.modulesDir, safeName);
160
+ const browserPath = join(moduleDir, "browser.js");
161
+ const serverPath = join(moduleDir, "server.js");
162
+ const pkgPath = join(moduleDir, "package.json");
163
+ const accessPath = join(moduleDir, "access.json");
164
+
165
+ // Fall back to legacy <name>.js path while module-builder still emits
166
+ // the flat layout. Same hash either way.
167
+ const browserActual = existsSync(browserPath)
168
+ ? browserPath
169
+ : join(ws.modulesDir, `${safeName}.js`);
170
+ if (!existsSync(browserActual)) {
171
+ throw new Error(`Missing browser bundle for module ${mod.name}`);
172
+ }
173
+
112
174
  const form = new FormData();
113
- for (const absPath of shellFiles) {
114
- const rel = relative(ws.shellDir, absPath);
115
- form.append(rel, new Blob([readFileSync(absPath)]), rel);
175
+ form.append(
176
+ "browser.js",
177
+ new Blob([readFileSync(browserActual)]),
178
+ "browser.js",
179
+ );
180
+
181
+ const pkg = pkgByName.get(safeName);
182
+ if (pkg && isContextPackage(pkg.packageJson) && existsSync(serverPath)) {
183
+ form.append(
184
+ "server.js",
185
+ new Blob([readFileSync(serverPath)]),
186
+ "server.js",
187
+ );
116
188
  }
117
- const res = await fetch(`${base}/api/deploy/shell`, {
189
+ if (existsSync(pkgPath)) {
190
+ form.append(
191
+ "package.json",
192
+ new Blob([readFileSync(pkgPath)]),
193
+ "package.json",
194
+ );
195
+ }
196
+ if (existsSync(accessPath)) {
197
+ form.append(
198
+ "access.json",
199
+ new Blob([readFileSync(accessPath)]),
200
+ "access.json",
201
+ );
202
+ }
203
+
204
+ console.log(`[arc] Pushing module ${safeName}...`);
205
+ const res = await fetch(`${base()}/api/deploy/modules/${safeName}`, {
118
206
  method: "POST",
119
207
  body: form,
120
208
  });
121
- if (!res.ok)
122
- throw new Error(`Shell upload failed: ${res.status} ${await res.text()}`);
209
+ if (!res.ok) {
210
+ throw new Error(
211
+ `module ${safeName} push failed: ${res.status} ${await res.text()}`,
212
+ );
213
+ }
123
214
  }
124
215
 
125
- // 5b. Upload changed styles files alongside shell request if styles changed
216
+ // 4. Styles push
126
217
  if (diff.stylesChanged) {
218
+ console.log("[arc] Pushing styles...");
127
219
  const form = new FormData();
128
220
  for (const name of ["styles.css", "theme.css"] as const) {
129
221
  const p = join(ws.arcDir, name);
@@ -131,47 +223,40 @@ export async function syncEnv(inputs: SyncInputs): Promise<SyncOutcome> {
131
223
  form.append(name, new Blob([readFileSync(p)]), name);
132
224
  }
133
225
  }
134
- const res = await fetch(`${base}/api/deploy/shell`, {
226
+ const res = await fetch(`${base()}/api/deploy/styles`, {
135
227
  method: "POST",
136
228
  body: form,
137
229
  });
138
- if (!res.ok)
139
- throw new Error(`Styles upload failed: ${res.status} ${await res.text()}`);
140
- }
141
-
142
- // 5c. Upload changed modules
143
- if (diff.changedModules.length > 0) {
144
- const form = new FormData();
145
- for (const mod of diff.changedModules) {
146
- const p = join(ws.modulesDir, mod.file);
147
- form.append(mod.file, new Blob([readFileSync(p)]), mod.file);
148
- }
149
- const res = await fetch(`${base}/api/deploy/modules`, {
150
- method: "POST",
151
- body: form,
152
- });
153
- if (!res.ok)
230
+ if (!res.ok) {
154
231
  throw new Error(
155
- `Modules upload failed: ${res.status} ${await res.text()}`,
232
+ `styles push failed: ${res.status} ${await res.text()}`,
156
233
  );
234
+ }
157
235
  }
158
236
 
159
- // 6. Post the new manifest so the server flips getManifest() + SSE
160
- const res = await fetch(`${base}/api/deploy/manifest`, {
237
+ // 5. Manifest commit
238
+ const commitRes = await fetch(`${base()}/api/deploy/manifest`, {
161
239
  method: "POST",
162
240
  headers: { "Content-Type": "application/json" },
163
241
  body: JSON.stringify(localManifest),
164
242
  });
165
- if (!res.ok)
243
+ if (!commitRes.ok) {
166
244
  throw new Error(
167
- `Manifest update failed: ${res.status} ${await res.text()}`,
245
+ `manifest commit failed: ${commitRes.status} ${await commitRes.text()}`,
168
246
  );
247
+ }
248
+ const commit = (await commitRes.json()) as { needsRestart?: boolean };
249
+ if (commit.needsRestart) {
250
+ tunnel = await restartAndReopen(cfg, env, tunnel);
251
+ restarts += 1;
252
+ }
169
253
 
170
254
  return {
171
255
  env,
256
+ frameworkChanged,
172
257
  changedModules: diff.changedModules.map((m) => m.name),
173
- shellChanged: diff.shellChanged,
174
258
  stylesChanged: diff.stylesChanged,
259
+ restarts,
175
260
  };
176
261
  } finally {
177
262
  tunnel.close();
@@ -182,16 +267,52 @@ export async function syncEnv(inputs: SyncInputs): Promise<SyncOutcome> {
182
267
  // Helpers
183
268
  // ---------------------------------------------------------------------------
184
269
 
185
- function collectFiles(dir: string): string[] {
186
- if (!existsSync(dir)) return [];
187
- const { readdirSync } = require("fs") as typeof import("fs");
188
- const out: string[] = [];
189
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
190
- const p = join(dir, entry.name);
191
- if (entry.isDirectory()) out.push(...collectFiles(p));
192
- else if (entry.isFile()) out.push(p);
270
+ function readDepsHash(path: string): string | null {
271
+ if (!existsSync(path)) return null;
272
+ return readFileSync(path, "utf-8").trim() || null;
273
+ }
274
+
275
+ function sanitizeName(name: string): string {
276
+ // Strip package scope ("@ndt/auth" → "auth"); manifest already stores names
277
+ // this way, but be defensive.
278
+ return basename(name);
279
+ }
280
+
281
+ async function restartAndReopen(
282
+ cfg: DeployConfig,
283
+ env: string,
284
+ oldTunnel: { close: () => void; localPort: number },
285
+ ): Promise<{ close: () => void; localPort: number }> {
286
+ console.log(`[arc] Restarting arc-${env}...`);
287
+ oldTunnel.close();
288
+ await assertExec(cfg.target, `docker restart arc-${env}`);
289
+
290
+ const tunnel = await openTunnel(
291
+ cfg.target,
292
+ 15500 + hashEnvToOffset(env),
293
+ "127.0.0.1",
294
+ 2019,
295
+ );
296
+ await waitForHealthy(`http://127.0.0.1:${tunnel.localPort}/env/${env}`, 60_000);
297
+ return tunnel;
298
+ }
299
+
300
+ async function waitForHealthy(baseUrl: string, timeoutMs: number): Promise<void> {
301
+ const deadline = Date.now() + timeoutMs;
302
+ let lastErr: unknown;
303
+ while (Date.now() < deadline) {
304
+ try {
305
+ const res = await fetch(`${baseUrl}/api/deploy/health`, {
306
+ signal: AbortSignal.timeout(2_000),
307
+ });
308
+ if (res.ok) return;
309
+ lastErr = `status ${res.status}`;
310
+ } catch (e) {
311
+ lastErr = e;
312
+ }
313
+ await new Promise((r) => setTimeout(r, 1_000));
193
314
  }
194
- return out;
315
+ throw new Error(`Health check timeout: ${String(lastErr)}`);
195
316
  }
196
317
 
197
318
  /** Deterministic per-env tunnel port offset so parallel syncs don't collide. */