@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.
- package/dist/index.js +1696 -1663
- package/package.json +7 -7
- package/src/builder/access-extractor.ts +64 -46
- package/src/builder/build-cache.ts +3 -1
- package/src/builder/chunk-planner.ts +107 -0
- package/src/builder/dependency-collector.ts +83 -41
- package/src/builder/framework-peers.ts +81 -0
- package/src/builder/module-builder.ts +322 -106
- package/src/commands/platform-build.ts +2 -1
- package/src/commands/platform-deploy.ts +121 -64
- package/src/commands/platform-dev.ts +11 -100
- package/src/commands/platform-start.ts +4 -90
- package/src/deploy/ansible.ts +23 -3
- package/src/deploy/assets/ansible/site.yml +23 -7
- package/src/deploy/assets.ts +23 -7
- package/src/deploy/bootstrap.ts +270 -10
- package/src/deploy/caddyfile.ts +19 -23
- package/src/deploy/compose.ts +44 -27
- package/src/deploy/config.ts +67 -3
- package/src/deploy/deploy-env.ts +129 -0
- package/src/deploy/env-file.ts +103 -0
- package/src/deploy/htpasswd.ts +28 -0
- package/src/deploy/image-template.ts +74 -0
- package/src/deploy/image.ts +243 -0
- package/src/deploy/registry.ts +79 -0
- package/src/deploy/ssh.ts +52 -122
- package/src/deploy/survey.ts +64 -0
- package/src/index.ts +20 -13
- package/src/platform/server.ts +119 -94
- package/src/platform/shared.ts +139 -292
- package/src/platform/startup.ts +159 -0
- package/runtime/Dockerfile +0 -29
- package/runtime/build-and-push.sh +0 -23
- package/runtime/entrypoint.sh +0 -58
- package/src/commands/build-shell.ts +0 -152
- package/src/deploy/remote-sync.ts +0 -321
- 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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
"
|
|
132
|
-
"-
|
|
133
|
-
|
|
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
|
-
|
package/src/deploy/survey.ts
CHANGED
|
@@ -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
|
-
.
|
|
63
|
-
|
|
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
|
|