@arcote.tech/arc-cli 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/index.js +1696 -1663
  2. package/package.json +7 -7
  3. package/src/builder/access-extractor.ts +64 -46
  4. package/src/builder/build-cache.ts +3 -1
  5. package/src/builder/chunk-planner.ts +107 -0
  6. package/src/builder/dependency-collector.ts +83 -41
  7. package/src/builder/framework-peers.ts +81 -0
  8. package/src/builder/module-builder.ts +322 -106
  9. package/src/commands/platform-build.ts +2 -1
  10. package/src/commands/platform-deploy.ts +121 -64
  11. package/src/commands/platform-dev.ts +11 -100
  12. package/src/commands/platform-start.ts +4 -90
  13. package/src/deploy/ansible.ts +23 -3
  14. package/src/deploy/assets/ansible/site.yml +23 -7
  15. package/src/deploy/assets.ts +23 -7
  16. package/src/deploy/bootstrap.ts +270 -10
  17. package/src/deploy/caddyfile.ts +19 -23
  18. package/src/deploy/compose.ts +44 -27
  19. package/src/deploy/config.ts +67 -3
  20. package/src/deploy/deploy-env.ts +129 -0
  21. package/src/deploy/env-file.ts +103 -0
  22. package/src/deploy/htpasswd.ts +28 -0
  23. package/src/deploy/image-template.ts +74 -0
  24. package/src/deploy/image.ts +243 -0
  25. package/src/deploy/registry.ts +79 -0
  26. package/src/deploy/ssh.ts +52 -122
  27. package/src/deploy/survey.ts +64 -0
  28. package/src/index.ts +20 -13
  29. package/src/platform/server.ts +119 -94
  30. package/src/platform/shared.ts +139 -292
  31. package/src/platform/startup.ts +159 -0
  32. package/runtime/Dockerfile +0 -29
  33. package/runtime/build-and-push.sh +0 -23
  34. package/runtime/entrypoint.sh +0 -58
  35. package/src/commands/build-shell.ts +0 -152
  36. package/src/deploy/remote-sync.ts +0 -321
  37. package/src/platform/deploy-api.ts +0 -400
