diff --git a/.gitignore b/.gitignore index 81e68b26a..0ad2ac519 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ projects/ .local/ run-spaceapp-*.tgz +.dev-stack/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 078772388..aa56f701d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,119 @@ select a short temporary directory you own and set `TMPDIR` for the command. On Windows run this particular smoke check inside WSL or a Linux development container. It does not certify the Windows/macOS installer. +## Run the full app from source + +When you need the complete SpaceApp stack (PostgreSQL with `pgvector`, Temporal, +the API in watch mode, the Vite web dev server, the Temporal worker, and the +CLI host with the preconfigured OpenCode CLI) running from your checkout with +hot reload, start the contributor source stack after `npm ci`: + +```sh +npm run dev:stack +``` + +Prerequisites: + +- Node **>=22.12 <23** and npm (same as above) +- Docker Engine / Docker Desktop with `docker compose` available (Linux, macOS, + or Windows 11 with WSL2 integration enabled) + +What `npm run dev:stack` does: + +1. Reads the digest-pinned PostgreSQL (`pgvector`) and Temporal container images + from `packages/run-spaceapp/templates/compose.yml` and starts them under the + isolated `spaceapp-dev` Compose project bound only to `127.0.0.1`. +2. Generates per-checkout secrets and runtime state under `.dev-stack/` + (git-ignored, `0700`/`0600` permissions) and runs `npm run db:migrate`. +3. Installs the pinned `@opencode/cli` version from `Dockerfile` into + `.dev-stack/opencode` and writes local CLI wrappers into `.dev-stack/bin` so + the **Getting Started** room's OpenCode CLI pane reaches `RUNNING` without an + external AI provider account. +4. Builds workspace packages and supervises `packages/cli-host`, `apps/worker`, + `apps/api` (`tsx watch`), and `apps/web` (Vite HMR). +5. Prints the Web URL, API URL, and the one-time owner setup token (until owner + setup is claimed). + +Default loopback ports (chosen so `dev:stack` never collides with an installed +`run-spaceapp` instance on `4910`/`4911`): + +- Web (Vite dev server): `127.0.0.1:5911` (override with `SPACE_WEB_PORT` or `--web-port`) +- API: `127.0.0.1:5910` (override with `SPACE_API_PORT` or `--api-port`) +- PostgreSQL: `127.0.0.1:55432` (override with `SPACE_DEV_POSTGRES_PORT` or `--postgres-port`) +- Temporal: `127.0.0.1:57233` (override with `SPACE_DEV_TEMPORAL_PORT` or `--temporal-port`) + +Stop the stack while keeping your database volume and claimed owner account: + +```sh +npm run dev:stack -- down +``` + +Remove the `spaceapp-dev` containers, volumes, and `.dev-stack/` state directory +to return to a clean first-run state: + +```sh +npm run dev:stack -- reset +``` + +Verify readiness, claim owner setup over HTTP, and verify that the Getting +Started OpenCode CLI pane reaches `RUNNING` without a browser TTY: + +```sh +npm run dev:stack:smoke +``` + +Sample readiness and smoke output: + +```text +============================================================ +SpaceApp contributor dev stack is ready! + Web URL: http://127.0.0.1:5911 + API URL: http://127.0.0.1:5910 + Readiness: http://127.0.0.1:5910/readyz + Setup token: + (One-time owner setup token — enter this at the Web URL to create the owner account) + Stop stack: npm run dev:stack -- down + Reset stack: npm run dev:stack -- reset +============================================================ + +{ + "ok": true, + "readyzOk": true, + "roomName": "Getting Started", + "runtimeId": "cli:opencode", + "status": "RUNNING" +} +``` + +### Platform notes (Linux, macOS, Windows 11 WSL2) + +- **Linux:** Ensure your user can reach the Docker daemon (`docker info`) and + `docker compose version` succeeds. All ports bind strictly to `127.0.0.1`. +- **macOS (Docker Desktop or Colima):** Start Docker Desktop or Colima + (`colima start --cpu 4 --memory 8`) before running `npm run dev:stack`. Keep + the repository inside your macOS user home directory so bind-mounted secret + files under `.dev-stack/secrets/` are shared into the Linux VM automatically. + If `node-pty` needs to compile from source, install Xcode Command Line Tools + (`xcode-select --install`). +- **Windows 11 (WSL2):** Run `npm run dev:stack` inside a WSL2 Linux + distribution (for example Ubuntu 24.04) with Docker Desktop WSL2 integration + enabled, and keep the checkout on the Linux ext4 filesystem inside WSL2 (not + `/mnt/c/`) for fast file watching and Unix domain socket support. + +### Troubleshooting + +- **Port already in use:** Override any colliding loopback port with + `SPACE_API_PORT`, `SPACE_WEB_PORT`, `SPACE_DEV_POSTGRES_PORT`, or + `SPACE_DEV_TEMPORAL_PORT` (or `--api-port`, `--web-port`, `--postgres-port`, + `--temporal-port`). +- **Docker daemon or container health check failure:** Verify `docker ps` works + and run `npm run dev:stack -- reset` to recreate the `spaceapp-dev` containers + and volumes cleanly. +- **Socket path length limit:** The CLI host Unix socket lives at + `.dev-stack/run/pane-host.sock`. If your checkout path is deeply nested + (>90 characters), pass `--state-dir /tmp/spaceapp-dev-stack` so the socket + path stays within the OS `sun_path` limit. + ## Make and verify a small change Work on a branch, not on `main`, so the change can become a pull request: diff --git a/PUBLIC_EXPORT_MANIFEST.json b/PUBLIC_EXPORT_MANIFEST.json index 42a9b2f62..3fbdb759a 100644 --- a/PUBLIC_EXPORT_MANIFEST.json +++ b/PUBLIC_EXPORT_MANIFEST.json @@ -3,13 +3,13 @@ "schemaVersion": 2, "sourceCommit": "7619ef952387dba2480afecdb1ae332a0dc489ff", "fileCount": 4729, - "transformations": 393, + "transformations": 395, "files": [ { "path": ".gitignore", "sourceObjectId": "1fdf2ac77a391fc36521502732002c96a11913d4", - "sha256": "18242d3b694d31c2e42ccf6b02261a946906352537ab4b83a5f3278b09584aea", - "size": 1144 + "sha256": "1d53a86657539079e992eaf1bc0bfd368d53e407e02066eb1c507b10d6553ed1", + "size": 1156 }, { "path": ".nvmrc", @@ -20,8 +20,8 @@ { "path": "CONTRIBUTING.md", "sourceObjectId": "078772388754304e33d238fefaad2f04e9120107", - "sha256": "14f559c40ed46089c3fb74339d1b225cdd817fc61ef5a39bf1dce8ea28a83216", - "size": 5143 + "sha256": "c19a86615553367d0dfb6a3f0cfba59c30b9923cda2909b47681e6f078d39faf", + "size": 9839 }, { "path": "LICENSE", @@ -21512,8 +21512,8 @@ { "path": "apps/web/tests/vite-config.test.ts", "sourceObjectId": "f33db638236a1673b595039d8c602a983d09d012", - "sha256": "9f39d7a43d245ebafbc6720d6a99a94461a82890fb5423c7d2ae520830df1d7d", - "size": 1259 + "sha256": "0ea0f47866f63699de3d73a0a346c0455b0a0ca16b98f21ce72f731187f374af", + "size": 1701 }, { "path": "apps/web/tests/vnc-pane.test.tsx", @@ -21662,8 +21662,8 @@ { "path": "apps/web/vite.config.ts", "sourceObjectId": "440a79e31a3f86df0c66bb67ed1d44a4dbcba128", - "sha256": "6903aa14b349193f17c903d4c14f984dbb547868a650ef3acc7a7f12b9d8849c", - "size": 2700 + "sha256": "369aaeebf0150947abd4abb98f0f6fbaee91b2622fc469e138d09542ef2ea71b", + "size": 3506 }, { "path": "apps/web/vite.demo-boundary.ts", @@ -22520,8 +22520,8 @@ { "path": "package.json", "sourceObjectId": "62128b7e59e9f56b96f4d6685e4a50155f02f4c5", - "sha256": "211f8b2305dd627f738e6b1f1246cea555165cdb1d657669c220c271f6ed5ef4", - "size": 4220 + "sha256": "f7de908634509f6e54b81b5d895c478c4496fa9f12dcc79acb2693316f907734", + "size": 4300 }, { "path": "packages/browser-host/package.json", diff --git a/apps/web/tests/vite-config.test.ts b/apps/web/tests/vite-config.test.ts index f33db6382..c5a6004f2 100644 --- a/apps/web/tests/vite-config.test.ts +++ b/apps/web/tests/vite-config.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { manualChunkForModule } from "../vite.config.js"; +import { manualChunkForModule, resolveViteApiOrigin } from "../vite.config.js"; describe("web bundle chunk policy", () => { it("isolates React and xterm while leaving feature modules available for lazy chunks", () => { @@ -15,4 +15,10 @@ describe("web bundle chunk policy", () => { expect(manualChunkForModule("/workspace/node_modules/graphology/dist/graphology.js")).toBeUndefined(); expect(manualChunkForModule("/workspace/src/features/browser-pane/BrowserPane.tsx")).toBeUndefined(); }); + + it("resolves the Vite proxy target from SPACE_API_ORIGIN or SPACE_API_PORT while keeping 4910 as default", () => { + expect(resolveViteApiOrigin({})).toBe("http://127.0.0.1:4910"); + expect(resolveViteApiOrigin({ SPACE_API_PORT: "5910" })).toBe("http://127.0.0.1:5910"); + expect(resolveViteApiOrigin({ SPACE_API_ORIGIN: "http://127.0.0.1:5999/", SPACE_API_PORT: "5910" })).toBe("http://127.0.0.1:5999"); + }); }); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cd7612d98..ac93372f8 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,6 +4,12 @@ import { retainOpenTabAssets } from "./vite.asset-retention.js"; import react from "@vitejs/plugin-react"; import { demoBundleBoundaryPlugin } from "./vite.demo-boundary.js"; +/** + * Maps a Rollup module identifier to its manual vendor chunk name when applicable. + * + * @param moduleId - Absolute or virtual module identifier passed by Rollup. + * @returns The vendor chunk name, or `undefined` to let Rollup decide. + */ export function manualChunkForModule(moduleId: string): string | undefined { const normalizedId = moduleId.replaceAll("\\", "/"); if ( @@ -17,6 +23,21 @@ export function manualChunkForModule(moduleId: string): string | undefined { return undefined; } +/** + * Resolves the upstream API origin used by the Vite dev server proxy. + * + * @param env - Process environment variables to inspect. + * @returns Normalized HTTP origin for the SpaceApp API server. + */ +export function resolveViteApiOrigin(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.SPACE_API_ORIGIN?.trim(); + if (explicit) return explicit.replace(/\/+$/, ""); + const apiPort = env.SPACE_API_PORT?.trim() || "4910"; + return `http://127.0.0.1:${apiPort}`; +} + +const apiOrigin = resolveViteApiOrigin(); + export default defineConfig({ plugins: [react(), demoBundleBoundaryPlugin(), retainOpenTabAssets()], // The icon package ships as source; the alias keeps worktrees (which share live node_modules) resolving this checkout. @@ -34,11 +55,16 @@ export default defineConfig({ host: process.env.SPACE_WEB_HOST ?? "0.0.0.0", port: Number.parseInt(process.env.SPACE_WEB_PORT ?? "4911", 10), proxy: { - "/api": "http://127.0.0.1:4910", - "/plugins": "http://127.0.0.1:4910", - "/healthz": "http://127.0.0.1:4910", - "/readyz": "http://127.0.0.1:4910", - "/version": "http://127.0.0.1:4910" + "/api": { + target: apiOrigin, + ws: true + }, + "/plugins": apiOrigin, + "/logout": apiOrigin, + "/healthz": apiOrigin, + "/livez": apiOrigin, + "/readyz": apiOrigin, + "/version": apiOrigin } }, test: { diff --git a/package.json b/package.json index 207743196..979285b60 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "dev:api": "npm run dev -w @space/api", "dev:web": "npm run dev -w @space/web", "dev:worker": "npm run dev -w @space/worker", + "dev:stack": "node scripts/dev-stack.mjs", "db:migrate": "npm run migrate -w @space/db", "build": "npm run build -w @space/contracts && npm run build -w @space/icons && npm run build -w @space/streaming && npm run build -w @space/opencode-control && npm run build -w @space/memory-graph && npm run build -w @space/browser-host && npm run build -w @space/cli-host && npm run build -w @space/codex-app-server && npm run build -w @space/runtime && npm run build -w @space/db && npm run build -w @space/gateway && node scripts/build-demo-projects.mjs && npm run build -w @space/api && npm run build -w @space/worker && npm run build -w @space/web && node scripts/fix-build-permissions.mjs", "public:export:verify": "node scripts/public-export.mjs --verify", @@ -58,7 +59,7 @@ "build:demos": "node scripts/build-demo-projects.mjs", "test:architecture": "node --test scripts/tests/domain-boundaries.test.mjs scripts/tests/composition-root-size.test.mjs scripts/tests/quality-known-limitations.test.mjs && node scripts/domain-boundaries.mjs", "smoke:offline": "npm run build -w @space/cli-host && node scripts/contributor-smoke.mjs", - "test:contributor": "node --test scripts/tests/contributor-smoke.test.mjs", + "test:contributor": "node --test scripts/tests/contributor-smoke.test.mjs scripts/tests/dev-stack.test.mjs", "lint": "eslint --max-warnings=-1 apps packages scripts" }, "devDependencies": { diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs new file mode 100755 index 000000000..e97cf61c5 --- /dev/null +++ b/scripts/dev-stack.mjs @@ -0,0 +1,1788 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const defaultRepoRoot = path.resolve(__dirname, ".."); + +export const defaultDevStackPorts = Object.freeze({ + apiPort: 5910, + webPort: 5911, + postgresPort: 55432, + temporalPort: 57233 +}); + +export const defaultDevStackProjectName = "spaceapp-dev"; +export const workspaceBuildOrder = Object.freeze([ + "@space/contracts", + "@space/icons", + "@space/streaming", + "@space/opencode-control", + "@space/memory-graph", + "@space/browser-host", + "@space/cli-host", + "@space/codex-app-server", + "@space/runtime", + "@space/db", + "@space/gateway", + "@space/worker" +]); +const stateDirMarkerFile = ".dev-stack-marker.json"; +const protectedRepoEntries = new Set([ + ".git", + ".github", + "apps", + "packages", + "scripts", + "docs", + "deploy", + "node_modules" +]); + +/** + * Parses and validates an unprivileged TCP port number. + */ +function parsePort(value, flagName, fallback) { + if (value === undefined || value === null || String(value).trim() === "") { + return fallback; + } + const parsed = Number.parseInt(String(value).trim(), 10); + if (!Number.isInteger(parsed) || parsed < 1024 || parsed > 65535) { + throw new Error(`Invalid port for ${flagName}: "${value}". Expected an integer between 1024 and 65535.`); + } + return parsed; +} + +/** + * Validates that a candidate state directory path is safe to create and reset. + */ +export function assertSafeStateDir(stateDir, repoRoot = defaultRepoRoot) { + if (typeof stateDir !== "string" || !stateDir.trim()) { + throw new Error("State directory path must be a non-empty string."); + } + const resolvedRepoRoot = path.resolve(repoRoot); + const resolvedStateDir = path.resolve(resolvedRepoRoot, stateDir.trim()); + const parsed = path.parse(resolvedStateDir); + if (resolvedStateDir === parsed.root) { + throw new Error(`Refusing unsafe state directory "${resolvedStateDir}": cannot be filesystem root.`); + } + const homeDir = path.resolve(os.homedir()); + const tmpDir = path.resolve(os.tmpdir()); + if (resolvedStateDir === homeDir) { + throw new Error(`Refusing unsafe state directory "${resolvedStateDir}": cannot be the user home directory.`); + } + if (resolvedStateDir === tmpDir) { + throw new Error(`Refusing unsafe state directory "${resolvedStateDir}": cannot be the system temp root.`); + } + if (resolvedStateDir === resolvedRepoRoot) { + throw new Error(`Refusing unsafe state directory "${resolvedStateDir}": cannot be the repository root.`); + } + if (resolvedRepoRoot.startsWith(`${resolvedStateDir}${path.sep}`)) { + throw new Error(`Refusing unsafe state directory "${resolvedStateDir}": cannot be a parent of the repository root.`); + } + if (homeDir.startsWith(`${resolvedStateDir}${path.sep}`)) { + throw new Error(`Refusing unsafe state directory "${resolvedStateDir}": cannot be a parent of the home directory.`); + } + const relativeToRepo = path.relative(resolvedRepoRoot, resolvedStateDir); + if (!relativeToRepo.startsWith("..") && !path.isAbsolute(relativeToRepo)) { + const firstSegment = relativeToRepo.split(path.sep)[0]; + if (protectedRepoEntries.has(firstSegment)) { + throw new Error( + `Refusing unsafe state directory "${resolvedStateDir}": cannot target repository source directory "${firstSegment}".` + ); + } + } + return resolvedStateDir; +} + +/** + * Parses CLI arguments and environment variables into a dev-stack configuration. + */ +export function parseDevStackArgs(argv = [], env = process.env, repoRoot = defaultRepoRoot) { + const args = [...argv]; + let command = "up"; + if (args[0] && !args[0].startsWith("-")) { + command = args.shift(); + } + const allowedCommands = new Set(["up", "down", "reset", "status", "smoke", "help"]); + if (!allowedCommands.has(command)) { + throw new Error( + `Unknown dev:stack command "${command}". Expected one of: up, down, reset, status, smoke.` + ); + } + + let stateDirRaw = env.SPACE_DEV_STACK_STATE_DIR || path.join(repoRoot, ".dev-stack"); + let projectName = (env.SPACE_DEV_STACK_PROJECT_NAME || defaultDevStackProjectName).trim(); + let apiPortRaw = env.SPACE_DEV_STACK_API_PORT ?? env.SPACE_API_PORT; + let webPortRaw = env.SPACE_DEV_STACK_WEB_PORT ?? env.SPACE_WEB_PORT; + let postgresPortRaw = env.SPACE_DEV_STACK_POSTGRES_PORT ?? env.SPACE_DEV_POSTGRES_PORT ?? env.SPACE_POSTGRES_PORT; + let temporalPortRaw = env.SPACE_DEV_STACK_TEMPORAL_PORT ?? env.SPACE_DEV_TEMPORAL_PORT ?? env.SPACE_TEMPORAL_PORT; + let packageWatch = env.SPACE_DEV_STACK_PACKAGE_WATCH !== "false"; + let skipBuild = env.SPACE_DEV_STACK_SKIP_BUILD === "true"; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--help" || arg === "-h") { + command = "help"; + continue; + } + if (arg === "--no-package-watch") { + packageWatch = false; + continue; + } + if (arg === "--skip-build") { + skipBuild = true; + continue; + } + const readValue = (flag) => { + if (index + 1 >= args.length) { + throw new Error(`Missing value for ${flag}.`); + } + index += 1; + return args[index]; + }; + if (arg === "--state-dir") { + stateDirRaw = readValue("--state-dir"); + } else if (arg.startsWith("--state-dir=")) { + stateDirRaw = arg.slice("--state-dir=".length); + } else if (arg === "--project-name") { + projectName = readValue("--project-name").trim(); + } else if (arg.startsWith("--project-name=")) { + projectName = arg.slice("--project-name=".length).trim(); + } else if (arg === "--api-port") { + apiPortRaw = readValue("--api-port"); + } else if (arg.startsWith("--api-port=")) { + apiPortRaw = arg.slice("--api-port=".length); + } else if (arg === "--web-port") { + webPortRaw = readValue("--web-port"); + } else if (arg.startsWith("--web-port=")) { + webPortRaw = arg.slice("--web-port=".length); + } else if (arg === "--postgres-port") { + postgresPortRaw = readValue("--postgres-port"); + } else if (arg.startsWith("--postgres-port=")) { + postgresPortRaw = arg.slice("--postgres-port=".length); + } else if (arg === "--temporal-port") { + temporalPortRaw = readValue("--temporal-port"); + } else if (arg.startsWith("--temporal-port=")) { + temporalPortRaw = arg.slice("--temporal-port=".length); + } else { + throw new Error(`Unknown option "${arg}". Run "npm run dev:stack -- --help" for usage.`); + } + } + + if (!/^[a-z0-9][a-z0-9_-]{1,62}$/.test(projectName)) { + throw new Error( + `Invalid compose project name "${projectName}". Must match /^[a-z0-9][a-z0-9_-]{1,62}$/.` + ); + } + + const stateDir = assertSafeStateDir(stateDirRaw, repoRoot); + const apiPort = parsePort(apiPortRaw, "SPACE_API_PORT / --api-port", defaultDevStackPorts.apiPort); + const webPort = parsePort(webPortRaw, "SPACE_WEB_PORT / --web-port", defaultDevStackPorts.webPort); + const postgresPort = parsePort( + postgresPortRaw, + "SPACE_DEV_POSTGRES_PORT / --postgres-port", + defaultDevStackPorts.postgresPort + ); + const temporalPort = parsePort( + temporalPortRaw, + "SPACE_DEV_TEMPORAL_PORT / --temporal-port", + defaultDevStackPorts.temporalPort + ); + + const uniquePorts = new Set([apiPort, webPort, postgresPort, temporalPort]); + if (uniquePorts.size !== 4) { + throw new Error( + `Ports must be distinct (got api=${apiPort}, web=${webPort}, postgres=${postgresPort}, temporal=${temporalPort}).` + ); + } + + return { + command, + repoRoot: path.resolve(repoRoot), + stateDir, + projectName, + apiPort, + webPort, + postgresPort, + temporalPort, + packageWatch, + skipBuild + }; +} + +/** + * Extracts digest-pinned PostgreSQL and Temporal images from compose.yml. + */ +export function readPinnedComposeImages(composeTemplateText) { + if (typeof composeTemplateText !== "string" || !composeTemplateText.trim()) { + throw new Error("packages/run-spaceapp/templates/compose.yml content is empty."); + } + const pgMatch = composeTemplateText.match( + /^\s*image:\s*(pgvector\/pgvector:[^\s#]+@sha256:[0-9a-f]{64})\s*$/m + ); + if (!pgMatch) { + throw new Error( + "Could not find a digest-pinned pgvector/pgvector image (@sha256:...) in packages/run-spaceapp/templates/compose.yml." + ); + } + const temporalMatch = composeTemplateText.match( + /^\s*image:\s*(temporalio\/auto-setup:[^\s#]+@sha256:[0-9a-f]{64})\s*$/m + ); + if (!temporalMatch) { + throw new Error( + "Could not find a digest-pinned temporalio/auto-setup image (@sha256:...) in packages/run-spaceapp/templates/compose.yml." + ); + } + return { + postgresImage: pgMatch[1], + temporalImage: temporalMatch[1] + }; +} + +/** + * Extracts the pinned @opencode/cli package specifier from Dockerfile. + */ +export function readPinnedOpenCodePackageSpec(dockerfileText) { + if (typeof dockerfileText !== "string" || !dockerfileText.trim()) { + throw new Error("Dockerfile content is empty."); + } + const match = dockerfileText.match(/(@opencode\/cli@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)/); + if (!match) { + throw new Error("Could not find a pinned @opencode/cli@ package spec in Dockerfile."); + } + return match[1]; +} + +/** + * Normalizes a host path to forward slashes for Docker Compose. + */ +function toComposeHostPath(filePath) { + return filePath.replaceAll("\\", "/"); +} + +/** + * Renders the isolated Docker Compose configuration for the dev stack. + */ +export function renderDevStackCompose({ + projectName = defaultDevStackProjectName, + postgresImage, + temporalImage, + postgresPort = defaultDevStackPorts.postgresPort, + temporalPort = defaultDevStackPorts.temporalPort, + postgresPasswordFile, + temporalPasswordFile, + databaseName = "space_dev" +}) { + if (!postgresImage || !/@sha256:[0-9a-f]{64}$/.test(postgresImage)) { + throw new Error(`postgresImage must be digest-pinned (@sha256:...), got "${postgresImage}".`); + } + if (!temporalImage || !/@sha256:[0-9a-f]{64}$/.test(temporalImage)) { + throw new Error(`temporalImage must be digest-pinned (@sha256:...), got "${temporalImage}".`); + } + const pgSecretPath = toComposeHostPath(postgresPasswordFile); + const temporalSecretPath = toComposeHostPath(temporalPasswordFile); + return `name: ${projectName} + +services: + postgres: + image: ${postgresImage} + restart: unless-stopped + environment: + POSTGRES_DB: ${databaseName} + POSTGRES_USER: space + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + volumes: + - postgres-data:/var/lib/postgresql/data + - ${pgSecretPath}:/run/secrets/postgres_password:ro + ports: + - "127.0.0.1:${postgresPort}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U space -d ${databaseName}"] + interval: 2s + timeout: 5s + retries: 30 + + temporal: + image: ${temporalImage} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + DB: postgres12 + DB_PORT: 5432 + POSTGRES_USER: space + POSTGRES_PWD_FILE: /run/secrets/postgres_password_temporal + POSTGRES_SEEDS: postgres + DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/development-sql.yaml + volumes: + - ${temporalSecretPath}:/run/secrets/postgres_password_temporal:ro + ports: + - "127.0.0.1:${temporalPort}:7233" + healthcheck: + test: ["CMD", "tctl", "--address", "127.0.0.1:7233", "cluster", "health"] + interval: 3s + timeout: 5s + retries: 40 + +volumes: + postgres-data: +`; +} + +/** + * Writes a secret value to disk with strict POSIX file permissions. + */ +function writeSecretFile(filePath, value, mode = 0o600) { + writeFileSync(filePath, `${value}\n`, { encoding: "utf8", mode }); + try { + chmodSync(filePath, mode); + } catch { + // Best-effort chmod on filesystems that ignore POSIX permission bits. + } +} + +/** + * Reads an existing secret file or generates and persists a new secret. + */ +function readOrCreateSecret(filePath, generator, mode = 0o600) { + if (existsSync(filePath)) { + const existing = readFileSync(filePath, "utf8").trim(); + if (existing.length >= 16) { + try { + chmodSync(filePath, mode); + } catch {} + return existing; + } + } + const created = generator(); + writeSecretFile(filePath, created, mode); + return created; +} + +/** + * Ensures per-state-directory secret files exist with 0600 permissions. + */ +export function ensureDevStackSecrets(config) { + const stateDir = assertSafeStateDir(config.stateDir, config.repoRoot); + const secretsDir = path.join(stateDir, "secrets"); + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + mkdirSync(secretsDir, { recursive: true, mode: 0o700 }); + try { + chmodSync(stateDir, 0o700); + chmodSync(secretsDir, 0o700); + } catch {} + + const markerPath = path.join(stateDir, stateDirMarkerFile); + if (!existsSync(markerPath)) { + writeFileSync( + markerPath, + `${JSON.stringify({ createdBy: "spaceapp-dev-stack", createdAt: new Date().toISOString() }, null, 2)}\n`, + { encoding: "utf8", mode: 0o600 } + ); + } + + const postgresPasswordFile = path.join(secretsDir, "postgres-password"); + const temporalPasswordFile = path.join(secretsDir, "postgres-password-container"); + const sessionSecretFile = path.join(secretsDir, "session-secret"); + const setupTokenFile = path.join(secretsDir, "setup-token"); + const setupClaimedFile = path.join(secretsDir, "setup-token.claimed"); + const databaseUrlFile = path.join(secretsDir, "database-url"); + const connectorKeyFile = path.join(secretsDir, "space-connector.key"); + const designKeyFile = path.join(secretsDir, "space-design.key"); + + const postgresPassword = readOrCreateSecret( + postgresPasswordFile, + () => randomBytes(24).toString("hex"), + 0o600 + ); + // temporalio/auto-setup runs as UID 10000 inside the container, so its bind-mounted password + // file inside the 0700 stateDir needs world-read permission inside the container. + writeSecretFile(temporalPasswordFile, postgresPassword, 0o644); + + const sessionSecret = readOrCreateSecret( + sessionSecretFile, + () => randomBytes(32).toString("base64url"), + 0o600 + ); + const setupToken = readOrCreateSecret( + setupTokenFile, + () => randomBytes(24).toString("base64url"), + 0o600 + ); + readOrCreateSecret(connectorKeyFile, () => randomBytes(32).toString("base64url"), 0o600); + readOrCreateSecret(designKeyFile, () => randomBytes(32).toString("base64url"), 0o600); + + const databaseName = "space_dev"; + const databaseUrl = `postgres://space:${encodeURIComponent(postgresPassword)}@127.0.0.1:${config.postgresPort}/${databaseName}`; + writeSecretFile(databaseUrlFile, databaseUrl, 0o600); + + return { + secretsDir, + postgresPasswordFile, + temporalPasswordFile, + sessionSecretFile, + setupTokenFile, + setupClaimedFile, + databaseUrlFile, + connectorKeyFile, + designKeyFile, + databaseName, + databaseUrl, + sessionSecret, + setupToken, + isSetupClaimed: () => existsSync(setupClaimedFile), + markSetupClaimed: () => { + if (!existsSync(setupClaimedFile)) { + writeSecretFile(setupClaimedFile, new Date().toISOString(), 0o600); + } + } + }; +} + +/** + * Builds the default OpenCode configuration JSON. + */ +export function buildOpenCodeStarterConfig({ stateDir }) { + const resolvedStateDir = path.resolve(stateDir); + const scratchDir = path.join(resolvedStateDir, "var", "scratch").replaceAll("\\", "/"); + const memoryDir = path.join(resolvedStateDir, "var", "memory").replaceAll("\\", "/"); + return { + $schema: "https://opencode.ai/config.json", + model: "opencode/big-pickle", + instructions: [ + `Write temporary scratch files in ${scratchDir}. Do not create scratch files in /tmp or the project root unless the user asks.` + ], + permission: { + external_directory: { + [`${scratchDir}/**`]: "allow", + [`${memoryDir}/**`]: "allow" + } + } + }; +} + +/** + * Writes isolated CLI wrapper scripts and OpenCode config inside stateDir. + */ +export function writeCliWrapperScripts({ stateDir, opencodeBinPath }) { + const binDir = path.join(stateDir, "bin"); + const providersDir = path.join(stateDir, "providers"); + const opencodeProviderRoot = path.join(providersDir, "opencode"); + const opencodeConfigDir = path.join(opencodeProviderRoot, "home", ".config", "opencode"); + const scratchDir = path.join(stateDir, "var", "scratch"); + const memoryDir = path.join(stateDir, "var", "memory"); + + for (const dir of [ + binDir, + path.join(opencodeProviderRoot, "home", ".config"), + path.join(opencodeProviderRoot, "xdg-data"), + path.join(opencodeProviderRoot, "xdg-cache"), + path.join(opencodeProviderRoot, "xdg-state"), + path.join(opencodeProviderRoot, "tmp"), + scratchDir, + memoryDir, + opencodeConfigDir + ]) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + const opencodeConfigFile = path.join(opencodeConfigDir, "opencode.json"); + if (!existsSync(opencodeConfigFile)) { + writeFileSync( + opencodeConfigFile, + `${JSON.stringify(buildOpenCodeStarterConfig({ stateDir }), null, 2)}\n`, + { encoding: "utf8", mode: 0o600 } + ); + } + + const opencodeWrapperPath = path.join(binDir, "opencode-vscode-parity"); + const opencodeWrapperContent = `#!/usr/bin/env bash +set -euo pipefail +PROVIDER_ROOT=${JSON.stringify(opencodeProviderRoot)} +mkdir -p "$PROVIDER_ROOT/home/.config/opencode" "$PROVIDER_ROOT/xdg-data" "$PROVIDER_ROOT/xdg-cache" "$PROVIDER_ROOT/xdg-state" "$PROVIDER_ROOT/tmp" +export HOME="$PROVIDER_ROOT/home" +export XDG_CONFIG_HOME="$PROVIDER_ROOT/home/.config" +export XDG_DATA_HOME="$PROVIDER_ROOT/xdg-data" +export XDG_CACHE_HOME="$PROVIDER_ROOT/xdg-cache" +export XDG_STATE_HOME="$PROVIDER_ROOT/xdg-state" +export TMPDIR="$PROVIDER_ROOT/tmp" +exec ${JSON.stringify(opencodeBinPath)} "$@" +`; + writeFileSync(opencodeWrapperPath, opencodeWrapperContent, { encoding: "utf8", mode: 0o755 }); + chmodSync(opencodeWrapperPath, 0o755); + + const plainOpencodePath = path.join(binDir, "opencode"); + writeFileSync(plainOpencodePath, opencodeWrapperContent, { encoding: "utf8", mode: 0o755 }); + chmodSync(plainOpencodePath, 0o755); + + const passthroughProviders = ["codex", "claude", "gemini", "kimi", "grok"]; + for (const provider of passthroughProviders) { + const providerRoot = path.join(providersDir, provider); + mkdirSync(path.join(providerRoot, "home"), { recursive: true, mode: 0o700 }); + const wrapperPath = path.join(binDir, `${provider}-vscode-parity`); + const wrapperContent = `#!/usr/bin/env bash +set -euo pipefail +PROVIDER_Binary="$(PATH="\${SPACE_HOST_PATH:-\$PATH}" command -v ${provider} 2>/dev/null || true)" +if [[ -z "$PROVIDER_Binary" ]]; then + echo "${provider} CLI is not installed on this host. Install ${provider} or use the preconfigured OpenCode pane." >&2 + exit 127 +fi +exec "$PROVIDER_Binary" "$@" +`; + writeFileSync(wrapperPath, wrapperContent, { encoding: "utf8", mode: 0o755 }); + chmodSync(wrapperPath, 0o755); + } + + return { + binDir, + opencodeWrapperPath, + opencodeConfigFile + }; +} + +/** + * Installs the pinned @opencode/cli package inside stateDir and writes CLI wrappers. + */ +export function ensureDevStackCliWrappers(config, { execCommand = spawnSync } = {}) { + const dockerfilePath = path.join(config.repoRoot, "Dockerfile"); + const dockerfileText = readFileSync(dockerfilePath, "utf8"); + const opencodeSpec = readPinnedOpenCodePackageSpec(dockerfileText); + const expectedVersion = opencodeSpec.slice("@opencode/cli@".length); + + const opencodePrefix = path.join(config.stateDir, "opencode"); + const opencodePkgJsonPath = path.join( + opencodePrefix, + "node_modules", + "@opencode", + "cli", + "package.json" + ); + const opencodeBinName = process.platform === "win32" ? "opencode.cmd" : "opencode"; + const opencodeBinPath = path.join(opencodePrefix, "node_modules", ".bin", opencodeBinName); + + let needsInstall = !existsSync(opencodeBinPath) || !existsSync(opencodePkgJsonPath); + if (!needsInstall) { + try { + const installedPkg = JSON.parse(readFileSync(opencodePkgJsonPath, "utf8")); + if (installedPkg.version !== expectedVersion) { + needsInstall = true; + } + } catch { + needsInstall = true; + } + } + + if (needsInstall) { + mkdirSync(opencodePrefix, { recursive: true, mode: 0o700 }); + const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; + const installResult = execCommand( + npmCmd, + [ + "install", + "--prefix", + opencodePrefix, + "--no-audit", + "--no-fund", + "--save-exact", + opencodeSpec + ], + { + cwd: config.repoRoot, + stdio: "inherit", + env: process.env + } + ); + if (installResult.status !== 0) { + throw new Error(`Failed to install ${opencodeSpec} into ${opencodePrefix}.`); + } + } + + return { + opencodeSpec, + ...writeCliWrapperScripts({ stateDir: config.stateDir, opencodeBinPath }) + }; +} + +/** + * Constructs the isolated environment variables for supervised child processes. + */ +export function buildDevStackChildEnv(config, secrets, baseEnv = process.env) { + const stateDir = config.stateDir; + const runDir = path.join(stateDir, "run"); + const varDir = path.join(stateDir, "var"); + const artifactsDir = path.join(varDir, "artifacts"); + const memoryDir = path.join(varDir, "memory"); + const memoryGraphDir = path.join(varDir, "memory-graph"); + const appDiagnosticsDir = path.join(varDir, "app-diagnostics"); + const maintenanceDir = path.join(varDir, "maintenance-autopilot"); + const balancerDir = path.join(varDir, "cli-account-balancer"); + const projectsDir = path.join(stateDir, "projects"); + const workspacesDir = path.join(stateDir, "workspaces"); + const binDir = path.join(stateDir, "bin"); + const cliHomeDir = path.join(stateDir, "cli-home"); + + for (const dir of [ + runDir, + varDir, + artifactsDir, + memoryDir, + memoryGraphDir, + appDiagnosticsDir, + maintenanceDir, + balancerDir, + projectsDir, + workspacesDir, + binDir, + path.join(cliHomeDir, ".codex") + ]) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + const cliHostSocket = path.join(runDir, "pane-host.sock"); + const cliAdminHostSocket = path.join(runDir, "admin-pane-host.sock"); + const browserHostSocket = path.join(runDir, "browser-host.sock"); + const t3CodeSocket = path.join(runDir, "t3code.sock"); + const apiOrigin = `http://127.0.0.1:${config.apiPort}`; + const webOrigin = `http://127.0.0.1:${config.webPort}`; + + const env = { + ...baseEnv, + SPACE_HOST_PATH: baseEnv.PATH ?? "", + SPACE_PUBLIC_DISTRIBUTION: "true", + SPACEAPP_WORKFLOWS_ENABLED: "true", + SPACE_RUNTIME_STORE: "postgres", + SPACE_DATABASE_URL: secrets.databaseUrl, + SPACE_DATABASE_URL_FILE: secrets.databaseUrlFile, + SPACE_SESSION_SECRET_FILE: secrets.sessionSecretFile, + SPACE_ENABLE_DB_SWEEP: "true", + SPACE_ENABLE_DUMMY_TURNS: "true", + SPACE_DEV_LOGIN: "false", + SPACE_SECURE_COOKIES: "false", + SPACE_API_HOST: "127.0.0.1", + SPACE_API_PORT: String(config.apiPort), + SPACE_API_ORIGIN: apiOrigin, + SPACE_WEB_HOST: "127.0.0.1", + SPACE_WEB_PORT: String(config.webPort), + SPACE_TEMPORAL_ADDRESS: `127.0.0.1:${config.temporalPort}`, + SPACE_TEMPORAL_NAMESPACE: "default", + SPACE_TEMPORAL_TASK_QUEUE: `${config.projectName}-agent-turns`, + SPACE_CLI_ENABLED: "true", + SPACE_CLI_ROOT_ENABLED: "false", + SPACE_CLI_HOST_SOCKET: cliHostSocket, + SPACE_CLI_ADMIN_HOST_SOCKET: cliAdminHostSocket, + SPACE_CLI_COMMAND_PATH: binDir, + SPACE_CLI_WORKSPACE_ROOT: workspacesDir, + SPACE_PROJECTS_ROOT: projectsDir, + SPACE_INSTALL_ROOT: config.repoRoot, + SPACE_GIT_ROOT: config.repoRoot, + SPACE_REPO_ROOT: config.repoRoot, + SPACE_BROWSER_SESSIONS_ENABLED: "false", + SPACE_BROWSER_HOST_SOCKET: browserHostSocket, + SPACE_T3CODE_ENABLED: "false", + SPACE_T3CODE_SOCKET: t3CodeSocket, + SPACE_DEMO_PROJECTS_ENABLED: "false", + SPACE_ARTIFACT_ROOT: artifactsDir, + SPACE_MEMORY_INDEX_PATH: path.join(memoryDir, "space_memory.md"), + SPACE_MEMORY_MONTHLY_PATH: path.join(memoryDir, "space_memory_current.md"), + SPACE_MEMORY_LOCK_PATH: path.join(memoryDir, "space_memory.lock"), + SPACE_MEMORY_GRAPH_ROOT: memoryGraphDir, + SPACE_CODEX_GOALS_DB_PATH: path.join(varDir, "goals.sqlite"), + SPACE_APP_DIAGNOSTICS_ROOT: appDiagnosticsDir, + SPACE_MAINTENANCE_AUTOPILOT_STATE_FILE: path.join(maintenanceDir, "state.json"), + SPACE_CLI_ACCOUNT_BALANCER_STATE_FILE: path.join(balancerDir, "state.json"), + SPACE_CONNECTOR_KEY_FILE: secrets.connectorKeyFile, + SPACE_DESIGN_GATEWAY_KEY_FILE: secrets.designKeyFile, + SPACE_CODEX_APP_SERVER_HOME: path.join(cliHomeDir, ".codex"), + SPACE_CODEX_HOME: path.join(cliHomeDir, ".codex"), + SPACE_CHESS_MOVE_SOCKET: "disabled", + SPACE_TELEGRAM_SECRET_ROOT: path.join(secrets.secretsDir, "telegram"), + SPACE_TELEGRAM_OWNERSHIP_MANIFEST: path.join( + varDir, + "integrations", + "telegram", + "space-codex-thread-ownership.json" + ), + SPACE_STREAMING_SECRET_ROOT: path.join(varDir, "streaming-secrets"), + SPACE_STREAMING_BOT_API_BASE_URL: apiOrigin + }; + + if (!secrets.isSetupClaimed()) { + env.SPACE_SETUP_TOKEN_FILE = secrets.setupTokenFile; + } else { + delete env.SPACE_SETUP_TOKEN_FILE; + delete env.SPACE_SETUP_TOKEN; + } + + return { + env, + apiOrigin, + webOrigin, + cliHostSocket + }; +} + +/** + * Formats the startup banner displaying URLs and the one-time owner setup token. + */ +export function formatReadyBanner({ + webOrigin, + apiOrigin, + setupRequired, + setupToken +}) { + const lines = [ + "", + "============================================================", + "SpaceApp contributor dev stack is ready!", + ` Web URL: ${webOrigin}`, + ` API URL: ${apiOrigin}`, + ` Readiness: ${apiOrigin}/readyz` + ]; + if (setupRequired && setupToken) { + lines.push(` Setup token: ${setupToken}`); + lines.push(" (One-time owner setup token — enter this at the Web URL to create the owner account)"); + } else { + lines.push(" Owner setup: already claimed"); + } + lines.push(" Stop stack: npm run dev:stack -- down"); + lines.push(" Reset stack: npm run dev:stack -- reset"); + lines.push("============================================================", + "" + ); + return lines.join("\n"); +} + +/** + * Streams line-buffered output from a child stream with a [service] prefix. + */ +function prefixStreamLines(stream, prefix, target, onLine) { + let buffer = ""; + stream.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (line.trim().length > 0) { + target.write(`[${prefix}] ${line}\n`); + onLine?.(line); + } + } + }); + stream.on("end", () => { + if (buffer.trim().length > 0) { + target.write(`[${prefix}] ${buffer}\n`); + onLine?.(buffer); + buffer = ""; + } + }); +} + +/** + * Spawns and supervises all source child processes, failing fast on unexpected crashes + * and restarting dependent services when the packages watcher rebuilds. + */ +export function superviseChildProcesses(serviceSpecs, { + spawnProcess = spawn, + stdout = process.stdout, + stderr = process.stderr, + onUnexpectedExit, + onPidsChanged +} = {}) { + const children = new Map(); + let shuttingDown = false; + let firstFailure = null; + let resolveFailure; + const failurePromise = new Promise((resolve) => { + resolveFailure = resolve; + }); + + const getPids = () => { + const pids = {}; + for (const [name, { child }] of children) { + if (child.pid) pids[name] = child.pid; + } + return pids; + }; + + const stopAll = async (signal = "SIGTERM", timeoutMs = 5_000) => { + shuttingDown = true; + const exitPromises = []; + for (const [, entry] of children) { + const { child } = entry; + if (child.exitCode !== null || child.signalCode !== null) continue; + exitPromises.push( + new Promise((resolve) => { + child.once("exit", () => resolve()); + }) + ); + try { + child.kill(signal); + } catch {} + } + if (exitPromises.length === 0) return; + await Promise.race([ + Promise.all(exitPromises), + new Promise((resolve) => setTimeout(resolve, timeoutMs)) + ]); + for (const [, entry] of children) { + const { child } = entry; + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill("SIGKILL"); + } catch {} + } + } + }; + + let initialPackageWatchDone = false; + + const restartPackageDependents = async () => { + for (const [name, entry] of children) { + if (!entry.spec.restartOnPackageRebuild || shuttingDown || firstFailure) continue; + entry.restarting = true; + const oldChild = entry.child; + if (oldChild.exitCode === null && oldChild.signalCode === null) { + const exited = new Promise((resolve) => { + oldChild.once("exit", () => resolve()); + }); + try { + oldChild.kill("SIGTERM"); + } catch {} + await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 2_000))]); + if (oldChild.exitCode === null && oldChild.signalCode === null) { + try { + oldChild.kill("SIGKILL"); + } catch {} + } + } + if (shuttingDown || firstFailure) return; + stdout.write(`[dev:stack] Restarting "${name}" after workspace package rebuild...\n`); + startService(entry.spec); + } + onPidsChanged?.(getPids()); + }; + + /** + * Spawns a single service specification and wires up output logging and crash supervision. + */ + function startService(spec) { + const child = spawnProcess(spec.command, spec.args, { + cwd: spec.cwd, + env: spec.env, + stdio: ["ignore", "pipe", "pipe"] + }); + const entry = { spec, child, restarting: false }; + children.set(spec.name, entry); + + const handleOutputLine = + spec.name === "packages" + ? (line) => { + if (/Found 0 errors\.\s*Watching for file changes\./i.test(line)) { + if (!initialPackageWatchDone) { + initialPackageWatchDone = true; + } else { + void restartPackageDependents(); + } + } + } + : undefined; + + if (child.stdout) prefixStreamLines(child.stdout, spec.name, stdout, handleOutputLine); + if (child.stderr) prefixStreamLines(child.stderr, spec.name, stderr, handleOutputLine); + + child.once("error", (error) => { + if (entry.restarting || shuttingDown || firstFailure) return; + firstFailure = { + service: spec.name, + code: 1, + signal: null, + reason: error.message + }; + stderr.write( + `[dev:stack] ERROR: service "${spec.name}" failed to start: ${error.message}\n` + ); + void stopAll().finally(() => { + onUnexpectedExit?.(firstFailure); + resolveFailure(firstFailure); + }); + }); + + child.once("exit", (code, signal) => { + if (entry.restarting || shuttingDown || firstFailure) return; + firstFailure = { + service: spec.name, + code: code ?? 1, + signal: signal ?? null + }; + const detail = signal ? `signal ${signal}` : `exit code ${code ?? 1}`; + stderr.write( + `[dev:stack] ERROR: service "${spec.name}" exited unexpectedly (${detail}). Stopping dev:stack.\n` + ); + void stopAll().finally(() => { + onUnexpectedExit?.(firstFailure); + resolveFailure(firstFailure); + }); + }); + } + + for (const spec of serviceSpecs) { + startService(spec); + } + + return { + children, + failurePromise, + stopAll, + getPids + }; +} + +/** + * Sleeps for the specified duration in milliseconds. + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Fetches a URL and parses its response body as JSON when possible. + */ +async function fetchJson(url, options = {}) { + const response = await fetch(url, options); + const text = await response.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + return { response, body }; +} + +/** + * Polls API /readyz and the Vite web server until both report ready. + */ +export async function waitForStackReadiness({ + apiOrigin, + webOrigin, + failurePromise, + timeoutMs = 90_000 +}) { + const deadline = Date.now() + timeoutMs; + let lastReadyz = null; + let lastWebStatus = null; + + while (Date.now() < deadline) { + const raceResult = await Promise.race([ + (async () => { + try { + const { response: apiRes, body: readyz } = await fetchJson(`${apiOrigin}/readyz?strict=1`); + lastReadyz = readyz; + const deps = readyz?.dependencies ?? {}; + const apiReady = + apiRes.status === 200 && + readyz?.ok === true && + deps.database === "RUNNING" && + deps.worker === "RUNNING" && + deps.cliHost === "RUNNING"; + if (!apiReady) return false; + + const webIndex = await fetch(`${webOrigin}/`); + const { response: webReadyRes, body: webReadyBody } = await fetchJson(`${webOrigin}/readyz`); + lastWebStatus = { indexStatus: webIndex.status, readyStatus: webReadyRes.status }; + return webIndex.status === 200 && webReadyRes.status === 200 && webReadyBody?.ok === true; + } catch { + return false; + } + })(), + failurePromise ? failurePromise.then((failure) => ({ failure })) : new Promise(() => {}) + ]); + + if (raceResult && typeof raceResult === "object" && "failure" in raceResult) { + throw new Error( + `Service "${raceResult.failure.service}" exited before stack became ready (code=${raceResult.failure.code}, signal=${raceResult.failure.signal}).` + ); + } + if (raceResult === true) { + return lastReadyz; + } + await sleep(500); + } + + throw new Error( + `Timed out waiting for SpaceApp dev stack readiness. Last /readyz=${JSON.stringify(lastReadyz)} web=${JSON.stringify(lastWebStatus)}` + ); +} + +/** + * Checks whether a numeric process ID currently exists. + */ +function isPidAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Reads the command line string of a running process ID using /proc//cmdline or ps. + */ +export function readProcessCommand(pid) { + if (!Number.isInteger(pid) || pid <= 0) return ""; + const procCmdlinePath = `/proc/${pid}/cmdline`; + if (existsSync(procCmdlinePath)) { + try { + const cmdline = readFileSync(procCmdlinePath, "utf8").replaceAll("\0", " ").trim(); + if (cmdline) return cmdline; + } catch {} + } + try { + const psResult = spawnSync("ps", ["-p", String(pid), "-o", "command="], { + encoding: "utf8" + }); + if (psResult.status === 0 && typeof psResult.stdout === "string") { + return psResult.stdout.trim(); + } + } catch {} + return ""; +} + +/** + * Verifies that a live PID recorded in pids.json still belongs to this checkout dev stack. + */ +export function isRecordedDevStackProcess( + pid, + { + repoRoot = defaultRepoRoot, + stateDir = "", + inspectCommand = readProcessCommand + } = {} +) { + const commandLine = inspectCommand(pid); + if (typeof commandLine !== "string" || !commandLine.trim()) { + return false; + } + const normalizedCmd = commandLine.replaceAll("\\", "/"); + const normalizedRepo = repoRoot ? path.resolve(repoRoot).replaceAll("\\", "/") : ""; + const normalizedState = stateDir ? path.resolve(stateDir).replaceAll("\\", "/") : ""; + const markers = [ + normalizedRepo, + normalizedState, + "scripts/dev-stack.mjs", + "packages/cli-host/dist/main.js", + "apps/worker/dist/worker.js", + "apps/api/src/server.ts", + "vite" + ].filter(Boolean); + return markers.some((marker) => normalizedCmd.includes(marker)); +} + +/** + * Terminates any running supervisor or child processes recorded in /pids.json + * after verifying that each PID still belongs to the SpaceApp dev stack. + */ +export async function terminateRecordedProcesses( + stateDir, + { + repoRoot = defaultRepoRoot, + inspectCommand = readProcessCommand, + sendSignal = (pid, sig) => process.kill(pid, sig), + checkAlive = isPidAlive + } = {} +) { + const pidsPath = path.join(stateDir, "pids.json"); + if (!existsSync(pidsPath)) return; + let recorded; + try { + recorded = JSON.parse(readFileSync(pidsPath, "utf8")); + } catch { + rmSync(pidsPath, { force: true }); + return; + } + + const effectiveRepoRoot = recorded?.repoRoot || repoRoot; + const rawPids = []; + if (Number.isInteger(recorded?.supervisorPid) && recorded.supervisorPid !== process.pid) { + rawPids.push(recorded.supervisorPid); + } + if (recorded?.children && typeof recorded.children === "object") { + for (const pid of Object.values(recorded.children)) { + if (Number.isInteger(pid) && pid !== process.pid) { + rawPids.push(pid); + } + } + } + + const verifiedPids = rawPids.filter( + (pid) => + checkAlive(pid) && + isRecordedDevStackProcess(pid, { + repoRoot: effectiveRepoRoot, + stateDir, + inspectCommand + }) + ); + + for (const pid of verifiedPids) { + try { + sendSignal(pid, "SIGTERM"); + } catch {} + } + + const deadline = Date.now() + 6_000; + while (Date.now() < deadline) { + if (!verifiedPids.some((pid) => checkAlive(pid))) break; + await sleep(150); + } + + for (const pid of verifiedPids) { + if (checkAlive(pid)) { + try { + sendSignal(pid, "SIGKILL"); + } catch {} + } + } + rmSync(pidsPath, { force: true }); +} + +/** + * Executes docker compose scoped to the dev-stack project and state directory. + */ +function runDockerCompose(config, composeArgs, { allowFailure = false } = {}) { + const composeFilePath = path.join(config.stateDir, "compose.yml"); + const args = ["compose", "-p", config.projectName]; + if (existsSync(composeFilePath)) { + args.push("-f", composeFilePath); + } + args.push(...composeArgs); + const result = spawnSync("docker", args, { + cwd: config.repoRoot, + stdio: "inherit", + env: process.env + }); + if (result.status !== 0 && !allowFailure) { + throw new Error(`docker ${args.join(" ")} failed with exit code ${result.status ?? 1}.`); + } + return result; +} + +/** + * Removes Docker containers and volumes labeled with the dev-stack project name. + */ +function removeLabeledDockerResources(projectName) { + const containers = spawnSync( + "docker", + ["ps", "-aq", "--filter", `label=com.docker.compose.project=${projectName}`], + { encoding: "utf8" } + ); + const containerIds = (containers.stdout ?? "") + .split(/\r?\n/) + .map((id) => id.trim()) + .filter(Boolean); + if (containerIds.length > 0) { + spawnSync("docker", ["rm", "-f", "-v", ...containerIds], { stdio: "inherit" }); + } + + const volumes = spawnSync( + "docker", + ["volume", "ls", "-q", "--filter", `label=com.docker.compose.project=${projectName}`], + { encoding: "utf8" } + ); + const volumeNames = (volumes.stdout ?? "") + .split(/\r?\n/) + .map((name) => name.trim()) + .filter(Boolean); + if (volumeNames.length > 0) { + spawnSync("docker", ["volume", "rm", "-f", ...volumeNames], { stdio: "inherit" }); + } +} + +/** + * Stops dev-stack processes and Docker containers while preserving volumes and state. + */ +export async function downDevStack(config) { + assertSafeStateDir(config.stateDir, config.repoRoot); + await terminateRecordedProcesses(config.stateDir, { repoRoot: config.repoRoot }); + if (existsSync(path.join(config.stateDir, "compose.yml"))) { + runDockerCompose(config, ["down", "--remove-orphans"], { allowFailure: true }); + } else { + removeLabeledDockerResources(config.projectName); + } + process.stdout.write(`[dev:stack] Stopped "${config.projectName}" processes and containers (state preserved at ${config.stateDir}).\n`); +} + +/** + * Stops dev-stack processes and removes its containers, volumes, and state directory. + */ +export async function resetDevStack(config, { removeDocker = true } = {}) { + const resolvedStateDir = assertSafeStateDir(config.stateDir, config.repoRoot); + if (existsSync(resolvedStateDir)) { + const stat = statSync(resolvedStateDir); + if (!stat.isDirectory()) { + throw new Error(`Refusing to reset stateDir "${resolvedStateDir}": path is not a directory.`); + } + const entries = readdirSync(resolvedStateDir); + const hasMarker = existsSync(path.join(resolvedStateDir, stateDirMarkerFile)); + const isNamedDevStack = path.basename(resolvedStateDir).startsWith(".dev-stack"); + if (entries.length > 0 && !hasMarker && !isNamedDevStack) { + throw new Error( + `Refusing to delete non-empty directory "${resolvedStateDir}" without ${stateDirMarkerFile}.` + ); + } + } + + await terminateRecordedProcesses(resolvedStateDir, { repoRoot: config.repoRoot }); + if (removeDocker) { + if (existsSync(path.join(resolvedStateDir, "compose.yml"))) { + runDockerCompose(config, ["down", "-v", "--remove-orphans"], { allowFailure: true }); + } + removeLabeledDockerResources(config.projectName); + } + + rmSync(resolvedStateDir, { recursive: true, force: true }); + process.stdout.write(`[dev:stack] Reset complete: removed "${config.projectName}" containers, volumes, and ${resolvedStateDir}.\n`); +} + +/** + * Extracts the session cookie from an HTTP response. + */ +function extractSessionCookie(response) { + const setCookies = + typeof response.headers.getSetCookie === "function" + ? response.headers.getSetCookie() + : [response.headers.get("set-cookie")].filter(Boolean); + for (const header of setCookies) { + const match = header.match(/^(space_session(?:_dev)?=[^;]+)/); + if (match) return match[1]; + } + return null; +} + +/** + * Verifies that the CLI terminal WebSocket endpoint accepts an authenticated upgrade. + */ +async function verifyWebSocketAttach(wsUrl, origin, cookie) { + if (typeof WebSocket !== "function") return; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for CLI terminal WebSocket handshake at ${wsUrl}`)); + }, 15_000); + let ws; + try { + ws = new WebSocket(wsUrl, { + headers: { + Cookie: cookie, + Origin: origin + } + }); + } catch { + ws = new WebSocket(wsUrl); + } + ws.addEventListener("open", () => { + clearTimeout(timeout); + ws.close(); + resolve(); + }); + ws.addEventListener("error", () => { + clearTimeout(timeout); + reject(new Error(`WebSocket connection failed at ${wsUrl}`)); + }); + }); +} + +/** + * Runs an end-to-end HTTP and WebSocket smoke check against a running dev stack. + */ +export async function runDevStackSmoke(config) { + const apiOrigin = `http://127.0.0.1:${config.apiPort}`; + const webOrigin = `http://127.0.0.1:${config.webPort}`; + const readyz = await waitForStackReadiness({ apiOrigin, webOrigin, timeoutMs: 60_000 }); + + const { response: statusRes, body: setupStatus } = await fetchJson(`${webOrigin}/api/setup/status`); + if (statusRes.status !== 200) { + throw new Error(`Expected GET /api/setup/status to return 200, got ${statusRes.status}`); + } + + const setupTokenPath = path.join(config.stateDir, "secrets", "setup-token"); + const ownerEmail = process.env.SPACE_DEV_STACK_SMOKE_EMAIL || "owner@example.com"; + const ownerPassword = process.env.SPACE_DEV_STACK_SMOKE_PASSWORD || "ContributorPass!234"; + + let sessionCookie = null; + let starterRoomId = null; + + if (setupStatus.setupRequired) { + if (!existsSync(setupTokenPath)) { + throw new Error(`Setup is required, but setup token file is missing at ${setupTokenPath}.`); + } + const setupToken = readFileSync(setupTokenPath, "utf8").trim(); + const { response: claimRes, body: claimBody } = await fetchJson(`${webOrigin}/api/setup/claim`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + token: setupToken, + email: ownerEmail, + password: ownerPassword + }) + }); + if (claimRes.status !== 200) { + throw new Error(`POST /api/setup/claim failed (${claimRes.status}): ${JSON.stringify(claimBody)}`); + } + sessionCookie = extractSessionCookie(claimRes); + starterRoomId = claimBody?.starterRoomId ?? null; + writeSecretFile(path.join(config.stateDir, "secrets", "setup-token.claimed"), new Date().toISOString(), 0o600); + } else { + const { response: loginRes, body: loginBody } = await fetchJson(`${webOrigin}/api/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: ownerEmail, + password: ownerPassword + }) + }); + if (loginRes.status !== 200) { + throw new Error(`POST /api/auth/login failed (${loginRes.status}): ${JSON.stringify(loginBody)}`); + } + sessionCookie = extractSessionCookie(loginRes); + } + + if (!sessionCookie) { + throw new Error("Did not receive a session cookie after authenticating."); + } + + const { response: csrfRes, body: csrfBody } = await fetchJson(`${webOrigin}/api/auth/csrf`, { + headers: { Cookie: sessionCookie } + }); + if (csrfRes.status !== 200 || !csrfBody?.csrfToken) { + throw new Error(`Failed to fetch CSRF token (${csrfRes.status}): ${JSON.stringify(csrfBody)}`); + } + const authHeaders = { + Cookie: sessionCookie, + [csrfBody.headerName || "x-space-csrf-token"]: csrfBody.csrfToken, + "content-type": "application/json" + }; + + if (!starterRoomId) { + const { response: starterRes, body: starterBody } = await fetchJson(`${webOrigin}/api/setup/starter-room`, { + method: "POST", + headers: authHeaders, + body: JSON.stringify({}) + }); + if (starterRes.status === 200 && starterBody?.room?.id) { + starterRoomId = starterBody.room.id; + } + } + + const { response: roomsRes, body: roomsBody } = await fetchJson(`${webOrigin}/api/rooms`, { + headers: { Cookie: sessionCookie } + }); + if (roomsRes.status !== 200 || !Array.isArray(roomsBody?.rooms)) { + throw new Error(`GET /api/rooms failed (${roomsRes.status}): ${JSON.stringify(roomsBody)}`); + } + const gettingStartedRoom = + roomsBody.rooms.find((room) => room.id === starterRoomId) ?? + roomsBody.rooms.find((room) => room.name === "Getting Started") ?? + roomsBody.rooms[0]; + if (!gettingStartedRoom) { + throw new Error("Could not find the Getting Started room."); + } + + const panes = Array.isArray(gettingStartedRoom.panes) ? gettingStartedRoom.panes : []; + const opencodePane = + panes.find((pane) => pane.mode === "TERMINAL" && pane.terminalRuntimeId === "cli:opencode" && !pane.isClosed) ?? + panes.find((pane) => pane.mode === "TERMINAL" && !pane.isClosed); + if (!opencodePane) { + throw new Error(`Could not find an OpenCode terminal pane in room "${gettingStartedRoom.name}".`); + } + + const { response: startRes, body: startBody } = await fetchJson( + `${webOrigin}/api/panes/${encodeURIComponent(opencodePane.id)}/cli/session`, + { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ + runtimeId: opencodePane.terminalRuntimeId || "cli:opencode", + includeTranscript: false + }) + } + ); + if (startRes.status !== 200 || !startBody?.session) { + throw new Error( + `POST /api/panes/${opencodePane.id}/cli/session failed (${startRes.status}): ${JSON.stringify(startBody)}` + ); + } + + if (startBody.websocket?.path && startBody.websocket?.token) { + const wsUrl = `ws://127.0.0.1:${config.webPort}${startBody.websocket.path}?token=${encodeURIComponent(startBody.websocket.token)}`; + await verifyWebSocketAttach(wsUrl, webOrigin, sessionCookie); + } + + let currentSession = startBody.session; + const sessionDeadline = Date.now() + 30_000; + while (currentSession?.status !== "RUNNING" && Date.now() < sessionDeadline) { + await sleep(400); + const { response: pollRes, body: pollBody } = await fetchJson( + `${webOrigin}/api/panes/${encodeURIComponent(opencodePane.id)}/cli/session`, + { headers: { Cookie: sessionCookie } } + ); + if (pollRes.status === 200 && pollBody?.session) { + currentSession = pollBody.session; + if (currentSession.status === "ERROR" || currentSession.status === "EXITED") { + throw new Error( + `OpenCode pane session entered ${currentSession.status}: ${currentSession.statusReason ?? "no reason"}` + ); + } + } + } + + if (currentSession?.status !== "RUNNING") { + throw new Error( + `Expected OpenCode pane session to reach RUNNING, got ${JSON.stringify(currentSession)}` + ); + } + + const summary = { + ok: true, + readyzOk: readyz.ok, + roomId: gettingStartedRoom.id, + roomName: gettingStartedRoom.name, + paneId: opencodePane.id, + runtimeId: currentSession.runtimeId, + sessionId: currentSession.sessionId, + status: currentSession.status, + cwd: currentSession.cwd + }; + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + return summary; +} + +/** + * Queries and prints the current readiness status of the dev stack as JSON. + */ +export async function statusDevStack(config) { + const apiOrigin = `http://127.0.0.1:${config.apiPort}`; + const webOrigin = `http://127.0.0.1:${config.webPort}`; + let readyz = null; + try { + const res = await fetchJson(`${apiOrigin}/readyz`); + readyz = res.body; + } catch {} + const status = { + projectName: config.projectName, + stateDir: config.stateDir, + apiOrigin, + webOrigin, + ready: Boolean(readyz?.ok), + readyz + }; + process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); + return status; +} + +/** + * Prints CLI usage instructions for npm run dev:stack. + */ +function printUsage() { + process.stdout.write(`Usage: npm run dev:stack [-- [options]] + +Commands: + up (default) Start Postgres + Temporal in Docker, migrate DB, and run SpaceApp from source + down Stop running dev:stack processes and Docker containers (preserving volumes/state) + reset Stop processes, remove dev:stack Docker containers + volumes, and delete .dev-stack/ + status Print current readiness and port status as JSON + smoke Verify /readyz, claim owner setup over HTTP, and verify Getting Started OpenCode pane reaches RUNNING + +Options: + --state-dir State directory (default: .dev-stack or SPACE_DEV_STACK_STATE_DIR) + --project-name Docker Compose project name (default: spaceapp-dev or SPACE_DEV_STACK_PROJECT_NAME) + --api-port API port on 127.0.0.1 (default: 5910 or SPACE_API_PORT) + --web-port Web port on 127.0.0.1 (default: 5911 or SPACE_WEB_PORT) + --postgres-port Postgres host port on 127.0.0.1 (default: 55432 or SPACE_DEV_POSTGRES_PORT) + --temporal-port Temporal host port on 127.0.0.1 (default: 57233 or SPACE_DEV_TEMPORAL_PORT) + --no-package-watch Disable continuous tsc -b --watch for packages/* + --skip-build Skip initial npm run build:packages +`); +} + +/** + * Starts the full SpaceApp contributor stack from source. + */ +export async function upDevStack(config) { + await terminateRecordedProcesses(config.stateDir, { repoRoot: config.repoRoot }); + const secrets = ensureDevStackSecrets(config); + const cliWrappers = ensureDevStackCliWrappers(config); + + const composeTemplatePath = path.join( + config.repoRoot, + "packages", + "run-spaceapp", + "templates", + "compose.yml" + ); + const composeTemplateText = readFileSync(composeTemplatePath, "utf8"); + const { postgresImage, temporalImage } = readPinnedComposeImages(composeTemplateText); + + const composeYaml = renderDevStackCompose({ + projectName: config.projectName, + postgresImage, + temporalImage, + postgresPort: config.postgresPort, + temporalPort: config.temporalPort, + postgresPasswordFile: secrets.postgresPasswordFile, + temporalPasswordFile: secrets.temporalPasswordFile, + databaseName: secrets.databaseName + }); + const composeFilePath = path.join(config.stateDir, "compose.yml"); + writeFileSync(composeFilePath, composeYaml, { encoding: "utf8", mode: 0o600 }); + + const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; + const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"; + + if (!config.skipBuild) { + process.stdout.write("[dev:stack] Building workspace packages...\n"); + for (const workspaceName of workspaceBuildOrder) { + const buildRes = spawnSync(npmCmd, ["run", "build", "-w", workspaceName], { + cwd: config.repoRoot, + stdio: "inherit", + env: process.env + }); + if (buildRes.status !== 0) { + throw new Error(`npm run build -w ${workspaceName} failed.`); + } + } + } + + process.stdout.write(`[dev:stack] Starting Docker services (${config.projectName})...\n`); + runDockerCompose(config, ["up", "-d", "--wait"]); + + const { env: childEnv, apiOrigin, webOrigin } = buildDevStackChildEnv(config, secrets); + + process.stdout.write("[dev:stack] Running database migrations...\n"); + const migrateRes = spawnSync(npmCmd, ["run", "db:migrate"], { + cwd: config.repoRoot, + stdio: "inherit", + env: childEnv + }); + if (migrateRes.status !== 0) { + throw new Error("Database migration (npm run db:migrate) failed."); + } + + const serviceSpecs = [ + { + name: "cli-host", + command: process.execPath, + args: [path.join(config.repoRoot, "packages", "cli-host", "dist", "main.js")], + cwd: config.repoRoot, + env: childEnv, + restartOnPackageRebuild: true + }, + { + name: "worker", + command: process.execPath, + args: [path.join(config.repoRoot, "apps", "worker", "dist", "worker.js")], + cwd: config.repoRoot, + env: childEnv, + restartOnPackageRebuild: true + }, + { + name: "api", + command: npxCmd, + args: ["tsx", "watch", path.join(config.repoRoot, "apps", "api", "src", "server.ts")], + cwd: config.repoRoot, + env: childEnv + }, + { + name: "web", + command: npxCmd, + args: [ + "vite", + "--host", + "127.0.0.1", + "--port", + String(config.webPort), + "--strictPort" + ], + cwd: path.join(config.repoRoot, "apps", "web"), + env: childEnv + } + ]; + + if (config.packageWatch) { + serviceSpecs.unshift({ + name: "packages", + command: npxCmd, + args: [ + "tsc", + "-b", + "packages/contracts", + "packages/icons", + "packages/streaming", + "packages/opencode-control", + "packages/memory-graph", + "packages/browser-host", + "packages/cli-host", + "packages/codex-app-server", + "packages/runtime", + "packages/db", + "packages/gateway", + "apps/worker", + "--watch", + "--preserveWatchOutput" + ], + cwd: config.repoRoot, + env: childEnv + }); + } + + const pidsPath = path.join(config.stateDir, "pids.json"); + const writePidsFile = (childPids) => { + writeFileSync( + pidsPath, + `${JSON.stringify( + { + supervisorPid: process.pid, + repoRoot: config.repoRoot, + stateDir: config.stateDir, + projectName: config.projectName, + apiPort: config.apiPort, + webPort: config.webPort, + postgresPort: config.postgresPort, + temporalPort: config.temporalPort, + opencodeSpec: cliWrappers.opencodeSpec, + children: childPids + }, + null, + 2 + )}\n`, + { encoding: "utf8", mode: 0o600 } + ); + }; + + const supervisor = superviseChildProcesses(serviceSpecs, { + onPidsChanged: (updatedPids) => { + try { + writePidsFile(updatedPids); + } catch {} + } + }); + writePidsFile(supervisor.getPids()); + + let shuttingDown = false; + const handleSignal = (signal) => { + if (shuttingDown) return; + shuttingDown = true; + process.stdout.write(`\n[dev:stack] Received ${signal}, stopping child services...\n`); + void supervisor.stopAll().finally(() => { + rmSync(pidsPath, { force: true }); + process.exit(0); + }); + }; + process.once("SIGINT", () => handleSignal("SIGINT")); + process.once("SIGTERM", () => handleSignal("SIGTERM")); + process.once("SIGHUP", () => handleSignal("SIGHUP")); + + try { + await waitForStackReadiness({ + apiOrigin, + webOrigin, + failurePromise: supervisor.failurePromise + }); + + let setupRequired = !secrets.isSetupClaimed(); + try { + const { response, body } = await fetchJson(`${apiOrigin}/api/setup/status`); + if (response.status === 200 && typeof body?.setupRequired === "boolean") { + setupRequired = body.setupRequired; + if (!setupRequired) { + secrets.markSetupClaimed(); + } + } + } catch {} + + process.stdout.write( + formatReadyBanner({ + webOrigin, + apiOrigin, + setupRequired, + setupToken: setupRequired ? secrets.setupToken : null + }) + ); + + const statusPollTimer = setInterval(async () => { + if (secrets.isSetupClaimed()) return; + try { + const { response, body } = await fetchJson(`${apiOrigin}/api/setup/status`); + if (response.status === 200 && body?.setupRequired === false) { + secrets.markSetupClaimed(); + } + } catch {} + }, 5_000); + statusPollTimer.unref(); + + const failure = await supervisor.failurePromise; + clearInterval(statusPollTimer); + rmSync(pidsPath, { force: true }); + if (failure) { + process.exitCode = failure.code || 1; + } + } catch (error) { + await supervisor.stopAll(); + rmSync(pidsPath, { force: true }); + throw error; + } +} + +/** + * CLI entrypoint dispatching dev:stack subcommands. + */ +export async function runDevStack(argv = process.argv.slice(2), env = process.env) { + const config = parseDevStackArgs(argv, env, defaultRepoRoot); + if (config.command === "help") { + printUsage(); + return; + } + if (config.command === "down") { + await downDevStack(config); + return; + } + if (config.command === "reset") { + await resetDevStack(config); + return; + } + if (config.command === "status") { + await statusDevStack(config); + return; + } + if (config.command === "smoke") { + await runDevStackSmoke(config); + return; + } + await upDevStack(config); +} + +const isMain = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); + +if (isMain) { + runDevStack().catch((error) => { + process.stderr.write(`[dev:stack] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }); +} diff --git a/scripts/public-export.mjs b/scripts/public-export.mjs index c7dbddb50..9e3adafc4 100644 --- a/scripts/public-export.mjs +++ b/scripts/public-export.mjs @@ -135,7 +135,9 @@ const publicScripts = new Set([ "scripts/tests/quality-known-limitations.test.mjs", "scripts/contributor-smoke.mjs", "scripts/fixtures/offline-cli.mjs", - "scripts/tests/contributor-smoke.test.mjs" + "scripts/tests/contributor-smoke.test.mjs", + "scripts/dev-stack.mjs", + "scripts/tests/dev-stack.test.mjs" ]); // Root package.json scripts may name only files in the public tree: exported diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs new file mode 100644 index 000000000..8cb6dfe5f --- /dev/null +++ b/scripts/tests/dev-stack.test.mjs @@ -0,0 +1,382 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import test from "node:test"; +import { + assertSafeStateDir, + buildDevStackChildEnv, + defaultDevStackPorts, + defaultRepoRoot, + ensureDevStackSecrets, + formatReadyBanner, + parseDevStackArgs, + readPinnedComposeImages, + readPinnedOpenCodePackageSpec, + renderDevStackCompose, + resetDevStack, + isRecordedDevStackProcess, + superviseChildProcesses, + terminateRecordedProcesses, + writeCliWrapperScripts +} from "../dev-stack.mjs"; + +test("readPinnedComposeImages extracts digest-pinned Postgres and Temporal images from compose template", () => { + const composeTemplatePath = path.join( + defaultRepoRoot, + "packages", + "run-spaceapp", + "templates", + "compose.yml" + ); + const templateText = readFileSync(composeTemplatePath, "utf8"); + const { postgresImage, temporalImage } = readPinnedComposeImages(templateText); + + assert.match(postgresImage, /^pgvector\/pgvector:[^\s]+@sha256:[0-9a-f]{64}$/); + assert.match(temporalImage, /^temporalio\/auto-setup:[^\s]+@sha256:[0-9a-f]{64}$/); + + assert.throws( + () => readPinnedComposeImages("services:\n postgres:\n image: pgvector/pgvector:pg16\n"), + /digest-pinned pgvector\/pgvector/ + ); +}); + +test("readPinnedOpenCodePackageSpec extracts the pinned @opencode/cli version from Dockerfile", () => { + const dockerfileText = readFileSync(path.join(defaultRepoRoot, "Dockerfile"), "utf8"); + const spec = readPinnedOpenCodePackageSpec(dockerfileText); + assert.match(spec, /^@opencode\/cli@\d+\.\d+\.\d+$/); + + assert.throws( + () => readPinnedOpenCodePackageSpec("FROM node:22-bookworm-slim\n"), + /pinned @opencode\/cli/ + ); +}); + +test("parseDevStackArgs applies isolated defaults and honors environment and CLI overrides", () => { + const defaults = parseDevStackArgs([], {}, defaultRepoRoot); + assert.equal(defaults.command, "up"); + assert.equal(defaults.apiPort, defaultDevStackPorts.apiPort); + assert.equal(defaults.webPort, defaultDevStackPorts.webPort); + assert.equal(defaults.postgresPort, defaultDevStackPorts.postgresPort); + assert.equal(defaults.temporalPort, defaultDevStackPorts.temporalPort); + assert.equal(defaults.stateDir, path.join(defaultRepoRoot, ".dev-stack")); + + const overridden = parseDevStackArgs( + ["down", "--api-port", "6910", "--web-port=6911", "--state-dir", ".dev-stack-custom"], + { + SPACE_DEV_POSTGRES_PORT: "56432", + SPACE_DEV_TEMPORAL_PORT: "58233", + SPACE_DEV_STACK_PROJECT_NAME: "spaceapp-dev-custom" + }, + defaultRepoRoot + ); + assert.equal(overridden.command, "down"); + assert.equal(overridden.apiPort, 6910); + assert.equal(overridden.webPort, 6911); + assert.equal(overridden.postgresPort, 56432); + assert.equal(overridden.temporalPort, 58233); + assert.equal(overridden.projectName, "spaceapp-dev-custom"); + assert.equal(overridden.stateDir, path.join(defaultRepoRoot, ".dev-stack-custom")); +}); + +test("assertSafeStateDir refuses filesystem root, home, temp root, repo root, parents, and source dirs", () => { + assert.throws(() => assertSafeStateDir("/", defaultRepoRoot), /unsafe state directory/); + assert.throws(() => assertSafeStateDir(os.homedir(), defaultRepoRoot), /unsafe state directory/); + assert.throws(() => assertSafeStateDir(os.tmpdir(), defaultRepoRoot), /unsafe state directory/); + assert.throws(() => assertSafeStateDir(defaultRepoRoot, defaultRepoRoot), /unsafe state directory/); + assert.throws( + () => assertSafeStateDir(path.dirname(defaultRepoRoot), defaultRepoRoot), + /unsafe state directory/ + ); + assert.throws( + () => assertSafeStateDir(path.join(defaultRepoRoot, "apps", "api"), defaultRepoRoot), + /unsafe state directory/ + ); + assert.throws( + () => assertSafeStateDir(path.join(defaultRepoRoot, "packages"), defaultRepoRoot), + /unsafe state directory/ + ); +}); + +test("renderDevStackCompose binds ports strictly to 127.0.0.1 and pins digests", () => { + const composeTemplatePath = path.join( + defaultRepoRoot, + "packages", + "run-spaceapp", + "templates", + "compose.yml" + ); + const { postgresImage, temporalImage } = readPinnedComposeImages( + readFileSync(composeTemplatePath, "utf8") + ); + const yaml = renderDevStackCompose({ + projectName: "spaceapp-dev", + postgresImage, + temporalImage, + postgresPort: 55432, + temporalPort: 57233, + postgresPasswordFile: "/tmp/example/.dev-stack/secrets/postgres-password", + temporalPasswordFile: "/tmp/example/.dev-stack/secrets/postgres-password-container" + }); + + assert.match(yaml, /"127\.0\.0\.1:55432:5432"/); + assert.match(yaml, /"127\.0\.0\.1:57233:7233"/); + assert.doesNotMatch(yaml, /0\.0\.0\.0/); + assert.ok(yaml.includes(postgresImage)); + assert.ok(yaml.includes(temporalImage)); +}); + +test("ensureDevStackSecrets and formatReadyBanner protect secrets and print setup token only while unclaimed", async () => { + const scratchParent = mkdtempSync(path.join(os.tmpdir(), "spaceapp-dev-stack-test-")); + const stateDir = path.join(scratchParent, ".dev-stack"); + try { + const config = parseDevStackArgs(["--state-dir", stateDir], {}, defaultRepoRoot); + const secrets = ensureDevStackSecrets(config); + + if (process.platform !== "win32") { + assert.equal(statSync(secrets.secretsDir).mode & 0o777, 0o700); + assert.equal(statSync(secrets.postgresPasswordFile).mode & 0o777, 0o600); + assert.equal(statSync(secrets.sessionSecretFile).mode & 0o777, 0o600); + assert.equal(statSync(secrets.setupTokenFile).mode & 0o777, 0o600); + assert.equal(statSync(secrets.databaseUrlFile).mode & 0o777, 0o600); + } + + const unclaimedBanner = formatReadyBanner({ + webOrigin: "http://127.0.0.1:5911", + apiOrigin: "http://127.0.0.1:5910", + setupRequired: true, + setupToken: secrets.setupToken + }); + assert.ok(unclaimedBanner.includes(secrets.setupToken)); + assert.ok(!unclaimedBanner.includes(secrets.sessionSecret)); + assert.ok(!unclaimedBanner.includes(secrets.databaseUrl)); + + secrets.markSetupClaimed(); + assert.equal(secrets.isSetupClaimed(), true); + + const claimedBanner = formatReadyBanner({ + webOrigin: "http://127.0.0.1:5911", + apiOrigin: "http://127.0.0.1:5910", + setupRequired: false, + setupToken: secrets.setupToken + }); + assert.ok(!claimedBanner.includes(secrets.setupToken)); + assert.match(claimedBanner, /Owner setup:\s+already claimed/); + + const { env } = buildDevStackChildEnv(config, secrets, {}); + assert.equal(env.SPACE_PUBLIC_DISTRIBUTION, "true"); + assert.equal(env.SPACEAPP_WORKFLOWS_ENABLED, "true"); + assert.equal(env.SPACE_DEV_LOGIN, "false"); + assert.equal(env.SPACE_SETUP_TOKEN_FILE, undefined); + + const wrappers = writeCliWrapperScripts({ + stateDir, + opencodeBinPath: "/usr/local/bin/opencode" + }); + assert.equal(existsSync(wrappers.opencodeWrapperPath), true); + const opencodeCfg = JSON.parse(readFileSync(wrappers.opencodeConfigFile, "utf8")); + assert.equal(opencodeCfg.model, "opencode/big-pickle"); + + await resetDevStack(config, { removeDocker: false }); + assert.equal(existsSync(stateDir), false); + } finally { + rmSync(scratchParent, { recursive: true, force: true }); + } +}); + +test("resetDevStack refuses to delete an unmarked non-empty custom directory", async () => { + const unmarkedDir = mkdtempSync(path.join(os.tmpdir(), "spaceapp-unmarked-dir-")); + try { + mkdirSync(unmarkedDir, { recursive: true }); + writeFileSync(path.join(unmarkedDir, "important.txt"), "keep", "utf8"); + const config = parseDevStackArgs(["reset", "--state-dir", unmarkedDir], {}, defaultRepoRoot); + await assert.rejects( + () => resetDevStack(config, { removeDocker: false }), + /Refusing to delete non-empty directory/ + ); + assert.equal(existsSync(path.join(unmarkedDir, "important.txt")), true); + } finally { + rmSync(unmarkedDir, { recursive: true, force: true }); + } +}); + +test("superviseChildProcesses stops sibling services and reports failure when a child exits unexpectedly", async () => { + const fakeChildren = new Map(); + const fakeSpawn = (_cmd, _args, _opts) => { + const proc = new EventEmitter(); + proc.stdout = new PassThrough(); + proc.stderr = new PassThrough(); + proc.exitCode = null; + proc.signalCode = null; + proc.pid = 10000 + fakeChildren.size + 1; + proc.kill = (signal = "SIGTERM") => { + proc.signalCode = signal; + proc.emit("exit", null, signal); + }; + return proc; + }; + + const specs = [ + { name: "api", command: "node", args: ["api.js"], cwd: defaultRepoRoot, env: {} }, + { name: "worker", command: "node", args: ["worker.js"], cwd: defaultRepoRoot, env: {} } + ]; + + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let stderrText = ""; + stderr.on("data", (chunk) => { + stderrText += chunk.toString("utf8"); + }); + + const supervisor = superviseChildProcesses(specs, { + spawnProcess: (cmd, args, opts) => { + const child = fakeSpawn(cmd, args, opts); + fakeChildren.set(args[0], child); + return child; + }, + stdout, + stderr + }); + + const workerChild = fakeChildren.get("worker.js"); + workerChild.exitCode = 2; + workerChild.emit("exit", 2, null); + + const failure = await supervisor.failurePromise; + assert.equal(failure.service, "worker"); + assert.equal(failure.code, 2); + assert.match(stderrText, /service "worker" exited unexpectedly \(exit code 2\)/); + assert.equal(fakeChildren.get("api.js").signalCode, "SIGTERM"); +}); + +test("terminateRecordedProcesses verifies process identity and skips unrelated PIDs", async () => { + const scratchDir = mkdtempSync(path.join(os.tmpdir(), "spaceapp-pids-test-")); + try { + const pidsPath = path.join(scratchDir, "pids.json"); + writeFileSync( + pidsPath, + JSON.stringify({ + supervisorPid: 42001, + repoRoot: defaultRepoRoot, + stateDir: scratchDir, + children: { + "cli-host": 42002, + unrelated: 42003 + } + }), + "utf8" + ); + + const alive = new Set([42001, 42002, 42003]); + const signaled = []; + const commands = new Map([ + [42001, `node ${path.join(defaultRepoRoot, "scripts", "dev-stack.mjs")}`], + [42002, `node ${path.join(defaultRepoRoot, "packages", "cli-host", "dist", "main.js")}`], + [42003, "/usr/sbin/sshd -D"] + ]); + + assert.equal( + isRecordedDevStackProcess(42001, { + repoRoot: defaultRepoRoot, + stateDir: scratchDir, + inspectCommand: (pid) => commands.get(pid) ?? "" + }), + true + ); + assert.equal( + isRecordedDevStackProcess(42003, { + repoRoot: defaultRepoRoot, + stateDir: scratchDir, + inspectCommand: (pid) => commands.get(pid) ?? "" + }), + false + ); + + await terminateRecordedProcesses(scratchDir, { + repoRoot: defaultRepoRoot, + inspectCommand: (pid) => commands.get(pid) ?? "", + checkAlive: (pid) => alive.has(pid), + sendSignal: (pid, sig) => { + signaled.push({ pid, sig }); + alive.delete(pid); + } + }); + + assert.deepEqual(signaled, [ + { pid: 42001, sig: "SIGTERM" }, + { pid: 42002, sig: "SIGTERM" } + ]); + assert.equal(alive.has(42003), true); + assert.equal(existsSync(pidsPath), false); + } finally { + rmSync(scratchDir, { recursive: true, force: true }); + } +}); + +test("superviseChildProcesses restarts restartOnPackageRebuild services after incremental tsc rebuild", async () => { + const spawned = []; + const fakeSpawn = (_cmd, args, _opts) => { + const proc = new EventEmitter(); + proc.stdout = new PassThrough(); + proc.stderr = new PassThrough(); + proc.exitCode = null; + proc.signalCode = null; + proc.pid = 20000 + spawned.length + 1; + proc.kill = (signal = "SIGTERM") => { + proc.signalCode = signal; + proc.emit("exit", null, signal); + }; + spawned.push({ arg0: args[0], proc }); + return proc; + }; + + const specs = [ + { name: "packages", command: "npx", args: ["tsc"], cwd: defaultRepoRoot, env: {} }, + { + name: "worker", + command: "node", + args: ["worker.js"], + cwd: defaultRepoRoot, + env: {}, + restartOnPackageRebuild: true + } + ]; + + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let updatedPids = null; + + const supervisor = superviseChildProcesses(specs, { + spawnProcess: fakeSpawn, + stdout, + stderr, + onPidsChanged: (pids) => { + updatedPids = pids; + } + }); + + const pkgProc = spawned[0].proc; + // First watch completion is initial build -> should not restart worker yet + pkgProc.stdout.write("Found 0 errors. Watching for file changes.\n"); + assert.equal(spawned.length, 2); + + // Second watch completion is an incremental rebuild -> should restart worker cleanly + pkgProc.stdout.write("Found 0 errors. Watching for file changes.\n"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.equal(spawned.length, 3); + assert.equal(spawned[2].arg0, "worker.js"); + assert.equal(updatedPids?.worker, spawned[2].proc.pid); + + await supervisor.stopAll(); +}); diff --git a/scripts/tests/public-package-metadata.test.mjs b/scripts/tests/public-package-metadata.test.mjs index 4bcafcdc7..726969503 100644 --- a/scripts/tests/public-package-metadata.test.mjs +++ b/scripts/tests/public-package-metadata.test.mjs @@ -30,7 +30,8 @@ const publicTestModules = [ "reset-owner-password.test.mjs", "rotate-owner-setup-token.test.mjs", "trivy-report-summary.test.mjs", - "verify-export-provenance.test.mjs" + "verify-export-provenance.test.mjs", + "dev-stack.test.mjs" ]; async function packageFiles(directory) { diff --git a/scripts/tests/public-suite.test.mjs b/scripts/tests/public-suite.test.mjs index 1b541bb80..b91bc5f58 100644 --- a/scripts/tests/public-suite.test.mjs +++ b/scripts/tests/public-suite.test.mjs @@ -18,6 +18,7 @@ import "./reset-owner-password.test.mjs"; import "./rotate-owner-setup-token.test.mjs"; import "./trivy-report-summary.test.mjs"; import "./verify-export-provenance.test.mjs"; +import "./dev-stack.test.mjs"; import './opencode-first-run.test.mjs'; import './opencode-standalone.test.mjs';