@arcote.tech/arc-cli 0.6.2 → 0.7.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.
package/src/deploy/ssh.ts CHANGED
@@ -20,23 +20,17 @@ export interface SshExecResult {
20
20
  exitCode: number;
21
21
  }
22
22
 
23
- function baseSshArgs(target: DeployTarget): string[] {
24
- // IdentitiesOnly=yes pins auth to the explicit key. Without it ssh-agent
25
- // offers every loaded identity and trips MaxAuthTries on hardened sshd
26
- // (ansible's sshd hardening + fail2ban lowers the threshold).
27
- const key = target.sshKey ?? `${process.env.HOME}/.ssh/id_ed25519`;
28
- return [
23
+ export function baseSshArgs(target: DeployTarget): string[] {
24
+ const args = [
29
25
  "-o",
30
26
  "BatchMode=yes",
31
27
  "-o",
32
28
  "StrictHostKeyChecking=accept-new",
33
- "-o",
34
- "IdentitiesOnly=yes",
35
- "-i",
36
- key,
37
29
  "-p",
38
30
  String(target.port),
39
31
  ];
32
+ if (target.sshKey) args.push("-i", target.sshKey);
33
+ return args;
40
34
  }
41
35
 
42
36
  /**
@@ -121,19 +115,15 @@ export async function scpUpload(
121
115
  localPath: string,
122
116
  remotePath: string,
123
117
  ): Promise<void> {
124
- const key = target.sshKey ?? `${process.env.HOME}/.ssh/id_ed25519`;
125
118
  const args = [
126
119
  "-o",
127
120
  "BatchMode=yes",
128
121
  "-o",
129
122
  "StrictHostKeyChecking=accept-new",
130
- "-o",
131
- "IdentitiesOnly=yes",
132
- "-i",
133
- key,
134
123
  "-P",
135
124
  String(target.port),
136
125
  ];
126
+ if (target.sshKey) args.push("-i", target.sshKey);
137
127
  args.push(localPath, `${target.user}@${target.host}:${remotePath}`);
138
128
 
139
129
  const proc = spawn({ cmd: ["scp", ...args], stderr: "pipe" });
@@ -145,112 +135,3 @@ export async function scpUpload(
145
135
  throw new Error(`scp failed (${exitCode}): ${stderr}`);
146
136
  }
147
137
  }
148
-
149
- /**
150
- * Rsync a directory to the remote host (preserving permissions, deleting stale files).
151
- *
152
- * `-L` dereferences symlinks on the source side. This is essential because Arc
153
- * projects typically use `bun link` for framework packages — `node_modules/@arcote.tech/*`
154
- * and workspace `@ndt/*` packages are symlinks pointing at paths that don't
155
- * exist on the remote host. Without `-L`, rsync copies them as dangling
156
- * symlinks and the container can't resolve `node_modules/.bin/arc`.
157
- */
158
- export async function rsyncDir(
159
- target: DeployTarget,
160
- localDir: string,
161
- remoteDir: string,
162
- opts: { delete?: boolean } = {},
163
- ): Promise<void> {
164
- const sshCmdParts = ["ssh", "-p", String(target.port)];
165
- if (target.sshKey) sshCmdParts.push("-i", target.sshKey);
166
- const sshCmd = sshCmdParts.join(" ");
167
-
168
- const args: string[] = ["-azL", "-e", sshCmd];
169
- if (opts.delete) args.push("--delete");
170
- // Trailing slash: sync contents, not the dir itself
171
- const src = localDir.endsWith("/") ? localDir : `${localDir}/`;
172
- args.push(src, `${target.user}@${target.host}:${remoteDir}`);
173
-
174
- const proc = spawn({
175
- cmd: ["rsync", ...args],
176
- stderr: "pipe",
177
- stdout: "pipe",
178
- });
179
- const [stderr, exitCode] = await Promise.all([
180
- streamToString(proc.stderr),
181
- proc.exited,
182
- ]);
183
- if (exitCode !== 0) {
184
- throw new Error(`rsync failed (${exitCode}): ${stderr}`);
185
- }
186
- }
187
-
188
- /**
189
- * Open an SSH -L tunnel. Returns a Disposable-like object with `.close()`.
190
- * Caller MUST call close() (or use `using` in Bun) to release the tunnel.
191
- */
192
- export interface SshTunnel {
193
- localPort: number;
194
- close(): void;
195
- }
196
-
197
- export async function openTunnel(
198
- target: DeployTarget,
199
- localPort: number,
200
- remoteHost: string,
201
- remotePort: number,
202
- ): Promise<SshTunnel> {
203
- const args = [
204
- ...baseSshArgs(target),
205
- "-N",
206
- "-L",
207
- `${localPort}:${remoteHost}:${remotePort}`,
208
- `${target.user}@${target.host}`,
209
- ];
210
- const proc = spawn({
211
- cmd: ["ssh", ...args],
212
- stdin: "ignore",
213
- stdout: "pipe",
214
- stderr: "pipe",
215
- });
216
- // Wait briefly for the tunnel to establish. ssh prints nothing on success
217
- // with -N, so we poll a TCP connect on localPort.
218
- const deadline = Date.now() + 10_000;
219
- let lastErr: unknown;
220
- while (Date.now() < deadline) {
221
- if (proc.exitCode !== null) {
222
- const stderr = await streamToString(proc.stderr);
223
- throw new Error(`ssh tunnel exited early: ${stderr}`);
224
- }
225
- try {
226
- const probe = await Bun.connect({
227
- hostname: "127.0.0.1",
228
- port: localPort,
229
- socket: { data() {}, open() {}, close() {}, error() {} },
230
- });
231
- probe.end();
232
- return {
233
- localPort,
234
- close() {
235
- try {
236
- proc.kill();
237
- } catch {
238
- // ignore
239
- }
240
- },
241
- };
242
- } catch (e) {
243
- lastErr = e;
244
- await Bun.sleep(200);
245
- }
246
- }
247
- try {
248
- proc.kill();
249
- } catch {
250
- // ignore
251
- }
252
- throw new Error(
253
- `Failed to establish SSH tunnel on localhost:${localPort}: ${String(lastErr)}`,
254
- );
255
- }
256
-
@@ -151,6 +151,55 @@ export async function runSurvey(): Promise<DeployConfig> {
151
151
  })) as string;
