@arcote.tech/arc-cli 0.5.2 → 0.5.5
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 +2854 -637
- package/package.json +8 -7
- package/src/builder/module-builder.ts +35 -9
- package/src/commands/platform-deploy.ts +143 -0
- package/src/commands/platform-start.ts +4 -1
- package/src/deploy/ansible.ts +69 -0
- package/src/deploy/assets/ansible/site.yml +169 -0
- package/src/deploy/assets/terraform/main.tf +38 -0
- package/src/deploy/assets/terraform/variables.tf +35 -0
- package/src/deploy/assets.ts +282 -0
- package/src/deploy/bootstrap.ts +131 -0
- package/src/deploy/caddyfile.ts +59 -0
- package/src/deploy/compose.ts +73 -0
- package/src/deploy/config.ts +279 -0
- package/src/deploy/remote-state.ts +92 -0
- package/src/deploy/remote-sync.ts +202 -0
- package/src/deploy/ssh.ts +246 -0
- package/src/deploy/survey.ts +172 -0
- package/src/deploy/terraform.ts +109 -0
- package/src/index.ts +12 -0
- package/src/platform/deploy-api.ts +183 -0
- package/src/platform/server.ts +49 -25
- package/src/platform/shared.ts +45 -3
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { ArcHttpHandler } from "@arcote.tech/arc-host";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { dirname, join, normalize, relative, resolve } from "path";
|
|
4
|
+
import { sha256Hex } from "../builder/module-builder";
|
|
5
|
+
import type { BuildManifest, WorkspaceInfo } from "./shared";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Deploy API — hot-swap modules/shell/styles via HTTP
|
|
9
|
+
//
|
|
10
|
+
// Mounted on the same Bun.serve as the main platform server. Gated by
|
|
11
|
+
// ARC_DEPLOY_API=1 so dev/localhost starts never expose it. Security boundary
|
|
12
|
+
// is entirely network-layer: in production the arc container publishes :5005
|
|
13
|
+
// only to docker-network, and the Caddy delegator exposes /api/deploy/* only
|
|
14
|
+
// on its 127.0.0.1-bound management listener (:2019). Developer reaches it
|
|
15
|
+
// exclusively through `ssh -L`.
|
|
16
|
+
//
|
|
17
|
+
// No auth tokens in code — the SSH key is the auth.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface DeployApiOptions {
|
|
21
|
+
ws: WorkspaceInfo;
|
|
22
|
+
getManifest: () => BuildManifest;
|
|
23
|
+
setManifest: (m: BuildManifest) => void;
|
|
24
|
+
notifyReload: (m: BuildManifest) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create deploy API handler. Returns null for non-deploy requests so the main
|
|
29
|
+
* handler chain continues. Routes:
|
|
30
|
+
*
|
|
31
|
+
* GET /api/deploy/manifest — current BuildManifest (hashes etc.)
|
|
32
|
+
* POST /api/deploy/manifest — replace manifest; no file writes
|
|
33
|
+
* POST /api/deploy/modules — multipart upload of .js module files
|
|
34
|
+
* POST /api/deploy/shell — multipart upload of shell/ files + styles.css
|
|
35
|
+
* GET /api/deploy/health — liveness
|
|
36
|
+
*/
|
|
37
|
+
export function createDeployApiHandler(opts: DeployApiOptions): ArcHttpHandler {
|
|
38
|
+
return async (req, url, ctx) => {
|
|
39
|
+
const p = url.pathname;
|
|
40
|
+
if (!p.startsWith("/api/deploy/")) return null;
|
|
41
|
+
|
|
42
|
+
if (p === "/api/deploy/health" && req.method === "GET") {
|
|
43
|
+
return Response.json(
|
|
44
|
+
{ ok: true, modules: opts.getManifest().modules.length },
|
|
45
|
+
{ headers: ctx.corsHeaders },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (p === "/api/deploy/manifest" && req.method === "GET") {
|
|
50
|
+
return Response.json(opts.getManifest(), { headers: ctx.corsHeaders });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (p === "/api/deploy/manifest" && req.method === "POST") {
|
|
54
|
+
const body = (await req.json()) as BuildManifest;
|
|
55
|
+
if (!validateManifest(body)) {
|
|
56
|
+
return Response.json(
|
|
57
|
+
{ error: "Invalid manifest body" },
|
|
58
|
+
{ status: 400, headers: ctx.corsHeaders },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
writeFileSync(
|
|
62
|
+
join(opts.ws.modulesDir, "manifest.json"),
|
|
63
|
+
JSON.stringify(body, null, 2),
|
|
64
|
+
);
|
|
65
|
+
opts.setManifest(body);
|
|
66
|
+
opts.notifyReload(body);
|
|
67
|
+
return Response.json(
|
|
68
|
+
{ ok: true, moduleCount: body.modules.length },
|
|
69
|
+
{ headers: ctx.corsHeaders },
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (p === "/api/deploy/modules" && req.method === "POST") {
|
|
74
|
+
const form = await req.formData();
|
|
75
|
+
const written = await writeUploadedFiles(form, opts.ws.modulesDir);
|
|
76
|
+
return Response.json(
|
|
77
|
+
{ ok: true, written },
|
|
78
|
+
{ headers: ctx.corsHeaders },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (p === "/api/deploy/shell" && req.method === "POST") {
|
|
83
|
+
const form = await req.formData();
|
|
84
|
+
const shellFiles: File[] = [];
|
|
85
|
+
const rootFiles: Array<{ name: string; file: File }> = [];
|
|
86
|
+
for (const [name, value] of form.entries()) {
|
|
87
|
+
if (!isFile(value)) continue;
|
|
88
|
+
// styles.css / theme.css go to arcDir, everything else goes to shellDir
|
|
89
|
+
if (name === "styles.css" || name === "theme.css") {
|
|
90
|
+
rootFiles.push({ name, file: value });
|
|
91
|
+
} else {
|
|
92
|
+
shellFiles.push(value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const writtenShell = await writeUploadedFileList(
|
|
96
|
+
shellFiles,
|
|
97
|
+
opts.ws.shellDir,
|
|
98
|
+
);
|
|
99
|
+
const writtenRoot: string[] = [];
|
|
100
|
+
for (const { name, file } of rootFiles) {
|
|
101
|
+
const target = join(opts.ws.arcDir, name);
|
|
102
|
+
writeFileSync(target, Buffer.from(await file.arrayBuffer()));
|
|
103
|
+
writtenRoot.push(name);
|
|
104
|
+
}
|
|
105
|
+
return Response.json(
|
|
106
|
+
{ ok: true, written: [...writtenShell, ...writtenRoot] },
|
|
107
|
+
{ headers: ctx.corsHeaders },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return new Response("Not Found", {
|
|
112
|
+
status: 404,
|
|
113
|
+
headers: ctx.corsHeaders,
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// File writes — with path traversal guard
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
async function writeUploadedFiles(
|
|
123
|
+
form: FormData,
|
|
124
|
+
targetDir: string,
|
|
125
|
+
): Promise<string[]> {
|
|
126
|
+
const files: File[] = [];
|
|
127
|
+
for (const [, value] of form.entries()) {
|
|
128
|
+
if (isFile(value)) files.push(value);
|
|
129
|
+
}
|
|
130
|
+
return writeUploadedFileList(files, targetDir);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Runtime File check — DOM's File is a Blob subclass, available in Bun runtime. */
|
|
134
|
+
function isFile(v: unknown): v is File {
|
|
135
|
+
return (
|
|
136
|
+
typeof v === "object" &&
|
|
137
|
+
v !== null &&
|
|
138
|
+
typeof (v as File).arrayBuffer === "function" &&
|
|
139
|
+
typeof (v as File).name === "string"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function writeUploadedFileList(
|
|
144
|
+
files: File[],
|
|
145
|
+
targetDir: string,
|
|
146
|
+
): Promise<string[]> {
|
|
147
|
+
const written: string[] = [];
|
|
148
|
+
const safeRoot = resolve(targetDir);
|
|
149
|
+
mkdirSync(safeRoot, { recursive: true });
|
|
150
|
+
|
|
151
|
+
for (const file of files) {
|
|
152
|
+
const rel = normalize(file.name);
|
|
153
|
+
const full = resolve(safeRoot, rel);
|
|
154
|
+
if (!full.startsWith(safeRoot + "/") && full !== safeRoot) {
|
|
155
|
+
throw new Error(`Path traversal rejected: ${file.name}`);
|
|
156
|
+
}
|
|
157
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
158
|
+
writeFileSync(full, Buffer.from(await file.arrayBuffer()));
|
|
159
|
+
|
|
160
|
+
// Verify upload hash matches the file we wrote — defensive check that
|
|
161
|
+
// rejects accidental corruption mid-transit. Modules carry a hash prefix
|
|
162
|
+
// convention in their filename header ("<hash>:<name>") from the CLI.
|
|
163
|
+
written.push(relative(safeRoot, full) || rel);
|
|
164
|
+
}
|
|
165
|
+
return written;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function validateManifest(m: unknown): m is BuildManifest {
|
|
169
|
+
if (!m || typeof m !== "object") return false;
|
|
170
|
+
const cast = m as BuildManifest;
|
|
171
|
+
return (
|
|
172
|
+
Array.isArray(cast.modules) &&
|
|
173
|
+
typeof cast.shellHash === "string" &&
|
|
174
|
+
typeof cast.stylesHash === "string" &&
|
|
175
|
+
typeof cast.buildTime === "string"
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Recompute a module's hash from disk — exposed for tests. */
|
|
180
|
+
export function rehashModuleFile(filePath: string): string {
|
|
181
|
+
if (!existsSync(filePath)) throw new Error(`Missing: ${filePath}`);
|
|
182
|
+
return sha256Hex(readFileSync(filePath));
|
|
183
|
+
}
|
package/src/platform/server.ts
CHANGED
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
import { existsSync, mkdirSync } from "fs";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
import { readTranslationsConfig } from "../i18n";
|
|
14
|
-
import
|
|
14
|
+
import { createDeployApiHandler } from "./deploy-api";
|
|
15
|
+
import type { BuildManifest, ModuleDescriptor, WorkspaceInfo } from "./shared";
|
|
15
16
|
import type { ModuleAccess } from "@arcote.tech/platform";
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
@@ -30,6 +31,10 @@ export interface PlatformServerOptions {
|
|
|
30
31
|
dbPath?: string;
|
|
31
32
|
/** If true, enables SSE reload stream + mutable manifest (dev mode) */
|
|
32
33
|
devMode?: boolean;
|
|
34
|
+
/** If true, mounts /api/deploy/* endpoints for remote hot-swap. Off by default;
|
|
35
|
+
* opt-in via ARC_DEPLOY_API=1 or explicit CLI flag. In production, network-layer
|
|
36
|
+
* (Caddy + docker-compose port binding) restricts access to SSH tunnel only. */
|
|
37
|
+
deployApi?: boolean;
|
|
33
38
|
/** Arc shell entries [shortName, fullPkgName][] for import map. Auto-detected if omitted. */
|
|
34
39
|
arcEntries?: [string, string][];
|
|
35
40
|
}
|
|
@@ -213,7 +218,7 @@ async function filterManifestForTokens(
|
|
|
213
218
|
moduleAccessMap: Map<string, ModuleAccess>,
|
|
214
219
|
tokenPayloads: any[],
|
|
215
220
|
): Promise<BuildManifest> {
|
|
216
|
-
const filtered:
|
|
221
|
+
const filtered: ModuleDescriptor[] = [];
|
|
217
222
|
|
|
218
223
|
for (const mod of manifest.modules) {
|
|
219
224
|
const access = moduleAccessMap.get(mod.name);
|
|
@@ -237,10 +242,15 @@ async function filterManifestForTokens(
|
|
|
237
242
|
}
|
|
238
243
|
|
|
239
244
|
if (granted) {
|
|
240
|
-
filtered.push({ ...mod, url: signModuleUrl(mod.file) }
|
|
245
|
+
filtered.push({ ...mod, url: signModuleUrl(mod.file) });
|
|
241
246
|
}
|
|
242
247
|
}
|
|
243
|
-
return {
|
|
248
|
+
return {
|
|
249
|
+
modules: filtered,
|
|
250
|
+
shellHash: manifest.shellHash,
|
|
251
|
+
stylesHash: manifest.stylesHash,
|
|
252
|
+
buildTime: manifest.buildTime,
|
|
253
|
+
};
|
|
244
254
|
}
|
|
245
255
|
|
|
246
256
|
// ---------------------------------------------------------------------------
|
|
@@ -385,10 +395,35 @@ export async function startPlatformServer(
|
|
|
385
395
|
const moduleAccessMap = opts.moduleAccess ?? new Map();
|
|
386
396
|
let manifest = opts.manifest;
|
|
387
397
|
const getManifest = () => manifest;
|
|
398
|
+
const setManifest = (m: BuildManifest) => {
|
|
399
|
+
manifest = m;
|
|
400
|
+
};
|
|
388
401
|
|
|
389
402
|
const shellHtml = generateShellHtml(ws.appName, ws.manifest, opts.arcEntries);
|
|
390
403
|
const sseClients = new Set<ReadableStreamDefaultController>();
|
|
391
404
|
|
|
405
|
+
const notifyReload = (m: BuildManifest) => {
|
|
406
|
+
const data = JSON.stringify(m);
|
|
407
|
+
for (const c of sseClients) {
|
|
408
|
+
try {
|
|
409
|
+
c.enqueue(`data: ${data}\n\n`);
|
|
410
|
+
} catch {
|
|
411
|
+
sseClients.delete(c);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const deployApiEnabled =
|
|
417
|
+
opts.deployApi ?? process.env.ARC_DEPLOY_API === "1";
|
|
418
|
+
const deployApiHandler = deployApiEnabled
|
|
419
|
+
? createDeployApiHandler({
|
|
420
|
+
ws,
|
|
421
|
+
getManifest,
|
|
422
|
+
setManifest,
|
|
423
|
+
notifyReload,
|
|
424
|
+
})
|
|
425
|
+
: null;
|
|
426
|
+
|
|
392
427
|
if (!context) {
|
|
393
428
|
// No context — serve static files only (no WS/commands/queries)
|
|
394
429
|
const cors = {
|
|
@@ -400,7 +435,7 @@ export async function startPlatformServer(
|
|
|
400
435
|
|
|
401
436
|
const server = Bun.serve({
|
|
402
437
|
port,
|
|
403
|
-
fetch(req) {
|
|
438
|
+
async fetch(req) {
|
|
404
439
|
const url = new URL(req.url);
|
|
405
440
|
if (req.method === "OPTIONS")
|
|
406
441
|
return new Response(null, { headers: cors });
|
|
@@ -413,15 +448,15 @@ export async function startPlatformServer(
|
|
|
413
448
|
|
|
414
449
|
// Platform handlers only
|
|
415
450
|
const handlers: ArcHttpHandler[] = [
|
|
451
|
+
...(deployApiHandler ? [deployApiHandler] : []),
|
|
416
452
|
apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
|
|
417
|
-
|
|
453
|
+
devReloadHandler(sseClients),
|
|
418
454
|
staticFilesHandler(ws, !!devMode, moduleAccessMap),
|
|
419
455
|
spaFallbackHandler(shellHtml),
|
|
420
456
|
];
|
|
421
457
|
|
|
422
458
|
for (const handler of handlers) {
|
|
423
|
-
const response = handler(req, url, ctx);
|
|
424
|
-
if (response instanceof Promise) return response;
|
|
459
|
+
const response = await handler(req, url, ctx);
|
|
425
460
|
if (response) return response;
|
|
426
461
|
}
|
|
427
462
|
|
|
@@ -433,14 +468,8 @@ export async function startPlatformServer(
|
|
|
433
468
|
server,
|
|
434
469
|
contextHandler: null,
|
|
435
470
|
connectionManager: null,
|
|
436
|
-
setManifest
|
|
437
|
-
notifyReload
|
|
438
|
-
const data = JSON.stringify(m);
|
|
439
|
-
for (const c of sseClients) {
|
|
440
|
-
try { c.enqueue(`data: ${data}\n\n`); }
|
|
441
|
-
catch { sseClients.delete(c); }
|
|
442
|
-
}
|
|
443
|
-
},
|
|
471
|
+
setManifest,
|
|
472
|
+
notifyReload,
|
|
444
473
|
stop: () => server.stop(),
|
|
445
474
|
};
|
|
446
475
|
}
|
|
@@ -459,8 +488,9 @@ export async function startPlatformServer(
|
|
|
459
488
|
port,
|
|
460
489
|
httpHandlers: [
|
|
461
490
|
// Platform-specific handlers (checked AFTER arc handlers)
|
|
491
|
+
...(deployApiHandler ? [deployApiHandler] : []),
|
|
462
492
|
apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
|
|
463
|
-
|
|
493
|
+
devReloadHandler(sseClients),
|
|
464
494
|
staticFilesHandler(ws, !!devMode, moduleAccessMap),
|
|
465
495
|
spaFallbackHandler(shellHtml),
|
|
466
496
|
],
|
|
@@ -471,14 +501,8 @@ export async function startPlatformServer(
|
|
|
471
501
|
server: arcServer.server,
|
|
472
502
|
contextHandler: arcServer.contextHandler,
|
|
473
503
|
connectionManager: arcServer.connectionManager,
|
|
474
|
-
setManifest
|
|
475
|
-
notifyReload
|
|
476
|
-
const data = JSON.stringify(m);
|
|
477
|
-
for (const c of sseClients) {
|
|
478
|
-
try { c.enqueue(`data: ${data}\n\n`); }
|
|
479
|
-
catch { sseClients.delete(c); }
|
|
480
|
-
}
|
|
481
|
-
},
|
|
504
|
+
setManifest,
|
|
505
|
+
notifyReload,
|
|
482
506
|
stop: () => arcServer.stop(),
|
|
483
507
|
};
|
|
484
508
|
}
|
package/src/platform/shared.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import { findUpSync } from "find-up";
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
|
|
3
3
|
import { dirname, join } from "path";
|
|
4
4
|
import {
|
|
5
5
|
buildPackages,
|
|
6
6
|
buildStyles,
|
|
7
7
|
discoverPackages,
|
|
8
8
|
isContextPackage,
|
|
9
|
+
sha256OfFiles,
|
|
9
10
|
type BuildManifest,
|
|
11
|
+
type ModuleDescriptor,
|
|
10
12
|
type ModuleEntry,
|
|
11
13
|
type WorkspacePackage,
|
|
12
14
|
} from "../builder/module-builder";
|
|
13
15
|
|
|
14
16
|
// Re-export for convenience
|
|
15
17
|
export { buildPackages, buildStyles, isContextPackage };
|
|
16
|
-
export type { BuildManifest, ModuleEntry, WorkspacePackage };
|
|
18
|
+
export type { BuildManifest, ModuleDescriptor, ModuleEntry, WorkspacePackage };
|
|
17
19
|
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
19
21
|
// Logging
|
|
@@ -138,7 +140,47 @@ export async function buildAll(ws: WorkspaceInfo): Promise<BuildManifest> {
|
|
|
138
140
|
await buildShell(ws.shellDir, ws.packages);
|
|
139
141
|
ok("Shell built");
|
|
140
142
|
|
|
141
|
-
|
|
143
|
+
// Compute aggregate hashes now that all artifacts are on disk, then rewrite
|
|
144
|
+
// manifest.json with full hash data (modules + shellHash + stylesHash).
|
|
145
|
+
const finalManifest = finalizeManifest(ws, manifest);
|
|
146
|
+
writeFileSync(
|
|
147
|
+
join(ws.modulesDir, "manifest.json"),
|
|
148
|
+
JSON.stringify(finalManifest, null, 2),
|
|
149
|
+
);
|
|
150
|
+
return finalManifest;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Compute shellHash + stylesHash and produce the final manifest.
|
|
155
|
+
* Pure function — does not touch disk.
|
|
156
|
+
*/
|
|
157
|
+
export function finalizeManifest(
|
|
158
|
+
ws: WorkspaceInfo,
|
|
159
|
+
manifest: BuildManifest,
|
|
160
|
+
): BuildManifest {
|
|
161
|
+
const shellFiles = listFilesRec(ws.shellDir);
|
|
162
|
+
const stylesFiles = [
|
|
163
|
+
join(ws.arcDir, "styles.css"),
|
|
164
|
+
join(ws.arcDir, "theme.css"),
|
|
165
|
+
].filter((p) => existsSync(p));
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
modules: manifest.modules,
|
|
169
|
+
shellHash: sha256OfFiles(shellFiles),
|
|
170
|
+
stylesHash: sha256OfFiles(stylesFiles),
|
|
171
|
+
buildTime: manifest.buildTime,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function listFilesRec(dir: string): string[] {
|
|
176
|
+
if (!existsSync(dir)) return [];
|
|
177
|
+
const out: string[] = [];
|
|
178
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
179
|
+
const p = join(dir, entry.name);
|
|
180
|
+
if (entry.isDirectory()) out.push(...listFilesRec(p));
|
|
181
|
+
else if (entry.isFile()) out.push(p);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
142
184
|
}
|
|
143
185
|
|
|
144
186
|
// ---------------------------------------------------------------------------
|