@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/platform/server.ts
CHANGED
|
@@ -11,9 +11,8 @@ import {
|
|
|
11
11
|
import { existsSync, mkdirSync } from "fs";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
import { readTranslationsConfig } from "../i18n";
|
|
14
|
-
import {
|
|
15
|
-
import type {
|
|
16
|
-
import type { ModuleAccess } from "@arcote.tech/platform";
|
|
14
|
+
import type { BuildManifest, WorkspaceInfo } from "./shared";
|
|
15
|
+
import type { BuildManifestGroup, ModuleAccess } from "@arcote.tech/platform";
|
|
17
16
|
|
|
18
17
|
// ---------------------------------------------------------------------------
|
|
19
18
|
// Types
|
|
@@ -31,12 +30,6 @@ export interface PlatformServerOptions {
|
|
|
31
30
|
dbPath?: string;
|
|
32
31
|
/** If true, enables SSE reload stream + mutable manifest (dev mode) */
|
|
33
32
|
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;
|
|
38
|
-
/** Arc shell entries [shortName, fullPkgName][] for import map. Auto-detected if omitted. */
|
|
39
|
-
arcEntries?: [string, string][];
|
|
40
33
|
}
|
|
41
34
|
|
|
42
35
|
export interface PlatformServer {
|
|
@@ -79,25 +72,15 @@ export async function initContextHandler(
|
|
|
79
72
|
export function generateShellHtml(
|
|
80
73
|
appName: string,
|
|
81
74
|
manifest?: { title: string; favicon?: string },
|
|
82
|
-
|
|
75
|
+
initial?: { file: string; hash: string },
|
|
83
76
|
): string {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
77
|
+
// Initial bundle carries framework, public modules, and PlatformApp re-export.
|
|
78
|
+
// No importmap — single Bun.build with splitting:true inlines + dedups everything
|
|
79
|
+
// across initial and per-token group bundles via auto-emitted chunk-<hash>.js.
|
|
80
|
+
const initialUrl = initial ? `/browser/${initial.file}` : null;
|
|
81
|
+
if (!initialUrl) {
|
|
82
|
+
throw new Error("generateShellHtml: initial bundle missing from manifest");
|
|
89
83
|
}
|
|
90
|
-
const importMap = {
|
|
91
|
-
imports: {
|
|
92
|
-
react: "/shell/react.js",
|
|
93
|
-
"react/jsx-runtime": "/shell/jsx-runtime.js",
|
|
94
|
-
"react/jsx-dev-runtime": "/shell/jsx-dev-runtime.js",
|
|
95
|
-
"react-dom": "/shell/react-dom.js",
|
|
96
|
-
"react-dom/client": "/shell/react-dom-client.js",
|
|
97
|
-
...arcImports,
|
|
98
|
-
},
|
|
99
|
-
};
|
|
100
|
-
|
|
101
84
|
return `<!doctype html>
|
|
102
85
|
<html lang="en">
|
|
103
86
|
<head>
|
|
@@ -106,18 +89,13 @@ export function generateShellHtml(
|
|
|
106
89
|
<title>${manifest?.title ?? appName}</title>${manifest?.favicon ? `\n <link rel="icon" href="${manifest.favicon}">` : ""}${manifest ? `\n <link rel="manifest" href="/manifest.json">` : ""}
|
|
107
90
|
<link rel="stylesheet" href="/styles.css" />
|
|
108
91
|
<link rel="stylesheet" href="/theme.css" />
|
|
109
|
-
<
|
|
92
|
+
<link rel="modulepreload" href="${initialUrl}" />
|
|
110
93
|
</head>
|
|
111
94
|
<body>
|
|
112
95
|
<div id="root"></div>
|
|
113
96
|
<script type="module">
|
|
114
|
-
import {
|
|
115
|
-
|
|
116
|
-
import { PlatformApp } from "@arcote.tech/platform";
|
|
117
|
-
|
|
118
|
-
createRoot(document.getElementById("root")).render(
|
|
119
|
-
createElement(PlatformApp)
|
|
120
|
-
);
|
|
97
|
+
import { startApp } from "${initialUrl}";
|
|
98
|
+
startApp("root");
|
|
121
99
|
</script>
|
|
122
100
|
</body>
|
|
123
101
|
</html>`;
|
|
@@ -165,22 +143,49 @@ function serveFile(
|
|
|
165
143
|
// Module access — signed URLs
|
|
166
144
|
// ---------------------------------------------------------------------------
|
|
167
145
|
|
|
168
|
-
|
|
146
|
+
// Secret is initialized lazily by `startPlatformServer` so dev mode can derive
|
|
147
|
+
// a stable, workspace-scoped value (browser sessions survive a watcher restart;
|
|
148
|
+
// without this, every restart invalidates every signed URL in the open page).
|
|
149
|
+
// Prod uses ARC_MODULE_SECRET if set, otherwise a random UUID per process.
|
|
150
|
+
let MODULE_SIG_SECRET: string = process.env.ARC_MODULE_SECRET ?? "";
|
|
169
151
|
const MODULE_SIG_TTL = 3600; // 1 hour
|
|
170
152
|
|
|
171
|
-
function
|
|
153
|
+
function ensureModuleSigSecret(ws: WorkspaceInfo, devMode: boolean): void {
|
|
154
|
+
if (MODULE_SIG_SECRET) return;
|
|
155
|
+
if (devMode) {
|
|
156
|
+
// Deterministic per workspace — restart-safe in dev.
|
|
157
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
158
|
+
hasher.update(`arc-dev-secret:${ws.rootDir}`);
|
|
159
|
+
MODULE_SIG_SECRET = hasher.digest("hex").slice(0, 32);
|
|
160
|
+
} else {
|
|
161
|
+
MODULE_SIG_SECRET = crypto.randomUUID();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Signed URL for a token-group bundle. HMAC payload binds the filename so
|
|
167
|
+
* a sig minted for `/browser/admin.<hash>.js` cannot be replayed for any
|
|
168
|
+
* other file. Shared chunks (chunk-<hash>.js) are NEVER signed — their
|
|
169
|
+
* filenames are content-hashed and they don't carry private code on their
|
|
170
|
+
* own (group entries side-effect-register the modules).
|
|
171
|
+
*/
|
|
172
|
+
function signGroupUrl(file: string): string {
|
|
172
173
|
const exp = Math.floor(Date.now() / 1000) + MODULE_SIG_TTL;
|
|
173
174
|
const hasher = new Bun.CryptoHasher("sha256");
|
|
174
|
-
hasher.update(`${
|
|
175
|
+
hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
|
|
175
176
|
const sig = hasher.digest("hex").slice(0, 16);
|
|
176
|
-
return `/
|
|
177
|
+
return `/browser/${file}?sig=${sig}&exp=${exp}`;
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
function
|
|
180
|
+
function verifyGroupSignature(
|
|
181
|
+
file: string,
|
|
182
|
+
sig: string | null,
|
|
183
|
+
exp: string | null,
|
|
184
|
+
): boolean {
|
|
180
185
|
if (!sig || !exp) return false;
|
|
181
186
|
if (Number(exp) < Date.now() / 1000) return false;
|
|
182
187
|
const hasher = new Bun.CryptoHasher("sha256");
|
|
183
|
-
hasher.update(`${
|
|
188
|
+
hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
|
|
184
189
|
return hasher.digest("hex").slice(0, 16) === sig;
|
|
185
190
|
}
|
|
186
191
|
|
|
@@ -216,41 +221,60 @@ function parseArcTokensHeader(header: string | null): any[] {
|
|
|
216
221
|
return payloads;
|
|
217
222
|
}
|
|
218
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Filter manifest groups to those the caller's tokens grant access to. A
|
|
226
|
+
* group is unlocked when (a) the caller carries a token whose `tokenType`
|
|
227
|
+
* matches the group name, and (b) every per-module rule declared via
|
|
228
|
+
* `protectedBy(token, check)` passes for at least one of those tokens.
|
|
229
|
+
*
|
|
230
|
+
* Returns a manifest with `groups` filtered + each remaining entry's `url`
|
|
231
|
+
* filled with a signed URL (HMAC, 1h TTL). `initial` + `sharedChunks` ride
|
|
232
|
+
* along unchanged — those are public/content-addressed.
|
|
233
|
+
*/
|
|
219
234
|
async function filterManifestForTokens(
|
|
220
235
|
manifest: BuildManifest,
|
|
221
236
|
moduleAccessMap: Map<string, ModuleAccess>,
|
|
222
237
|
tokenPayloads: any[],
|
|
223
238
|
): Promise<BuildManifest> {
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (!access) {
|
|
230
|
-
filtered.push(mod);
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (tokenPayloads.length === 0) continue;
|
|
239
|
+
const allowedGroups = new Set<string>();
|
|
240
|
+
for (const t of tokenPayloads) {
|
|
241
|
+
if (t?.tokenType) allowedGroups.add(t.tokenType);
|
|
242
|
+
}
|
|
235
243
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
244
|
+
const filteredGroups: Record<string, BuildManifestGroup> = {};
|
|
245
|
+
|
|
246
|
+
for (const [name, group] of Object.entries(manifest.groups)) {
|
|
247
|
+
if (!allowedGroups.has(name)) continue;
|
|
248
|
+
|
|
249
|
+
// Every module in the group must pass its per-rule check (if any).
|
|
250
|
+
// If all rules pass (or none declared), unlock the group bundle.
|
|
251
|
+
let allGranted = true;
|
|
252
|
+
for (const moduleName of group.modules) {
|
|
253
|
+
const access = moduleAccessMap.get(moduleName);
|
|
254
|
+
if (!access || access.rules.length === 0) continue;
|
|
255
|
+
let granted = false;
|
|
256
|
+
for (const rule of access.rules) {
|
|
257
|
+
if (rule.token.name !== name) continue;
|
|
258
|
+
const matching = tokenPayloads.find((t) => t.tokenType === name);
|
|
259
|
+
if (!matching) continue;
|
|
242
260
|
granted = rule.check ? await rule.check(matching) : true;
|
|
243
261
|
if (granted) break;
|
|
244
262
|
}
|
|
263
|
+
if (!granted) {
|
|
264
|
+
allGranted = false;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
245
267
|
}
|
|
246
268
|
|
|
247
|
-
if (
|
|
248
|
-
|
|
269
|
+
if (allGranted) {
|
|
270
|
+
filteredGroups[name] = { ...group, url: signGroupUrl(group.file) };
|
|
249
271
|
}
|
|
250
272
|
}
|
|
273
|
+
|
|
251
274
|
return {
|
|
252
|
-
|
|
253
|
-
|
|
275
|
+
initial: manifest.initial,
|
|
276
|
+
groups: filteredGroups,
|
|
277
|
+
sharedChunks: manifest.sharedChunks,
|
|
254
278
|
stylesHash: manifest.stylesHash,
|
|
255
279
|
buildTime: manifest.buildTime,
|
|
256
280
|
};
|
|
@@ -260,30 +284,40 @@ async function filterManifestForTokens(
|
|
|
260
284
|
// Platform-specific HTTP handlers
|
|
261
285
|
// ---------------------------------------------------------------------------
|
|
262
286
|
|
|
287
|
+
/** Browser bundle filenames: `initial.<hash>.js`, `<tokenName>.<hash>.js`,
|
|
288
|
+
* `chunk-<hash>.js`. All Bun.build outputs with content-addressed hashes. */
|
|
289
|
+
const BROWSER_FILE_RE = /^[A-Za-z0-9_.-]+\.js$/;
|
|
290
|
+
|
|
263
291
|
function staticFilesHandler(
|
|
264
292
|
ws: WorkspaceInfo,
|
|
265
293
|
devMode: boolean,
|
|
266
|
-
|
|
294
|
+
getManifest: () => BuildManifest,
|
|
267
295
|
): ArcHttpHandler {
|
|
268
296
|
return (_req, url, ctx) => {
|
|
269
297
|
const path = url.pathname;
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (path.startsWith("/
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
298
|
+
// Single flat browser dir: initial bundle, per-token group entries,
|
|
299
|
+
// and auto-emitted shared chunks all live in <arcDir>/browser/.
|
|
300
|
+
if (path.startsWith("/browser/")) {
|
|
301
|
+
const file = path.slice(9);
|
|
302
|
+
if (!BROWSER_FILE_RE.test(file)) {
|
|
303
|
+
return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Only token-group entries are signed. The initial bundle and shared
|
|
307
|
+
// chunks are content-addressed (filename = hash) — unsigned by design.
|
|
308
|
+
const manifest = getManifest();
|
|
309
|
+
const isGroupEntry = Object.values(manifest.groups).some(
|
|
310
|
+
(g) => g.file === file,
|
|
311
|
+
);
|
|
312
|
+
if (isGroupEntry) {
|
|
279
313
|
const sig = url.searchParams.get("sig");
|
|
280
314
|
const exp = url.searchParams.get("exp");
|
|
281
|
-
if (!
|
|
315
|
+
if (!verifyGroupSignature(file, sig, exp)) {
|
|
282
316
|
return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
|
|
283
317
|
}
|
|
284
318
|
}
|
|
285
319
|
|
|
286
|
-
return serveFile(join(ws.
|
|
320
|
+
return serveFile(join(ws.browserDir, file), {
|
|
287
321
|
...ctx.corsHeaders,
|
|
288
322
|
"Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
|
|
289
323
|
});
|
|
@@ -343,7 +377,7 @@ function apiEndpointsHandler(
|
|
|
343
377
|
return Response.json(
|
|
344
378
|
{
|
|
345
379
|
status: "ok",
|
|
346
|
-
|
|
380
|
+
groups: Object.keys(getManifest().groups).length,
|
|
347
381
|
clients: cm?.clientCount ?? 0,
|
|
348
382
|
},
|
|
349
383
|
{ headers: ctx.corsHeaders },
|
|
@@ -381,9 +415,9 @@ function devReloadHandler(
|
|
|
381
415
|
};
|
|
382
416
|
}
|
|
383
417
|
|
|
384
|
-
function spaFallbackHandler(
|
|
418
|
+
function spaFallbackHandler(getShellHtml: () => string): ArcHttpHandler {
|
|
385
419
|
return (_req, _url, ctx) => {
|
|
386
|
-
return new Response(
|
|
420
|
+
return new Response(getShellHtml(), {
|
|
387
421
|
headers: { ...ctx.corsHeaders, "Content-Type": "text/html" },
|
|
388
422
|
});
|
|
389
423
|
};
|
|
@@ -397,6 +431,7 @@ export async function startPlatformServer(
|
|
|
397
431
|
opts: PlatformServerOptions,
|
|
398
432
|
): Promise<PlatformServer> {
|
|
399
433
|
const { ws, port, devMode, context } = opts;
|
|
434
|
+
ensureModuleSigSecret(ws, !!devMode);
|
|
400
435
|
const moduleAccessMap = opts.moduleAccess ?? new Map();
|
|
401
436
|
let manifest = opts.manifest;
|
|
402
437
|
const getManifest = () => manifest;
|
|
@@ -404,7 +439,10 @@ export async function startPlatformServer(
|
|
|
404
439
|
manifest = m;
|
|
405
440
|
};
|
|
406
441
|
|
|
407
|
-
|
|
442
|
+
// Recompute on every request — manifest.initial.hash changes when public
|
|
443
|
+
// modules are rebuilt in dev, and we want the new URL in the HTML.
|
|
444
|
+
const getShellHtml = (): string =>
|
|
445
|
+
generateShellHtml(ws.appName, ws.manifest, manifest?.initial);
|
|
408
446
|
const sseClients = new Set<ReadableStreamDefaultController>();
|
|
409
447
|
|
|
410
448
|
const notifyReload = (m: BuildManifest) => {
|
|
@@ -418,17 +456,6 @@ export async function startPlatformServer(
|
|
|
418
456
|
}
|
|
419
457
|
};
|
|
420
458
|
|
|
421
|
-
const deployApiEnabled =
|
|
422
|
-
opts.deployApi ?? process.env.ARC_DEPLOY_API === "1";
|
|
423
|
-
const deployApiHandler = deployApiEnabled
|
|
424
|
-
? createDeployApiHandler({
|
|
425
|
-
ws,
|
|
426
|
-
getManifest,
|
|
427
|
-
setManifest,
|
|
428
|
-
notifyReload,
|
|
429
|
-
})
|
|
430
|
-
: null;
|
|
431
|
-
|
|
432
459
|
if (!context) {
|
|
433
460
|
// No context — serve static files only (no WS/commands/queries)
|
|
434
461
|
const cors = {
|
|
@@ -458,11 +485,10 @@ export async function startPlatformServer(
|
|
|
458
485
|
|
|
459
486
|
// Platform handlers only
|
|
460
487
|
const handlers: ArcHttpHandler[] = [
|
|
461
|
-
...(deployApiHandler ? [deployApiHandler] : []),
|
|
462
488
|
apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
|
|
463
489
|
devReloadHandler(sseClients),
|
|
464
|
-
staticFilesHandler(ws, !!devMode,
|
|
465
|
-
spaFallbackHandler(
|
|
490
|
+
staticFilesHandler(ws, !!devMode, getManifest),
|
|
491
|
+
spaFallbackHandler(getShellHtml),
|
|
466
492
|
];
|
|
467
493
|
|
|
468
494
|
for (const handler of handlers) {
|
|
@@ -498,11 +524,10 @@ export async function startPlatformServer(
|
|
|
498
524
|
port,
|
|
499
525
|
httpHandlers: [
|
|
500
526
|
// Platform-specific handlers (checked AFTER arc handlers)
|
|
501
|
-
...(deployApiHandler ? [deployApiHandler] : []),
|
|
502
527
|
apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
|
|
503
528
|
devReloadHandler(sseClients),
|
|
504
|
-
staticFilesHandler(ws, !!devMode,
|
|
505
|
-
spaFallbackHandler(
|
|
529
|
+
staticFilesHandler(ws, !!devMode, getManifest),
|
|
530
|
+
spaFallbackHandler(getShellHtml),
|
|
506
531
|
],
|
|
507
532
|
onWsClose: (clientId) => cleanupClientSubs(clientId),
|
|
508
533
|
});
|