@arcote.tech/arc-cli 0.5.8 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-cli",
3
- "version": "0.5.8",
3
+ "version": "0.6.0",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -12,12 +12,12 @@
12
12
  "build": "bun build --target=bun ./src/index.ts --outdir=dist --external @arcote.tech/arc --external @arcote.tech/arc-ds --external @arcote.tech/arc-react --external @arcote.tech/platform && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.5.8",
16
- "@arcote.tech/arc-ds": "^0.5.8",
17
- "@arcote.tech/arc-react": "^0.5.8",
18
- "@arcote.tech/arc-host": "^0.5.8",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.5.8",
20
- "@arcote.tech/platform": "^0.5.8",
15
+ "@arcote.tech/arc": "^0.6.0",
16
+ "@arcote.tech/arc-ds": "^0.6.0",
17
+ "@arcote.tech/arc-react": "^0.6.0",
18
+ "@arcote.tech/arc-host": "^0.6.0",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.6.0",
20
+ "@arcote.tech/platform": "^0.6.0",
21
21
  "@clack/prompts": "^0.9.0",
22
22
  "commander": "^11.1.0",
23
23
  "chokidar": "^3.5.3",
@@ -0,0 +1,29 @@
1
+ # arcote/runtime — generic Arc platform runtime container.
2
+ #
3
+ # Image is intentionally version-agnostic. CLI version comes from the
4
+ # ARC_CLI_VERSION env var set by docker-compose (generated per-deploy by
5
+ # `arc platform deploy`), so a single `arcote/runtime:1` tag serves every
6
+ # CLI release. Bumping CLI only requires `bun publish` — no image rebuild.
7
+ #
8
+ # Layout in container:
9
+ # /app/.arc/cli/<ARC_CLI_VERSION>/ ← arc-cli + direct deps (installed by entrypoint, cached)
10
+ # /app/.arc/platform/ ← user volume: code, framework + per-module deps
11
+ # /app/.arc/data/ ← user volume: sqlite
12
+ # /root/.bun/install/cache/ ← shared bun store (volume)
13
+
14
+ FROM oven/bun:1-alpine
15
+
16
+ # tini for proper signal handling. build-base/python3/git in case any user dep
17
+ # has a native postinstall step (e.g. better-sqlite3). curl for healthchecks.
18
+ RUN apk add --no-cache tini ca-certificates build-base python3 git curl
19
+
20
+ WORKDIR /app
21
+
22
+ COPY entrypoint.sh /entrypoint.sh
23
+ RUN chmod +x /entrypoint.sh
24
+
25
+ EXPOSE 5005
26
+ ENV PORT=5005 \
27
+ ARC_DEPLOY_API=1
28
+
29
+ ENTRYPOINT ["tini", "--", "/entrypoint.sh"]
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ # build-and-push.sh — manual multi-arch publish of pkrasinski/arc-runtime to Docker Hub.
3
+ #
4
+ # Image is generic (no CLI version baked in) — one tag covers every CLI release.
5
+ # Re-run only when the entrypoint script or base image needs to change.
6
+ #
7
+ # Prereqs: docker buildx + multi-arch builder configured, `docker login` done.
8
+ #
9
+ # Usage: bash build-and-push.sh [tag=1]
10
+
11
+ set -euo pipefail
12
+
13
+ TAG="${1:-1}"
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+
16
+ docker buildx build \
17
+ --platform linux/amd64,linux/arm64 \
18
+ --tag "pkrasinski/arc-runtime:${TAG}" \
19
+ --tag "pkrasinski/arc-runtime:latest" \
20
+ --push \
21
+ "${SCRIPT_DIR}"
22
+
23
+ echo "✓ Published pkrasinski/arc-runtime:${TAG} (multi-arch)"
@@ -0,0 +1,27 @@
1
+ #!/bin/sh
2
+ # entrypoint.sh — installs arc-cli at ARC_CLI_VERSION (cached per version),
3
+ # then hands off to `arc platform start`. The CLI itself decides between
4
+ # pre-deploy mode (no manifest yet) and full mode based on volume state —
5
+ # no logic for that lives here.
6
+ #
7
+ # bun install of framework peers and per-module deps is done by the CLI in
8
+ # response to /api/deploy/framework and /api/deploy/modules/:name — this
9
+ # script only ensures the CLI binary itself is present.
10
+
11
+ set -e
12
+
13
+ : "${ARC_CLI_VERSION:?ARC_CLI_VERSION env var required (set by docker-compose)}"
14
+
15
+ CLI_DIR="/app/.arc/cli/${ARC_CLI_VERSION}"
16
+ CLI_BIN="${CLI_DIR}/node_modules/@arcote.tech/arc-cli/dist/index.js"
17
+
18
+ if [ ! -f "$CLI_BIN" ]; then
19
+ echo "[entrypoint] installing @arcote.tech/arc-cli@${ARC_CLI_VERSION}..."
20
+ mkdir -p "$CLI_DIR"
21
+ cd "$CLI_DIR"
22
+ echo '{"name":"arc-runtime-cli","private":true,"type":"module"}' > package.json
23
+ bun add "@arcote.tech/arc-cli@${ARC_CLI_VERSION}"
24
+ fi
25
+
26
+ echo "[entrypoint] starting arc platform (cli=${ARC_CLI_VERSION})"
27
+ exec bun run "$CLI_BIN" platform start
@@ -0,0 +1,127 @@
1
+ import { spawn } from "bun";
2
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { basename, join } from "path";
5
+ import type { WorkspacePackage } from "./module-builder";
6
+ import { isContextPackage } from "./module-builder";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // access-extractor — runs platform module-access discovery in an ISOLATED
10
+ // subprocess so the global registry (singleton in @arcote.tech/platform) is
11
+ // not polluted between builds. The subprocess imports each context bundle,
12
+ // calls getAllModuleAccess(), and serializes a plain-JSON view to
13
+ // `<arcDir>/access.json`.
14
+ //
15
+ // The `check` callback per rule is NOT serialized — it's a function reference
16
+ // kept inside the server bundle. We only record `{ token: { name }, hasCheck }`.
17
+ // Runtime resolves the actual check via the loaded server bundle at request
18
+ // time.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface SerializedAccessRule {
22
+ token: { name: string };
23
+ hasCheck: boolean;
24
+ }
25
+
26
+ export interface SerializedModuleAccess {
27
+ rules: SerializedAccessRule[];
28
+ }
29
+
30
+ export type SerializedAccessMap = Record<string, SerializedModuleAccess>;
31
+
32
+ export async function extractAccessMap(
33
+ arcDir: string,
34
+ packages: WorkspacePackage[],
35
+ ): Promise<SerializedAccessMap> {
36
+ // Prefer the v0.6 per-module location; fall back to the legacy per-package
37
+ // dist path used while the build pipeline migration is in progress. The
38
+ // fallback goes away once module-builder always emits server bundles
39
+ // under <arcDir>/modules/<safeName>/server.js.
40
+ const serverBundles = packages
41
+ .filter((p) => isContextPackage(p.packageJson))
42
+ .map((p) => {
43
+ const v06Path = join(arcDir, "modules", basename(p.path), "server.js");
44
+ const legacyPath = join(p.path, "dist", "server", "main", "index.js");
45
+ return { name: p.name, path: v06Path, fallback: legacyPath };
46
+ });
47
+
48
+ // Output target — subprocess writes here, we read back.
49
+ const outPath = join(arcDir, "access.json");
50
+ mkdirSync(arcDir, { recursive: true });
51
+
52
+ // Worker script as a tempfile so we can `bun run <path>` (Bun has no -e flag
53
+ // for TypeScript). Cleanup in finally.
54
+ const workerPath = join(tmpdir(), `arc-access-extractor-${Date.now()}.mjs`);
55
+ writeFileSync(workerPath, WORKER_SOURCE);
56
+
57
+ try {
58
+ const proc = spawn({
59
+ cmd: ["bun", "run", workerPath],
60
+ env: {
61
+ ...process.env,
62
+ ARC_ACCESS_BUNDLES: JSON.stringify(serverBundles),
63
+ ARC_ACCESS_OUT: outPath,
64
+ },
65
+ stdout: "pipe",
66
+ stderr: "inherit",
67
+ });
68
+ const exit = await proc.exited;
69
+ if (exit !== 0) {
70
+ throw new Error(`access-extractor subprocess exited with ${exit}`);
71
+ }
72
+ return JSON.parse(readFileSync(outPath, "utf-8")) as SerializedAccessMap;
73
+ } finally {
74
+ try {
75
+ unlinkSync(workerPath);
76
+ } catch {
77
+ // best effort
78
+ }
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Worker source (inlined — runs in fresh Bun process)
84
+ // ---------------------------------------------------------------------------
85
+
86
+ const WORKER_SOURCE = `
87
+ import { existsSync } from "node:fs";
88
+
89
+ globalThis.ONLY_SERVER = true;
90
+
91
+ const bundles = JSON.parse(process.env.ARC_ACCESS_BUNDLES || "[]");
92
+ const out = process.env.ARC_ACCESS_OUT;
93
+ if (!out) {
94
+ console.error("[access-extractor-worker] ARC_ACCESS_OUT not set");
95
+ process.exit(2);
96
+ }
97
+
98
+ const platform = await import("@arcote.tech/platform");
99
+
100
+ for (const { name, path, fallback } of bundles) {
101
+ const target = existsSync(path) ? path : fallback;
102
+ if (!target || !existsSync(target)) {
103
+ // No server bundle on either path — module has no protected access rules
104
+ // to discover. Skip silently rather than logging a misleading error.
105
+ continue;
106
+ }
107
+ try {
108
+ await import(target);
109
+ } catch (e) {
110
+ console.error("[access-extractor-worker] failed to import", name, "from", target, e);
111
+ // Continue — partial access map is better than total failure.
112
+ }
113
+ }
114
+
115
+ const result = {};
116
+ for (const [name, access] of platform.getAllModuleAccess()) {
117
+ result[name] = {
118
+ rules: (access.rules ?? []).map((r) => ({
119
+ token: { name: r.token?.name ?? "" },
120
+ hasCheck: typeof r.check === "function",
121
+ })),
122
+ };
123
+ }
124
+
125
+ const { writeFileSync } = await import("node:fs");
126
+ writeFileSync(out, JSON.stringify(result, null, 2) + "\\n");
127
+ `.trim();
@@ -0,0 +1,155 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { basename, join } from "path";
4
+ import type { WorkspacePackage } from "./module-builder";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // dependency-collector — generates per-module + global framework dep manifests
8
+ // that the runtime container uses to `bun install`.
9
+ //
10
+ // Global manifest (`<arcDir>/package.json` + `bun.lock`): framework peers only,
11
+ // shared across modules so `instanceof` checks survive cross-module imports.
12
+ //
13
+ // Per-module manifest (`<arcDir>/modules/<safeName>/package.json`): everything
14
+ // else this module pulls in (fragments, user libs) — installed in isolation
15
+ // into the module's own node_modules at deploy time.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Packages that MUST be singleton across all modules — they define classes
20
+ * checked with `instanceof` cross-module. Container resolves these from the
21
+ * platform-level `node_modules/`, not per-module.
22
+ */
23
+ export const FRAMEWORK_PEERS = [
24
+ "@arcote.tech/arc",
25
+ "@arcote.tech/arc-ds",
26
+ "@arcote.tech/arc-react",
27
+ "@arcote.tech/platform",
28
+ "react",
29
+ "react-dom",
30
+ ] as const;
31
+
32
+ export type FrameworkPeer = (typeof FRAMEWORK_PEERS)[number];
33
+
34
+ export interface CollectedDeps {
35
+ /** sha256 of the on-disk manifest+lockfile pair — drives deploy diff. */
36
+ hash: string;
37
+ /** Path of the generated package.json. */
38
+ manifestPath: string;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Global framework peers
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export function collectFrameworkDeps(
46
+ arcDir: string,
47
+ rootDir: string,
48
+ packages: WorkspacePackage[],
49
+ ): CollectedDeps {
50
+ mkdirSync(arcDir, { recursive: true });
51
+
52
+ const versions = resolveFrameworkVersions(rootDir, packages);
53
+ const manifest = {
54
+ name: "arc-platform-framework",
55
+ private: true,
56
+ type: "module" as const,
57
+ dependencies: versions,
58
+ };
59
+ const manifestPath = join(arcDir, "package.json");
60
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
61
+
62
+ // Copy workspace bun.lock so the container can use --frozen-lockfile for
63
+ // deterministic framework installs. Per-module installs run without it (no
64
+ // per-module lockfile in MVP).
65
+ const rootLock = join(rootDir, "bun.lock");
66
+ const targetLock = join(arcDir, "bun.lock");
67
+ if (existsSync(rootLock)) {
68
+ copyFileSync(rootLock, targetLock);
69
+ }
70
+
71
+ const hash = sha256OfFiles([manifestPath, targetLock]);
72
+ writeFileSync(join(arcDir, ".deps-hash"), hash + "\n");
73
+ return { hash, manifestPath };
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Per-module deps
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export function collectModuleDeps(
81
+ arcDir: string,
82
+ pkg: WorkspacePackage,
83
+ ): CollectedDeps {
84
+ const safeName = basename(pkg.path);
85
+ const moduleDir = join(arcDir, "modules", safeName);
86
+ mkdirSync(moduleDir, { recursive: true });
87
+
88
+ const pkgDeps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
89
+ const filtered: Record<string, string> = {};
90
+ const frameworkSet = new Set<string>(FRAMEWORK_PEERS);
91
+ for (const [name, spec] of Object.entries(pkgDeps)) {
92
+ if (frameworkSet.has(name)) continue;
93
+ if (spec.startsWith("workspace:")) continue;
94
+ filtered[name] = spec;
95
+ }
96
+
97
+ const manifest = {
98
+ name: `arc-module-${safeName}`,
99
+ private: true,
100
+ type: "module" as const,
101
+ dependencies: filtered,
102
+ };
103
+ const manifestPath = join(moduleDir, "package.json");
104
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
105
+
106
+ const hash = sha256OfFiles([manifestPath]);
107
+ writeFileSync(join(moduleDir, ".deps-hash"), hash + "\n");
108
+ return { hash, manifestPath };
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Internals
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function resolveFrameworkVersions(
116
+ rootDir: string,
117
+ packages: WorkspacePackage[],
118
+ ): Record<string, string> {
119
+ // Workspace root deps win — that's where the user pins versions. Fall back
120
+ // to whatever a workspace package declared, then `*` as last resort.
121
+ const rootPkg = JSON.parse(
122
+ readFileSync(join(rootDir, "package.json"), "utf-8"),
123
+ );
124
+ const rootDeps = (rootPkg.dependencies ?? {}) as Record<string, string>;
125
+ const out: Record<string, string> = {};
126
+ for (const name of FRAMEWORK_PEERS) {
127
+ const rootSpec = rootDeps[name];
128
+ if (rootSpec) {
129
+ out[name] = rootSpec;
130
+ continue;
131
+ }
132
+ let found: string | undefined;
133
+ for (const pkg of packages) {
134
+ const spec = (pkg.packageJson.dependencies ?? {})[name];
135
+ if (spec) {
136
+ found = spec;
137
+ break;
138
+ }
139
+ }
140
+ out[name] = found ?? "*";
141
+ }
142
+ return out;
143
+ }
144
+
145
+ function sha256OfFiles(paths: string[]): string {
146
+ const hash = createHash("sha256");
147
+ for (const p of paths.sort()) {
148
+ if (!existsSync(p)) continue;
149
+ hash.update(p);
150
+ hash.update("\0");
151
+ hash.update(readFileSync(p));
152
+ hash.update("\0");
153
+ }
154
+ return hash.digest("hex");
155
+ }
@@ -0,0 +1,152 @@
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
+ }
@@ -14,13 +14,42 @@ import {
14
14
  export async function platformStart(): Promise<void> {
15
15
  const ws = resolveWorkspace();
16
16
  const port = parseInt(process.env.PORT || "5005", 10);
17
+ const deployApi = process.env.ARC_DEPLOY_API === "1";
17
18
 
18
- // Read pre-built manifest
19
+ // Pre-deploy mode: container started with empty volume (first boot of an
20
+ // arcote/runtime container — manifest hasn't been pushed yet). Boot a
21
+ // minimal server so the deploy CLI can reach /api/deploy/* to push the
22
+ // initial framework + modules. Container restart (after first manifest
23
+ // commit) re-enters this function with manifest present → full mode.
19
24
  const manifestPath = join(ws.modulesDir, "manifest.json");
20
25
  if (!existsSync(manifestPath)) {
21
- err("No build found. Run `arc platform build` first.");
22
- process.exit(1);
26
+ if (!deployApi) {
27
+ err("No build found. Run `arc platform build` first.");
28
+ process.exit(1);
29
+ }
30
+ log("Pre-deploy mode — no manifest yet, awaiting first /api/deploy/*");
31
+ const emptyManifest: BuildManifest = {
32
+ modules: [],
33
+ shellHash: "",
34
+ stylesHash: "",
35
+ buildTime: new Date().toISOString(),
36
+ };
37
+ const platform = await startPlatformServer({
38
+ ws,
39
+ port,
40
+ manifest: emptyManifest,
41
+ context: null,
42
+ moduleAccess: new Map(),
43
+ dbPath: join(ws.rootDir, ".arc", "data", "prod.db"),
44
+ devMode: false,
45
+ deployApi: true,
46
+ arcEntries: [],
47
+ });
48
+ ok(`Pre-deploy server on http://localhost:${port}`);
49
+ registerSignalCleanup(platform);
50
+ return;
23
51
  }
52
+
24
53
  const manifest: BuildManifest = JSON.parse(
25
54
  readFileSync(manifestPath, "utf-8"),
26
55
  );
@@ -36,7 +65,6 @@ export async function platformStart(): Promise<void> {
36
65
 
37
66
  // Start server (production mode = aggressive caching, SSE only used for deploy hot-swap)
38
67
  const arcEntries = collectArcPeerDeps(ws.packages);
39
- const deployApi = process.env.ARC_DEPLOY_API === "1";
40
68
  if (deployApi) ok("Deploy API enabled (/api/deploy/*)");
41
69
  const platform = await startPlatformServer({
42
70
  ws,
@@ -53,7 +81,10 @@ export async function platformStart(): Promise<void> {
53
81
  ok(`Server on http://localhost:${port}`);
54
82
  if (platform.contextHandler) ok("Commands, queries, WebSocket — all on same port");
55
83
 
56
- // Cleanup
84
+ registerSignalCleanup(platform);
85
+ }
86
+
87
+ function registerSignalCleanup(platform: { stop: () => void }): void {
57
88
  const cleanup = () => {
58
89
  platform.stop();
59
90
  process.exit(0);
@@ -1,4 +1,4 @@
1
- import { spawn } from "bun";
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
2
  import { mkdirSync, writeFileSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
@@ -38,31 +38,34 @@ export async function runAnsible(inputs: AnsibleInputs): Promise<void> {
38
38
  ].join("\n");
39
39
  writeFileSync(join(workDir, "inventory.ini"), inventory);
40
40
 
41
- const extraVars = [
42
- `username=${inputs.target.user}`,
43
- `ssh_port=${port}`,
44
- ];
45
- if (inputs.ansible?.extraAllowedIps?.length) {
46
- extraVars.push(
47
- `extra_allowed_ips=${JSON.stringify(inputs.ansible.extraAllowedIps)}`,
48
- );
49
- }
41
+ // JSON dict so ansible parses types correctly (key=value form makes empty
42
+ // arrays look like the string "[]" and trips recursive templating).
43
+ const extraVarsJson = JSON.stringify({
44
+ username: inputs.target.user,
45
+ ssh_port: port,
46
+ extra_allowed_ips: inputs.ansible?.extraAllowedIps ?? [],
47
+ });
50
48
 
51
- const proc = spawn({
52
- cmd: [
49
+ // Ansible aborts if its stdout/stderr fds have O_NONBLOCK set — which
50
+ // happens when our own process is invoked with non-blocking stdio (e.g.
51
+ // backgrounded by Claude harness, piped through tail). Solution: pipe
52
+ // stdio (always blocking, Node-managed) and forward chunks manually so the
53
+ // user still sees ansible output in real time.
54
+ const exit = await new Promise<number>((resolve, reject) => {
55
+ const proc = nodeSpawn(
53
56
  "ansible-playbook",
54
- "-i",
55
- "inventory.ini",
56
- "site.yml",
57
- "-e",
58
- extraVars.join(" "),
59
- ],
60
- cwd: workDir,
61
- stdout: "inherit",
62
- stderr: "inherit",
63
- env: { ...process.env, ANSIBLE_HOST_KEY_CHECKING: "False" },
57
+ ["-i", "inventory.ini", "site.yml", "-e", extraVarsJson],
58
+ {
59
+ cwd: workDir,
60
+ stdio: ["ignore", "pipe", "pipe"],
61
+ env: { ...process.env, ANSIBLE_HOST_KEY_CHECKING: "False" },
62
+ },
63
+ );
64
+ proc.stdout?.on("data", (chunk: Buffer) => process.stdout.write(chunk));
65
+ proc.stderr?.on("data", (chunk: Buffer) => process.stderr.write(chunk));
66
+ proc.on("error", reject);
67
+ proc.on("exit", (code) => resolve(code ?? 1));
64
68
  });
65
- const exit = await proc.exited;
66
69
  if (exit !== 0) {
67
70
  throw new Error(`ansible-playbook failed (exit ${exit})`);
68
71
  }