152
152
  if (clack.isCancel(email)) cancel();
153
153
 
154
+ // Phase 6: Private Docker Registry
155
+ clack.note(
156
+ "The host runs a private Docker registry behind Caddy.\nCreate an A record for the registry domain pointing to your host before deploy.",
157
+ "Registry",
158
+ );
159
+ const registryDomain = (await clack.text({
160
+ message: "Registry domain (full FQDN)",
161
+ placeholder: "registry.example.com",
162
+ validate: (v) =>
163
+ /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(v) ? undefined : "Expected a domain",
164
+ })) as string;
165
+ if (clack.isCancel(registryDomain)) cancel();
166
+
167
+ const registryUser = (await clack.text({
168
+ message: "Registry username",
169
+ initialValue: "deploy",
170
+ validate: (v) =>
171
+ /^[a-z_][a-z0-9_-]*$/.test(v) ? undefined : "Invalid username",
172
+ })) as string;
173
+ if (clack.isCancel(registryUser)) cancel();
174
+
175
+ const registryPasswordEnv = (await clack.text({
176
+ message: "Env var holding the registry password",
177
+ initialValue: "ARC_REGISTRY_PASSWORD",
178
+ validate: (v) =>
179
+ /^[A-Z][A-Z0-9_]*$/.test(v)
180
+ ? undefined
181
+ : "Must be UPPER_SNAKE_CASE",
182
+ })) as string;
183
+ if (clack.isCancel(registryPasswordEnv)) cancel();
184
+
185
+ const generatePassword = (await clack.confirm({
186
+ message: "Generate a random password now?",
187
+ initialValue: true,
188
+ })) as boolean;
189
+ if (clack.isCancel(generatePassword)) cancel();
190
+ if (generatePassword) {
191
+ const random = generateRandomPassword(32);
192
+ clack.note(
193
+ `Save this password — you'll need it on every deploy.\n\n export ${registryPasswordEnv}=${random}\n\nThis prompt is the only time it's shown.`,
194
+ "Registry password",
195
+ );
196
+ } else {
197
+ clack.note(
198
+ `Set ${registryPasswordEnv} in your shell before running deploy.`,
199
+ "Registry password",
200
+ );
201
+ }
202
+
154
203
  clack.outro("Configuration ready — writing deploy.arc.json");
