@arcote.tech/arc-cli 0.7.0 → 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.
@@ -55,7 +55,7 @@ export async function buildImage(
55
55
  ): Promise<BuildImageResult> {
56
56
  await ensureDocker();
57
57
 
58
- const manifestPath = join(ws.modulesDir, "manifest.json");
58
+ const manifestPath = join(ws.arcDir, "manifest.json");
59
59
  if (!existsSync(manifestPath)) {
60
60
  throw new Error(
61
61
  `No build manifest at ${manifestPath}. Run \`arc platform build\` first or omit --skip-build.`,
@@ -89,8 +89,14 @@ export async function buildImage(
89
89
  const dockerfilePath = join(dockerfileDir, "Dockerfile");
90
90
  writeFileSync(dockerfilePath, dockerfile);
91
91
 
92
+ // Always build linux/amd64. Most production servers (Hetzner cpx*, AWS x86,
93
+ // DigitalOcean, etc.) are x86_64. Without --platform, buildx defaults to
94
+ // the host arch — Apple Silicon devs produce arm64 images that fail with
95
+ // `exec format error` on AMD hosts. Cross-arch QEMU emulation is slow but
96
+ // reliable; we accept the tradeoff for portability.
92
97
  const buildArgs = [
93
98
  "build",
99
+ "--platform=linux/amd64",
94
100
  "-f",
95
101
  dockerfilePath,
96
102
  "-t",
package/src/deploy/ssh.ts CHANGED
@@ -1,6 +1,29 @@
1
1
  import { spawn } from "bun";
2
+ import { existsSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
2
5
  import type { DeployTarget } from "./config";
3
6
 
7
+ /**
8
+ * Pick a private SSH key to authenticate with. Honors target.sshKey if set,
9
+ * otherwise tries the standard candidates in ~/.ssh/. Returns null if no
10
+ * usable key is found — caller can then fall back to default ssh-agent
11
+ * behavior (i.e. omit IdentitiesOnly).
12
+ */
13
+ function pickSshKey(target: DeployTarget): string | null {
14
+ if (target.sshKey) {
15
+ const expanded = target.sshKey.startsWith("~")
16
+ ? join(homedir(), target.sshKey.slice(1))
17
+ : target.sshKey;
18
+ return existsSync(expanded) ? expanded : null;
19
+ }
20
+ for (const name of ["id_ed25519", "id_ecdsa", "id_rsa"]) {
21
+ const path = join(homedir(), ".ssh", name);
22
+ if (existsSync(path)) return path;
23
+ }
24
+ return null;
25
+ }
26
+
4
27
  /** Convert a Bun subprocess stream (which may be a ReadableStream or undefined) to string. */
5
28
  async function streamToString(
6
29
  stream: ReadableStream<Uint8Array> | number | undefined,
@@ -21,15 +44,34 @@ export interface SshExecResult {
21
44
  }
22
45
 
23
46
  export function baseSshArgs(target: DeployTarget): string[] {
47
+ // Pin to a single identity to avoid "Too many authentication failures":
48
+ // the server's MaxAuthTries=3 (set by ansible) trips when ssh-agent has
49
+ // more than 3 keys loaded and walks all of them before reaching ours.
50
+ // PreferredAuthentications=publickey skips gssapi/keyboard prompts entirely.
51
+ const key = pickSshKey(target);
24
52
  const args = [
25
53
  "-o",
26
54
  "BatchMode=yes",
27
55
  "-o",
28
56
  "StrictHostKeyChecking=accept-new",
57
+ "-o",
58
+ "PreferredAuthentications=publickey",
59
+ // Fail fast: TCP-level connect attempts give up after 5s instead of
60
+ // hanging on the default ~75s when ufw drops, fail2ban bans, or the
61
+ // VM is briefly unreachable. ServerAlive* keeps an established
62
+ // connection from silently stalling on a dead route.
63
+ "-o",
64
+ "ConnectTimeout=5",
65
+ "-o",
66
+ "ServerAliveInterval=10",
67
+ "-o",
68
+ "ServerAliveCountMax=2",
29
69
  "-p",
30
70
  String(target.port),
31
71
  ];
32
- if (target.sshKey) args.push("-i", target.sshKey);
72
+ if (key) {
73
+ args.push("-o", "IdentitiesOnly=yes", "-i", key);
74
+ }
33
75
  return args;
34
76
  }
35
77
 
@@ -115,15 +157,22 @@ export async function scpUpload(
115
157
  localPath: string,
116
158
  remotePath: string,
117
159
  ): Promise<void> {
160
+ const key = pickSshKey(target);
118
161
  const args = [
119
162
  "-o",
120
163
  "BatchMode=yes",
121
164
  "-o",
122
165
  "StrictHostKeyChecking=accept-new",
166
+ "-o",
167
+ "PreferredAuthentications=publickey",
168
+ "-o",
169
+ "ConnectTimeout=5",
123
170
  "-P",
124
171
  String(target.port),
125
172
  ];
126
- if (target.sshKey) args.push("-i", target.sshKey);
173
+ if (key) {
174
+ args.push("-o", "IdentitiesOnly=yes", "-i", key);
175
+ }
127
176
  args.push(localPath, `${target.user}@${target.host}:${remotePath}`);
128
177
 
129
178
  const proc = spawn({ cmd: ["scp", ...args], stderr: "pipe" });
package/src/index.ts CHANGED
@@ -63,6 +63,10 @@ platform
63
63
  "--image-tag <hash>",
64
64
  "Roll back / pin to an existing image tag instead of building a new one",
65
65
  )
66
+ .option(
67
+ "--force-bootstrap",
68
+ "Re-run Ansible host bootstrap even if the server is already configured",
69
+ )
66
70
  .action(
67
71
  (
68
72
  env: string | undefined,
@@ -71,6 +75,7 @@ platform
71
75
  rebuild?: boolean;
72
76
  buildOnly?: boolean;
73
77
  imageTag?: string;
78
+ forceBootstrap?: boolean;
74
79
  },
75
80
  ) => platformDeploy(env, opts),
76
81
  );
@@ -11,8 +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, ModuleDescriptor, WorkspaceInfo } from "./shared";
15
- import type { ModuleAccess } from "@arcote.tech/platform";
14
+ import type { BuildManifest, WorkspaceInfo } from "./shared";
15
+ import type { BuildManifestGroup, ModuleAccess } from "@arcote.tech/platform";
16
16
 
17
17
  // ---------------------------------------------------------------------------
18
18
  // Types
@@ -30,8 +30,6 @@ export interface PlatformServerOptions {
30
30
  dbPath?: string;
31
31
  /** If true, enables SSE reload stream + mutable manifest (dev mode) */
32
32
  devMode?: boolean;
33
- /** Arc shell entries [shortName, fullPkgName][] for import map. Auto-detected if omitted. */
34
- arcEntries?: [string, string][];
35
33
  }
36
34
 
37
35
  export interface PlatformServer {
@@ -74,25 +72,15 @@ export async function initContextHandler(
74
72
  export function generateShellHtml(
75
73
  appName: string,
76
74
  manifest?: { title: string; favicon?: string },
77
- arcEntries?: [string, string][],
75
+ initial?: { file: string; hash: string },
78
76
  ): string {
79
- const arcImports: Record<string, string> = {};
80
- if (arcEntries) {
81
- for (const [short, pkg] of arcEntries) {
82
- arcImports[pkg] = `/shell/${short}.js`;
83
- }
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");
84
83
  }
85
- const importMap = {
86
- imports: {
87
- react: "/shell/react.js",
88
- "react/jsx-runtime": "/shell/jsx-runtime.js",
89
- "react/jsx-dev-runtime": "/shell/jsx-dev-runtime.js",
90
- "react-dom": "/shell/react-dom.js",
91
- "react-dom/client": "/shell/react-dom-client.js",
92
- ...arcImports,
93
- },
94
- };
95
-
96
84
  return `<!doctype html>
97
85
  <html lang="en">
98
86
  <head>
@@ -101,18 +89,13 @@ export function generateShellHtml(
101
89
  <title>${manifest?.title ?? appName}</title>${manifest?.favicon ? `\n <link rel="icon" href="${manifest.favicon}">` : ""}${manifest ? `\n <link rel="manifest" href="/manifest.json">` : ""}
102
90
  <link rel="stylesheet" href="/styles.css" />
103
91
  <link rel="stylesheet" href="/theme.css" />
104
- <script type="importmap">${JSON.stringify(importMap)}</script>
92
+ <link rel="modulepreload" href="${initialUrl}" />
105
93
  </head>
106
94
  <body>
107
95
  <div id="root"></div>
108
96
  <script type="module">
109
- import { createRoot } from "react-dom/client";
110
- import { createElement } from "react";
111
- import { PlatformApp } from "@arcote.tech/platform";
112
-
113
- createRoot(document.getElementById("root")).render(
114
- createElement(PlatformApp)
115
- );
97
+ import { startApp } from "${initialUrl}";
98
+ startApp("root");
116
99
  </script>
117
100
  </body>
118
101
  </html>`;
@@ -160,25 +143,41 @@ function serveFile(
160
143
  // Module access — signed URLs
161
144
  // ---------------------------------------------------------------------------
162
145
 
163
- 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 ?? "";
164
151
  const MODULE_SIG_TTL = 3600; // 1 hour
165
152
 
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
+
166
165
  /**
167
- * Signed URL for a chunk-scoped module file. HMAC payload includes the chunk
168
- * name so a sig minted for `/modules/admin/foo.js` cannot be replayed as
169
- * `/modules/user/foo.js` even if the same filename exists in both groups.
170
- * Public chunks are NEVER signed (no protection needed).
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
171
  */
172
- function signChunkUrl(chunk: string, file: string): string {
172
+ function signGroupUrl(file: string): string {
173
173
  const exp = Math.floor(Date.now() / 1000) + MODULE_SIG_TTL;
174
174
  const hasher = new Bun.CryptoHasher("sha256");
175
- hasher.update(`${chunk}/${file}:${exp}:${MODULE_SIG_SECRET}`);
175
+ hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
176
176
  const sig = hasher.digest("hex").slice(0, 16);
177
- return `/modules/${chunk}/${file}?sig=${sig}&exp=${exp}`;
177
+ return `/browser/${file}?sig=${sig}&exp=${exp}`;
178
178
  }
179
179
 
180
- function verifyChunkSignature(
181
- chunk: string,
180
+ function verifyGroupSignature(
182
181
  file: string,
183
182
  sig: string | null,
184
183
  exp: string | null,
@@ -186,7 +185,7 @@ function verifyChunkSignature(
186
185
  if (!sig || !exp) return false;
187
186
  if (Number(exp) < Date.now() / 1000) return false;
188
187
  const hasher = new Bun.CryptoHasher("sha256");
189
- hasher.update(`${chunk}/${file}:${exp}:${MODULE_SIG_SECRET}`);
188
+ hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
190
189
  return hasher.digest("hex").slice(0, 16) === sig;
191
190
  }
192
191
 
@@ -223,59 +222,59 @@ function parseArcTokensHeader(header: string | null): any[] {
223
222
  }
224
223
 
225
224
  /**
226
- * Filter manifest to modules the caller's tokens grant access to. Two-stage
227
- * check: chunk-level (token type matches chunk name group is unlocked) +
228
- * per-rule async `check` callback (if declared in `protectedBy(token, check)`).
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
229
  *
230
- * Public chunk modules pass unconditionally; non-public modules are returned
231
- * with a signed URL (chunk-aware HMAC, 1h TTL) the URL is what the browser
232
- * fetches for the actual JS file.
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
233
  */
234
234
  async function filterManifestForTokens(
235
235
  manifest: BuildManifest,
236
236
  moduleAccessMap: Map<string, ModuleAccess>,
237
237
  tokenPayloads: any[],
238
238
  ): Promise<BuildManifest> {
239
- const allowedChunks = new Set<string>(["public"]);
239
+ const allowedGroups = new Set<string>();
240
240
  for (const t of tokenPayloads) {
241
- if (t?.tokenType) allowedChunks.add(t.tokenType);
241
+ if (t?.tokenType) allowedGroups.add(t.tokenType);
242
242
  }
243
243
 
244
- const filtered: ModuleDescriptor[] = [];
244
+ const filteredGroups: Record<string, BuildManifestGroup> = {};
245
245
 
246
- for (const mod of manifest.modules) {
247
- if (!allowedChunks.has(mod.chunk)) continue;
246
+ for (const [name, group] of Object.entries(manifest.groups)) {
247
+ if (!allowedGroups.has(name)) continue;
248
248
 
249
- if (mod.chunk === "public") {
250
- filtered.push(mod);
251
- continue;
252
- }
253
-
254
- // Caller has the right token type — run per-rule `check` if declared.
255
- const access = moduleAccessMap.get(mod.name);
256
- let granted = true;
257
- if (access && access.rules.length > 0) {
258
- granted = false;
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;
259
256
  for (const rule of access.rules) {
260
- if (rule.token.name !== mod.chunk) continue;
261
- const matching = tokenPayloads.find(
262
- (t) => t.tokenType === rule.token.name,
263
- );
257
+ if (rule.token.name !== name) continue;
258
+ const matching = tokenPayloads.find((t) => t.tokenType === name);
264
259
  if (!matching) continue;
265
260
  granted = rule.check ? await rule.check(matching) : true;
266
261
  if (granted) break;
267
262
  }
263
+ if (!granted) {
264
+ allGranted = false;
265
+ break;
266
+ }
268
267
  }
269
268
 
270
- if (granted) {
271
- filtered.push({ ...mod, url: signChunkUrl(mod.chunk, mod.file) });
269
+ if (allGranted) {
270
+ filteredGroups[name] = { ...group, url: signGroupUrl(group.file) };
272
271
  }
273
272
  }
274
273
 
275
274
  return {
276
- modules: filtered,
277
- chunks: manifest.chunks,
278
- shellHash: manifest.shellHash,
275
+ initial: manifest.initial,
276
+ groups: filteredGroups,
277
+ sharedChunks: manifest.sharedChunks,
279
278
  stylesHash: manifest.stylesHash,
280
279
  buildTime: manifest.buildTime,
281
280
  };
@@ -285,43 +284,40 @@ async function filterManifestForTokens(
285
284
  // Platform-specific HTTP handlers
286
285
  // ---------------------------------------------------------------------------
287
286
 
288
- /** Chunk names must be alphanumeric + dash/underscore — defends against
289
- * path traversal in URL segments like `/modules/../../etc/passwd`. */
290
- const CHUNK_NAME_RE = /^[A-Za-z0-9_-]+$/;
291
- /** Module filenames are Bun.build outputs — `<safeName>.js` or `chunk-<hash>.js`. */
292
- const MODULE_FILE_RE = /^[A-Za-z0-9_.-]+\.js$/;
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$/;
293
290
 
294
291
  function staticFilesHandler(
295
292
  ws: WorkspaceInfo,
296
293
  devMode: boolean,
294
+ getManifest: () => BuildManifest,
297
295
  ): ArcHttpHandler {
298
296
  return (_req, url, ctx) => {
299
297
  const path = url.pathname;
300
- if (path.startsWith("/shell/"))
301
- return serveFile(join(ws.shellDir, path.slice(7)), ctx.corsHeaders);
302
- if (path.startsWith("/modules/")) {
303
- // Expected: /modules/<chunk>/<file>.js
304
- const rest = path.slice(9);
305
- const slash = rest.indexOf("/");
306
- if (slash <= 0) {
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)) {
307
303
  return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
308
304
  }
309
- const chunk = rest.slice(0, slash);
310
- const file = rest.slice(slash + 1);
311
305
 
312
- if (!CHUNK_NAME_RE.test(chunk) || !MODULE_FILE_RE.test(file)) {
313
- return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
314
- }
315
-
316
- if (chunk !== "public") {
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) {
317
313
  const sig = url.searchParams.get("sig");
318
314
  const exp = url.searchParams.get("exp");
319
- if (!verifyChunkSignature(chunk, file, sig, exp)) {
315
+ if (!verifyGroupSignature(file, sig, exp)) {
320
316
  return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
321
317
  }
322
318
  }
323
319
 
324
- return serveFile(join(ws.modulesDir, chunk, file), {
320
+ return serveFile(join(ws.browserDir, file), {
325
321
  ...ctx.corsHeaders,
326
322
  "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
327
323
  });
@@ -381,7 +377,7 @@ function apiEndpointsHandler(
381
377
  return Response.json(
382
378
  {
383
379
  status: "ok",
384
- modules: getManifest().modules.length,
380
+ groups: Object.keys(getManifest().groups).length,
385
381
  clients: cm?.clientCount ?? 0,
386
382
  },
387
383
  { headers: ctx.corsHeaders },
@@ -419,9 +415,9 @@ function devReloadHandler(
419
415
  };
420
416
  }
421
417
 
422
- function spaFallbackHandler(shellHtml: string): ArcHttpHandler {
418
+ function spaFallbackHandler(getShellHtml: () => string): ArcHttpHandler {
423
419
  return (_req, _url, ctx) => {
424
- return new Response(shellHtml, {
420
+ return new Response(getShellHtml(), {
425
421
  headers: { ...ctx.corsHeaders, "Content-Type": "text/html" },
426
422
  });
427
423
  };
@@ -435,6 +431,7 @@ export async function startPlatformServer(
435
431
  opts: PlatformServerOptions,
436
432
  ): Promise<PlatformServer> {
437
433
  const { ws, port, devMode, context } = opts;
434
+ ensureModuleSigSecret(ws, !!devMode);
438
435
  const moduleAccessMap = opts.moduleAccess ?? new Map();
439
436
  let manifest = opts.manifest;
440
437
  const getManifest = () => manifest;
@@ -442,7 +439,10 @@ export async function startPlatformServer(
442
439
  manifest = m;
443
440
  };
444
441
 
445
- 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);
446
446
  const sseClients = new Set<ReadableStreamDefaultController>();
447
447
 
448
448
  const notifyReload = (m: BuildManifest) => {
@@ -487,8 +487,8 @@ export async function startPlatformServer(
487
487
  const handlers: ArcHttpHandler[] = [
488
488
  apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
489
489
  devReloadHandler(sseClients),
490
- staticFilesHandler(ws, !!devMode),
491
- spaFallbackHandler(shellHtml),
490
+ staticFilesHandler(ws, !!devMode, getManifest),
491
+ spaFallbackHandler(getShellHtml),
492
492
  ];
493
493
 
494
494
  for (const handler of handlers) {
@@ -526,8 +526,8 @@ export async function startPlatformServer(
526
526
  // Platform-specific handlers (checked AFTER arc handlers)
527
527
  apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
528
528
  devReloadHandler(sseClients),
529
- staticFilesHandler(ws, !!devMode),
530
- spaFallbackHandler(shellHtml),
529
+ staticFilesHandler(ws, !!devMode, getManifest),
530
+ spaFallbackHandler(getShellHtml),
531
531
  ],
532
532
  onWsClose: (clientId) => cleanupClientSubs(clientId),
533
533
  });