@arcote.tech/arc-cli 0.6.1 → 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.
@@ -1,152 +0,0 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
2
- import { join } from "path";
3
-
4
- // ---------------------------------------------------------------------------
5
- // _build-shell — hidden subcommand used by the runtime container's
6
- // /api/deploy/framework handler after `bun install` of the framework peers.
7
- // Discovers every @arcote.tech/* package + react/react-dom under --from and
8
- // emits one ESM shell bundle per package under --out. The browser then loads
9
- // these as singletons (shared across all user modules).
10
- //
11
- // Decoupled from `arc platform build` (which runs in the user's workspace) —
12
- // this command operates purely on an installed node_modules tree, with no
13
- // concept of workspace packages or build cache.
14
- // ---------------------------------------------------------------------------
15
-
16
- export interface BuildShellOptions {
17
- out: string;
18
- from: string;
19
- }
20
-
21
- const REACT_ENTRIES: Array<[string, string]> = [
22
- ["react", `export * from "react";\nimport * as React from "react";\nexport default React;`],
23
- ["react-dom", `export * from "react-dom";\nimport * as ReactDOM from "react-dom";\nexport default ReactDOM;`],
24
- ["jsx-runtime", `export * from "react/jsx-runtime";`],
25
- ["jsx-dev-runtime", `export * from "react/jsx-dev-runtime";`],
26
- ["react-dom-client", `export { createRoot, hydrateRoot } from "react-dom/client";`],
27
- ];
28
-
29
- const SHELL_BASE_EXTERNAL = [
30
- "react",
31
- "react-dom",
32
- "react/jsx-runtime",
33
- "react/jsx-dev-runtime",
34
- "react-dom/client",
35
- ];
36
-
37
- export async function buildShell(opts: BuildShellOptions): Promise<void> {
38
- const outDir = opts.out;
39
- const fromDir = opts.from;
40
-
41
- if (!existsSync(fromDir)) {
42
- console.error(`[_build-shell] --from not found: ${fromDir}`);
43
- process.exit(1);
44
- }
45
-
46
- mkdirSync(outDir, { recursive: true });
47
- const tmpDir = join(outDir, "_tmp");
48
- mkdirSync(tmpDir, { recursive: true });
49
-
50
- try {
51
- const arcPkgs = discoverArcPackages(fromDir);
52
- if (arcPkgs.length === 0) {
53
- console.warn("[_build-shell] no @arcote.tech/* packages discovered");
54
- }
55
-
56
- console.log(
57
- `[_build-shell] building shell for react + ${arcPkgs.length} @arcote.tech/* package(s)`,
58
- );
59
-
60
- await buildReactShell(outDir, tmpDir, fromDir);
61
- for (const pkg of arcPkgs) {
62
- await buildArcEntry(pkg, arcPkgs, outDir, tmpDir, fromDir);
63
- }
64
-
65
- console.log(`[_build-shell] done → ${outDir}`);
66
- } finally {
67
- rmSync(tmpDir, { recursive: true, force: true });
68
- }
69
- }
70
-
71
- // ---------------------------------------------------------------------------
72
- // Discovery — list every @arcote.tech/* dir under node_modules
73
- // ---------------------------------------------------------------------------
74
-
75
- function discoverArcPackages(fromDir: string): string[] {
76
- const arcDir = join(fromDir, "@arcote.tech");
77
- if (!existsSync(arcDir)) return [];
78
- return readdirSync(arcDir)
79
- .filter((name) => existsSync(join(arcDir, name, "package.json")))
80
- .map((name) => `@arcote.tech/${name}`);
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // React shell (separated because it's not under @arcote.tech)
85
- // ---------------------------------------------------------------------------
86
-
87
- async function buildReactShell(
88
- outDir: string,
89
- tmpDir: string,
90
- fromDir: string,
91
- ): Promise<void> {
92
- const eps: string[] = [];
93
- for (const [name, code] of REACT_ENTRIES) {
94
- const f = join(tmpDir, `${name}.ts`);
95
- await Bun.write(f, code);
96
- eps.push(f);
97
- }
98
-
99
- const r = await Bun.build({
100
- entrypoints: eps,
101
- outdir: outDir,
102
- splitting: true,
103
- format: "esm",
104
- target: "browser",
105
- naming: "[name].[ext]",
106
- root: fromDir,
107
- });
108
- if (!r.success) {
109
- for (const l of r.logs) console.error(l);
110
- throw new Error("React shell build failed");
111
- }
112
- }
113
-
114
- // ---------------------------------------------------------------------------
115
- // Per-@arcote.tech package shell
116
- // ---------------------------------------------------------------------------
117
-
118
- async function buildArcEntry(
119
- pkg: string,
120
- allArcPkgs: string[],
121
- outDir: string,
122
- tmpDir: string,
123
- fromDir: string,
124
- ): Promise<void> {
125
- const shortName = pkg.replace("@arcote.tech/", "");
126
- const otherExternals = allArcPkgs.filter((p) => p !== pkg);
127
-
128
- const f = join(tmpDir, `${shortName}.ts`);
129
- await Bun.write(f, `export * from "${pkg}";\n`);
130
-
131
- // Bun needs to resolve `pkg` from fromDir. Setting `root: fromDir` and
132
- // letting Bun walk up node_modules works because @arcote.tech/* lives at
133
- // `fromDir/@arcote.tech/<name>`.
134
- const r = await Bun.build({
135
- entrypoints: [f],
136
- outdir: outDir,
137
- format: "esm",
138
- target: "browser",
139
- naming: "[name].[ext]",
140
- root: fromDir,
141
- external: [...SHELL_BASE_EXTERNAL, ...otherExternals],
142
- define: {
143
- ONLY_SERVER: "false",
144
- ONLY_BROWSER: "true",
145
- ONLY_CLIENT: "true",
146
- },
147
- });
148
- if (!r.success) {
149
- for (const l of r.logs) console.error(l);
150
- throw new Error(`Shell build failed for ${pkg}`);
151
- }
152
- }
@@ -1,323 +0,0 @@
1
- import { existsSync, readFileSync, readdirSync } from "fs";
2
- import { basename, join } from "path";
3
- import type { BuildManifest, ModuleDescriptor } from "@arcote.tech/platform";
4
- import type { DeployConfig } from "./config";
5
- import { assertExec, openTunnel } from "./ssh";
6
- import { isContextPackage } from "../builder/module-builder";
7
- import type { WorkspaceInfo } from "../platform/shared";
8
-
9
- // ---------------------------------------------------------------------------
10
- // v0.6 sync driver — API-only, per-module. No rsync of user code.
11
- //
12
- // Flow per env:
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
- // ---------------------------------------------------------------------------
26
-
27
- export interface SyncInputs {
28
- cfg: DeployConfig;
29
- env: string;
30
- ws: WorkspaceInfo;
31
- /** Path to the project root. */
32
- projectDir: string;
33
- }
34
-
35
- export interface SyncOutcome {
36
- env: string;
37
- frameworkChanged: boolean;
38
- changedModules: readonly string[];
39
- stylesChanged: boolean;
40
- restarts: number;
41
- }
42
-
43
- // ---------------------------------------------------------------------------
44
- // Pure diff — exported for tests
45
- // ---------------------------------------------------------------------------
46
-
47
- export interface ManifestDiff {
48
- changedModules: ModuleDescriptor[];
49
- stylesChanged: boolean;
50
- }
51
-
52
- export function diffManifests(
53
- local: BuildManifest,
54
- remote: BuildManifest,
55
- ): ManifestDiff {
56
- const remoteByName = new Map(remote.modules.map((m) => [m.name, m]));
57
- const changedModules = local.modules.filter(
58
- (m) => remoteByName.get(m.name)?.hash !== m.hash,
59
- );
60
- return {
61
- changedModules: [...changedModules],
62
- stylesChanged: local.stylesHash !== remote.stylesHash,
63
- };
64
- }
65
-
66
- // ---------------------------------------------------------------------------
67
- // Sync driver
68
- // ---------------------------------------------------------------------------
69
-
70
- export async function syncEnv(inputs: SyncInputs): Promise<SyncOutcome> {
71
- const { cfg, env, ws } = inputs;
72
- const envConfig = cfg.envs[env];
73
- if (!envConfig) throw new Error(`Unknown env: ${env}`);
74
-
75
- // Local artifacts must exist (arc platform build was run)
76
- const localManifestPath = join(ws.modulesDir, "manifest.json");
77
- if (!existsSync(localManifestPath)) {
78
- throw new Error(
79
- `Local build missing at ${localManifestPath}. Run arc platform build first.`,
80
- );
81
- }
82
- const localManifest = JSON.parse(
83
- readFileSync(localManifestPath, "utf-8"),
84
- ) as BuildManifest;
85
-
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;
99
-
100
- try {
101
- const base = () =>
102
- `http://127.0.0.1:${tunnel.localPort}/env/${env}`;
103
-
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",
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
- }
142
- }
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);
154
- const diff = diffManifests(localManifest, remoteManifest);
155
-
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
-
174
- const form = new FormData();
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
- );
188
- }
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}`, {
206
- method: "POST",
207
- body: form,
208
- });
209
- if (!res.ok) {
210
- throw new Error(
211
- `module ${safeName} push failed: ${res.status} ${await res.text()}`,
212
- );
213
- }
214
- }
215
-
216
- // 4. Styles push
217
- if (diff.stylesChanged) {
218
- console.log("[arc] Pushing styles...");
219
- const form = new FormData();
220
- for (const name of ["styles.css", "theme.css"] as const) {
221
- const p = join(ws.arcDir, name);
222
- if (existsSync(p)) {
223
- form.append(name, new Blob([readFileSync(p)]), name);
224
- }
225
- }
226
- const res = await fetch(`${base()}/api/deploy/styles`, {
227
- method: "POST",
228
- body: form,
229
- });
230
- if (!res.ok) {
231
- throw new Error(
232
- `styles push failed: ${res.status} ${await res.text()}`,
233
- );
234
- }
235
- }
236
-
237
- // 5. Manifest commit
238
- const commitRes = await fetch(`${base()}/api/deploy/manifest`, {
239
- method: "POST",
240
- headers: { "Content-Type": "application/json" },
241
- body: JSON.stringify(localManifest),
242
- });
243
- if (!commitRes.ok) {
244
- throw new Error(
245
- `manifest commit failed: ${commitRes.status} ${await commitRes.text()}`,
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
- }
253
-
254
- return {
255
- env,
256
- frameworkChanged,
257
- changedModules: diff.changedModules.map((m) => m.name),
258
- stylesChanged: diff.stylesChanged,
259
- restarts,
260
- };
261
- } finally {
262
- tunnel.close();
263
- }
264
- }
265
-
266
- // ---------------------------------------------------------------------------
267
- // Helpers
268
- // ---------------------------------------------------------------------------
269
-
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));
314
- }
315
- throw new Error(`Health check timeout: ${String(lastErr)}`);
316
- }
317
-
318
- /** Deterministic per-env tunnel port offset so parallel syncs don't collide. */
319
- function hashEnvToOffset(env: string): number {
320
- let h = 0;
321
- for (const ch of env) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
322
- return h % 100;
323
- }