@@ -11,9 +11,8 @@ import {
11
11
  import { existsSync, mkdirSync } from "fs";
12
12
  import { join } from "path";
13
13
  import { readTranslationsConfig } from "../i18n";
14
- import { createDeployApiHandler } from "./deploy-api";
15
- import type { BuildManifest, ModuleDescriptor, WorkspaceInfo } from "./shared";
16
- import type { ModuleAccess } from "@arcote.tech/platform";
14
+ import type { BuildManifest, WorkspaceInfo } from "./shared";
15
+ import type { BuildManifestGroup, ModuleAccess } from "@arcote.tech/platform";
17
16
 
18
17
  // ---------------------------------------------------------------------------
19
18
  // Types
@@ -31,12 +30,6 @@ export interface PlatformServerOptions {
31
30
  dbPath?: string;
32
31
  /** If true, enables SSE reload stream + mutable manifest (dev mode) */
33
32
  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;
38
- /** Arc shell entries [shortName, fullPkgName][] for import map. Auto-detected if omitted. */
39
- arcEntries?: [string, string][];
40
33
  }
41
34
 
42
35
  export interface PlatformServer {
@@ -79,25 +72,15 @@ export async function initContextHandler(
79
72
  export function generateShellHtml(
80
73
  appName: string,
81
74
  manifest?: { title: string; favicon?: string },
82
- arcEntries?: [string, string][],
75
+ initial?: { file: string; hash: string },
83
76
  ): string {
84
- const arcImports: Record<string, string> = {};
85
- if (arcEntries) {
86
- for (const [short, pkg] of arcEntries) {
87
- arcImports[pkg] = `/shell/${short}.js`;
88
- }
77
+ // Initial bundle carries framework, public modules, and PlatformApp re-export.
78
+ // No importmap — single Bun.build with splitting:true inlines + dedups everything
79
+ // across initial and per-token group bundles via auto-emitted chunk-<hash>.js.
80
+ const initialUrl = initial ? `/browser/${initial.file}` : null;
81
+ if (!initialUrl) {
82
+ throw new Error("generateShellHtml: initial bundle missing from manifest");
89
83
  }
90
- const importMap = {
91
- imports: {
92
- react: "/shell/react.js",
93
- "react/jsx-runtime": "/shell/jsx-runtime.js",
94
- "react/jsx-dev-runtime": "/shell/jsx-dev-runtime.js",
95
- "react-dom": "/shell/react-dom.js",
96
- "react-dom/client": "/shell/react-dom-client.js",
97
- ...arcImports,
98
- },
99
- };
100
-
101
84
  return `<!doctype html>
102
85
  <html lang="en">
103
86
  <head>
@@ -106,18 +89,13 @@ export function generateShellHtml(
106
89
  <title>${manifest?.title ?? appName}</title>${manifest?.favicon ? `\n <link rel="icon" href="${manifest.favicon}">` : ""}${manifest ? `\n <link rel="manifest" href="/manifest.json">` : ""}
107
90
  <link rel="stylesheet" href="/styles.css" />
108
91
  <link rel="stylesheet" href="/theme.css" />
109
- <script type="importmap">${JSON.stringify(importMap)}</script>
92
+ <link rel="modulepreload" href="${initialUrl}" />
110
93
  </head>
111
94
  <body>
112
95
  <div id="root"></div>
113
96
  <script type="module">
114
- import { createRoot } from "react-dom/client";
115
- import { createElement } from "react";
116
- import { PlatformApp } from "@arcote.tech/platform";
117
-
118
- createRoot(document.getElementById("root")).render(
119
- createElement(PlatformApp)
120
- );
97
+ import { startApp } from "${initialUrl}";
98
+ startApp("root");
121
99
  </script>
122
100
  </body>
123
101
  </html>`;
@@ -165,22 +143,49 @@ function serveFile(
165
143
  // Module access — signed URLs
166
144
  // ---------------------------------------------------------------------------
167
145
 
168
- const MODULE_SIG_SECRET = process.env.ARC_MODULE_SECRET ?? crypto.randomUUID();
146
+ // Secret is initialized lazily by `startPlatformServer` so dev mode can derive
147
+ // a stable, workspace-scoped value (browser sessions survive a watcher restart;
148
+ // without this, every restart invalidates every signed URL in the open page).
149
+ // Prod uses ARC_MODULE_SECRET if set, otherwise a random UUID per process.
150
+ let MODULE_SIG_SECRET: string = process.env.ARC_MODULE_SECRET ?? "";
169
151
  const MODULE_SIG_TTL = 3600; // 1 hour
170
152
 
171
- function signModuleUrl(filename: string): string {
153
+ function ensureModuleSigSecret(ws: WorkspaceInfo, devMode: boolean): void {
154
+ if (MODULE_SIG_SECRET) return;
155
+ if (devMode) {
156
+ // Deterministic per workspace — restart-safe in dev.
157
+ const hasher = new Bun.CryptoHasher("sha256");
158
+ hasher.update(`arc-dev-secret:${ws.rootDir}`);
159
+ MODULE_SIG_SECRET = hasher.digest("hex").slice(0, 32);
160
+ } else {
161
+ MODULE_SIG_SECRET = crypto.randomUUID();
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Signed URL for a token-group bundle. HMAC payload binds the filename so
167
+ * a sig minted for `/browser/admin.<hash>.js` cannot be replayed for any
168
+ * other file. Shared chunks (chunk-<hash>.js) are NEVER signed — their
169
+ * filenames are content-hashed and they don't carry private code on their
170
+ * own (group entries side-effect-register the modules).
171
+ */
172
+ function signGroupUrl(file: string): string {
172
173
  const exp = Math.floor(Date.now() / 1000) + MODULE_SIG_TTL;
173
174
  const hasher = new Bun.CryptoHasher("sha256");
174
- hasher.update(`${filename}:${exp}:${MODULE_SIG_SECRET}`);
175
+ hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
175
176
  const sig = hasher.digest("hex").slice(0, 16);
176
- return `/modules/${filename}?sig=${sig}&exp=${exp}`;
177
+ return `/browser/${file}?sig=${sig}&exp=${exp}`;
177
178
  }
178
179
 
179
- function verifyModuleSignature(filename: string, sig: string | null, exp: string | null): boolean {
180
+ function verifyGroupSignature(
181
+ file: string,
182
+ sig: string | null,
183
+ exp: string | null,
184
+ ): boolean {
180
185
  if (!sig || !exp) return false;
181
186
  if (Number(exp) < Date.now() / 1000) return false;
182
187
  const hasher = new Bun.CryptoHasher("sha256");
183
- hasher.update(`${filename}:${exp}:${MODULE_SIG_SECRET}`);
188
+ hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
184
189
  return hasher.digest("hex").slice(0, 16) === sig;
185
190
  }
186
191
 
@@ -216,41 +221,60 @@ function parseArcTokensHeader(header: string | null): any[] {
216
221
  return payloads;
217
222
  }
218
223
 
224
+ /**
225
+ * Filter manifest groups to those the caller's tokens grant access to. A
226
+ * group is unlocked when (a) the caller carries a token whose `tokenType`
227
+ * matches the group name, and (b) every per-module rule declared via
228
+ * `protectedBy(token, check)` passes for at least one of those tokens.
229
+ *
230
+ * Returns a manifest with `groups` filtered + each remaining entry's `url`
231
+ * filled with a signed URL (HMAC, 1h TTL). `initial` + `sharedChunks` ride
232
+ * along unchanged — those are public/content-addressed.
233
+ */
219
234
  async function filterManifestForTokens(
220
235
  manifest: BuildManifest,
221
236
  moduleAccessMap: Map<string, ModuleAccess>,
222
237
  tokenPayloads: any[],
223
238
  ): Promise<BuildManifest> {
224
- const filtered: ModuleDescriptor[] = [];
225
-
226
- for (const mod of manifest.modules) {
227
- const access = moduleAccessMap.get(mod.name);
228
-
229
- if (!access) {
230
- filtered.push(mod);
231
- continue;
232
- }
233
-
234
- if (tokenPayloads.length === 0) continue;
239
+ const allowedGroups = new Set<string>();
240
+ for (const t of tokenPayloads) {
241
+ if (t?.tokenType) allowedGroups.add(t.tokenType);
242
+ }
235
243
 
236
- let granted = false;
237
- for (const rule of access.rules) {
238
- const matching = tokenPayloads.find(
239
- (t) => t.tokenType === rule.token.name,
240
- );
241
- if (matching) {
244
+ const filteredGroups: Record<string, BuildManifestGroup> = {};
245
+
246
+ for (const [name, group] of Object.entries(manifest.groups)) {
247
+ if (!allowedGroups.has(name)) continue;
248
+
249
+ // Every module in the group must pass its per-rule check (if any).
250
+ // If all rules pass (or none declared), unlock the group bundle.
251
+ let allGranted = true;
252
+ for (const moduleName of group.modules) {
253
+ const access = moduleAccessMap.get(moduleName);
254
+ if (!access || access.rules.length === 0) continue;
255
+ let granted = false;
256
+ for (const rule of access.rules) {
257
+ if (rule.token.name !== name) continue;
258
+ const matching = tokenPayloads.find((t) => t.tokenType === name);
259
+ if (!matching) continue;
242
260
  granted = rule.check ? await rule.check(matching) : true;
243
261
  if (granted) break;
244
262
  }
263
+ if (!granted) {
264
+ allGranted = false;
265
+ break;
266
+ }
245
267
  }
246
268
 
247
- if (granted) {
248
- filtered.push({ ...mod, url: signModuleUrl(mod.file) });
269
+ if (allGranted) {
270
+ filteredGroups[name] = { ...group, url: signGroupUrl(group.file) };
249
271
  }
250
272
  }
273
+
251
274
  return {
252
- modules: filtered,
253
- shellHash: manifest.shellHash,
275
+ initial: manifest.initial,
276
+ groups: filteredGroups,
277
+ sharedChunks: manifest.sharedChunks,
254
278
  stylesHash: manifest.stylesHash,
255
279
  buildTime: manifest.buildTime,
256
280
  };
@@ -260,30 +284,40 @@ async function filterManifestForTokens(
260
284
  // Platform-specific HTTP handlers
261
285
  // ---------------------------------------------------------------------------
262
286
 
287
+ /** Browser bundle filenames: `initial.<hash>.js`, `<tokenName>.<hash>.js`,
288
+ * `chunk-<hash>.js`. All Bun.build outputs with content-addressed hashes. */
289
+ const BROWSER_FILE_RE = /^[A-Za-z0-9_.-]+\.js$/;
290
+
263
291
  function staticFilesHandler(
264
292
  ws: WorkspaceInfo,
265
293
  devMode: boolean,
266
- moduleAccessMap: Map<string, ModuleAccess>,
294
+ getManifest: () => BuildManifest,
267
295
  ): ArcHttpHandler {
268
296
  return (_req, url, ctx) => {
269
297
  const path = url.pathname;
270
- if (path.startsWith("/shell/"))
271
- return serveFile(join(ws.shellDir, path.slice(7)), ctx.corsHeaders);
272
- if (path.startsWith("/modules/")) {
273
- const fileWithParams = path.slice(9);
274
- const filename = fileWithParams.split("?")[0];
275
- const moduleName = filename.replace(/\.js$/, "");
276
-
277
- // Check access for protected modules
278
- if (moduleAccessMap.has(moduleName)) {
298
+ // Single flat browser dir: initial bundle, per-token group entries,
299
+ // and auto-emitted shared chunks all live in <arcDir>/browser/.
300
+ if (path.startsWith("/browser/")) {
301
+ const file = path.slice(9);
302
+ if (!BROWSER_FILE_RE.test(file)) {
303
+ return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
304
+ }
305
+
306
+ // Only token-group entries are signed. The initial bundle and shared
307
+ // chunks are content-addressed (filename = hash) — unsigned by design.
308
+ const manifest = getManifest();
309
+ const isGroupEntry = Object.values(manifest.groups).some(
310
+ (g) => g.file === file,
311
+ );
312
+ if (isGroupEntry) {
279
313
  const sig = url.searchParams.get("sig");
280
314
  const exp = url.searchParams.get("exp");
281
- if (!verifyModuleSignature(filename, sig, exp)) {
315
+ if (!verifyGroupSignature(file, sig, exp)) {
282
316
  return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
283
317
  }
284
318
  }
285
319
 
286
- return serveFile(join(ws.modulesDir, filename), {
320
+ return serveFile(join(ws.browserDir, file), {
287
321
  ...ctx.corsHeaders,
288
322
  "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
289
323
  });
@@ -343,7 +377,7 @@ function apiEndpointsHandler(
343
377
  return Response.json(
344
378
  {
345
379
  status: "ok",
346
- modules: getManifest().modules.length,
380
+ groups: Object.keys(getManifest().groups).length,
347
381
  clients: cm?.clientCount ?? 0,
348
382
  },
349
383
  { headers: ctx.corsHeaders },
@@ -381,9 +415,9 @@ function devReloadHandler(
381
415
  };
382
416
  }
383
417
 
384
- function spaFallbackHandler(shellHtml: string): ArcHttpHandler {
418
+ function spaFallbackHandler(getShellHtml: () => string): ArcHttpHandler {
385
419
  return (_req, _url, ctx) => {
386
- return new Response(shellHtml, {
420
+ return new Response(getShellHtml(), {
387
421
  headers: { ...ctx.corsHeaders, "Content-Type": "text/html" },
388
422
  });
389
423
  };
@@ -397,6 +431,7 @@ export async function startPlatformServer(
397
431
  opts: PlatformServerOptions,
398
432
  ): Promise<PlatformServer> {
399
433
  const { ws, port, devMode, context } = opts;
434
+ ensureModuleSigSecret(ws, !!devMode);
400
435
  const moduleAccessMap = opts.moduleAccess ?? new Map();
401
436
  let manifest = opts.manifest;
402
437
  const getManifest = () => manifest;
@@ -404,7 +439,10 @@ export async function startPlatformServer(
404
439
  manifest = m;
405
440
  };
406
441
 
407
- const shellHtml = generateShellHtml(ws.appName, ws.manifest, opts.arcEntries);
442
+ // Recompute on every request — manifest.initial.hash changes when public
443
+ // modules are rebuilt in dev, and we want the new URL in the HTML.
444
+ const getShellHtml = (): string =>
445
+ generateShellHtml(ws.appName, ws.manifest, manifest?.initial);
408
446
  const sseClients = new Set<ReadableStreamDefaultController>();
409
447
 
410
448
  const notifyReload = (m: BuildManifest) => {
@@ -418,17 +456,6 @@ export async function startPlatformServer(
418
456
  }
419
457
  };
420
458
 
421
- const deployApiEnabled =
422
- opts.deployApi ?? process.env.ARC_DEPLOY_API === "1";
423
- const deployApiHandler = deployApiEnabled
424
- ? createDeployApiHandler({
425
- ws,
426
- getManifest,
427
- setManifest,
428
- notifyReload,
429
- })
430
- : null;
431
-
432
459
  if (!context) {
433
460
  // No context — serve static files only (no WS/commands/queries)
434
461
  const cors = {
@@ -458,11 +485,10 @@ export async function startPlatformServer(
458
485
 
459
486
  // Platform handlers only
460
487
  const handlers: ArcHttpHandler[] = [
461
- ...(deployApiHandler ? [deployApiHandler] : []),
462
488
  apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
463
489
  devReloadHandler(sseClients),
464
- staticFilesHandler(ws, !!devMode, moduleAccessMap),
465
- spaFallbackHandler(shellHtml),
490
+ staticFilesHandler(ws, !!devMode, getManifest),
491
+ spaFallbackHandler(getShellHtml),
466
492
  ];
467
493
 
468
494
  for (const handler of handlers) {
@@ -498,11 +524,10 @@ export async function startPlatformServer(
498
524
  port,
499
525
  httpHandlers: [
500
526
  // Platform-specific handlers (checked AFTER arc handlers)
501
- ...(deployApiHandler ? [deployApiHandler] : []),
502
527
  apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
503
528
  devReloadHandler(sseClients),
504
- staticFilesHandler(ws, !!devMode, moduleAccessMap),
505
- spaFallbackHandler(shellHtml),
529
+ staticFilesHandler(ws, !!devMode, getManifest),
530
+ spaFallbackHandler(getShellHtml),
506
531
  ],
507
532
  onWsClose: (clientId) => cleanupClientSubs(clientId),
508
533
  });