155
204
 
156
205
  return {
@@ -162,10 +211,25 @@ export async function runSurvey(): Promise<DeployConfig> {
162
211
  },
163
212
  envs,
164
213
  caddy: { email },
214
+ registry: {
215
+ domain: registryDomain,
216
+ username: registryUser,
217
+ passwordEnv: registryPasswordEnv,
218
+ },
165
219
  provision,
166
220
  };
167
221
  }
168
222
 
223
+ function generateRandomPassword(bytes: number): string {
224
+ // 32 random bytes → 43-char base64url. Plenty of entropy for a registry.
225
+ const buf = new Uint8Array(bytes);
226
+ crypto.getRandomValues(buf);
227
+ return btoa(String.fromCharCode(...buf))
228
+ .replace(/\+/g, "-")
229
+ .replace(/\//g, "_")
230
+ .replace(/=+$/, "");
231
+ }
232
+
169
233
  function cancel(): never {
170
234
  clack.cancel("Cancelled.");
171
235
  process.exit(0);
package/src/index.ts CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { Command } from "commander";
4
4
  import { build } from "./commands/build";
5
- import { buildShell } from "./commands/build-shell";
6
5
  import { dev } from "./commands/dev";
7
6
  import { platformBuild } from "./commands/platform-build";
8
7
  import { platformDeploy } from "./commands/platform-deploy";
@@ -59,20 +58,23 @@ platform
59
58
  )
60
59
  .option("--skip-build", "Skip local build step")
61
60
  .option("--rebuild", "Force rebuild before deploy")
62
- .action((env: string | undefined, opts: { skipBuild?: boolean; rebuild?: boolean }) =>
63
- platformDeploy(env, opts),
61
+ .option("--build-only", "Build the Docker image locally, then exit (no remote push)")
62
+ .option(
63
+ "--image-tag <hash>",
64
+ "Roll back / pin to an existing image tag instead of building a new one",
65
+ )
66
+ .action(
67
+ (
68
+ env: string | undefined,
69
+ opts: {
70
+ skipBuild?: boolean;
71
+ rebuild?: boolean;
72
+ buildOnly?: boolean;
73
+ imageTag?: string;
74
+ },
75
+ ) => platformDeploy(env, opts),
64
76
  );
65
77
 
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
-
76
78
  // Parse command line arguments
77
79
  program.parse(process.argv);
78
80
 
@@ -11,7 +11,6 @@ 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
14
  import type { BuildManifest, ModuleDescriptor, WorkspaceInfo } from "./shared";
16
15
  import type { ModuleAccess } from "@arcote.tech/platform";
17
16
 
@@ -31,10 +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
33
  /** Arc shell entries [shortName, fullPkgName][] for import map. Auto-detected if omitted. */
39
34
  arcEntries?: [string, string][];
40
35
  }
@@ -168,19 +163,30 @@ function serveFile(
168
163
  const MODULE_SIG_SECRET = process.env.ARC_MODULE_SECRET ?? crypto.randomUUID();
169
164
  const MODULE_SIG_TTL = 3600; // 1 hour
170
165
 
171
- function signModuleUrl(filename: string): string {
166
+ /**
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).
171
+ */
172
+ function signChunkUrl(chunk: string, 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(`${chunk}/${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 `/modules/${chunk}/${file}?sig=${sig}&exp=${exp}`;
177
178
  }
178
179
 
179
- function verifyModuleSignature(filename: string, sig: string | null, exp: string | null): boolean {
180
+ function verifyChunkSignature(
181
+ chunk: string,
182
+ file: string,
183
+ sig: string | null,
184
+ exp: string | null,
185
+ ): boolean {
180
186
  if (!sig || !exp) return false;
181
187
  if (Number(exp) < Date.now() / 1000) return false;
182
188
  const hasher = new Bun.CryptoHasher("sha256");
183
- hasher.update(`${filename}:${exp}:${MODULE_SIG_SECRET}`);
189
+ hasher.update(`${chunk}/${file}:${exp}:${MODULE_SIG_SECRET}`);
184
190
  return hasher.digest("hex").slice(0, 16) === sig;
185
191
  }
186
192
 
@@ -216,40 +222,59 @@ function parseArcTokensHeader(header: string | null): any[] {
216
222
  return payloads;
217
223
  }
218
224
 
225
+ /**
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)`).
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.
233
+ */
219
234
  async function filterManifestForTokens(
220
235
  manifest: BuildManifest,
221
236
  moduleAccessMap: Map<string, ModuleAccess>,
222
237
  tokenPayloads: any[],
223
238
  ): Promise<BuildManifest> {
239
+ const allowedChunks = new Set<string>(["public"]);
240
+ for (const t of tokenPayloads) {
241
+ if (t?.tokenType) allowedChunks.add(t.tokenType);
242
+ }
243
+
224
244
  const filtered: ModuleDescriptor[] = [];
225
245
 
226
246
  for (const mod of manifest.modules) {
227
- const access = moduleAccessMap.get(mod.name);
247
+ if (!allowedChunks.has(mod.chunk)) continue;
228
248
 
229
- if (!access) {
249
+ if (mod.chunk === "public") {
230
250
  filtered.push(mod);
231
251
  continue;
232
252
  }
233
253
 
234
- if (tokenPayloads.length === 0) continue;
235
-
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) {
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;
259
+ 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
+ );
264
+ if (!matching) continue;
242
265
  granted = rule.check ? await rule.check(matching) : true;
243
266
  if (granted) break;
244
267
  }
245
268
  }
246
269
 
247
270
  if (granted) {
248
- filtered.push({ ...mod, url: signModuleUrl(mod.file) });
271
+ filtered.push({ ...mod, url: signChunkUrl(mod.chunk, mod.file) });
249
272
  }
250
273
  }
274
+
251
275
  return {
252
276
  modules: filtered,
277
+ chunks: manifest.chunks,
253
278
  shellHash: manifest.shellHash,
254
279
  stylesHash: manifest.stylesHash,
255
280
  buildTime: manifest.buildTime,
@@ -260,30 +285,43 @@ async function filterManifestForTokens(
260
285
  // Platform-specific HTTP handlers
261
286
  // ---------------------------------------------------------------------------
262
287
 
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$/;
293
+
263
294
  function staticFilesHandler(
264
295
  ws: WorkspaceInfo,
265
296
  devMode: boolean,
266
- moduleAccessMap: Map<string, ModuleAccess>,
267
297
  ): ArcHttpHandler {
268
298
  return (_req, url, ctx) => {
269
299
  const path = url.pathname;
270
300
  if (path.startsWith("/shell/"))
271
301
  return serveFile(join(ws.shellDir, path.slice(7)), ctx.corsHeaders);
272
302
  if (path.startsWith("/modules/")) {
273
- const fileWithParams = path.slice(9);
274
- const filename = fileWithParams.split("?")[0];
275
- const moduleName = filename.replace(/\.js$/, "");
303
+ // Expected: /modules/<chunk>/<file>.js
304
+ const rest = path.slice(9);
305
+ const slash = rest.indexOf("/");
306
+ if (slash <= 0) {
307
+ return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
308
+ }
309
+ const chunk = rest.slice(0, slash);
310
+ const file = rest.slice(slash + 1);
276
311
 
277
- // Check access for protected modules
278
- if (moduleAccessMap.has(moduleName)) {
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") {
279
317
  const sig = url.searchParams.get("sig");
280
318
  const exp = url.searchParams.get("exp");
281
- if (!verifyModuleSignature(filename, sig, exp)) {
319
+ if (!verifyChunkSignature(chunk, file, sig, exp)) {
282
320
  return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
283
321
  }
284
322
  }
285
323
 
286
- return serveFile(join(ws.modulesDir, filename), {
324
+ return serveFile(join(ws.modulesDir, chunk, file), {
287
325
  ...ctx.corsHeaders,
288
326
  "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
289
327
  });
@@ -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,10 +485,9 @@ 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),
490
+ staticFilesHandler(ws, !!devMode),
465
491
  spaFallbackHandler(shellHtml),
466
492
  ];
467
493
 
@@ -498,10 +524,9 @@ 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),
529
+ staticFilesHandler(ws, !!devMode),
505
530
  spaFallbackHandler(shellHtml),
506
531
  ],
507
532
  onWsClose: (clientId) => cleanupClientSubs(clientId),