@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,321 +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
- // Ship a placeholder bun.lock (server validates its presence) but
120
- // intentionally empty — workspace bun.lock has the full dep graph and
121
- // would trip --frozen-lockfile against our slim framework package.json.
122
- // The server side runs `bun install --production` without freeze, so an
123
- // empty lock is harmless: bun re-resolves against package.json.
124
- form.append("bun.lock", new Blob([""]), "bun.lock");
125
- const res = await fetch(`${base()}/api/deploy/framework`, {
126
- method: "POST",
127
- body: form,
128
- });
129
- if (!res.ok) {
130
- throw new Error(
131
- `framework push failed: ${res.status} ${await res.text()}`,
132
- );
133
- }
134
- frameworkChanged = true;
135
- const result = (await res.json()) as { needsRestart?: boolean };
136
- if (result.needsRestart) {
137
- tunnel = await restartAndReopen(cfg, env, tunnel);
138
- restarts += 1;
139
- }
140
- }
141
-
142
- // 2. Remote manifest + diff
143
- const remoteManifestRes = await fetch(`${base()}/api/deploy/manifest`);
144
- const remoteManifest: BuildManifest = remoteManifestRes.ok
145
- ? await remoteManifestRes.json()
146
- : ({
147
- modules: [],
148
- shellHash: "",
149
- stylesHash: "",
150
- buildTime: "",
151
- } satisfies BuildManifest);
152
- const diff = diffManifests(localManifest, remoteManifest);
153
-
154
- // 3. Per-module push
155
- for (const mod of diff.changedModules) {
156
- const safeName = sanitizeName(mod.name);
157
- const moduleDir = join(ws.modulesDir, safeName);
158
- const browserPath = join(moduleDir, "browser.js");
159
- const serverPath = join(moduleDir, "server.js");
160
- const pkgPath = join(moduleDir, "package.json");
161
- const accessPath = join(moduleDir, "access.json");
162
-
163
- // Fall back to legacy <name>.js path while module-builder still emits
164
- // the flat layout. Same hash either way.
165
- const browserActual = existsSync(browserPath)
166
- ? browserPath
167
- : join(ws.modulesDir, `${safeName}.js`);
168
- if (!existsSync(browserActual)) {
169
- throw new Error(`Missing browser bundle for module ${mod.name}`);
170
- }
171
-
172
- const form = new FormData();
173
- form.append(
174
- "browser.js",
175
- new Blob([readFileSync(browserActual)]),
176
- "browser.js",
177
- );
178
-
179
- const pkg = pkgByName.get(safeName);
180
- if (pkg && isContextPackage(pkg.packageJson) && existsSync(serverPath)) {
181
- form.append(
182
- "server.js",
183
- new Blob([readFileSync(serverPath)]),
184
- "server.js",
185
- );
186
- }
187
- if (existsSync(pkgPath)) {
188
- form.append(
189
- "package.json",
190
- new Blob([readFileSync(pkgPath)]),
191
- "package.json",
192
- );
193
- }
194
- if (existsSync(accessPath)) {
195
- form.append(
196
- "access.json",
197
- new Blob([readFileSync(accessPath)]),
198
- "access.json",
199
- );
200
- }
201
-
202
- console.log(`[arc] Pushing module ${safeName}...`);
203
- const res = await fetch(`${base()}/api/deploy/modules/${safeName}`, {
204
- method: "POST",
205
- body: form,
206
- });
207
- if (!res.ok) {
208
- throw new Error(
209
- `module ${safeName} push failed: ${res.status} ${await res.text()}`,
210
- );
211
- }
212
- }
213
-
214
- // 4. Styles push
215
- if (diff.stylesChanged) {
216
- console.log("[arc] Pushing styles...");
217
- const form = new FormData();
218
- for (const name of ["styles.css", "theme.css"] as const) {
219
- const p = join(ws.arcDir, name);
220
- if (existsSync(p)) {
221
- form.append(name, new Blob([readFileSync(p)]), name);
222
- }
223
- }
224
- const res = await fetch(`${base()}/api/deploy/styles`, {
225
- method: "POST",
226
- body: form,
227
- });
228
- if (!res.ok) {
229
- throw new Error(
230
- `styles push failed: ${res.status} ${await res.text()}`,
231
- );
232
- }
233
- }
234
-
235
- // 5. Manifest commit
236
- const commitRes = await fetch(`${base()}/api/deploy/manifest`, {
237
- method: "POST",
238
- headers: { "Content-Type": "application/json" },
239
- body: JSON.stringify(localManifest),
240
- });
241
- if (!commitRes.ok) {
242
- throw new Error(
243
- `manifest commit failed: ${commitRes.status} ${await commitRes.text()}`,
244
- );
245
- }
246
- const commit = (await commitRes.json()) as { needsRestart?: boolean };
247
- if (commit.needsRestart) {
248
- tunnel = await restartAndReopen(cfg, env, tunnel);
249
- restarts += 1;
250
- }
251
-
252
- return {
253
- env,
254
- frameworkChanged,
255
- changedModules: diff.changedModules.map((m) => m.name),
256
- stylesChanged: diff.stylesChanged,
257
- restarts,
258
- };
259
- } finally {
260
- tunnel.close();
261
- }
262
- }
263
-
264
- // ---------------------------------------------------------------------------
265
- // Helpers
266
- // ---------------------------------------------------------------------------
267
-
268
- function readDepsHash(path: string): string | null {
269
- if (!existsSync(path)) return null;
270
- return readFileSync(path, "utf-8").trim() || null;
271
- }
272
-
273
- function sanitizeName(name: string): string {
274
- // Strip package scope ("@ndt/auth" → "auth"); manifest already stores names
275
- // this way, but be defensive.
276
- return basename(name);
277
- }
278
-
279
- async function restartAndReopen(
280
- cfg: DeployConfig,
281
- env: string,
282
- oldTunnel: { close: () => void; localPort: number },
283
- ): Promise<{ close: () => void; localPort: number }> {
284
- console.log(`[arc] Restarting arc-${env}...`);
285
- oldTunnel.close();
286
- await assertExec(cfg.target, `docker restart arc-${env}`);
287
-
288
- const tunnel = await openTunnel(
289
- cfg.target,
290
- 15500 + hashEnvToOffset(env),
291
- "127.0.0.1",
292
- 2019,
293
- );
294
- await waitForHealthy(`http://127.0.0.1:${tunnel.localPort}/env/${env}`, 60_000);
295
- return tunnel;
296
- }
297
-
298
- async function waitForHealthy(baseUrl: string, timeoutMs: number): Promise<void> {
299
- const deadline = Date.now() + timeoutMs;
300
- let lastErr: unknown;
301
- while (Date.now() < deadline) {
302
- try {
303
- const res = await fetch(`${baseUrl}/api/deploy/health`, {
304
- signal: AbortSignal.timeout(2_000),
305
- });
306
- if (res.ok) return;
307
- lastErr = `status ${res.status}`;
308
- } catch (e) {
309
- lastErr = e;
310
- }
311
- await new Promise((r) => setTimeout(r, 1_000));
312
- }
313
- throw new Error(`Health check timeout: ${String(lastErr)}`);
314
- }
315
-
316
- /** Deterministic per-env tunnel port offset so parallel syncs don't collide. */
317
- function hashEnvToOffset(env: string): number {
318
- let h = 0;
319
- for (const ch of env) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
320
- return h % 100;
321
- }