@arcote.tech/arc-cli 0.5.2 → 0.5.5

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.
@@ -0,0 +1,183 @@
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";
4
+ import { sha256Hex } from "../builder/module-builder";
5
+ import type { BuildManifest, WorkspaceInfo } from "./shared";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Deploy API — hot-swap modules/shell/styles via HTTP
9
+ //
10
+ // Mounted on the same Bun.serve as the main platform server. Gated by
11
+ // 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`.
16
+ //
17
+ // No auth tokens in code — the SSH key is the auth.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface DeployApiOptions {
21
+ ws: WorkspaceInfo;
22
+ getManifest: () => BuildManifest;
23
+ setManifest: (m: BuildManifest) => void;
24
+ notifyReload: (m: BuildManifest) => void;
25
+ }
26
+
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
+ export function createDeployApiHandler(opts: DeployApiOptions): ArcHttpHandler {
38
+ return async (req, url, ctx) => {
39
+ const p = url.pathname;
40
+ if (!p.startsWith("/api/deploy/")) return null;
41
+
42
+ if (p === "/api/deploy/health" && req.method === "GET") {
43
+ return Response.json(
44
+ { ok: true, modules: opts.getManifest().modules.length },
45
+ { headers: ctx.corsHeaders },
46
+ );
47
+ }
48
+
49
+ if (p === "/api/deploy/manifest" && req.method === "GET") {
50
+ return Response.json(opts.getManifest(), { headers: ctx.corsHeaders });
51
+ }
52
+
53
+ if (p === "/api/deploy/manifest" && req.method === "POST") {
54
+ const body = (await req.json()) as BuildManifest;
55
+ if (!validateManifest(body)) {
56
+ return Response.json(
57
+ { error: "Invalid manifest body" },
58
+ { status: 400, headers: ctx.corsHeaders },
59
+ );
60
+ }
61
+ writeFileSync(
62
+ join(opts.ws.modulesDir, "manifest.json"),
63
+ JSON.stringify(body, null, 2),
64
+ );
65
+ opts.setManifest(body);
66
+ opts.notifyReload(body);
67
+ return Response.json(
68
+ { ok: true, moduleCount: body.modules.length },
69
+ { headers: ctx.corsHeaders },
70
+ );
71
+ }
72
+
73
+ if (p === "/api/deploy/modules" && req.method === "POST") {
74
+ const form = await req.formData();
75
+ const written = await writeUploadedFiles(form, opts.ws.modulesDir);
76
+ return Response.json(
77
+ { ok: true, written },
78
+ { headers: ctx.corsHeaders },
79
+ );
80
+ }
81
+
82
+ if (p === "/api/deploy/shell" && req.method === "POST") {
83
+ 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);
93
+ }
94
+ }
95
+ const writtenShell = await writeUploadedFileList(
96
+ shellFiles,
97
+ opts.ws.shellDir,
98
+ );
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);
104
+ }
105
+ return Response.json(
106
+ { ok: true, written: [...writtenShell, ...writtenRoot] },
107
+ { headers: ctx.corsHeaders },
108
+ );
109
+ }
110
+
111
+ return new Response("Not Found", {
112
+ status: 404,
113
+ headers: ctx.corsHeaders,
114
+ });
115
+ };
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // File writes — with path traversal guard
120
+ // ---------------------------------------------------------------------------
121
+
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);
131
+ }
132
+
133
+ /** Runtime File check — DOM's File is a Blob subclass, available in Bun runtime. */
134
+ function isFile(v: unknown): v is File {
135
+ return (
136
+ typeof v === "object" &&
137
+ v !== null &&
138
+ typeof (v as File).arrayBuffer === "function" &&
139
+ typeof (v as File).name === "string"
140
+ );
141
+ }
142
+
143
+ async function writeUploadedFileList(
144
+ files: File[],
145
+ targetDir: string,
146
+ ): Promise<string[]> {
147
+ const written: string[] = [];
148
+ const safeRoot = resolve(targetDir);
149
+ mkdirSync(safeRoot, { recursive: true });
150
+
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()));
159
+
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;
166
+ }
167
+
168
+ function validateManifest(m: unknown): m is BuildManifest {
169
+ if (!m || typeof m !== "object") return false;
170
+ const cast = m as BuildManifest;
171
+ return (
172
+ Array.isArray(cast.modules) &&
173
+ typeof cast.shellHash === "string" &&
174
+ typeof cast.stylesHash === "string" &&
175
+ typeof cast.buildTime === "string"
176
+ );
177
+ }
178
+
179
+ /** Recompute a module's hash from disk — exposed for tests. */
180
+ export function rehashModuleFile(filePath: string): string {
181
+ if (!existsSync(filePath)) throw new Error(`Missing: ${filePath}`);
182
+ return sha256Hex(readFileSync(filePath));
183
+ }
@@ -11,7 +11,8 @@ import {
11
11
  import { existsSync, mkdirSync } from "fs";
12
12
  import { join } from "path";
13
13
  import { readTranslationsConfig } from "../i18n";
14
- import type { BuildManifest, ModuleEntry, WorkspaceInfo } from "./shared";
14
+ import { createDeployApiHandler } from "./deploy-api";
15
+ import type { BuildManifest, ModuleDescriptor, WorkspaceInfo } from "./shared";
15
16
  import type { ModuleAccess } from "@arcote.tech/platform";
16
17
 
17
18
  // ---------------------------------------------------------------------------
@@ -30,6 +31,10 @@ export interface PlatformServerOptions {
30
31
  dbPath?: string;
31
32
  /** If true, enables SSE reload stream + mutable manifest (dev mode) */
32
33
  devMode?: boolean;
34
+ /** If true, mounts /api/deploy/* endpoints for remote hot-swap. Off by default;
35
+ * opt-in via ARC_DEPLOY_API=1 or explicit CLI flag. In production, network-layer
36
+ * (Caddy + docker-compose port binding) restricts access to SSH tunnel only. */
37
+ deployApi?: boolean;
33
38
  /** Arc shell entries [shortName, fullPkgName][] for import map. Auto-detected if omitted. */
34
39
  arcEntries?: [string, string][];
35
40
  }
@@ -213,7 +218,7 @@ async function filterManifestForTokens(
213
218
  moduleAccessMap: Map<string, ModuleAccess>,
214
219
  tokenPayloads: any[],
215
220
  ): Promise<BuildManifest> {
216
- const filtered: ModuleEntry[] = [];
221
+ const filtered: ModuleDescriptor[] = [];
217
222
 
218
223
  for (const mod of manifest.modules) {
219
224
  const access = moduleAccessMap.get(mod.name);
@@ -237,10 +242,15 @@ async function filterManifestForTokens(
237
242
  }
238
243
 
239
244
  if (granted) {
240
- filtered.push({ ...mod, url: signModuleUrl(mod.file) } as any);
245
+ filtered.push({ ...mod, url: signModuleUrl(mod.file) });
241
246
  }
242
247
  }
243
- return { modules: filtered, buildTime: manifest.buildTime };
248
+ return {
249
+ modules: filtered,
250
+ shellHash: manifest.shellHash,
251
+ stylesHash: manifest.stylesHash,
252
+ buildTime: manifest.buildTime,
253
+ };
244
254
  }
245
255
 
246
256
  // ---------------------------------------------------------------------------
@@ -385,10 +395,35 @@ export async function startPlatformServer(
385
395
  const moduleAccessMap = opts.moduleAccess ?? new Map();
386
396
  let manifest = opts.manifest;
387
397
  const getManifest = () => manifest;
398
+ const setManifest = (m: BuildManifest) => {
399
+ manifest = m;
400
+ };
388
401
 
389
402
  const shellHtml = generateShellHtml(ws.appName, ws.manifest, opts.arcEntries);
390
403
  const sseClients = new Set<ReadableStreamDefaultController>();
391
404
 
405
+ const notifyReload = (m: BuildManifest) => {
406
+ const data = JSON.stringify(m);
407
+ for (const c of sseClients) {
408
+ try {
409
+ c.enqueue(`data: ${data}\n\n`);
410
+ } catch {
411
+ sseClients.delete(c);
412
+ }
413
+ }
414
+ };
415
+
416
+ const deployApiEnabled =
417
+ opts.deployApi ?? process.env.ARC_DEPLOY_API === "1";
418
+ const deployApiHandler = deployApiEnabled
419
+ ? createDeployApiHandler({
420
+ ws,
421
+ getManifest,
422
+ setManifest,
423
+ notifyReload,
424
+ })
425
+ : null;
426
+
392
427
  if (!context) {
393
428
  // No context — serve static files only (no WS/commands/queries)
394
429
  const cors = {
@@ -400,7 +435,7 @@ export async function startPlatformServer(
400
435
 
401
436
  const server = Bun.serve({
402
437
  port,
403
- fetch(req) {
438
+ async fetch(req) {
404
439
  const url = new URL(req.url);
405
440
  if (req.method === "OPTIONS")
406
441
  return new Response(null, { headers: cors });
@@ -413,15 +448,15 @@ export async function startPlatformServer(
413
448
 
414
449
  // Platform handlers only
415
450
  const handlers: ArcHttpHandler[] = [
451
+ ...(deployApiHandler ? [deployApiHandler] : []),
416
452
  apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
417
- ...(devMode ? [devReloadHandler(sseClients)] : []),
453
+ devReloadHandler(sseClients),
418
454
  staticFilesHandler(ws, !!devMode, moduleAccessMap),
419
455
  spaFallbackHandler(shellHtml),
420
456
  ];
421
457
 
422
458
  for (const handler of handlers) {
423
- const response = handler(req, url, ctx);
424
- if (response instanceof Promise) return response;
459
+ const response = await handler(req, url, ctx);
425
460
  if (response) return response;
426
461
  }
427
462
 
@@ -433,14 +468,8 @@ export async function startPlatformServer(
433
468
  server,
434
469
  contextHandler: null,
435
470
  connectionManager: null,
436
- setManifest: (m) => { manifest = m; },
437
- notifyReload: (m) => {
438
- const data = JSON.stringify(m);
439
- for (const c of sseClients) {
440
- try { c.enqueue(`data: ${data}\n\n`); }
441
- catch { sseClients.delete(c); }
442
- }
443
- },
471
+ setManifest,
472
+ notifyReload,
444
473
  stop: () => server.stop(),
445
474
  };
446
475
  }
@@ -459,8 +488,9 @@ export async function startPlatformServer(
459
488
  port,
460
489
  httpHandlers: [
461
490
  // Platform-specific handlers (checked AFTER arc handlers)
491
+ ...(deployApiHandler ? [deployApiHandler] : []),
462
492
  apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
463
- ...(devMode ? [devReloadHandler(sseClients)] : []),
493
+ devReloadHandler(sseClients),
464
494
  staticFilesHandler(ws, !!devMode, moduleAccessMap),
465
495
  spaFallbackHandler(shellHtml),
466
496
  ],
@@ -471,14 +501,8 @@ export async function startPlatformServer(
471
501
  server: arcServer.server,
472
502
  contextHandler: arcServer.contextHandler,
473
503
  connectionManager: arcServer.connectionManager,
474
- setManifest: (m) => { manifest = m; },
475
- notifyReload: (m) => {
476
- const data = JSON.stringify(m);
477
- for (const c of sseClients) {
478
- try { c.enqueue(`data: ${data}\n\n`); }
479
- catch { sseClients.delete(c); }
480
- }
481
- },
504
+ setManifest,
505
+ notifyReload,
482
506
  stop: () => arcServer.stop(),
483
507
  };
484
508
  }
@@ -1,19 +1,21 @@
1
1
  import { findUpSync } from "find-up";
2
- import { copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
3
3
  import { dirname, join } from "path";
4
4
  import {
5
5
  buildPackages,
6
6
  buildStyles,
7
7
  discoverPackages,
8
8
  isContextPackage,
9
+ sha256OfFiles,
9
10
  type BuildManifest,
11
+ type ModuleDescriptor,
10
12
  type ModuleEntry,
11
13
  type WorkspacePackage,
12
14
  } from "../builder/module-builder";
13
15
 
14
16
  // Re-export for convenience
15
17
  export { buildPackages, buildStyles, isContextPackage };
16
- export type { BuildManifest, ModuleEntry, WorkspacePackage };
18
+ export type { BuildManifest, ModuleDescriptor, ModuleEntry, WorkspacePackage };
17
19
 
18
20
  // ---------------------------------------------------------------------------
19
21
  // Logging
@@ -138,7 +140,47 @@ export async function buildAll(ws: WorkspaceInfo): Promise<BuildManifest> {
138
140
  await buildShell(ws.shellDir, ws.packages);
139
141
  ok("Shell built");
140
142
 
141
- return manifest;
143
+ // Compute aggregate hashes now that all artifacts are on disk, then rewrite
144
+ // manifest.json with full hash data (modules + shellHash + stylesHash).
145
+ const finalManifest = finalizeManifest(ws, manifest);
146
+ writeFileSync(
147
+ join(ws.modulesDir, "manifest.json"),
148
+ JSON.stringify(finalManifest, null, 2),
149
+ );
150
+ return finalManifest;
151
+ }
152
+
153
+ /**
154
+ * Compute shellHash + stylesHash and produce the final manifest.
155
+ * Pure function — does not touch disk.
156
+ */
157
+ export function finalizeManifest(
158
+ ws: WorkspaceInfo,
159
+ manifest: BuildManifest,
160
+ ): BuildManifest {
161
+ const shellFiles = listFilesRec(ws.shellDir);
162
+ const stylesFiles = [
163
+ join(ws.arcDir, "styles.css"),
164
+ join(ws.arcDir, "theme.css"),
165
+ ].filter((p) => existsSync(p));
166
+
167
+ return {
168
+ modules: manifest.modules,
169
+ shellHash: sha256OfFiles(shellFiles),
170
+ stylesHash: sha256OfFiles(stylesFiles),
171
+ buildTime: manifest.buildTime,
172
+ };
173
+ }
174
+
175
+ function listFilesRec(dir: string): string[] {
176
+ if (!existsSync(dir)) return [];
177
+ const out: string[] = [];
178
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
179
+ const p = join(dir, entry.name);
180
+ if (entry.isDirectory()) out.push(...listFilesRec(p));
181
+ else if (entry.isFile()) out.push(p);
182
+ }
183
+ return out;
142
184
  }
143
185
 
144
186
  // ---------------------------------------------------------------------------