@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
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,
@@ -20,23 +43,36 @@ export interface SshExecResult {
20
43
  exitCode: number;
21
44
  }
22
45
 
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 [
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);
52
+ const args = [
29
53
  "-o",
30
54
  "BatchMode=yes",
31
55
  "-o",
32
56
  "StrictHostKeyChecking=accept-new",
33
57
  "-o",
34
- "IdentitiesOnly=yes",
35
- "-i",
36
- key,
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",
37
69
  "-p",
38
70
  String(target.port),
39
71
  ];
72
+ if (key) {
73
+ args.push("-o", "IdentitiesOnly=yes", "-i", key);
74
+ }
75
+ return args;
40
76
  }
41
77
 
42
78
  /**
@@ -121,19 +157,22 @@ export async function scpUpload(
121
157
  localPath: string,
122
158
  remotePath: string,
123
159
  ): Promise<void> {
124
- const key = target.sshKey ?? `${process.env.HOME}/.ssh/id_ed25519`;
160
+ const key = pickSshKey(target);
125
161
  const args = [
126
162
  "-o",
127
163
  "BatchMode=yes",
128
164
  "-o",
129
165
  "StrictHostKeyChecking=accept-new",
130
166
  "-o",
131
- "IdentitiesOnly=yes",
132
- "-i",
133
- key,
167
+ "PreferredAuthentications=publickey",
168
+ "-o",
169
+ "ConnectTimeout=5",
134
170
  "-P",
135
171
  String(target.port),
136
172
  ];
173
+ if (key) {
174
+ args.push("-o", "IdentitiesOnly=yes", "-i", key);
175
+ }
137
176
  args.push(localPath, `${target.user}@${target.host}:${remotePath}`);
138
177
 
139
178
  const proc = spawn({ cmd: ["scp", ...args], stderr: "pipe" });
@@ -145,112 +184,3 @@ export async function scpUpload(
145
184
  throw new Error(`scp failed (${exitCode}): ${stderr}`);
146
185
  }
147
186
  }
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,28 @@ 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
+ .option(
67
+ "--force-bootstrap",
68
+ "Re-run Ansible host bootstrap even if the server is already configured",
69
+ )
70
+ .action(
71
+ (
72
+ env: string | undefined,
73
+ opts: {
74
+ skipBuild?: boolean;
75
+ rebuild?: boolean;
76
+ buildOnly?: boolean;
77
+ imageTag?: string;
78
+ forceBootstrap?: boolean;
79
+ },
80
+ ) => platformDeploy(env, opts),
64
81
  );
65
82
 
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
83
  // Parse command line arguments
77
84
  program.parse(process.argv);
78
85