@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/dist/index.js +1213 -1219
- package/package.json +7 -7
- package/src/builder/access-extractor.ts +79 -47
- 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 +186 -110
- package/src/commands/platform-deploy.ts +103 -55
- package/src/commands/platform-dev.ts +11 -100
- package/src/commands/platform-start.ts +4 -90
- package/src/deploy/bootstrap.ts +157 -6
- package/src/deploy/caddyfile.ts +19 -23
- package/src/deploy/compose.ts +43 -27
- package/src/deploy/config.ts +29 -0
- package/src/deploy/deploy-env.ts +129 -0
- package/src/deploy/htpasswd.ts +28 -0
- package/src/deploy/image-template.ts +74 -0
- package/src/deploy/image.ts +237 -0
- package/src/deploy/registry.ts +79 -0
- package/src/deploy/ssh.ts +5 -124
- package/src/deploy/survey.ts +64 -0
- package/src/index.ts +15 -13
- package/src/platform/server.ts +69 -44
- package/src/platform/shared.ts +124 -65
- package/src/platform/startup.ts +160 -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
|
@@ -1,400 +0,0 @@
|
|
|
1
|
-
import type { ArcHttpHandler } from "@arcote.tech/arc-host";
|
|
2
|
-
import { spawn } from "bun";
|
|
3
|
-
import {
|
|
4
|
-
cpSync,
|
|
5
|
-
existsSync,
|
|
6
|
-
mkdirSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
rmSync,
|
|
9
|
-
writeFileSync,
|
|
10
|
-
} from "fs";
|
|
11
|
-
import { dirname, join, normalize, resolve } from "path";
|
|
12
|
-
import { sha256Hex } from "../builder/module-builder";
|
|
13
|
-
import type { BuildManifest, WorkspaceInfo } from "./shared";
|
|
14
|
-
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
// Deploy API — v0.6 per-module push model.
|
|
17
|
-
//
|
|
18
|
-
// Mounted on the same Bun.serve as the main platform server. Gated by
|
|
19
|
-
// ARC_DEPLOY_API=1 so dev/localhost starts never expose it. Security boundary
|
|
20
|
-
// is network-layer: in production the arc container publishes :5005 only to
|
|
21
|
-
// docker-network; Caddy exposes /api/deploy/* only on its 127.0.0.1:2019
|
|
22
|
-
// management listener. Developer reaches it exclusively through `ssh -L`.
|
|
23
|
-
// No auth tokens — the SSH key is the auth.
|
|
24
|
-
//
|
|
25
|
-
// Endpoints:
|
|
26
|
-
// GET /api/deploy/health — liveness + module count
|
|
27
|
-
// GET /api/deploy/framework — { depsHash } for diff vs local
|
|
28
|
-
// GET /api/deploy/manifest — full current manifest
|
|
29
|
-
// POST /api/deploy/framework — multipart (package.json + bun.lock)
|
|
30
|
-
// → bun install w platform dir, response
|
|
31
|
-
// needsRestart=true (CLI does docker restart)
|
|
32
|
-
// POST /api/deploy/modules/:name — multipart (browser.js, server.js?,
|
|
33
|
-
// package.json, access.json?)
|
|
34
|
-
// → write to .staging/modules/<name>/,
|
|
35
|
-
// bun install jeśli deps changed
|
|
36
|
-
// POST /api/deploy/styles — multipart (styles.css, theme.css?)
|
|
37
|
-
// → write to .staging/
|
|
38
|
-
// POST /api/deploy/manifest — JSON commit phase: atomic swap staging→live,
|
|
39
|
-
// setManifest, SSE notify; response includes
|
|
40
|
-
// needsRestart when any module's server.js
|
|
41
|
-
// changed.
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
|
|
44
|
-
export interface DeployApiOptions {
|
|
45
|
-
ws: WorkspaceInfo;
|
|
46
|
-
getManifest: () => BuildManifest;
|
|
47
|
-
setManifest: (m: BuildManifest) => void;
|
|
48
|
-
notifyReload: (m: BuildManifest) => void;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function createDeployApiHandler(opts: DeployApiOptions): ArcHttpHandler {
|
|
52
|
-
return async (req, url, ctx) => {
|
|
53
|
-
const p = url.pathname;
|
|
54
|
-
if (!p.startsWith("/api/deploy/")) return null;
|
|
55
|
-
const cors = ctx.corsHeaders;
|
|
56
|
-
|
|
57
|
-
// -----------------------------------------------------------------------
|
|
58
|
-
// GET endpoints — state probes
|
|
59
|
-
// -----------------------------------------------------------------------
|
|
60
|
-
if (p === "/api/deploy/health" && req.method === "GET") {
|
|
61
|
-
return Response.json(
|
|
62
|
-
{ ok: true, modules: opts.getManifest().modules.length },
|
|
63
|
-
{ headers: cors },
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (p === "/api/deploy/framework" && req.method === "GET") {
|
|
68
|
-
const hashPath = join(opts.ws.arcDir, ".deps-hash");
|
|
69
|
-
const depsHash = existsSync(hashPath)
|
|
70
|
-
? readFileSync(hashPath, "utf-8").trim()
|
|
71
|
-
: null;
|
|
72
|
-
return Response.json({ depsHash }, { headers: cors });
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (p === "/api/deploy/manifest" && req.method === "GET") {
|
|
76
|
-
return Response.json(opts.getManifest(), { headers: cors });
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// -----------------------------------------------------------------------
|
|
80
|
-
// POST /api/deploy/framework — install framework peers + rebuild shell
|
|
81
|
-
// -----------------------------------------------------------------------
|
|
82
|
-
if (p === "/api/deploy/framework" && req.method === "POST") {
|
|
83
|
-
const form = await req.formData();
|
|
84
|
-
const pkgFile = form.get("package.json");
|
|
85
|
-
const lockFile = form.get("bun.lock");
|
|
86
|
-
if (!isFile(pkgFile) || !isFile(lockFile)) {
|
|
87
|
-
return badRequest("framework requires package.json + bun.lock", cors);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
mkdirSync(opts.ws.arcDir, { recursive: true });
|
|
91
|
-
writeFileSync(
|
|
92
|
-
join(opts.ws.arcDir, "package.json"),
|
|
93
|
-
Buffer.from(await pkgFile.arrayBuffer()),
|
|
94
|
-
);
|
|
95
|
-
writeFileSync(
|
|
96
|
-
join(opts.ws.arcDir, "bun.lock"),
|
|
97
|
-
Buffer.from(await lockFile.arrayBuffer()),
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
const start = Date.now();
|
|
101
|
-
// No --frozen-lockfile: workspace bun.lock from the source project
|
|
102
|
-
// contains the full dep graph, but our framework manifest is a subset.
|
|
103
|
-
// Bun would reject as "lockfile had changes". Let bun re-resolve against
|
|
104
|
-
// the framework-only package.json.
|
|
105
|
-
const installOk = await runBun(
|
|
106
|
-
["install", "--production"],
|
|
107
|
-
opts.ws.arcDir,
|
|
108
|
-
);
|
|
109
|
-
if (!installOk) {
|
|
110
|
-
return Response.json(
|
|
111
|
-
{ error: "bun install failed" },
|
|
112
|
-
{ status: 500, headers: cors },
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Rebuild shell from freshly-installed framework peers. Hidden _build-shell
|
|
117
|
-
// subcommand discovers @arcote.tech/* + react under --from.
|
|
118
|
-
const cliBin = process.argv[1] ?? "arc";
|
|
119
|
-
const shellOk = await runBun(
|
|
120
|
-
[
|
|
121
|
-
"run",
|
|
122
|
-
cliBin,
|
|
123
|
-
"_build-shell",
|
|
124
|
-
"--out",
|
|
125
|
-
opts.ws.shellDir,
|
|
126
|
-
"--from",
|
|
127
|
-
join(opts.ws.arcDir, "node_modules"),
|
|
128
|
-
],
|
|
129
|
-
opts.ws.arcDir,
|
|
130
|
-
);
|
|
131
|
-
if (!shellOk) {
|
|
132
|
-
return Response.json(
|
|
133
|
-
{ error: "shell build failed" },
|
|
134
|
-
{ status: 500, headers: cors },
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Compute fresh deps-hash and persist
|
|
139
|
-
const newHash = sha256Hex(
|
|
140
|
-
Buffer.concat([
|
|
141
|
-
readFileSync(join(opts.ws.arcDir, "package.json")),
|
|
142
|
-
readFileSync(join(opts.ws.arcDir, "bun.lock")),
|
|
143
|
-
]),
|
|
144
|
-
);
|
|
145
|
-
writeFileSync(join(opts.ws.arcDir, ".deps-hash"), newHash + "\n");
|
|
146
|
-
|
|
147
|
-
return Response.json(
|
|
148
|
-
{
|
|
149
|
-
changed: true,
|
|
150
|
-
tookMs: Date.now() - start,
|
|
151
|
-
needsRestart: true,
|
|
152
|
-
},
|
|
153
|
-
{ headers: cors },
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// -----------------------------------------------------------------------
|
|
158
|
-
// POST /api/deploy/modules/:name — per-module push (browser + server +
|
|
159
|
-
// deps + access) into staging
|
|
160
|
-
// -----------------------------------------------------------------------
|
|
161
|
-
const moduleMatch = p.match(/^\/api\/deploy\/modules\/([^/]+)$/);
|
|
162
|
-
if (moduleMatch && req.method === "POST") {
|
|
163
|
-
const safeName = sanitizeName(moduleMatch[1]);
|
|
164
|
-
if (!safeName) return badRequest("invalid module name", cors);
|
|
165
|
-
|
|
166
|
-
const form = await req.formData();
|
|
167
|
-
const browser = form.get("browser.js");
|
|
168
|
-
if (!isFile(browser)) {
|
|
169
|
-
return badRequest("modules/<name> requires browser.js", cors);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const stagingDir = join(
|
|
173
|
-
opts.ws.arcDir,
|
|
174
|
-
".staging",
|
|
175
|
-
"modules",
|
|
176
|
-
safeName,
|
|
177
|
-
);
|
|
178
|
-
mkdirSync(stagingDir, { recursive: true });
|
|
179
|
-
|
|
180
|
-
await writeField(stagingDir, "browser.js", browser);
|
|
181
|
-
const server = form.get("server.js");
|
|
182
|
-
if (isFile(server)) {
|
|
183
|
-
await writeField(stagingDir, "server.js", server);
|
|
184
|
-
}
|
|
185
|
-
const pkg = form.get("package.json");
|
|
186
|
-
if (isFile(pkg)) {
|
|
187
|
-
await writeField(stagingDir, "package.json", pkg);
|
|
188
|
-
}
|
|
189
|
-
const access = form.get("access.json");
|
|
190
|
-
if (isFile(access)) {
|
|
191
|
-
await writeField(stagingDir, "access.json", access);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Compare staging deps-hash vs live; install only when different.
|
|
195
|
-
const stagingPkgPath = join(stagingDir, "package.json");
|
|
196
|
-
const livePkgPath = join(opts.ws.arcDir, "modules", safeName, "package.json");
|
|
197
|
-
const stagingPkgBytes = existsSync(stagingPkgPath)
|
|
198
|
-
? readFileSync(stagingPkgPath)
|
|
199
|
-
: Buffer.from("");
|
|
200
|
-
const livePkgBytes = existsSync(livePkgPath)
|
|
201
|
-
? readFileSync(livePkgPath)
|
|
202
|
-
: Buffer.from("");
|
|
203
|
-
const depsChanged =
|
|
204
|
-
sha256Hex(stagingPkgBytes) !== sha256Hex(livePkgBytes);
|
|
205
|
-
|
|
206
|
-
writeFileSync(
|
|
207
|
-
join(stagingDir, ".deps-hash"),
|
|
208
|
-
sha256Hex(stagingPkgBytes) + "\n",
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
if (depsChanged && existsSync(stagingPkgPath)) {
|
|
212
|
-
const ok = await runBun(["install", "--production"], stagingDir);
|
|
213
|
-
if (!ok) {
|
|
214
|
-
rmSync(stagingDir, { recursive: true, force: true });
|
|
215
|
-
return Response.json(
|
|
216
|
-
{ error: `bun install failed for module ${safeName}` },
|
|
217
|
-
{ status: 500, headers: cors },
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
} else if (!depsChanged) {
|
|
221
|
-
// Preserve previous node_modules so atomic swap doesn't blow it away.
|
|
222
|
-
const liveNodeModules = join(
|
|
223
|
-
opts.ws.arcDir,
|
|
224
|
-
"modules",
|
|
225
|
-
safeName,
|
|
226
|
-
"node_modules",
|
|
227
|
-
);
|
|
228
|
-
if (existsSync(liveNodeModules)) {
|
|
229
|
-
cpSync(liveNodeModules, join(stagingDir, "node_modules"), {
|
|
230
|
-
recursive: true,
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return Response.json(
|
|
236
|
-
{
|
|
237
|
-
ok: true,
|
|
238
|
-
name: safeName,
|
|
239
|
-
depsChanged,
|
|
240
|
-
serverIncluded: isFile(server),
|
|
241
|
-
},
|
|
242
|
-
{ headers: cors },
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// -----------------------------------------------------------------------
|
|
247
|
-
// POST /api/deploy/styles — global styles to staging
|
|
248
|
-
// -----------------------------------------------------------------------
|
|
249
|
-
if (p === "/api/deploy/styles" && req.method === "POST") {
|
|
250
|
-
const form = await req.formData();
|
|
251
|
-
const stagingDir = join(opts.ws.arcDir, ".staging");
|
|
252
|
-
mkdirSync(stagingDir, { recursive: true });
|
|
253
|
-
const written: string[] = [];
|
|
254
|
-
for (const name of ["styles.css", "theme.css"] as const) {
|
|
255
|
-
const f = form.get(name);
|
|
256
|
-
if (isFile(f)) {
|
|
257
|
-
await writeField(stagingDir, name, f);
|
|
258
|
-
written.push(name);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
return Response.json({ ok: true, written }, { headers: cors });
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// -----------------------------------------------------------------------
|
|
265
|
-
// POST /api/deploy/manifest — commit phase
|
|
266
|
-
// -----------------------------------------------------------------------
|
|
267
|
-
if (p === "/api/deploy/manifest" && req.method === "POST") {
|
|
268
|
-
const body = (await req.json()) as BuildManifest;
|
|
269
|
-
if (!validateManifest(body)) {
|
|
270
|
-
return badRequest("invalid manifest body", cors);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const stagingRoot = join(opts.ws.arcDir, ".staging");
|
|
274
|
-
const stagingModules = join(stagingRoot, "modules");
|
|
275
|
-
let serverSideChanged = false;
|
|
276
|
-
|
|
277
|
-
// Per-module atomic swap. We don't use rename(2) across volumes; cp+rm
|
|
278
|
-
// is good enough on a single docker volume.
|
|
279
|
-
if (existsSync(stagingModules)) {
|
|
280
|
-
const stagedNames = readDir(stagingModules);
|
|
281
|
-
for (const name of stagedNames) {
|
|
282
|
-
const src = join(stagingModules, name);
|
|
283
|
-
const dst = join(opts.ws.arcDir, "modules", name);
|
|
284
|
-
if (existsSync(join(src, "server.js"))) serverSideChanged = true;
|
|
285
|
-
if (existsSync(dst)) {
|
|
286
|
-
rmSync(dst, { recursive: true, force: true });
|
|
287
|
-
}
|
|
288
|
-
mkdirSync(dirname(dst), { recursive: true });
|
|
289
|
-
cpSync(src, dst, { recursive: true });
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Styles swap
|
|
294
|
-
for (const name of ["styles.css", "theme.css"] as const) {
|
|
295
|
-
const src = join(stagingRoot, name);
|
|
296
|
-
if (existsSync(src)) {
|
|
297
|
-
cpSync(src, join(opts.ws.arcDir, name));
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Persist new manifest
|
|
302
|
-
writeFileSync(
|
|
303
|
-
join(opts.ws.modulesDir, "manifest.json"),
|
|
304
|
-
JSON.stringify(body, null, 2),
|
|
305
|
-
);
|
|
306
|
-
opts.setManifest(body);
|
|
307
|
-
|
|
308
|
-
// Cleanup staging
|
|
309
|
-
rmSync(stagingRoot, { recursive: true, force: true });
|
|
310
|
-
|
|
311
|
-
if (!serverSideChanged) {
|
|
312
|
-
// Browser-only change → SSE notify clients, zero restart.
|
|
313
|
-
opts.notifyReload(body);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
return Response.json(
|
|
317
|
-
{
|
|
318
|
-
ok: true,
|
|
319
|
-
moduleCount: body.modules.length,
|
|
320
|
-
needsRestart: serverSideChanged,
|
|
321
|
-
},
|
|
322
|
-
{ headers: cors },
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
return new Response("Not Found", { status: 404, headers: cors });
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// ---------------------------------------------------------------------------
|
|
331
|
-
// Helpers
|
|
332
|
-
// ---------------------------------------------------------------------------
|
|
333
|
-
|
|
334
|
-
function badRequest(msg: string, cors: HeadersInit): Response {
|
|
335
|
-
return Response.json({ error: msg }, { status: 400, headers: cors });
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function isFile(v: unknown): v is File {
|
|
339
|
-
return (
|
|
340
|
-
typeof v === "object" &&
|
|
341
|
-
v !== null &&
|
|
342
|
-
typeof (v as File).arrayBuffer === "function" &&
|
|
343
|
-
typeof (v as File).name === "string"
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
async function writeField(
|
|
348
|
-
targetDir: string,
|
|
349
|
-
name: string,
|
|
350
|
-
file: File,
|
|
351
|
-
): Promise<void> {
|
|
352
|
-
// Safety: reject names attempting traversal. Field names are CLI-controlled
|
|
353
|
-
// but treat as untrusted anyway (network-facing endpoint).
|
|
354
|
-
const safe = normalize(name);
|
|
355
|
-
const safeRoot = resolve(targetDir);
|
|
356
|
-
const full = resolve(safeRoot, safe);
|
|
357
|
-
if (!full.startsWith(safeRoot + "/") && full !== safeRoot) {
|
|
358
|
-
throw new Error(`Path traversal rejected: ${name}`);
|
|
359
|
-
}
|
|
360
|
-
mkdirSync(dirname(full), { recursive: true });
|
|
361
|
-
writeFileSync(full, Buffer.from(await file.arrayBuffer()));
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function sanitizeName(name: string): string | null {
|
|
365
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(name)) return null;
|
|
366
|
-
return name;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function readDir(dir: string): string[] {
|
|
370
|
-
if (!existsSync(dir)) return [];
|
|
371
|
-
return require("fs").readdirSync(dir) as string[];
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
async function runBun(args: string[], cwd: string): Promise<boolean> {
|
|
375
|
-
const proc = spawn({
|
|
376
|
-
cmd: ["bun", ...args],
|
|
377
|
-
cwd,
|
|
378
|
-
stdout: "inherit",
|
|
379
|
-
stderr: "inherit",
|
|
380
|
-
});
|
|
381
|
-
const exit = await proc.exited;
|
|
382
|
-
return exit === 0;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function validateManifest(m: unknown): m is BuildManifest {
|
|
386
|
-
if (!m || typeof m !== "object") return false;
|
|
387
|
-
const cast = m as BuildManifest;
|
|
388
|
-
return (
|
|
389
|
-
Array.isArray(cast.modules) &&
|
|
390
|
-
typeof cast.shellHash === "string" &&
|
|
391
|
-
typeof cast.stylesHash === "string" &&
|
|
392
|
-
typeof cast.buildTime === "string"
|
|
393
|
-
);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/** Recompute a module's hash from disk — exposed for tests. */
|
|
397
|
-
export function rehashModuleFile(filePath: string): string {
|
|
398
|
-
if (!existsSync(filePath)) throw new Error(`Missing: ${filePath}`);
|
|
399
|
-
return sha256Hex(readFileSync(filePath));
|
|
400
|
-
}
|