@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
@@ -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
- }