@arcote.tech/arc-cli 0.5.8 → 0.6.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/dist/index.js +917 -416
- package/package.json +7 -7
- package/runtime/Dockerfile +29 -0
- package/runtime/build-and-push.sh +23 -0
- package/runtime/entrypoint.sh +27 -0
- package/src/builder/access-extractor.ts +127 -0
- package/src/builder/dependency-collector.ts +155 -0
- package/src/commands/build-shell.ts +152 -0
- package/src/commands/platform-start.ts +36 -5
- package/src/deploy/ansible.ts +26 -23
- package/src/deploy/bootstrap.ts +11 -5
- package/src/deploy/compose.ts +31 -13
- package/src/deploy/config.ts +9 -4
- package/src/deploy/remote-state.ts +7 -0
- package/src/deploy/remote-sync.ts +199 -78
- package/src/deploy/terraform.ts +42 -22
- package/src/index.ts +11 -0
- package/src/platform/deploy-api.ts +303 -90
- package/src/platform/shared.ts +21 -0
package/src/deploy/bootstrap.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { saveDeployConfig } from "./config";
|
|
|
10
10
|
import { ok, log, err } from "../platform/shared";
|
|
11
11
|
import { writeStateMarker, STATE_MARKER_PATH } from "./remote-state";
|
|
12
12
|
import type { RemoteState } from "./remote-state";
|
|
13
|
-
import { assertExec, scpUpload, waitForSsh } from "./ssh";
|
|
13
|
+
import { assertExec, canSsh, scpUpload, waitForSsh } from "./ssh";
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
// Bootstrap orchestrator.
|
|
@@ -55,6 +55,7 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
|
55
55
|
tf: cfg.provision.terraform,
|
|
56
56
|
token,
|
|
57
57
|
serverName: `arc-${Object.keys(cfg.envs)[0] ?? "host"}`,
|
|
58
|
+
workspaceDir: rootDir,
|
|
58
59
|
});
|
|
59
60
|
ok(`Server provisioned: ${tfOut.serverIp}`);
|
|
60
61
|
|
|
@@ -69,9 +70,11 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
|
69
70
|
|
|
70
71
|
if (state.kind === "unreachable" || state.kind === "no-docker") {
|
|
71
72
|
log("Running Ansible bootstrap (Docker + firewall + SSH hardening)...");
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
const
|
|
73
|
+
// Run as root whenever the configured user can't SSH (covers both freshly
|
|
74
|
+
// provisioned VMs and second-attempt deploys after ansible failure).
|
|
75
|
+
const deployUserWorks =
|
|
76
|
+
state.kind === "no-docker" && (await canSsh(cfg.target));
|
|
77
|
+
const asRoot = !deployUserWorks;
|
|
75
78
|
await runAnsible({
|
|
76
79
|
target: cfg.target,
|
|
77
80
|
ansible: cfg.provision?.ansible,
|
|
@@ -99,7 +102,10 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
99
102
|
mkdirSync(workDir, { recursive: true });
|
|
100
103
|
|
|
101
104
|
writeFileSync(join(workDir, "Caddyfile"), generateCaddyfile(cfg));
|
|
102
|
-
writeFileSync(
|
|
105
|
+
writeFileSync(
|
|
106
|
+
join(workDir, "docker-compose.yml"),
|
|
107
|
+
generateCompose({ cfg, cliVersion: inputs.cliVersion }),
|
|
108
|
+
);
|
|
103
109
|
|
|
104
110
|
// Ensure remoteDir exists
|
|
105
111
|
await assertExec(
|
package/src/deploy/compose.ts
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import type { DeployConfig } from "./config";
|
|
2
2
|
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
|
-
// docker-compose.yml generator
|
|
4
|
+
// docker-compose.yml generator — v0.6
|
|
5
5
|
//
|
|
6
6
|
// Services:
|
|
7
7
|
// - caddy (public 80/443, loopback 127.0.0.1:2019 for deploy tunnel)
|
|
8
|
-
// - arc-<env> per entry in deploy.arc.json envs
|
|
8
|
+
// - arc-<env> per entry in deploy.arc.json envs
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// arc-<env> uses `arcote/runtime:1` (generic image). CLI version is passed
|
|
11
|
+
// via ARC_CLI_VERSION env var — entrypoint installs arc-cli on first boot,
|
|
12
|
+
// then `arc platform start` decides between pre-deploy and full mode based
|
|
13
|
+
// on volume state. No user code or node_modules on host disk (everything
|
|
14
|
+
// lives in named volumes, populated via /api/deploy/* multipart pushes).
|
|
13
15
|
// ---------------------------------------------------------------------------
|
|
14
16
|
|
|
15
17
|
export interface ComposeOptions {
|
|
16
18
|
cfg: DeployConfig;
|
|
19
|
+
/** CLI version used by entrypoint.sh to `bun add @arcote.tech/arc-cli@VER`. */
|
|
20
|
+
cliVersion: string;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
const RESERVED_ENV = new Set(["PORT", "ARC_DEPLOY_API", "ARC_CLI_VERSION"]);
|
|
24
|
+
|
|
25
|
+
export function generateCompose({ cfg, cliVersion }: ComposeOptions): string {
|
|
20
26
|
const lines: string[] = [];
|
|
21
27
|
lines.push("# Generated by `arc platform deploy` — do not edit by hand.");
|
|
22
28
|
lines.push("");
|
|
23
29
|
lines.push("services:");
|
|
30
|
+
|
|
31
|
+
// Caddy
|
|
24
32
|
lines.push(" caddy:");
|
|
25
33
|
lines.push(" image: caddy:2-alpine");
|
|
26
34
|
lines.push(" restart: unless-stopped");
|
|
@@ -36,22 +44,28 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
36
44
|
lines.push(" - arc-net");
|
|
37
45
|
lines.push("");
|
|
38
46
|
|
|
47
|
+
// Per-env runtime containers
|
|
39
48
|
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
40
49
|
lines.push(` arc-${name}:`);
|
|
41
|
-
lines.push(" image:
|
|
50
|
+
lines.push(" image: pkrasinski/arc-runtime:1");
|
|
42
51
|
lines.push(" restart: unless-stopped");
|
|
43
|
-
lines.push(` working_dir: /app`);
|
|
44
52
|
lines.push(" volumes:");
|
|
45
|
-
lines.push(` -
|
|
53
|
+
lines.push(` - arc-platform-${name}:/app/.arc/platform`);
|
|
46
54
|
lines.push(` - arc-data-${name}:/app/.arc/data`);
|
|
55
|
+
lines.push(" - arc-cli-cache:/app/.arc/cli");
|
|
56
|
+
lines.push(" - arc-bun-cache:/root/.bun/install/cache");
|
|
47
57
|
lines.push(" environment:");
|
|
48
58
|
lines.push(" PORT: 5005");
|
|
49
|
-
lines.push(
|
|
50
|
-
lines.push(
|
|
51
|
-
|
|
59
|
+
lines.push(' ARC_DEPLOY_API: "1"');
|
|
60
|
+
lines.push(` ARC_CLI_VERSION: ${JSON.stringify(cliVersion)}`);
|
|
61
|
+
const userEnv = env.envVars ?? {};
|
|
62
|
+
if (!("NODE_ENV" in userEnv)) {
|
|
63
|
+
lines.push(" NODE_ENV: production");
|
|
64
|
+
}
|
|
65
|
+
for (const [k, v] of Object.entries(userEnv)) {
|
|
66
|
+
if (RESERVED_ENV.has(k)) continue;
|
|
52
67
|
lines.push(` ${k}: ${JSON.stringify(v)}`);
|
|
53
68
|
}
|
|
54
|
-
lines.push(" command: [\"node_modules/.bin/arc\", \"platform\", \"start\"]");
|
|
55
69
|
lines.push(" networks:");
|
|
56
70
|
lines.push(" - arc-net");
|
|
57
71
|
lines.push(" expose:");
|
|
@@ -59,13 +73,17 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
59
73
|
lines.push("");
|
|
60
74
|
}
|
|
61
75
|
|
|
76
|
+
// Networks + volumes
|
|
62
77
|
lines.push("networks:");
|
|
63
78
|
lines.push(" arc-net:");
|
|
64
79
|
lines.push("");
|
|
65
80
|
lines.push("volumes:");
|
|
66
81
|
lines.push(" caddy_data:");
|
|
67
82
|
lines.push(" caddy_config:");
|
|
83
|
+
lines.push(" arc-cli-cache:");
|
|
84
|
+
lines.push(" arc-bun-cache:");
|
|
68
85
|
for (const [name] of Object.entries(cfg.envs)) {
|
|
86
|
+
lines.push(` arc-platform-${name}:`);
|
|
69
87
|
lines.push(` arc-data-${name}:`);
|
|
70
88
|
}
|
|
71
89
|
|
package/src/deploy/config.ts
CHANGED
|
@@ -96,10 +96,15 @@ export function loadDeployConfig(rootDir: string): DeployConfig {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
export function saveDeployConfig(rootDir: string, cfg: DeployConfig): void {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
99
|
+
const path = deployConfigPath(rootDir);
|
|
100
|
+
// Re-read raw JSON so we preserve `${VAR}` placeholders and don't accidentally
|
|
101
|
+
// serialize expanded secrets back to disk. We only patch target (the only
|
|
102
|
+
// field bootstrap ever mutates — terraform-assigned host IP).
|
|
103
|
+
const raw = existsSync(path)
|
|
104
|
+
? (JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>)
|
|
105
|
+
: {};
|
|
106
|
+
raw.target = { ...(raw.target as object | undefined), ...cfg.target };
|
|
107
|
+
writeFileSync(path, JSON.stringify(raw, null, 2) + "\n");
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
// ---------------------------------------------------------------------------
|
|
@@ -37,6 +37,13 @@ export async function detectRemoteState(
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
if (!(await canSsh(cfg.target))) {
|
|
40
|
+
// On a freshly provisioned VM, only root exists — ansible creates `deploy`
|
|
41
|
+
// later. If root SSH works but the configured user doesn't, treat as
|
|
42
|
+
// no-docker so bootstrap re-runs ansible (idempotent) instead of spinning
|
|
43
|
+
// up a duplicate server via terraform.
|
|
44
|
+
if (await canSsh({ ...cfg.target, user: "root" })) {
|
|
45
|
+
return { kind: "no-docker" };
|
|
46
|
+
}
|
|
40
47
|
return { kind: "unreachable", reason: "ssh connection failed" };
|
|
41
48
|
}
|
|
42
49
|
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "fs";
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
2
|
+
import { basename, join } from "path";
|
|
3
3
|
import type { BuildManifest, ModuleDescriptor } from "@arcote.tech/platform";
|
|
4
4
|
import type { DeployConfig } from "./config";
|
|
5
|
-
import {
|
|
5
|
+
import { assertExec, openTunnel } from "./ssh";
|
|
6
|
+
import { isContextPackage } from "../builder/module-builder";
|
|
6
7
|
import type { WorkspaceInfo } from "../platform/shared";
|
|
7
8
|
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
|
-
//
|
|
10
|
+
// v0.6 sync driver — API-only, per-module. No rsync of user code.
|
|
10
11
|
//
|
|
11
12
|
// Flow per env:
|
|
12
|
-
// 1.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// 4.
|
|
18
|
-
// 5.
|
|
19
|
-
// 6.
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// and push an SSE event so connected browser clients reload without F5.
|
|
13
|
+
// 1. Open SSH tunnel to Caddy 127.0.0.1:2019 (admin listener)
|
|
14
|
+
// 2. GET /api/deploy/framework → remote framework depsHash
|
|
15
|
+
// 3. If local hash differs → POST /api/deploy/framework (multipart
|
|
16
|
+
// package.json + bun.lock). Response needsRestart=true → close tunnel,
|
|
17
|
+
// `docker restart arc-${env}`, reopen tunnel, wait for /health.
|
|
18
|
+
// 4. GET /api/deploy/manifest → remote manifest
|
|
19
|
+
// 5. diffManifests → per-module list of changes
|
|
20
|
+
// 6. For each changed module: POST /api/deploy/modules/<name>
|
|
21
|
+
// (browser.js + server.js? + package.json + access.json?)
|
|
22
|
+
// 7. If styles changed: POST /api/deploy/styles
|
|
23
|
+
// 8. POST /api/deploy/manifest (commit). Response needsRestart=true when
|
|
24
|
+
// any module had server.js change → second restart.
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
|
|
27
27
|
export interface SyncInputs {
|
|
@@ -34,9 +34,10 @@ export interface SyncInputs {
|
|
|
34
34
|
|
|
35
35
|
export interface SyncOutcome {
|
|
36
36
|
env: string;
|
|
37
|
+
frameworkChanged: boolean;
|
|
37
38
|
changedModules: readonly string[];
|
|
38
|
-
shellChanged: boolean;
|
|
39
39
|
stylesChanged: boolean;
|
|
40
|
+
restarts: number;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
// ---------------------------------------------------------------------------
|
|
@@ -45,7 +46,6 @@ export interface SyncOutcome {
|
|
|
45
46
|
|
|
46
47
|
export interface ManifestDiff {
|
|
47
48
|
changedModules: ModuleDescriptor[];
|
|
48
|
-
shellChanged: boolean;
|
|
49
49
|
stylesChanged: boolean;
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -59,7 +59,6 @@ export function diffManifests(
|
|
|
59
59
|
);
|
|
60
60
|
return {
|
|
61
61
|
changedModules: [...changedModules],
|
|
62
|
-
shellChanged: local.shellHash !== remote.shellHash,
|
|
63
62
|
stylesChanged: local.stylesHash !== remote.stylesHash,
|
|
64
63
|
};
|
|
65
64
|
}
|
|
@@ -69,15 +68,11 @@ export function diffManifests(
|
|
|
69
68
|
// ---------------------------------------------------------------------------
|
|
70
69
|
|
|
71
70
|
export async function syncEnv(inputs: SyncInputs): Promise<SyncOutcome> {
|
|
72
|
-
const { cfg, env, ws
|
|
71
|
+
const { cfg, env, ws } = inputs;
|
|
73
72
|
const envConfig = cfg.envs[env];
|
|
74
73
|
if (!envConfig) throw new Error(`Unknown env: ${env}`);
|
|
75
74
|
|
|
76
|
-
//
|
|
77
|
-
const remotePath = `${cfg.target.remoteDir}/${env}`;
|
|
78
|
-
await rsyncDir(cfg.target, projectDir, remotePath);
|
|
79
|
-
|
|
80
|
-
// 2. Read local manifest
|
|
75
|
+
// Local artifacts must exist (arc platform build was run)
|
|
81
76
|
const localManifestPath = join(ws.modulesDir, "manifest.json");
|
|
82
77
|
if (!existsSync(localManifestPath)) {
|
|
83
78
|
throw new Error(
|
|
@@ -88,42 +83,139 @@ export async function syncEnv(inputs: SyncInputs): Promise<SyncOutcome> {
|
|
|
88
83
|
readFileSync(localManifestPath, "utf-8"),
|
|
89
84
|
) as BuildManifest;
|
|
90
85
|
|
|
91
|
-
//
|
|
92
|
-
const
|
|
93
|
-
|
|
86
|
+
// Map module name → workspace package (needed to decide if server.js exists)
|
|
87
|
+
const pkgByName = new Map(
|
|
88
|
+
ws.packages.map((p) => [p.name.includes("/") ? p.name.split("/").pop()! : p.name, p]),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
let tunnel = await openTunnel(
|
|
92
|
+
cfg.target,
|
|
93
|
+
15500 + hashEnvToOffset(env),
|
|
94
|
+
"127.0.0.1",
|
|
95
|
+
2019,
|
|
96
|
+
);
|
|
97
|
+
let restarts = 0;
|
|
98
|
+
let frameworkChanged = false;
|
|
94
99
|
|
|
95
100
|
try {
|
|
96
|
-
const base =
|
|
101
|
+
const base = () =>
|
|
102
|
+
`http://127.0.0.1:${tunnel.localPort}/env/${env}`;
|
|
97
103
|
|
|
98
|
-
//
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
104
|
+
// 1. Framework deps — diff and push if changed
|
|
105
|
+
const localFrameworkHash = readDepsHash(join(ws.arcDir, ".deps-hash"));
|
|
106
|
+
const remoteFwRes = await fetch(`${base()}/api/deploy/framework`);
|
|
107
|
+
const remoteFw = remoteFwRes.ok
|
|
108
|
+
? ((await remoteFwRes.json()) as { depsHash: string | null })
|
|
109
|
+
: { depsHash: null };
|
|
110
|
+
|
|
111
|
+
if (localFrameworkHash && localFrameworkHash !== remoteFw.depsHash) {
|
|
112
|
+
console.log("[arc] Pushing framework deps...");
|
|
113
|
+
const form = new FormData();
|
|
114
|
+
form.append(
|
|
115
|
+
"package.json",
|
|
116
|
+
new Blob([readFileSync(join(ws.arcDir, "package.json"))]),
|
|
117
|
+
"package.json",
|
|
103
118
|
);
|
|
119
|
+
const lockPath = join(ws.arcDir, "bun.lock");
|
|
120
|
+
if (existsSync(lockPath)) {
|
|
121
|
+
form.append(
|
|
122
|
+
"bun.lock",
|
|
123
|
+
new Blob([readFileSync(lockPath)]),
|
|
124
|
+
"bun.lock",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const res = await fetch(`${base()}/api/deploy/framework`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
body: form,
|
|
130
|
+
});
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`framework push failed: ${res.status} ${await res.text()}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
frameworkChanged = true;
|
|
137
|
+
const result = (await res.json()) as { needsRestart?: boolean };
|
|
138
|
+
if (result.needsRestart) {
|
|
139
|
+
tunnel = await restartAndReopen(cfg, env, tunnel);
|
|
140
|
+
restarts += 1;
|
|
141
|
+
}
|
|
104
142
|
}
|
|
105
|
-
const remoteManifest = (await remoteManifestRes.json()) as BuildManifest;
|
|
106
143
|
|
|
144
|
+
// 2. Remote manifest + diff
|
|
145
|
+
const remoteManifestRes = await fetch(`${base()}/api/deploy/manifest`);
|
|
146
|
+
const remoteManifest: BuildManifest = remoteManifestRes.ok
|
|
147
|
+
? await remoteManifestRes.json()
|
|
148
|
+
: ({
|
|
149
|
+
modules: [],
|
|
150
|
+
shellHash: "",
|
|
151
|
+
stylesHash: "",
|
|
152
|
+
buildTime: "",
|
|
153
|
+
} satisfies BuildManifest);
|
|
107
154
|
const diff = diffManifests(localManifest, remoteManifest);
|
|
108
155
|
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
const
|
|
156
|
+
// 3. Per-module push
|
|
157
|
+
for (const mod of diff.changedModules) {
|
|
158
|
+
const safeName = sanitizeName(mod.name);
|
|
159
|
+
const moduleDir = join(ws.modulesDir, safeName);
|
|
160
|
+
const browserPath = join(moduleDir, "browser.js");
|
|
161
|
+
const serverPath = join(moduleDir, "server.js");
|
|
162
|
+
const pkgPath = join(moduleDir, "package.json");
|
|
163
|
+
const accessPath = join(moduleDir, "access.json");
|
|
164
|
+
|
|
165
|
+
// Fall back to legacy <name>.js path while module-builder still emits
|
|
166
|
+
// the flat layout. Same hash either way.
|
|
167
|
+
const browserActual = existsSync(browserPath)
|
|
168
|
+
? browserPath
|
|
169
|
+
: join(ws.modulesDir, `${safeName}.js`);
|
|
170
|
+
if (!existsSync(browserActual)) {
|
|
171
|
+
throw new Error(`Missing browser bundle for module ${mod.name}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
112
174
|
const form = new FormData();
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
175
|
+
form.append(
|
|
176
|
+
"browser.js",
|
|
177
|
+
new Blob([readFileSync(browserActual)]),
|
|
178
|
+
"browser.js",
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const pkg = pkgByName.get(safeName);
|
|
182
|
+
if (pkg && isContextPackage(pkg.packageJson) && existsSync(serverPath)) {
|
|
183
|
+
form.append(
|
|
184
|
+
"server.js",
|
|
185
|
+
new Blob([readFileSync(serverPath)]),
|
|
186
|
+
"server.js",
|
|
187
|
+
);
|
|
116
188
|
}
|
|
117
|
-
|
|
189
|
+
if (existsSync(pkgPath)) {
|
|
190
|
+
form.append(
|
|
191
|
+
"package.json",
|
|
192
|
+
new Blob([readFileSync(pkgPath)]),
|
|
193
|
+
"package.json",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (existsSync(accessPath)) {
|
|
197
|
+
form.append(
|
|
198
|
+
"access.json",
|
|
199
|
+
new Blob([readFileSync(accessPath)]),
|
|
200
|
+
"access.json",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log(`[arc] Pushing module ${safeName}...`);
|
|
205
|
+
const res = await fetch(`${base()}/api/deploy/modules/${safeName}`, {
|
|
118
206
|
method: "POST",
|
|
119
207
|
body: form,
|
|
120
208
|
});
|
|
121
|
-
if (!res.ok)
|
|
122
|
-
throw new Error(
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`module ${safeName} push failed: ${res.status} ${await res.text()}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
123
214
|
}
|
|
124
215
|
|
|
125
|
-
//
|
|
216
|
+
// 4. Styles push
|
|
126
217
|
if (diff.stylesChanged) {
|
|
218
|
+
console.log("[arc] Pushing styles...");
|
|
127
219
|
const form = new FormData();
|
|
128
220
|
for (const name of ["styles.css", "theme.css"] as const) {
|
|
129
221
|
const p = join(ws.arcDir, name);
|
|
@@ -131,47 +223,40 @@ export async function syncEnv(inputs: SyncInputs): Promise<SyncOutcome> {
|
|
|
131
223
|
form.append(name, new Blob([readFileSync(p)]), name);
|
|
132
224
|
}
|
|
133
225
|
}
|
|
134
|
-
const res = await fetch(`${base}/api/deploy/
|
|
226
|
+
const res = await fetch(`${base()}/api/deploy/styles`, {
|
|
135
227
|
method: "POST",
|
|
136
228
|
body: form,
|
|
137
229
|
});
|
|
138
|
-
if (!res.ok)
|
|
139
|
-
throw new Error(`Styles upload failed: ${res.status} ${await res.text()}`);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 5c. Upload changed modules
|
|
143
|
-
if (diff.changedModules.length > 0) {
|
|
144
|
-
const form = new FormData();
|
|
145
|
-
for (const mod of diff.changedModules) {
|
|
146
|
-
const p = join(ws.modulesDir, mod.file);
|
|
147
|
-
form.append(mod.file, new Blob([readFileSync(p)]), mod.file);
|
|
148
|
-
}
|
|
149
|
-
const res = await fetch(`${base}/api/deploy/modules`, {
|
|
150
|
-
method: "POST",
|
|
151
|
-
body: form,
|
|
152
|
-
});
|
|
153
|
-
if (!res.ok)
|
|
230
|
+
if (!res.ok) {
|
|
154
231
|
throw new Error(
|
|
155
|
-
`
|
|
232
|
+
`styles push failed: ${res.status} ${await res.text()}`,
|
|
156
233
|
);
|
|
234
|
+
}
|
|
157
235
|
}
|
|
158
236
|
|
|
159
|
-
//
|
|
160
|
-
const
|
|
237
|
+
// 5. Manifest commit
|
|
238
|
+
const commitRes = await fetch(`${base()}/api/deploy/manifest`, {
|
|
161
239
|
method: "POST",
|
|
162
240
|
headers: { "Content-Type": "application/json" },
|
|
163
241
|
body: JSON.stringify(localManifest),
|
|
164
242
|
});
|
|
165
|
-
if (!
|
|
243
|
+
if (!commitRes.ok) {
|
|
166
244
|
throw new Error(
|
|
167
|
-
`
|
|
245
|
+
`manifest commit failed: ${commitRes.status} ${await commitRes.text()}`,
|
|
168
246
|
);
|
|
247
|
+
}
|
|
248
|
+
const commit = (await commitRes.json()) as { needsRestart?: boolean };
|
|
249
|
+
if (commit.needsRestart) {
|
|
250
|
+
tunnel = await restartAndReopen(cfg, env, tunnel);
|
|
251
|
+
restarts += 1;
|
|
252
|
+
}
|
|
169
253
|
|
|
170
254
|
return {
|
|
171
255
|
env,
|
|
256
|
+
frameworkChanged,
|
|
172
257
|
changedModules: diff.changedModules.map((m) => m.name),
|
|
173
|
-
shellChanged: diff.shellChanged,
|
|
174
258
|
stylesChanged: diff.stylesChanged,
|
|
259
|
+
restarts,
|
|
175
260
|
};
|
|
176
261
|
} finally {
|
|
177
262
|
tunnel.close();
|
|
@@ -182,16 +267,52 @@ export async function syncEnv(inputs: SyncInputs): Promise<SyncOutcome> {
|
|
|
182
267
|
// Helpers
|
|
183
268
|
// ---------------------------------------------------------------------------
|
|
184
269
|
|
|
185
|
-
function
|
|
186
|
-
if (!existsSync(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
270
|
+
function readDepsHash(path: string): string | null {
|
|
271
|
+
if (!existsSync(path)) return null;
|
|
272
|
+
return readFileSync(path, "utf-8").trim() || null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function sanitizeName(name: string): string {
|
|
276
|
+
// Strip package scope ("@ndt/auth" → "auth"); manifest already stores names
|
|
277
|
+
// this way, but be defensive.
|
|
278
|
+
return basename(name);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function restartAndReopen(
|
|
282
|
+
cfg: DeployConfig,
|
|
283
|
+
env: string,
|
|
284
|
+
oldTunnel: { close: () => void; localPort: number },
|
|
285
|
+
): Promise<{ close: () => void; localPort: number }> {
|
|
286
|
+
console.log(`[arc] Restarting arc-${env}...`);
|
|
287
|
+
oldTunnel.close();
|
|
288
|
+
await assertExec(cfg.target, `docker restart arc-${env}`);
|
|
289
|
+
|
|
290
|
+
const tunnel = await openTunnel(
|
|
291
|
+
cfg.target,
|
|
292
|
+
15500 + hashEnvToOffset(env),
|
|
293
|
+
"127.0.0.1",
|
|
294
|
+
2019,
|
|
295
|
+
);
|
|
296
|
+
await waitForHealthy(`http://127.0.0.1:${tunnel.localPort}/env/${env}`, 60_000);
|
|
297
|
+
return tunnel;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function waitForHealthy(baseUrl: string, timeoutMs: number): Promise<void> {
|
|
301
|
+
const deadline = Date.now() + timeoutMs;
|
|
302
|
+
let lastErr: unknown;
|
|
303
|
+
while (Date.now() < deadline) {
|
|
304
|
+
try {
|
|
305
|
+
const res = await fetch(`${baseUrl}/api/deploy/health`, {
|
|
306
|
+
signal: AbortSignal.timeout(2_000),
|
|
307
|
+
});
|
|
308
|
+
if (res.ok) return;
|
|
309
|
+
lastErr = `status ${res.status}`;
|
|
310
|
+
} catch (e) {
|
|
311
|
+
lastErr = e;
|
|
312
|
+
}
|
|
313
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
193
314
|
}
|
|
194
|
-
|
|
315
|
+
throw new Error(`Health check timeout: ${String(lastErr)}`);
|
|
195
316
|
}
|
|
196
317
|
|
|
197
318
|
/** Deterministic per-env tunnel port offset so parallel syncs don't collide. */
|
package/src/deploy/terraform.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { spawn } from "
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
3
|
-
import {
|
|
4
|
+
import { homedir } from "os";
|
|
4
5
|
import { join } from "path";
|
|
5
6
|
import { ASSETS, materializeAssets } from "./assets";
|
|
6
7
|
import type { DeployProvisionTerraform } from "./config";
|
|
7
8
|
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
|
-
// Runs Terraform from embedded assets.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// Runs Terraform from embedded assets. State is persisted per workspace under
|
|
11
|
+
// ~/.arc-deploy/<workspaceHash>/tf/ so retries pick up the existing state file
|
|
12
|
+
// instead of trying to recreate already-provisioned resources from scratch.
|
|
13
|
+
// Inputs are passed via a generated terraform.tfvars file — nothing touches
|
|
14
|
+
// the user's project directory.
|
|
12
15
|
// ---------------------------------------------------------------------------
|
|
13
16
|
|
|
14
17
|
export interface TerraformInputs {
|
|
@@ -17,20 +20,28 @@ export interface TerraformInputs {
|
|
|
17
20
|
token: string;
|
|
18
21
|
/** Deterministic server name shown in Hetzner Console. */
|
|
19
22
|
serverName: string;
|
|
23
|
+
/** Workspace root — used to scope state dir per project. */
|
|
24
|
+
workspaceDir: string;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
export interface TerraformOutputs {
|
|
23
28
|
serverIp: string;
|
|
24
29
|
serverName: string;
|
|
25
|
-
/** Working dir where state + vars live —
|
|
30
|
+
/** Working dir where state + vars live — persistent across runs. */
|
|
26
31
|
workDir: string;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
export async function runTerraform(
|
|
30
35
|
inputs: TerraformInputs,
|
|
31
36
|
): Promise<TerraformOutputs> {
|
|
32
|
-
const
|
|
37
|
+
const wsHash = createHash("sha256")
|
|
38
|
+
.update(inputs.workspaceDir)
|
|
39
|
+
.digest("hex")
|
|
40
|
+
.slice(0, 16);
|
|
41
|
+
const workDir = join(homedir(), ".arc-deploy", wsHash, "tf");
|
|
33
42
|
mkdirSync(workDir, { recursive: true });
|
|
43
|
+
// Idempotent: re-materialize assets each run (they may have changed across
|
|
44
|
+
// CLI versions). State files (.tfstate, .terraform/) are preserved.
|
|
34
45
|
await materializeAssets(workDir, ASSETS.terraform);
|
|
35
46
|
|
|
36
47
|
// Write tfvars — NEVER put token inline in main.tf
|
|
@@ -72,29 +83,38 @@ export async function runTerraform(
|
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
async function runTf(workDir: string, args: string[]): Promise<void> {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
// node:child_process with piped stdio + manual forwarding — Bun.spawn's
|
|
87
|
+
// "inherit" inherits FDs as-is, which breaks when our own stdio is
|
|
88
|
+
// non-blocking (e.g. backgrounded by an outer process).
|
|
89
|
+
const exit = await new Promise<number>((resolve, reject) => {
|
|
90
|
+
const proc = nodeSpawn("terraform", args, {
|
|
91
|
+
cwd: workDir,
|
|
92
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
93
|
+
});
|
|
94
|
+
proc.stdout?.on("data", (chunk: Buffer) => process.stdout.write(chunk));
|
|
95
|
+
proc.stderr?.on("data", (chunk: Buffer) => process.stderr.write(chunk));
|
|
96
|
+
proc.on("error", reject);
|
|
97
|
+
proc.on("exit", (code) => resolve(code ?? 1));
|
|
80
98
|
});
|
|
81
|
-
const exit = await proc.exited;
|
|
82
99
|
if (exit !== 0) {
|
|
83
100
|
throw new Error(`terraform ${args[0]} failed (exit ${exit})`);
|
|
84
101
|
}
|
|
85
102
|
}
|
|
86
103
|
|
|
87
104
|
async function runTfCapture(workDir: string, args: string[]): Promise<string> {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
105
|
+
let stdout = "";
|
|
106
|
+
const exit = await new Promise<number>((resolve, reject) => {
|
|
107
|
+
const proc = nodeSpawn("terraform", args, {
|
|
108
|
+
cwd: workDir,
|
|
109
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
110
|
+
});
|
|
111
|
+
proc.stdout?.on("data", (chunk: Buffer) => {
|
|
112
|
+
stdout += chunk.toString("utf-8");
|
|
113
|
+
});
|
|
114
|
+
proc.stderr?.on("data", (chunk: Buffer) => process.stderr.write(chunk));
|
|
115
|
+
proc.on("error", reject);
|
|
116
|
+
proc.on("exit", (code) => resolve(code ?? 1));
|
|
93
117
|
});
|
|
94
|
-
const [stdout, exit] = await Promise.all([
|
|
95
|
-
new Response(proc.stdout).text(),
|
|
96
|
-
proc.exited,
|
|
97
|
-
]);
|
|
98
118
|
if (exit !== 0) {
|
|
99
119
|
throw new Error(`terraform ${args[0]} failed (exit ${exit})`);
|
|
100
120
|
}
|