@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,14 +1,17 @@
1
- import { spawn } from "bun";
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { existsSync, mkdirSync, writeFileSync } from "fs";
3
- import { tmpdir } from "os";
4
+ import { homedir } from "os";
4
5
  import { join } from "path";
5
6
  import { ASSETS, materializeAssets } from "./assets";
6
7
  import type { DeployProvisionTerraform } from "./config";
7
8
 
8
9
  // ---------------------------------------------------------------------------
9
- // Runs Terraform from embedded assets. Inputs are passed via a generated
10
- // terraform.tfvars file in the temp working dir nothing touches the user's
11
- // project directory.
10
+ // Runs Terraform from embedded assets. State is persisted per workspace under
11
+ // ~/.arc-deploy/<workspaceHash>/tf/ so retries pick up the existing state file
12
+ // instead of trying to recreate already-provisioned resources from scratch.
13
+ // Inputs are passed via a generated terraform.tfvars file — nothing touches
14
+ // the user's project directory.
12
15
  // ---------------------------------------------------------------------------
13
16
 
14
17
  export interface TerraformInputs {
@@ -17,20 +20,28 @@ export interface TerraformInputs {
17
20
  token: string;
18
21
  /** Deterministic server name shown in Hetzner Console. */
19
22
  serverName: string;
23
+ /** Workspace root — used to scope state dir per project. */
24
+ workspaceDir: string;
20
25
  }
21
26
 
22
27
  export interface TerraformOutputs {
23
28
  serverIp: string;
24
29
  serverName: string;
25
- /** Working dir where state + vars live — caller may keep or delete. */
30
+ /** Working dir where state + vars live — persistent across runs. */
26
31
  workDir: string;
27
32
  }
28
33
 
29
34
  export async function runTerraform(
30
35
  inputs: TerraformInputs,
31
36
  ): Promise<TerraformOutputs> {
32
- const workDir = join(tmpdir(), "arc-deploy", `tf-${Date.now()}`);
37
+ const wsHash = createHash("sha256")
38
+ .update(inputs.workspaceDir)
39
+ .digest("hex")
40
+ .slice(0, 16);
41
+ const workDir = join(homedir(), ".arc-deploy", wsHash, "tf");
33
42
  mkdirSync(workDir, { recursive: true });
43
+ // Idempotent: re-materialize assets each run (they may have changed across
44
+ // CLI versions). State files (.tfstate, .terraform/) are preserved.
34
45
  await materializeAssets(workDir, ASSETS.terraform);
35
46
 
36
47
  // Write tfvars — NEVER put token inline in main.tf
@@ -72,29 +83,38 @@ export async function runTerraform(
72
83
  }
73
84
 
74
85
  async function runTf(workDir: string, args: string[]): Promise<void> {
75
- const proc = spawn({
76
- cmd: ["terraform", ...args],
77
- cwd: workDir,
78
- stdout: "inherit",
79
- stderr: "inherit",
86
+ // node:child_process with piped stdio + manual forwarding — Bun.spawn's
87
+ // "inherit" inherits FDs as-is, which breaks when our own stdio is
88
+ // non-blocking (e.g. backgrounded by an outer process).
89
+ const exit = await new Promise<number>((resolve, reject) => {
90
+ const proc = nodeSpawn("terraform", args, {
91
+ cwd: workDir,
92
+ stdio: ["ignore", "pipe", "pipe"],
93
+ });
94
+ proc.stdout?.on("data", (chunk: Buffer) => process.stdout.write(chunk));
95
+ proc.stderr?.on("data", (chunk: Buffer) => process.stderr.write(chunk));
96
+ proc.on("error", reject);
97
+ proc.on("exit", (code) => resolve(code ?? 1));
80
98
  });
81
- const exit = await proc.exited;
82
99
  if (exit !== 0) {
83
100
  throw new Error(`terraform ${args[0]} failed (exit ${exit})`);
84
101
  }
85
102
  }
86
103
 
87
104
  async function runTfCapture(workDir: string, args: string[]): Promise<string> {
88
- const proc = spawn({
89
- cmd: ["terraform", ...args],
90
- cwd: workDir,
91
- stdout: "pipe",
92
- stderr: "pipe",
105
+ let stdout = "";
106
+ const exit = await new Promise<number>((resolve, reject) => {
107
+ const proc = nodeSpawn("terraform", args, {
108
+ cwd: workDir,
109
+ stdio: ["ignore", "pipe", "pipe"],
110
+ });
111
+ proc.stdout?.on("data", (chunk: Buffer) => {
112
+ stdout += chunk.toString("utf-8");
113
+ });
114
+ proc.stderr?.on("data", (chunk: Buffer) => process.stderr.write(chunk));
115
+ proc.on("error", reject);
116
+ proc.on("exit", (code) => resolve(code ?? 1));
93
117
  });
94
- const [stdout, exit] = await Promise.all([
95
- new Response(proc.stdout).text(),
96
- proc.exited,
97
- ]);
98
118
  if (exit !== 0) {
99
119
  throw new Error(`terraform ${args[0]} failed (exit ${exit})`);
100
120
  }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Command } from "commander";
4
4
  import { build } from "./commands/build";
5
+ import { buildShell } from "./commands/build-shell";
5
6
  import { dev } from "./commands/dev";
6
7
  import { platformBuild } from "./commands/platform-build";
7
8
  import { platformDeploy } from "./commands/platform-deploy";
@@ -62,6 +63,16 @@ platform
62
63
  platformDeploy(env, opts),
63
64
  );
64
65
 
66
+ // Hidden subcommand used by the runtime container's entrypoint / API handlers.
67
+ // Not shown in --help; runs against an installed node_modules tree to emit
68
+ // browser shell bundles for the discovered framework peers.
69
+ program
70
+ .command("_build-shell", { hidden: true })
71
+ .description("Build framework shell bundles from a node_modules dir")
72
+ .requiredOption("--out <dir>", "Output directory for shell .js bundles")
73
+ .requiredOption("--from <dir>", "node_modules directory to discover packages in")
74
+ .action((opts: { out: string; from: string }) => buildShell(opts));
75
+
65
76
  // Parse command line arguments
66
77
  program.parse(process.argv);
67
78
 
@@ -1,20 +1,44 @@
1
1
  import type { ArcHttpHandler } from "@arcote.tech/arc-host";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
- import { dirname, join, normalize, relative, resolve } from "path";
2
+ import { spawn } from "bun";
3
+ import {
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "fs";
11
+ import { dirname, join, normalize, resolve } from "path";
4
12
  import { sha256Hex } from "../builder/module-builder";
5
13
  import type { BuildManifest, WorkspaceInfo } from "./shared";
6
14
 
7
15
  // ---------------------------------------------------------------------------
8
- // Deploy API — hot-swap modules/shell/styles via HTTP
16
+ // Deploy API — v0.6 per-module push model.
9
17
  //
10
18
  // Mounted on the same Bun.serve as the main platform server. Gated by
11
19
  // ARC_DEPLOY_API=1 so dev/localhost starts never expose it. Security boundary
12
- // is entirely network-layer: in production the arc container publishes :5005
13
- // only to docker-network, and the Caddy delegator exposes /api/deploy/* only
14
- // on its 127.0.0.1-bound management listener (:2019). Developer reaches it
15
- // exclusively through `ssh -L`.
20
+ // is network-layer: in production the arc container publishes :5005 only to
21
+ // docker-network; Caddy exposes /api/deploy/* only on its 127.0.0.1:2019
22
+ // management listener. Developer reaches it exclusively through `ssh -L`.
23
+ // No auth tokens — the SSH key is the auth.
16
24
  //
17
- // No auth tokens in code — the SSH key is the auth.
25
+ // Endpoints:
26
+ // GET /api/deploy/health — liveness + module count
27
+ // GET /api/deploy/framework — { depsHash } for diff vs local
28
+ // GET /api/deploy/manifest — full current manifest
29
+ // POST /api/deploy/framework — multipart (package.json + bun.lock)
30
+ // → bun install w platform dir, response
31
+ // needsRestart=true (CLI does docker restart)
32
+ // POST /api/deploy/modules/:name — multipart (browser.js, server.js?,
33
+ // package.json, access.json?)
34
+ // → write to .staging/modules/<name>/,
35
+ // bun install jeśli deps changed
36
+ // POST /api/deploy/styles — multipart (styles.css, theme.css?)
37
+ // → write to .staging/
38
+ // POST /api/deploy/manifest — JSON commit phase: atomic swap staging→live,
39
+ // setManifest, SSE notify; response includes
40
+ // needsRestart when any module's server.js
41
+ // changed.
18
42
  // ---------------------------------------------------------------------------
19
43
 
20
44
  export interface DeployApiOptions {
@@ -24,113 +48,289 @@ export interface DeployApiOptions {
24
48
  notifyReload: (m: BuildManifest) => void;
25
49
  }
26
50
 
27
- /**
28
- * Create deploy API handler. Returns null for non-deploy requests so the main
29
- * handler chain continues. Routes:
30
- *
31
- * GET /api/deploy/manifest — current BuildManifest (hashes etc.)
32
- * POST /api/deploy/manifest — replace manifest; no file writes
33
- * POST /api/deploy/modules — multipart upload of .js module files
34
- * POST /api/deploy/shell — multipart upload of shell/ files + styles.css
35
- * GET /api/deploy/health — liveness
36
- */
37
51
  export function createDeployApiHandler(opts: DeployApiOptions): ArcHttpHandler {
38
52
  return async (req, url, ctx) => {
39
53
  const p = url.pathname;
40
54
  if (!p.startsWith("/api/deploy/")) return null;
55
+ const cors = ctx.corsHeaders;
41
56
 
57
+ // -----------------------------------------------------------------------
58
+ // GET endpoints — state probes
59
+ // -----------------------------------------------------------------------
42
60
  if (p === "/api/deploy/health" && req.method === "GET") {
43
61
  return Response.json(
44
62
  { ok: true, modules: opts.getManifest().modules.length },
45
- { headers: ctx.corsHeaders },
63
+ { headers: cors },
46
64
  );
47
65
  }
48
66
 
67
+ if (p === "/api/deploy/framework" && req.method === "GET") {
68
+ const hashPath = join(opts.ws.arcDir, ".deps-hash");
69
+ const depsHash = existsSync(hashPath)
70
+ ? readFileSync(hashPath, "utf-8").trim()
71
+ : null;
72
+ return Response.json({ depsHash }, { headers: cors });
73
+ }
74
+
49
75
  if (p === "/api/deploy/manifest" && req.method === "GET") {
50
- return Response.json(opts.getManifest(), { headers: ctx.corsHeaders });
76
+ return Response.json(opts.getManifest(), { headers: cors });
51
77
  }
52
78
 
53
- if (p === "/api/deploy/manifest" && req.method === "POST") {
54
- const body = (await req.json()) as BuildManifest;
55
- if (!validateManifest(body)) {
79
+ // -----------------------------------------------------------------------
80
+ // POST /api/deploy/framework install framework peers + rebuild shell
81
+ // -----------------------------------------------------------------------
82
+ if (p === "/api/deploy/framework" && req.method === "POST") {
83
+ const form = await req.formData();
84
+ const pkgFile = form.get("package.json");
85
+ const lockFile = form.get("bun.lock");
86
+ if (!isFile(pkgFile) || !isFile(lockFile)) {
87
+ return badRequest("framework requires package.json + bun.lock", cors);
88
+ }
89
+
90
+ mkdirSync(opts.ws.arcDir, { recursive: true });
91
+ writeFileSync(
92
+ join(opts.ws.arcDir, "package.json"),
93
+ Buffer.from(await pkgFile.arrayBuffer()),
94
+ );
95
+ writeFileSync(
96
+ join(opts.ws.arcDir, "bun.lock"),
97
+ Buffer.from(await lockFile.arrayBuffer()),
98
+ );
99
+
100
+ const start = Date.now();
101
+ const installOk = await runBun(
102
+ ["install", "--production", "--frozen-lockfile"],
103
+ opts.ws.arcDir,
104
+ );
105
+ if (!installOk) {
56
106
  return Response.json(
57
- { error: "Invalid manifest body" },
58
- { status: 400, headers: ctx.corsHeaders },
107
+ { error: "bun install failed" },
108
+ { status: 500, headers: cors },
59
109
  );
60
110
  }
61
- writeFileSync(
62
- join(opts.ws.modulesDir, "manifest.json"),
63
- JSON.stringify(body, null, 2),
111
+
112
+ // Rebuild shell from freshly-installed framework peers. Hidden _build-shell
113
+ // subcommand discovers @arcote.tech/* + react under --from.
114
+ const cliBin = process.argv[1] ?? "arc";
115
+ const shellOk = await runBun(
116
+ [
117
+ "run",
118
+ cliBin,
119
+ "_build-shell",
120
+ "--out",
121
+ opts.ws.shellDir,
122
+ "--from",
123
+ join(opts.ws.arcDir, "node_modules"),
124
+ ],
125
+ opts.ws.arcDir,
64
126
  );
65
- opts.setManifest(body);
66
- opts.notifyReload(body);
127
+ if (!shellOk) {
128
+ return Response.json(
129
+ { error: "shell build failed" },
130
+ { status: 500, headers: cors },
131
+ );
132
+ }
133
+
134
+ // Compute fresh deps-hash and persist
135
+ const newHash = sha256Hex(
136
+ Buffer.concat([
137
+ readFileSync(join(opts.ws.arcDir, "package.json")),
138
+ readFileSync(join(opts.ws.arcDir, "bun.lock")),
139
+ ]),
140
+ );
141
+ writeFileSync(join(opts.ws.arcDir, ".deps-hash"), newHash + "\n");
142
+
67
143
  return Response.json(
68
- { ok: true, moduleCount: body.modules.length },
69
- { headers: ctx.corsHeaders },
144
+ {
145
+ changed: true,
146
+ tookMs: Date.now() - start,
147
+ needsRestart: true,
148
+ },
149
+ { headers: cors },
70
150
  );
71
151
  }
72
152
 
73
- if (p === "/api/deploy/modules" && req.method === "POST") {
153
+ // -----------------------------------------------------------------------
154
+ // POST /api/deploy/modules/:name — per-module push (browser + server +
155
+ // deps + access) into staging
156
+ // -----------------------------------------------------------------------
157
+ const moduleMatch = p.match(/^\/api\/deploy\/modules\/([^/]+)$/);
158
+ if (moduleMatch && req.method === "POST") {
159
+ const safeName = sanitizeName(moduleMatch[1]);
160
+ if (!safeName) return badRequest("invalid module name", cors);
161
+
74
162
  const form = await req.formData();
75
- const written = await writeUploadedFiles(form, opts.ws.modulesDir);
163
+ const browser = form.get("browser.js");
164
+ if (!isFile(browser)) {
165
+ return badRequest("modules/<name> requires browser.js", cors);
166
+ }
167
+
168
+ const stagingDir = join(
169
+ opts.ws.arcDir,
170
+ ".staging",
171
+ "modules",
172
+ safeName,
173
+ );
174
+ mkdirSync(stagingDir, { recursive: true });
175
+
176
+ await writeField(stagingDir, "browser.js", browser);
177
+ const server = form.get("server.js");
178
+ if (isFile(server)) {
179
+ await writeField(stagingDir, "server.js", server);
180
+ }
181
+ const pkg = form.get("package.json");
182
+ if (isFile(pkg)) {
183
+ await writeField(stagingDir, "package.json", pkg);
184
+ }
185
+ const access = form.get("access.json");
186
+ if (isFile(access)) {
187
+ await writeField(stagingDir, "access.json", access);
188
+ }
189
+
190
+ // Compare staging deps-hash vs live; install only when different.
191
+ const stagingPkgPath = join(stagingDir, "package.json");
192
+ const livePkgPath = join(opts.ws.arcDir, "modules", safeName, "package.json");
193
+ const stagingPkgBytes = existsSync(stagingPkgPath)
194
+ ? readFileSync(stagingPkgPath)
195
+ : Buffer.from("");
196
+ const livePkgBytes = existsSync(livePkgPath)
197
+ ? readFileSync(livePkgPath)
198
+ : Buffer.from("");
199
+ const depsChanged =
200
+ sha256Hex(stagingPkgBytes) !== sha256Hex(livePkgBytes);
201
+
202
+ writeFileSync(
203
+ join(stagingDir, ".deps-hash"),
204
+ sha256Hex(stagingPkgBytes) + "\n",
205
+ );
206
+
207
+ if (depsChanged && existsSync(stagingPkgPath)) {
208
+ const ok = await runBun(["install", "--production"], stagingDir);
209
+ if (!ok) {
210
+ rmSync(stagingDir, { recursive: true, force: true });
211
+ return Response.json(
212
+ { error: `bun install failed for module ${safeName}` },
213
+ { status: 500, headers: cors },
214
+ );
215
+ }
216
+ } else if (!depsChanged) {
217
+ // Preserve previous node_modules so atomic swap doesn't blow it away.
218
+ const liveNodeModules = join(
219
+ opts.ws.arcDir,
220
+ "modules",
221
+ safeName,
222
+ "node_modules",
223
+ );
224
+ if (existsSync(liveNodeModules)) {
225
+ cpSync(liveNodeModules, join(stagingDir, "node_modules"), {
226
+ recursive: true,
227
+ });
228
+ }
229
+ }
230
+
76
231
  return Response.json(
77
- { ok: true, written },
78
- { headers: ctx.corsHeaders },
232
+ {
233
+ ok: true,
234
+ name: safeName,
235
+ depsChanged,
236
+ serverIncluded: isFile(server),
237
+ },
238
+ { headers: cors },
79
239
  );
80
240
  }
81
241
 
82
- if (p === "/api/deploy/shell" && req.method === "POST") {
242
+ // -----------------------------------------------------------------------
243
+ // POST /api/deploy/styles — global styles to staging
244
+ // -----------------------------------------------------------------------
245
+ if (p === "/api/deploy/styles" && req.method === "POST") {
83
246
  const form = await req.formData();
84
- const shellFiles: File[] = [];
85
- const rootFiles: Array<{ name: string; file: File }> = [];
86
- for (const [name, value] of form.entries()) {
87
- if (!isFile(value)) continue;
88
- // styles.css / theme.css go to arcDir, everything else goes to shellDir
89
- if (name === "styles.css" || name === "theme.css") {
90
- rootFiles.push({ name, file: value });
91
- } else {
92
- shellFiles.push(value);
247
+ const stagingDir = join(opts.ws.arcDir, ".staging");
248
+ mkdirSync(stagingDir, { recursive: true });
249
+ const written: string[] = [];
250
+ for (const name of ["styles.css", "theme.css"] as const) {
251
+ const f = form.get(name);
252
+ if (isFile(f)) {
253
+ await writeField(stagingDir, name, f);
254
+ written.push(name);
255
+ }
256
+ }
257
+ return Response.json({ ok: true, written }, { headers: cors });
258
+ }
259
+
260
+ // -----------------------------------------------------------------------
261
+ // POST /api/deploy/manifest — commit phase
262
+ // -----------------------------------------------------------------------
263
+ if (p === "/api/deploy/manifest" && req.method === "POST") {
264
+ const body = (await req.json()) as BuildManifest;
265
+ if (!validateManifest(body)) {
266
+ return badRequest("invalid manifest body", cors);
267
+ }
268
+
269
+ const stagingRoot = join(opts.ws.arcDir, ".staging");
270
+ const stagingModules = join(stagingRoot, "modules");
271
+ let serverSideChanged = false;
272
+
273
+ // Per-module atomic swap. We don't use rename(2) across volumes; cp+rm
274
+ // is good enough on a single docker volume.
275
+ if (existsSync(stagingModules)) {
276
+ const stagedNames = readDir(stagingModules);
277
+ for (const name of stagedNames) {
278
+ const src = join(stagingModules, name);
279
+ const dst = join(opts.ws.arcDir, "modules", name);
280
+ if (existsSync(join(src, "server.js"))) serverSideChanged = true;
281
+ if (existsSync(dst)) {
282
+ rmSync(dst, { recursive: true, force: true });
283
+ }
284
+ mkdirSync(dirname(dst), { recursive: true });
285
+ cpSync(src, dst, { recursive: true });
286
+ }
287
+ }
288
+
289
+ // Styles swap
290
+ for (const name of ["styles.css", "theme.css"] as const) {
291
+ const src = join(stagingRoot, name);
292
+ if (existsSync(src)) {
293
+ cpSync(src, join(opts.ws.arcDir, name));
93
294
  }
94
295
  }
95
- const writtenShell = await writeUploadedFileList(
96
- shellFiles,
97
- opts.ws.shellDir,
296
+
297
+ // Persist new manifest
298
+ writeFileSync(
299
+ join(opts.ws.modulesDir, "manifest.json"),
300
+ JSON.stringify(body, null, 2),
98
301
  );
99
- const writtenRoot: string[] = [];
100
- for (const { name, file } of rootFiles) {
101
- const target = join(opts.ws.arcDir, name);
102
- writeFileSync(target, Buffer.from(await file.arrayBuffer()));
103
- writtenRoot.push(name);
302
+ opts.setManifest(body);
303
+
304
+ // Cleanup staging
305
+ rmSync(stagingRoot, { recursive: true, force: true });
306
+
307
+ if (!serverSideChanged) {
308
+ // Browser-only change → SSE notify clients, zero restart.
309
+ opts.notifyReload(body);
104
310
  }
311
+
105
312
  return Response.json(
106
- { ok: true, written: [...writtenShell, ...writtenRoot] },
107
- { headers: ctx.corsHeaders },
313
+ {
314
+ ok: true,
315
+ moduleCount: body.modules.length,
316
+ needsRestart: serverSideChanged,
317
+ },
318
+ { headers: cors },
108
319
  );
109
320
  }
110
321
 
111
- return new Response("Not Found", {
112
- status: 404,
113
- headers: ctx.corsHeaders,
114
- });
322
+ return new Response("Not Found", { status: 404, headers: cors });
115
323
  };
116
324
  }
117
325
 
118
326
  // ---------------------------------------------------------------------------
119
- // File writes — with path traversal guard
327
+ // Helpers
120
328
  // ---------------------------------------------------------------------------
121
329
 
122
- async function writeUploadedFiles(
123
- form: FormData,
124
- targetDir: string,
125
- ): Promise<string[]> {
126
- const files: File[] = [];
127
- for (const [, value] of form.entries()) {
128
- if (isFile(value)) files.push(value);
129
- }
130
- return writeUploadedFileList(files, targetDir);
330
+ function badRequest(msg: string, cors: HeadersInit): Response {
331
+ return Response.json({ error: msg }, { status: 400, headers: cors });
131
332
  }
132
333
 
133
- /** Runtime File check — DOM's File is a Blob subclass, available in Bun runtime. */
134
334
  function isFile(v: unknown): v is File {
135
335
  return (
136
336
  typeof v === "object" &&
@@ -140,29 +340,42 @@ function isFile(v: unknown): v is File {
140
340
  );
141
341
  }
142
342
 
143
- async function writeUploadedFileList(
144
- files: File[],
343
+ async function writeField(
145
344
  targetDir: string,
146
- ): Promise<string[]> {
147
- const written: string[] = [];
345
+ name: string,
346
+ file: File,
347
+ ): Promise<void> {
348
+ // Safety: reject names attempting traversal. Field names are CLI-controlled
349
+ // but treat as untrusted anyway (network-facing endpoint).
350
+ const safe = normalize(name);
148
351
  const safeRoot = resolve(targetDir);
149
- mkdirSync(safeRoot, { recursive: true });
352
+ const full = resolve(safeRoot, safe);
353
+ if (!full.startsWith(safeRoot + "/") && full !== safeRoot) {
354
+ throw new Error(`Path traversal rejected: ${name}`);
355
+ }
356
+ mkdirSync(dirname(full), { recursive: true });
357
+ writeFileSync(full, Buffer.from(await file.arrayBuffer()));
358
+ }
150
359
 
151
- for (const file of files) {
152
- const rel = normalize(file.name);
153
- const full = resolve(safeRoot, rel);
154
- if (!full.startsWith(safeRoot + "/") && full !== safeRoot) {
155
- throw new Error(`Path traversal rejected: ${file.name}`);
156
- }
157
- mkdirSync(dirname(full), { recursive: true });
158
- writeFileSync(full, Buffer.from(await file.arrayBuffer()));
360
+ function sanitizeName(name: string): string | null {
361
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) return null;
362
+ return name;
363
+ }
159
364
 
160
- // Verify upload hash matches the file we wrote — defensive check that
161
- // rejects accidental corruption mid-transit. Modules carry a hash prefix
162
- // convention in their filename header ("<hash>:<name>") from the CLI.
163
- written.push(relative(safeRoot, full) || rel);
164
- }
165
- return written;
365
+ function readDir(dir: string): string[] {
366
+ if (!existsSync(dir)) return [];
367
+ return require("fs").readdirSync(dir) as string[];
368
+ }
369
+
370
+ async function runBun(args: string[], cwd: string): Promise<boolean> {
371
+ const proc = spawn({
372
+ cmd: ["bun", ...args],
373
+ cwd,
374
+ stdout: "inherit",
375
+ stderr: "inherit",
376
+ });
377
+ const exit = await proc.exited;
378
+ return exit === 0;
166
379
  }
167
380
 
168
381
  function validateManifest(m: unknown): m is BuildManifest {
@@ -28,6 +28,11 @@ import {
28
28
  sha256OfJson,
29
29
  } from "../builder/hash";
30
30
  import { pAll } from "../builder/parallel";
31
+ import {
32
+ collectFrameworkDeps,
33
+ collectModuleDeps,
34
+ } from "../builder/dependency-collector";
35
+ import { extractAccessMap } from "../builder/access-extractor";
31
36
 
32
37
  // Re-export for convenience
33
38
  export { buildContextPackages, buildModulesBundle, buildStyles, isContextPackage };
@@ -168,6 +173,22 @@ export async function buildAll(
168
173
 
169
174
  saveBuildCache(ws.arcDir, cache);
170
175
 
176
+ // v0.6 deploy artifacts: framework + per-module dependency manifests + access
177
+ // map. Generated unconditionally — runtime container needs them to bun-install
178
+ // and to enforce protectBy rules. Order matters: deps before access (access
179
+ // extraction subprocess imports server bundles whose own deps may matter).
180
+ collectFrameworkDeps(ws.arcDir, ws.rootDir, ws.packages);
181
+ for (const pkg of ws.packages) {
182
+ collectModuleDeps(ws.arcDir, pkg);
183
+ }
184
+ try {
185
+ await extractAccessMap(ws.arcDir, ws.packages);
186
+ } catch (e) {
187
+ err(`access-extractor failed: ${(e as Error).message}`);
188
+ // Don't fail the build — protection rules will be empty server-side but
189
+ // the rest of the deploy can still proceed.
190
+ }
191
+
171
192
  const finalManifest = assembleManifest(ws, modulesResult.modules, cache);
172
193
  writeFileSync(
173
194
  join(ws.modulesDir, "manifest.json"),