diff --git a/.dockerignore b/.dockerignore index 11092fa87..afca73ff6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ .git +.dev-stack .github .env .env.* diff --git a/.github/workflows/dev-stack.yml b/.github/workflows/dev-stack.yml new file mode 100644 index 000000000..53a06d373 --- /dev/null +++ b/.github/workflows/dev-stack.yml @@ -0,0 +1,67 @@ +name: Source development stack + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + source-stack: + name: Source stack / ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-15-intel] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + cache: npm + - name: Start Docker on macOS with Colima + if: runner.os == 'macOS' + run: | + brew install colima docker docker-compose + mkdir -p "$HOME/.docker/cli-plugins" + ln -sf "$(brew --prefix)/opt/docker-compose/bin/docker-compose" "$HOME/.docker/cli-plugins/docker-compose" + colima start --cpu 2 --memory 6 --disk 30 --runtime docker + docker info + - run: npm ci + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + - run: node --test scripts/tests/dev-stack.test.mjs scripts/tests/dev-stack-vite.test.mjs + - name: Real source stack, owner setup, OpenCode PTY and cleanup + run: npm run smoke:dev-stack + - name: Stop test VM + if: always() && runner.os == 'macOS' + run: colima stop + + source-quality: + name: Source quality gates + runs-on: ubuntu-latest + timeout-minutes: 75 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: .nvmrc + cache: npm + - name: Install native test dependencies + run: sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep ffmpeg + - run: npm ci + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + - run: npm run check + - run: npm test + - run: npm run build + - run: npm run hygiene:preflight diff --git a/.gitignore b/.gitignore index 81e68b26a..ba0b11dac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Generated outputs and local tool reports. These are safe to regenerate; do # not commit them, and clean them only with an allowlist. node_modules/ +.dev-stack/ dist/ dist-mock/ dist-types/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 078772388..2cd36bfe8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,78 @@ packaging requires its normal toolchain and separate platform acceptance. Dependency installation may use the network; the smoke command below does not need a provider account, API key, Docker, PostgreSQL or Temporal. +## Run the full app from source + +Use Node from `.nvmrc`, npm, a C++ build toolchain/Python for `node-pty`, and a +running Docker daemon with Compose v2 supporting `up --wait`. On macOS use +Docker Desktop or Colima (`colima start`); on Windows 11 run everything inside +WSL2 with Docker Desktop's WSL integration enabled. Native Windows is refused. + +```sh +ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm ci +npm run dev:stack +# In another terminal: +npm run dev:stack -- down +npm run dev:stack -- reset +``` + +The foreground launcher builds the internal packages and starts PostgreSQL +(pgvector) and Temporal in Docker, then runs migrations and the CLI host, +Temporal worker, API watcher and Vite **from this checkout**. It installs the +exact OpenCode package pinned in `Dockerfile` under `.dev-stack/opencode`, +never globally. First start needs network access for images and that package. + +Open the printed URL (`http://127.0.0.1:5911` by default) and use the printed +one-time token to create your owner account. Tokens expire after 15 minutes; +restart the stack if an unclaimed token has expired. Sign in normally and open +an OpenCode terminal in Getting Started; reaching its TUI needs no provider +account. Sending AI requests still requires a working model/provider. +Browser and optional companion services are disabled in this dev stack. + +Each checkout has its own Compose project, volume, Unix sockets and ignored +`.dev-stack/` state. Secret files are mode 0600. No installed instance's +credentials, environment configuration or provider homes are reused. All +published ports bind to `127.0.0.1`: + +| Service | Default | Override | +| --- | --- | --- | +| API | 5910 | `SPACE_DEV_API_PORT` | +| Vite | 5911 | `SPACE_DEV_WEB_PORT` | +| PostgreSQL | 55432 | `SPACE_DEV_POSTGRES_PORT` | +| Temporal | 57233 | `SPACE_DEV_TEMPORAL_PORT` | + +Use distinct free ports and keep the same overrides for `up`, `down` and +`reset`. Ports 4910/4911 are refused to avoid collisions with installed SpaceApp. +Vite fails instead of silently choosing another port. Do not share these +development endpoints through a public proxy. + +Edits in `apps/web/src` hot-reload; edits in `apps/api/src` restart the API. +After changes in `packages/*` or the worker, use **one command** in another +terminal: `npm run dev:stack -- rebuild`. It stops the old stack, rebuilds all +internal packages and starts a new foreground stack with the existing data. +Ctrl+C and `down` stop the source process groups and Docker services; `reset` +also removes only this checkout's volume and marked state directory. Unexpected +service exits fail the supervisor and stop the other services. + +Troubleshooting: + +- `docker info` / `docker compose version` must work before startup. No daemon + is installed or started for you. On macOS check Colima/Docker Desktop; in WSL2 + check Docker integration and install/build on the Linux filesystem. +- A port conflict: change the corresponding override; do not stop an installed + SpaceApp to free 4910/4911. A second stack in the same checkout is refused. +- A readiness timeout or failed worker: inspect the named service output; + authentication shortcuts and disabled workflows are not readiness fixes. +- After an uncatchable supervisor kill, a lock may remain. Inspect its PID and + confirm no source processes from this checkout are alive before removing + `.dev-stack/lock`; rerun `reset` to remove its Docker resources. Do not remove + a lock belonging to a running stack. +- To validate real acceptance in a **fresh checkout without dev state**, run + `npm run smoke:dev-stack`. It creates an owner, signs in over HTTP, opens and + attaches a real OpenCode PTY, and asserts that reset leaves no processes, + containers or volumes. The test creates and destroys only its own data. + A passing unit test is not evidence of a passing Docker or macOS/WSL2 run. + ## First executed task, without a provider ```sh diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index a2deafdcd..061fbc3d8 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -60,7 +60,8 @@ export const csrfHeaderName = CSRF_HEADER; export function getAuthConfig(env: NodeJS.ProcessEnv): AuthConfig { let googleClientId = env.SPACE_GOOGLE_CLIENT_ID || env.GOOGLE_CLIENT_ID || undefined; let googleClientSecret = env.SPACE_GOOGLE_CLIENT_SECRET || env.GOOGLE_CLIENT_SECRET || undefined; - if (!googleClientId) { + // A source dev stack must not discover an installed operator's OAuth secrets. + if (!googleClientId && env.SPACE_INSTANCE !== "dev" && env.SPACE_GOOGLE_AUTH_DISABLED !== "true") { try { const candidates = ["/opt/spaceapp/secrets/google-oauth.json", "/var/lib/spaceapp/google-oauth.json"]; for (const p of candidates) { diff --git a/apps/api/tests/auth-dev-isolation.test.ts b/apps/api/tests/auth-dev-isolation.test.ts new file mode 100644 index 000000000..16f58c0a1 --- /dev/null +++ b/apps/api/tests/auth-dev-isolation.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +const files = vi.hoisted(() => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => JSON.stringify({ clientId: "installed-client", clientSecret: "installed-secret" })) +})); +vi.mock("node:fs", async (original) => ({ ...await original(), ...files })); +import { getAuthConfig } from "../src/auth.js"; + +describe("source development OAuth isolation", () => { + it("does not probe installed files in dev or when Google auth is disabled", () => { + for (const env of [{ SPACE_INSTANCE: "dev" }, { SPACE_GOOGLE_AUTH_DISABLED: "true" }]) { + files.existsSync.mockClear(); files.readFileSync.mockClear(); + expect(getAuthConfig(env).googleClientId).toBeUndefined(); + expect(files.existsSync).not.toHaveBeenCalled(); + expect(files.readFileSync).not.toHaveBeenCalled(); + } + }); + it("retains installed-instance discovery and explicit dev configuration", () => { + expect(getAuthConfig({}).googleClientId).toBe("installed-client"); + expect(getAuthConfig({ SPACE_INSTANCE: "dev", SPACE_GOOGLE_CLIENT_ID: "own-client", SPACE_GOOGLE_CLIENT_SECRET: "own-secret" })) + .toMatchObject({ googleClientId: "own-client", googleClientSecret: "own-secret" }); + }); +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cd7612d98..8bf47c97c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -34,11 +34,11 @@ 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: process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", ws: true }, + "/plugins": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", + "/healthz": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", + "/readyz": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910", + "/version": process.env.SPACE_API_ORIGIN ?? "http://127.0.0.1:4910" } }, test: { diff --git a/package-lock.json b/package-lock.json index bc825c1cf..26a51c037 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,24 +104,6 @@ "node": "^18 || >=20" } }, - "apps/api/node_modules/sanitize-html": { - "version": "2.17.7", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz", - "integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==", - "license": "MIT", - "dependencies": { - "deepmerge": "^4.2.2", - "escape-string-regexp": "^4.0.0", - "htmlparser2": "^12.0.0", - "is-plain-object": "^5.0.0", - "launder": "^1.7.1", - "parse-srcset": "^1.0.2", - "postcss": "^8.3.11" - }, - "engines": { - "node": ">=22.12.0" - } - }, "apps/web": { "name": "@space/web", "version": "0.2.5", @@ -5567,9 +5549,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.21", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", - "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/debug": { @@ -5616,15 +5598,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6945,9 +6918,9 @@ } }, "node_modules/hono": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", - "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -7271,9 +7244,9 @@ } }, "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7502,12 +7475,12 @@ } }, "node_modules/launder": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", - "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz", + "integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==", "license": "MIT", "dependencies": { - "dayjs": "^1.11.7" + "dayjs": "^1.11.23" } }, "node_modules/levn": { @@ -8880,9 +8853,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -9342,6 +9315,7 @@ "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -9946,6 +9920,51 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz", + "integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^12.0.0", + "is-plain-object": "^5.1.0", + "launder": "^1.7.2", + "parse-srcset": "^1.0.2", + "postcss": "^8.5.27" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/sanitize-html/node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -11001,9 +11020,9 @@ } }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 207743196..3614e8871 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,9 @@ "fast-json-stringify@7": { "fast-uri": "4.2.2" }, - "sanitize-html": "2.17.7", + "sanitize-html": "2.18.1", "@fastify/busboy": "3.2.2", - "hono": "4.13.7", + "hono": "4.13.13", "qs": "6.16.0", "proxy-addr": "2.0.8", "http-cache-semantics": "4.3.0", @@ -43,6 +43,8 @@ "@napi-rs/canvas": "1.0.9" }, "scripts": { + "dev:stack": "node scripts/dev-stack.mjs", + "smoke:dev-stack": "node scripts/dev-stack-smoke.mjs", "dev:api": "npm run dev -w @space/api", "dev:web": "npm run dev -w @space/web", "dev:worker": "npm run dev -w @space/worker", diff --git a/packages/run-spaceapp/tests/codex-sessions-mount.test.mjs b/packages/run-spaceapp/tests/codex-sessions-mount.test.mjs index 82c831f12..1a3c1bd1e 100644 --- a/packages/run-spaceapp/tests/codex-sessions-mount.test.mjs +++ b/packages/run-spaceapp/tests/codex-sessions-mount.test.mjs @@ -4,7 +4,8 @@ import { readFile } from "node:fs/promises"; // 1.1.7 (Nous decision 2026-10-18): the API reads Codex's session logs to see a Codex pane's turns. It gets only // Codex's sessions/ (its own volume, read-only), never the Codex home, auth.json or any other credential. -const template = await readFile(new URL("../templates/compose.yml", import.meta.url), "utf8"); +// Git for Windows checks out CRLF; regex checks below operate on LF-normalized YAML. +const template = (await readFile(new URL("../templates/compose.yml", import.meta.url), "utf8")).replace(/\r\n/g, "\n"); const credentialInstall = await readFile(new URL("../../../deploy/docker/credential-install.mjs", import.meta.url), "utf8"); const service = (name) => template.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z][a-z-]*:\\n|^[a-z])`, "m"))?.[1] ?? ""; const mounts = (block) => [...(block.match(/^ volumes:\n((?: (?:- |#).*\n)+)/m)?.[1] ?? "").matchAll(/^ - (\S+)\s*$/gm)].map((match) => match[1]); diff --git a/scripts/dev-stack-smoke.mjs b/scripts/dev-stack-smoke.mjs new file mode 100644 index 000000000..eda95f606 --- /dev/null +++ b/scripts/dev-stack-smoke.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +// Real Docker/Postgres/Temporal/API/PTY acceptance. Never falls back to a mock. +import assert from "node:assert/strict"; +import { spawn, execFileSync } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; +import { once } from "node:events"; +import { createInterface } from "node:readline"; +import WebSocket from "ws"; +import { configuration } from "./dev-stack/config.mjs"; +import { redact } from "./dev-stack/processes.mjs"; + +const root = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..")); +const config = configuration(root); +const npmCli = process.env.npm_execpath; +if (!npmCli) throw new Error("Run npm run smoke:dev-stack."); +try { + await readFile(join(config.state, "owner.json")); + throw new Error("smoke:dev-stack requires a fresh checkout with no .dev-stack state; it creates and destroys its own data."); +} catch (error) { if (error.code !== "ENOENT") throw error; } + +const api = `http://127.0.0.1:${config.ports.api}`; +let cookie = ""; +let csrf = {}; +let socket; +let output = ""; +let childExit; +let stopping = false; +const child = spawn(process.execPath, [npmCli, "run", "dev:stack"], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); +const ended = new Promise(resolve => child.on("exit", (code) => { childExit = code; resolve(); })); +child.stdout.on("data", data => { output = (output + data).slice(-2_000_000); }); +child.stderr.on("data", data => { output = (output + data).slice(-2_000_000); }); +for (const stream of [child.stdout, child.stderr]) { + createInterface({ input: stream }).on("line", line => { + // Forward only fixed launcher milestones; raw service logs remain buffered/redacted. + if (line.startsWith("[dev:stack] ")) console.log(line); + }); +} +child.on("error", error => { output += error.message; childExit = 1; }); + +async function until(check, description, timeout = 90_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (childExit !== undefined && !stopping) throw new Error(`Stack exited (${childExit}) while waiting for ${description}.`); + if (await check()) return; + await delay(500); + } + throw new Error(`Timed out waiting for ${description}.`); +} + +async function request(path, body, anonymous = false) { + const response = await fetch(`${api}${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { + "content-type": "application/json", origin: api, + ...(!anonymous && cookie ? { cookie, ...csrf } : {}) + }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30_000) + }); + if (!response.ok) throw new Error(`${path} returned ${response.status}: ${(await response.text()).slice(0, 500)}`); + const setCookie = response.headers.getSetCookie(); + if (setCookie.length) cookie = setCookie.map(c => c.split(";")[0]).filter(c => !c.endsWith("=")).join("; "); + return response.json(); +} + +function docker(args) { + return execFileSync("docker", args, { cwd: root, encoding: "utf8", timeout: 60_000 }).trim(); +} + +let secrets = []; +let failure; +try { + await until(async () => { + try { const r = await fetch(`http://127.0.0.1:${config.ports.web}/readyz`, { signal: AbortSignal.timeout(2000) }); return r.ok && (await r.json()).ok === true; } + catch { return false; } + }, "real web/API/database/worker readiness", 15 * 60_000); + const readiness = await request("/readyz", undefined, true); + assert.equal(readiness.dependencies.database, "RUNNING"); + assert.equal(readiness.dependencies.worker, "RUNNING"); + assert.equal(readiness.dependencies.temporal, "enabled"); + const setupToken = await readFile(join(config.state, "secrets", "setup-token"), "utf8"); + secrets = await Promise.all(["setup-token", "session-secret", "postgres-password", "control-token"].map(n => readFile(join(config.state, "secrets", n), "utf8"))); + assert.equal((await request("/api/setup/status", undefined, true)).setupRequired, true); + const credentials = { email: "dev-stack@example.invalid", password: randomBytes(24).toString("hex") }; + secrets.push(credentials.password); + const owner = await request("/api/setup/claim", { token: setupToken, ...credentials }, true); + assert.equal(owner.isAuthenticated, true); + assert.notEqual(owner.user.id, "user:dev-operator"); + assert.equal((await request("/api/auth/login", credentials, true)).isAuthenticated, true); + const csrfInfo = await request("/api/auth/csrf"); + csrf = { [csrfInfo.headerName]: csrfInfo.csrfToken }; + const starter = await request("/api/setup/starter-room", {}); + assert.equal(starter.room.name, "Getting Started"); + const pane = await request("/api/panes", { roomId: starter.room.id, title: "OpenCode acceptance", mode: "TERMINAL", terminalRuntimeId: "cli:opencode" }); + const session = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`, { runtimeId: "cli:opencode" }); + assert.ok(session.websocket?.token); + const query = new URLSearchParams({ sessionId: session.session.sessionId, token: session.websocket.token, clientId: randomUUID(), initialCols: "100", initialRows: "30" }); + socket = new WebSocket(`${api.replace("http:", "ws:")}/api/panes/${encodeURIComponent(pane.id)}/cli/terminal?${query}`, { headers: { cookie, origin: api } }); + let terminal = ""; + let terminalError; + socket.on("message", data => { + try { + const message = JSON.parse(data.toString()); + if (message.type === "output") terminal = (terminal + message.data).slice(-1_000_000); + if (message.type === "error") terminalError = new Error(message.message); + } catch (error) { terminalError = error; } + }); + socket.on("error", error => { terminalError = error; }); + await once(socket, "open", { signal: AbortSignal.timeout(30_000) }); + await until(async () => { + if (terminalError) throw terminalError; + const current = await request(`/api/panes/${encodeURIComponent(pane.id)}/cli/session`); + if (["EXITED", "ERROR"].includes(current?.session?.status)) throw new Error("OpenCode terminated before TUI acceptance."); + return current?.session?.status === "RUNNING" && /opencode/i.test(terminal); + }, "a RUNNING OpenCode PTY with TUI output"); + console.log("PASS: normal owner claim/login, Getting Started room, real OpenCode terminal."); + const pids = JSON.parse(await readFile(join(config.state, "processes.json"), "utf8")); + socket.close(); socket = undefined; + stopping = true; + // reset must stop a running supervisor, not merely delete its data. + execFileSync(process.execPath, [npmCli, "run", "dev:stack", "--", "reset"], { cwd: root, stdio: "pipe", timeout: 90_000 }); + await until(async () => childExit !== undefined, "supervisor exit after reset", 10_000); + assert.equal(childExit, 0); + for (const { name, pid } of pids) { + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }, `${name} leaked its process`); + assert.throws(() => process.kill(-pid, 0), { code: "ESRCH" }, `${name} leaked a child process group`); + } + const filter = `label=com.docker.compose.project=${config.project}`; + assert.equal(docker(["ps", "--all", "--quiet", "--filter", filter]), ""); + assert.equal(docker(["volume", "ls", "--quiet", "--filter", filter]), ""); + console.log("PASS: reset removed every dev-stack container, volume and source process."); +} catch (error) { + // Setup tokens are deliberately printed by the interactive launcher. Never echo them in CI. + console.error(redact(output, secrets).replace(/One-time owner setup token: \S+/g, "One-time owner setup token: [REDACTED]")); + failure = error; +} finally { + socket?.terminate(); + stopping = true; + // A failed launcher can already have exited while retaining its Docker volume + // and private state. Cleanup must not depend on the launcher still being alive. + const stateExists = await lstat(config.state).then(() => true, error => { + if (error.code === "ENOENT") return false; + throw error; + }); + if (stateExists) { + try { execFileSync(process.execPath, [npmCli, "run", "dev:stack", "--", "reset"], { cwd: root, stdio: "pipe", timeout: 90_000 }); } + catch { + if (childExit === undefined) child.kill("SIGTERM"); + const cleanupError = new Error("Dev-stack cleanup failed; inspect this checkout's .dev-stack state and Docker resources."); + console.error(cleanupError.message); + failure ??= cleanupError; + } + } + if (childExit === undefined) { + if (!stateExists) child.kill("SIGTERM"); + await Promise.race([ended, delay(10_000)]); + } +} +if (failure) throw failure; diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs new file mode 100644 index 000000000..50c551580 --- /dev/null +++ b/scripts/dev-stack.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +import { createServer, createConnection } from "node:net"; +import { chmod, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { once } from "node:events"; +import { assertOwnedState, composeArgs, composeDocument, configuration, openCodePackage, prepareState, sourceEnvironment } from "./dev-stack/config.mjs"; +import { Processes, ensureMacNodePtySpawnHelper, redact, startWeb, waitForReady } from "./dev-stack/processes.mjs"; + +const root = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..")); +const config = configuration(root); +let command = process.argv[2] ?? "up"; +if (!["up", "down", "reset", "rebuild"].includes(command) || process.argv.length > 3) { + throw new Error("Usage: npm run dev:stack -- [up|down|reset|rebuild]"); +} +if (process.platform === "win32") throw new Error("Use WSL2; the source stack requires Unix sockets and process groups."); +if (Number(process.versions.node.split(".")[0]) !== 22 || Number(process.versions.node.split(".")[1]) < 12) { + throw new Error("Use the Node 22 version from .nvmrc (>=22.12 <23)."); +} + +const packages = ["contracts", "icons", "streaming", "opencode-control", "memory-graph", "browser-host", "cli-host", "codex-app-server", "runtime", "db", "gateway"]; +const npmCli = process.env.npm_execpath; +if (!npmCli) throw new Error("Start with npm run dev:stack, so the same npm installation is used for builds."); +const npmRun = (processes, name, args, options) => processes.run(name, process.execPath, [npmCli, ...args], options); + +async function build(processes) { + for (const name of packages) await npmRun(processes, `build:${name}`, ["run", "build", "-w", `@space/${name}`], { cwd: root }); + // The worker's Temporal bundle requires emitted .js files, even in dev. + await npmRun(processes, "build:worker", ["run", "build", "-w", "@space/worker"], { cwd: root }); + await npmRun(processes, "build:api", ["run", "build", "-w", "@space/api"], { cwd: root }); +} + +async function requestDown() { + await assertOwnedState(config); + const token = await readFile(join(config.state, "secrets", "control-token"), "utf8"); + return new Promise((resolve, reject) => { + const socket = createConnection(config.control); + let response = ""; + socket.setTimeout(60_000, () => socket.destroy(new Error("Dev-stack shutdown timed out."))); + socket.on("connect", () => socket.end(`${token}\n`)); + socket.on("data", (data) => { response += data; }); + socket.on("end", () => response.trim() === "stopped" ? resolve(true) : reject(new Error("Dev-stack shutdown was not acknowledged."))); + socket.on("error", (error) => { + if (["ENOENT", "ECONNREFUSED"].includes(error.code)) resolve(false); + else reject(error); + }); + }); +} + +async function removeStaleControlSocket() { + // Called only while holding this checkout's lock. Never unlink a live server. + const stale = await new Promise((resolve, reject) => { + const socket = createConnection(config.control); + socket.setTimeout(2000, () => socket.destroy(new Error("Cannot determine whether the control socket is in use."))); + socket.on("connect", () => { socket.destroy(); reject(new Error("A dev-stack control server is already running.")); }); + socket.on("error", error => { + if (error.code === "ENOENT") resolve(false); + else if (error.code === "ECONNREFUSED") resolve(true); + else reject(error); + }); + }); + if (stale) await rm(config.control, { force: true }); +} + +async function prepareOpenCode(processes) { + const spec = openCodePackage(await readFile(join(root, "Dockerfile"), "utf8")); + const vendor = join(config.state, "opencode"); + const installed = await readFile(join(vendor, "installed-spec"), "utf8").catch(() => ""); + if (installed !== spec) { + await npmRun(processes, "install:opencode", ["install", "--prefix", vendor, "--no-audit", "--no-fund", "--save-exact", spec], { cwd: root }); + await writeFile(join(vendor, "installed-spec"), spec); + } + const wrapper = `#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +const home = ${JSON.stringify(join(config.state, "providers", "opencode"))}; +const binary = ${JSON.stringify(join(vendor, "node_modules", ".bin", "opencode"))}; +const args = process.argv.slice(2); +if (args[0] === '--run-inner') args.shift(); +if (args[0] === 'credential-status') { console.log('READY'); process.exit(0); } +if (args[0] === 'credential-observation') { console.log('OBSERVATION:dev-stack-opencode'); process.exit(0); } +if (args[0] === 'credential-smoke') { console.error('Provider requests are not part of account-free TUI acceptance.'); process.exit(1); } +const env = { ...process.env, HOME: home, TMPDIR: join(home,'tmp'), OPENCODE_DISABLE_AUTOUPDATE: 'true' }; +env.TMP = env.TEMP = env.TMPDIR; +delete env.DBUS_SESSION_BUS_ADDRESS; +delete env.XDG_RUNTIME_DIR; +for (const [key, dir] of Object.entries({ XDG_CONFIG_HOME:'config', XDG_DATA_HOME:'data', XDG_CACHE_HOME:'cache', XDG_STATE_HOME:'state' })) { + env[key] = join(home,dir); mkdirSync(env[key], { recursive:true, mode:0o700 }); +} +mkdirSync(env.TMPDIR, { recursive:true, mode:0o700 }); +if (!args.some(arg => ['--help','-h','--version','-v'].includes(arg))) args.push('--standalone'); +const child = spawn(binary,args,{ env, stdio:'inherit' }); +child.on('error', () => process.exit(1)); +child.on('exit', (code) => process.exit(code ?? 1)); +`; + await writeFile(join(config.state, "bin", "opencode-vscode-parity"), wrapper, { mode: 0o700 }); +} + +async function main() { + if (command === "rebuild") { + await requestDown(); + command = "up"; // One command rebuilds and restarts every source service. + } + if (command === "down" || command === "reset") { + try { await assertOwnedState(config); } catch (error) { if (error.code === "ENOENT") { console.log("No dev stack state."); return; } throw error; } + await requestDown(); + // Never touch a live or stale process lock without a successful shutdown. + try { await readFile(join(config.state, "lock", "pid")); throw new Error("A startup or stale lock remains; inspect it before cleanup."); } + catch (error) { if (error.code !== "ENOENT") throw error; } + const processes = new Processes(); + try { + await processes.run("docker:down", "docker", composeArgs(config, "down", "--remove-orphans", ...(command === "reset" ? ["--volumes"] : [])), { cwd: config.state }); + await removeStaleControlSocket(); + await rm(config.hostSocket, { force: true }); + if (command === "reset") { await assertOwnedState(config); await rm(config.state, { recursive: true }); } + } finally { await processes.stop(); } + return; + } + + // Fail before writing state when Docker is absent or its daemon is unreachable. + const preflight = new Processes(); + try { + await preflight.run("docker:preflight", "docker", ["compose", "version"], { cwd: root }); + await preflight.run("docker:daemon", "docker", ["info", "--format", "{{.ServerVersion}}"], { cwd: root }); + } finally { await preflight.stop(); } + const secrets = await prepareState(config); + const lock = join(config.state, "lock"); + await mkdir(lock); // An atomic lock prevents two invocations sharing the same state/ports. + await writeFile(join(lock, "pid"), String(process.pid), { mode: 0o600 }); + const processes = new Processes({ secrets: Object.values(secrets) }); + const controller = new AbortController(); + const signal = AbortSignal.any([controller.signal, processes.abort.signal]); + const clients = new Set(); + const control = createServer({ allowHalfOpen: true }, (socket) => { + clients.add(socket); + socket.setTimeout(3000, () => socket.destroy()); + let input = ""; + socket.on("data", (data) => { + input += data; + if (input.length > 128) socket.destroy(); + if (input.includes("\n")) { + if (input.trim() !== secrets["control-token"]) { socket.end("denied\n"); return; } + socket.setTimeout(0); + controller.abort(new Error("Requested shutdown.")); + } + }); + socket.on("error", () => {}); + socket.on("close", () => clients.delete(socket)); + }); + let composeStarted = false; + let hostStarted = false; + let controlListening = false; + let stopPromise; + signal.addEventListener("abort", () => { stopPromise ??= processes.stop(); }, { once: true }); + const stop = () => controller.abort(new Error("Requested shutdown.")); + process.once("SIGINT", stop); process.once("SIGTERM", stop); + let cleanupError; + try { + await removeStaleControlSocket(); + control.listen(config.control); + await once(control, "listening"); + controlListening = true; + await chmod(config.control, 0o600); + const template = await readFile(join(root, "packages/run-spaceapp/templates/compose.yml"), "utf8"); + await writeFile(join(config.state, "compose.json"), JSON.stringify(composeDocument(config, template), null, 2), { mode: 0o600 }); + console.log("[dev:stack] Building source packages and installing pinned OpenCode."); + await build(processes); + await prepareOpenCode(processes); + await ensureMacNodePtySpawnHelper(root); + signal.throwIfAborted(); + composeStarted = true; + console.log("[dev:stack] Starting PostgreSQL and Temporal."); + await processes.run("docker:up", "docker", composeArgs(config, "up", "--detach", "--wait", "--wait-timeout", "240"), { cwd: config.state }); + await processes.run("temporal:namespace", "docker", composeArgs(config, "exec", "-T", "temporal", "sh", "-ec", + "temporal operator namespace describe --namespace spaceapp-dev --address temporal:7233 >/dev/null 2>&1 || temporal operator namespace create --namespace spaceapp-dev --address temporal:7233"), { cwd: config.state }); + console.log("[dev:stack] Applying migrations and starting source services."); + const env = sourceEnvironment(config, secrets); + await mkdir(env.TMPDIR, { recursive: true, mode: 0o700 }); + await processes.run("migrate", process.execPath, ["packages/db/dist/migrate.js"], { cwd: root, env }); + signal.throwIfAborted(); + processes.start("cli-host", process.execPath, ["packages/cli-host/dist/main.js"], { cwd: root, env }, true); + hostStarted = true; + processes.start("worker", process.execPath, ["apps/worker/dist/worker.js"], { cwd: root, env }, true); + processes.start("api", process.execPath, ["node_modules/tsx/dist/cli.mjs", "watch", "apps/api/src/server.ts"], { cwd: root, env }, true); + startWeb(processes, root, env); + await writeFile(join(config.state, "processes.json"), JSON.stringify([...processes.children].filter(r => !r.exited).map(r => ({ name: r.name, pid: r.child.pid }))), { mode: 0o600 }); + console.log("[dev:stack] Waiting for database, worker and CLI-host readiness."); + await waitForReady(`http://127.0.0.1:${config.ports.web}/readyz`, signal); + console.log(`\nSpaceApp: http://127.0.0.1:${config.ports.web}\nOne-time owner setup token: ${secrets["setup-token"]}\nOptional browser/companion services are disabled. Ctrl+C or npm run dev:stack -- down stops this stack.`); + await new Promise((resolve) => { if (signal.aborted) resolve(); else signal.addEventListener("abort", resolve, { once: true }); }); + if (processes.abort.signal.aborted) throw processes.abort.signal.reason; + } finally { + await stopPromise; + await processes.stop(); + if (composeStarted) { + const cleanup = new Processes({ secrets: Object.values(secrets) }); + if (!controller.signal.aborted) { + // Preserve useful startup diagnostics before removing failed containers. + await cleanup.run("docker:diagnostics", "docker", composeArgs(config, "logs", "--no-color", "--tail", "100"), { cwd: config.state }).catch(() => {}); + } + try { await cleanup.run("docker:down", "docker", composeArgs(config, "down", "--remove-orphans"), { cwd: config.state }); } + catch (error) { cleanupError = error; } + finally { await cleanup.stop(); } + } + if (controlListening) { control.close(); await rm(config.control, { force: true }); } + if (hostStarted) await rm(config.hostSocket, { force: true }); + await rm(lock, { recursive: true, force: true }); + await rm(join(config.state, "processes.json"), { force: true }); + for (const socket of clients) socket.end(cleanupError ? "cleanup failed\n" : "stopped\n"); + process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop); + } + if (cleanupError) throw cleanupError; +} + +main().catch((error) => { + console.error(`dev:stack failed: ${redact(error.message, [])}`); + process.exitCode = 1; +}); diff --git a/scripts/dev-stack/config.mjs b/scripts/dev-stack/config.mjs new file mode 100644 index 000000000..99845e6f0 --- /dev/null +++ b/scripts/dev-stack/config.mjs @@ -0,0 +1,164 @@ +import { createHash, randomBytes } from "node:crypto"; +import { chmod, lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +export function port(value, fallback) { + const result = value === undefined ? fallback : Number(value); + if (!Number.isInteger(result) || result < 1024 || result > 65535 || [4910, 4911].includes(result)) { + throw new Error("Dev-stack ports must be integers in 1024..65535, excluding installed ports 4910/4911."); + } + return result; +} + +export function configuration(root, env = process.env) { + const ports = { + api: port(env.SPACE_DEV_API_PORT, 5910), web: port(env.SPACE_DEV_WEB_PORT, 5911), + postgres: port(env.SPACE_DEV_POSTGRES_PORT, 55432), temporal: port(env.SPACE_DEV_TEMPORAL_PORT, 57233) + }; + if (new Set(Object.values(ports)).size !== 4) throw new Error("Dev-stack ports must be distinct."); + const id = createHash("sha256").update(root).digest("hex").slice(0, 16); + return { + root, state: join(root, ".dev-stack"), project: `spaceapp-dev-${id}`, ports, + control: join(tmpdir(), `spaceapp-dev-${id}.sock`), + hostSocket: join(tmpdir(), `spaceapp-cli-dev-${id}.sock`) + }; +} + +export async function assertOwnedState(config) { + const info = await lstat(config.state); + if (!info.isDirectory() || info.isSymbolicLink() || await realpath(config.state) !== config.state) { + throw new Error("Refusing non-directory or symlink dev-stack state."); + } + const marker = JSON.parse(await readFile(join(config.state, "owner.json"), "utf8")); + if (marker.root !== config.root || marker.project !== config.project || marker.version !== 1) { + throw new Error("State does not belong to this checkout; refusing cleanup."); + } +} + +export async function prepareState(config) { + try { await lstat(config.state); await assertOwnedState(config); } + catch (error) { + if (error.code !== "ENOENT") throw error; + // An existing directory without a marker is never adopted. + await mkdir(config.state, { mode: 0o700 }); + await writeFile(join(config.state, "owner.json"), JSON.stringify({ + version: 1, root: config.root, project: config.project + }), { flag: "wx", mode: 0o600 }); + } + await chmod(config.state, 0o700); + for (const dir of ["secrets", "memory", "memory-graph", "artifacts", "workspaces", "providers", "bin", "logs"]) { + const path = join(config.state, dir); + await mkdir(path, { recursive: true, mode: 0o700 }); + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Invalid dev-stack directory: ${dir}`); + await chmod(path, 0o700); + } + const secrets = {}; + for (const name of ["postgres-password", "session-secret", "setup-token", "control-token"]) { + const path = join(config.state, "secrets", name); + try { await writeFile(path, randomBytes(32).toString("hex"), { flag: "wx", mode: 0o600 }); } + catch (error) { if (error.code !== "EEXIST") throw error; } + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Invalid secret file: ${name}`); + await chmod(path, 0o600); + secrets[name] = await readFile(path, "utf8"); + if (!/^[a-f0-9]{64}$/.test(secrets[name])) throw new Error(`Invalid secret format: ${name}`); + } + for (const name of ["space_memory.md", "space_memory_monthly.md", "installation-guide.md"]) { + const destination = join(config.state, "memory", name); + try { await writeFile(destination, await readFile(join(config.root, "starter-memory", name)), { flag: "wx", mode: 0o600 }); } + catch (error) { if (error.code !== "EEXIST") throw error; } + } + return secrets; +} + +export function pinnedImage(template, service) { + const section = template.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-zA-Z]|^networks:|$(?![\\s\\S]))`, "m")); + const image = section?.[1].match(/^ {4}image: ([^\s]+)$/m)?.[1]; + if (!image || !/@sha256:[a-f0-9]{64}$/.test(image)) throw new Error(`Missing pinned ${service} image in launcher template.`); + return image; +} + +export function composeDocument(config, template) { + return { + services: { + postgres: { + image: pinnedImage(template, "postgres"), + environment: { POSTGRES_DB: "spaceapp_dev", POSTGRES_USER: "spaceapp", POSTGRES_PASSWORD_FILE: "/run/secrets/postgres-password" }, + ports: [`127.0.0.1:${config.ports.postgres}:5432`], + secrets: ["postgres-password"], volumes: ["database:/var/lib/postgresql/data"], + healthcheck: { test: ["CMD-SHELL", "pg_isready -U spaceapp -d spaceapp_dev"], interval: "2s", timeout: "5s", retries: 60 } + }, + temporal: { + image: pinnedImage(template, "temporal"), + // Compose mounts host secret files without remapping their 0600 ownership. + // The image defaults to UID 1000, which cannot read those files on Linux. + user: "0:0", + environment: { DB: "postgres12", DB_PORT: "5432", POSTGRES_USER: "spaceapp", POSTGRES_SEEDS: "postgres", DYNAMIC_CONFIG_FILE_PATH: "config/dynamicconfig/docker.yaml" }, + entrypoint: ["/bin/sh", "-ec"], + command: ['export POSTGRES_PWD="$$(cat /run/secrets/postgres-password)"; exec /etc/temporal/entrypoint.sh autosetup'], + ports: [`127.0.0.1:${config.ports.temporal}:7233`], + secrets: ["postgres-password"], depends_on: { postgres: { condition: "service_healthy" } }, + healthcheck: { + test: ["CMD-SHELL", "temporal operator cluster health --address temporal:7233 >/dev/null && temporal operator namespace describe --namespace default --address temporal:7233 >/dev/null"], + interval: "3s", timeout: "10s", retries: 60 + } + } + }, + secrets: { "postgres-password": { file: "./secrets/postgres-password" } }, + volumes: { database: {} } + }; +} + +export function sourceEnvironment(config, secrets, inherited = process.env) { + // Do not inherit operator SPACE_* configuration, provider credentials, NODE_OPTIONS or homes. + const env = {}; + for (const key of ["PATH", "LANG", "LC_ALL", "TERM", "TZ", "SYSTEMROOT", "COMSPEC"]) { + if (inherited[key]) env[key] = inherited[key]; + } + const path = (...parts) => join(config.state, ...parts); + return Object.assign(env, { + HOME: path("providers"), TMPDIR: path("providers", "tmp"), NODE_ENV: "development", + SPACE_INSTANCE: "dev", SPACE_INSTANCE_ENABLED_JOBS: "", SPACE_PUBLIC_DISTRIBUTION: "true", + SPACE_DEV_LOGIN: "false", SPACE_SESSION_SECRET: secrets["session-secret"], + SPACE_GOOGLE_AUTH_DISABLED: "true", SPACE_T3CODE_ENABLED: "false", + SPACE_SETUP_TOKEN_FILE: path("secrets", "setup-token"), + SPACE_RUNTIME_STORE: "postgres", SPACE_DATABASE_URL: `postgresql://spaceapp:${secrets["postgres-password"]}@127.0.0.1:${config.ports.postgres}/spaceapp_dev`, + SPACE_API_HOST: "127.0.0.1", SPACE_API_PORT: String(config.ports.api), + SPACE_API_ORIGIN: `http://127.0.0.1:${config.ports.api}`, + SPACE_BROWSER_EVIDENCE_TARGET_ORIGIN: `http://127.0.0.1:${config.ports.web}`, + SPACE_WEB_HOST: "127.0.0.1", SPACE_WEB_PORT: String(config.ports.web), + SPACE_TEMPORAL_ADDRESS: `127.0.0.1:${config.ports.temporal}`, SPACE_TEMPORAL_NAMESPACE: "spaceapp-dev", + SPACE_TEMPORAL_TASK_QUEUE: config.project, SPACEAPP_WORKFLOWS_ENABLED: "true", + SPACE_ENABLE_DUMMY_TURNS: "true", + SPACE_INSTALL_ROOT: config.root, SPACE_MEMORY_ROOT: path("memory"), + SPACE_MEMORY_INDEX_PATH: path("memory", "space_memory.md"), SPACE_MEMORY_MONTHLY_PATH: path("memory", "space_memory_monthly.md"), + SPACE_MEMORY_LOCK_PATH: path("memory", ".memory.lock"), SPACE_MEMORY_GRAPH_ROOT: path("memory-graph"), + SPACE_ARTIFACT_ROOT: path("artifacts"), SPACE_PROJECTS_ROOT: path("workspaces"), + SPACE_CLI_ENABLED: "true", SPACE_CLI_ROOT_ENABLED: "false", SPACE_CLI_COMMAND_PATH: path("bin"), + SPACE_CLI_WORKSPACE_ROOT: path("workspaces"), SPACE_CLI_HOST_SOCKET: config.hostSocket, + SPACE_CODEX_HOME: path("providers", "codex"), SPACE_CODEX_APP_SERVER_HOME: path("providers", "codex"), + SPACE_OPENCODE_STATE_ROOT: path("providers", "opencode"), + SPACE_CODEX_GOALS_DB_PATH: path("codex-goals.sqlite"), + SPACE_CODEX_CLI_MODE_DEFAULTS_PROJECTION_PATH: path("codex-mode-defaults.json"), + SPACE_TELEGRAM_SECRET_ROOT: path("providers", "telegram"), SPACE_STREAMING_SECRET_ROOT: path("providers", "streaming"), + SPACE_TELEGRAM_OWNERSHIP_MANIFEST: path("telegram-ownership.json"), + SPACE_MAINTENANCE_AUTOPILOT_STATE_FILE: path("maintenance.json"), SPACE_APP_DIAGNOSTICS_ROOT: path("diagnostics"), + SPACE_CONNECTOR_KEY_FILE: path("secrets", "connector.key"), SPACE_DOT_WORKSPACE_ROOT: path("workspaces", "dots"), + SPACE_DESIGN_GATEWAY_KEY_FILE: path("secrets", "design.key"), + SPACE_CHESS_MOVE_SOCKET: "disabled", SPACE_BROWSER_SESSIONS_ENABLED: "false", SPACE_BROWSER_EVIDENCE_ENABLED: "false", + SPACE_DEMO_PROJECTS_ENABLED: "false", SPACE_FILES_HOST_MODE: "disabled", SPACE_AGENT_TOOLS_WRITER: "disabled", + SPACE_ROOM_AGENT_AUTOPILOT_ENABLED: "false", SPACE_ROOM_AGENT_MONITOR_ENABLED: "false" + }); +} + +export function openCodePackage(dockerfile) { + const match = dockerfile.match(/(?:@opencode\/cli|opencode-ai)@\d+\.\d+\.\d+(?:[-\w.]*)?/); + if (!match) throw new Error("No pinned OpenCode package found in Dockerfile."); + return match[0]; +} + +export function composeArgs(config, ...args) { + return ["compose", "--project-name", config.project, "--file", resolve(config.state, "compose.json"), ...args]; +} diff --git a/scripts/dev-stack/processes.mjs b/scripts/dev-stack/processes.mjs new file mode 100644 index 000000000..8f0d5ddf4 --- /dev/null +++ b/scripts/dev-stack/processes.mjs @@ -0,0 +1,109 @@ +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { setTimeout as delay } from "node:timers/promises"; +import { join } from "node:path"; +import { chmod, lstat } from "node:fs/promises"; + +// node-pty 1.1.0 may install macOS spawn-helper without its execute bit +// (microsoft/node-pty#850). Repair the local dependency, not system files. +export async function ensureMacNodePtySpawnHelper(root, platform = process.platform, arch = process.arch) { + if (platform !== "darwin") return false; + const helper = join(root, "node_modules", "node-pty", "prebuilds", `darwin-${arch}`, "spawn-helper"); + const info = await lstat(helper); + if (!info.isFile() || info.isSymbolicLink()) { + throw new Error("Refusing invalid or symlinked node-pty spawn-helper."); + } + if ((info.mode & 0o111) !== 0) return false; + await chmod(helper, info.mode | 0o111); + return true; +} + +export function startWeb(processes, root, env) { + // npm workspaces hoists Vite to the checkout root, while its working + // directory must remain apps/web so it loads the web configuration. + return processes.start("web", process.execPath, + [join(root, "node_modules/vite/bin/vite.js"), "--strictPort"], + { cwd: join(root, "apps/web"), env }, true); +} + +export function redact(value, secrets) { + let result = String(value); + for (const secret of secrets) if (secret) result = result.split(secret).join("[REDACTED]"); + return result; +} + +// Every child owns a process group: watch-mode subprocesses and PTYs are stopped too. +export class Processes { + children = new Set(); + stopping = false; + stopPromise; + constructor({ secrets = [], output = (line) => process.stdout.write(`${line}\n`) } = {}) { + this.secrets = secrets; + this.output = output; + this.abort = new AbortController(); + } + start(name, command, args, options = {}, persistent = false) { + if (this.stopping) throw new Error("Dev-stack is stopping."); + const child = spawn(command, args, { ...options, detached: true, stdio: ["ignore", "pipe", "pipe"] }); + const record = { name, child, exited: false }; + this.children.add(record); + for (const stream of [child.stdout, child.stderr]) { + const lines = createInterface({ input: stream }); + lines.on("line", (line) => this.output(`[${name}] ${redact(line, this.secrets)}`)); + } + const done = new Promise((resolve, reject) => { + child.once("error", (error) => { record.exited = true; reject(error); }); + child.once("exit", (code, signal) => { + record.exited = true; + if (!persistent && code === 0) this.children.delete(record); + if (!this.stopping && (persistent || code !== 0)) { + const error = new Error(`${name} exited unexpectedly (${code ?? signal}).`); + this.abort.abort(error); + reject(error); + } else resolve(); + }); + }); + // Persistent failures also interrupt readiness waits; avoid unhandled rejections. + done.catch((error) => { if (!this.stopping) this.abort.abort(error); }); + return { child, done }; + } + async run(name, command, args, options) { + await this.start(name, command, args, options).done; + } + stop() { + this.stopPromise ??= this.stopAll(); + return this.stopPromise; + } + async stopAll() { + this.stopping = true; + for (const { child } of this.children) { + if (!child.pid) continue; + try { process.kill(-child.pid, "SIGTERM"); } catch (error) { if (error.code !== "ESRCH") throw error; } + } + const deadline = Date.now() + 5000; + while ([...this.children].some(({ exited }) => !exited) && Date.now() < deadline) await delay(50); + // A parent can exit while a watcher/PTY remains in its process group. + for (const { child } of this.children) { + if (!child.pid) continue; + try { process.kill(-child.pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; } + } + } +} + +export async function waitForReady(url, signal, timeoutMs = 180_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + signal.throwIfAborted(); + try { + const response = await fetch(url, { signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]) }); + if (response.ok) { + const status = await response.json(); + if (status.ok === true && status.dependencies?.database === "RUNNING" + && status.dependencies?.worker === "RUNNING" + && status.dependencies?.temporal === "enabled") return; + } + } catch (error) { if (signal.aborted) throw error; } + await delay(500, undefined, { signal }); + } + throw new Error(`Readiness timeout: ${url}`); +} diff --git a/scripts/public-export.mjs b/scripts/public-export.mjs index c7dbddb50..43a44420d 100644 --- a/scripts/public-export.mjs +++ b/scripts/public-export.mjs @@ -78,6 +78,12 @@ const publicDocs = new Set([ export const publicRepositoryMaintainedFiles = new Set(["docs/security-model.md"]); const publicScripts = new Set([ + "scripts/dev-stack.mjs", + "scripts/dev-stack-smoke.mjs", + "scripts/dev-stack/config.mjs", + "scripts/dev-stack/processes.mjs", + "scripts/tests/dev-stack.test.mjs", + "scripts/tests/dev-stack-vite.test.mjs", // Loaded by apps/api/src/cli-render-proof-fixture.ts (proof rooms); missing, that path failed (review F18). "scripts/cli-render-fixture.mjs", // Maintained here; listed so the CI re-export of this repository keeps them. diff --git a/scripts/tests/dev-stack-vite.test.mjs b/scripts/tests/dev-stack-vite.test.mjs new file mode 100644 index 000000000..a167e4ac3 --- /dev/null +++ b/scripts/tests/dev-stack-vite.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { test } from "node:test"; +import { createServer as createViteServer } from "vite"; +import WebSocket, { WebSocketServer } from "ws"; +import { setTimeout as delay } from "node:timers/promises"; +import { Processes, startWeb } from "../dev-stack/processes.mjs"; + +test("the source web server proxies readiness and a real terminal WebSocket to the selected API", async () => { + const api = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ ok: true, path: request.url })); + }); + const sockets = new WebSocketServer({ server: api }); + sockets.on("connection", socket => socket.on("message", data => socket.send(data.toString()))); + api.listen(0, "127.0.0.1"); await once(api, "listening"); + const previous = process.env.SPACE_API_ORIGIN; + process.env.SPACE_API_ORIGIN = `http://127.0.0.1:${api.address().port}`; + let web; + let terminal; + try { + web = await createViteServer({ + root: new URL("../../apps/web", import.meta.url).pathname, + configFile: new URL("../../apps/web/vite.config.ts", import.meta.url).pathname, + server: { host: "127.0.0.1", port: 0, watch: null }, logLevel: "error" + }); + await web.listen(); + const origin = `http://127.0.0.1:${web.httpServer.address().port}`; + assert.deepEqual(await (await fetch(`${origin}/readyz`)).json(), { ok: true, path: "/readyz" }); + terminal = new WebSocket(`${origin.replace("http:", "ws:")}/api/panes/test/cli/terminal`); + await once(terminal, "open", { signal: AbortSignal.timeout(10_000) }); + const received = once(terminal, "message", { signal: AbortSignal.timeout(10_000) }); + terminal.send("terminal transport"); + assert.equal((await received)[0].toString(), "terminal transport"); + } finally { + terminal?.terminate(); + for (const socket of sockets.clients) socket.terminate(); + await web?.close(); + await new Promise(resolve => sockets.close(resolve)); + await new Promise(resolve => api.close(resolve)); + if (previous === undefined) delete process.env.SPACE_API_ORIGIN; + else process.env.SPACE_API_ORIGIN = previous; + } +}); + +test("the launcher starts the hoisted Vite binary with apps/web as its working directory", async () => { + const api = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ ok: true, path: request.url })); + }); + api.listen(0, "127.0.0.1"); await once(api, "listening"); + const reservation = createServer(); + reservation.listen(0, "127.0.0.1"); await once(reservation, "listening"); + const port = reservation.address().port; + await new Promise(resolve => reservation.close(resolve)); + const lines = []; + const processes = new Processes({ output: line => lines.push(line) }); + try { + startWeb(processes, new URL("../../", import.meta.url).pathname, { + ...process.env, SPACE_WEB_HOST: "127.0.0.1", SPACE_WEB_PORT: String(port), + SPACE_API_ORIGIN: `http://127.0.0.1:${api.address().port}` + }); + const deadline = Date.now() + 15_000; + let result; + while (Date.now() < deadline) { + processes.abort.signal.throwIfAborted(); + try { + const response = await fetch(`http://127.0.0.1:${port}/readyz`, { signal: AbortSignal.timeout(500) }); + if (response.ok) { result = await response.json(); break; } + } catch { /* Vite is still starting. */ } + await delay(100); + } + assert.deepEqual(result, { ok: true, path: "/readyz" }, lines.join("\n")); + } finally { + await processes.stop(); + api.closeAllConnections(); + await new Promise(resolve => api.close(resolve)); + } +}); diff --git a/scripts/tests/dev-stack.test.mjs b/scripts/tests/dev-stack.test.mjs new file mode 100644 index 000000000..41a1dba51 --- /dev/null +++ b/scripts/tests/dev-stack.test.mjs @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { chmod, copyFile, mkdtemp, mkdir, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { configuration, prepareState, assertOwnedState, composeDocument, sourceEnvironment, openCodePackage } from "../dev-stack/config.mjs"; +import { Processes, ensureMacNodePtySpawnHelper, redact, waitForReady } from "../dev-stack/processes.mjs"; + +async function fixture(t) { + // macOS exposes /var through /private/var; match the launcher's canonical root. + const root = await realpath(await mkdtemp(join(tmpdir(), "space-dev-test-"))); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, "starter-memory")); + for (const name of ["space_memory.md", "space_memory_monthly.md", "installation-guide.md"]) await writeFile(join(root, "starter-memory", name), "starter"); + return configuration(root, {}); +} + +test("secrets persist across starts with private modes and the marker binds cleanup to its checkout", async (t) => { + const config = await fixture(t); + const first = await prepareState(config); + assert.deepEqual(await prepareState(config), first); + assert.equal((await stat(config.state)).mode & 0o777, 0o700); + for (const name of Object.keys(first)) assert.equal((await stat(join(config.state, "secrets", name))).mode & 0o777, 0o600); + await assertOwnedState(config); + await assert.rejects(assertOwnedState({ ...config, project: "operator-install" }), /does not belong/); +}); + +test("refuses an unmarked directory or a symlink without changing it", async (t) => { + const config = await fixture(t); + await mkdir(config.state); + await writeFile(join(config.state, "keep"), "important"); + await assert.rejects(prepareState(config)); + assert.equal(await readFile(join(config.state, "keep"), "utf8"), "important"); + await rm(config.state, { recursive: true }); + await symlink(join(config.root, "starter-memory"), config.state); + await assert.rejects(prepareState(config), /symlink/); +}); + +test("the dev compose uses the installer's exact image digests and exposes only loopback ports", async (t) => { + const config = await fixture(t); + const template = await readFile(new URL("../../packages/run-spaceapp/templates/compose.yml", import.meta.url), "utf8"); + const doc = composeDocument(config, template); + for (const service of Object.values(doc.services)) { + assert.ok(template.includes(`image: ${service.image}`)); + assert.match(service.image, /@sha256:[a-f0-9]{64}$/); + assert.ok(service.ports.every(p => p.startsWith("127.0.0.1:"))); + } + assert.ok(!JSON.stringify(doc).includes("password-value")); + assert.throws(() => composeDocument(config, template.replace(/@sha256:[a-f0-9]{64}/g, "")), /pinned/); +}); + +test("refuses redirected secret directories before writing outside checkout state", async (t) => { + const config = await fixture(t); + await prepareState(config); + await rm(join(config.state, "secrets"), { recursive: true }); + await symlink(join(config.root, "starter-memory"), join(config.state, "secrets")); + await assert.rejects(prepareState(config), /Invalid dev-stack directory/); + await assert.rejects(readFile(join(config.root, "starter-memory", "postgres-password")), { code: "ENOENT" }); +}); + +test("rejects installed ports, overlapping ports and invalid values before spawning anything", () => { + for (const value of ["4910", "4911", "0", "-1", "65536", "hello", "5910.5"]) { + assert.throws(() => configuration("/checkout", { SPACE_DEV_API_PORT: value })); + } + assert.throws(() => configuration("/checkout", { SPACE_DEV_API_PORT: "5911" }), /distinct/); +}); + +test("source services cannot inherit installed-instance paths, credentials or injected Node flags", async (t) => { + const config = await fixture(t); + const env = sourceEnvironment(config, await prepareState(config), { + PATH: "/usr/bin", SPACE_DATABASE_URL: "postgresql://operator/live", SPACE_CLI_HOST_SOCKET: "/run/live.sock", + OPENAI_API_KEY: "operator-key", NODE_OPTIONS: "--require operator-script", HOME: "/operator-home" + }); + assert.equal(env.SPACE_DEV_LOGIN, "false"); + assert.equal(env.SPACE_INSTANCE, "dev"); + assert.equal(env.SPACEAPP_WORKFLOWS_ENABLED, "true"); + assert.equal(env.OPENAI_API_KEY, undefined); + assert.equal(env.NODE_OPTIONS, undefined); + assert.ok(env.SPACE_DATABASE_URL.endsWith("/spaceapp_dev")); + assert.ok(env.HOME.startsWith(config.state)); + assert.equal(env.SPACE_CLI_HOST_SOCKET, config.hostSocket); +}); + +test("reads both generations of the pinned OpenCode package from Dockerfile", () => { + assert.equal(openCodePackage("npm install --global opencode-ai@1.18.4"), "opencode-ai@1.18.4"); + assert.equal(openCodePackage("npm install --global @opencode/cli@2.0.32"), "@opencode/cli@2.0.32"); + assert.throws(() => openCodePackage("npm install opencode-ai@latest"), /pinned/); +}); + +test("a failed worker aborts supervision and real child groups are terminated", async () => { + const lines = []; + const processes = new Processes({ output: line => lines.push(line), secrets: ["private-password"] }); + try { + const alive = processes.start("web", process.execPath, ["-e", "setInterval(()=>{},1000)"], {}, true); + const failure = processes.start("worker", process.execPath, ["-e", "console.error('private-password'); process.exit(7)"], {}, true); + await assert.rejects(failure.done, /worker exited unexpectedly \(7\)/); + assert.equal(processes.abort.signal.aborted, true); + assert.ok(lines.some(line => line.includes("[REDACTED]"))); + await processes.stop(); + await alive.done; + assert.throws(() => process.kill(alive.child.pid, 0), { code: "ESRCH" }); + } finally { await processes.stop(); } +}); + +test("readiness requires ok:true rather than any HTTP response and respects abort", async () => { + let status = { ok: false }; + const server = createServer((req, res) => { res.setHeader("content-type", "application/json"); res.end(JSON.stringify(status)); }); + server.listen(0, "127.0.0.1"); await once(server, "listening"); + try { + const url = `http://127.0.0.1:${server.address().port}/readyz`; + await assert.rejects(waitForReady(url, new AbortController().signal, 30), /timeout/); + for (const dependencies of [undefined, + { database: "RUNNING", worker: "disabled", temporal: "disabled" }, + { database: "RUNNING", worker: "STALE", temporal: "enabled" }, + { database: "UNAVAILABLE", worker: "RUNNING", temporal: "enabled" }]) { + status = { ok: true, dependencies }; + await assert.rejects(waitForReady(url, new AbortController().signal, 30), /timeout/); + } + status = { ok: true, dependencies: { database: "RUNNING", worker: "RUNNING", temporal: "enabled" } }; + await waitForReady(url, new AbortController().signal, 1000); + const abort = new AbortController(); abort.abort(new Error("worker died")); + await assert.rejects(waitForReady(url, abort.signal), /worker died/); + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); } + assert.equal(redact("url: secret-value", ["secret-value"]), "url: [REDACTED]"); +}); + +test("smoke cleanup resets resources even when the launcher has already failed", async (t) => { + const config = await fixture(t); + const scripts = join(config.root, "scripts"); + await mkdir(join(scripts, "dev-stack"), { recursive: true }); + for (const file of ["dev-stack-smoke.mjs", "dev-stack/config.mjs", "dev-stack/processes.mjs"]) { + await copyFile(new URL(`../${file}`, import.meta.url), join(scripts, file)); + } + await symlink(new URL("../../node_modules", import.meta.url).pathname, join(config.root, "node_modules")); + const fakeNpm = join(config.root, "fake-npm.mjs"); + await writeFile(fakeNpm, ` + import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; + if (process.argv.includes('reset')) { + rmSync('.dev-stack', { recursive: true, force: true }); + writeFileSync('reset-proof', 'cleaned'); + } else { + mkdirSync('.dev-stack'); + process.exitCode = 9; + } + `); + const processes = new Processes({ output: () => {} }); + try { + await assert.rejects(processes.run("smoke", process.execPath, [join(scripts, "dev-stack-smoke.mjs")], { + cwd: config.root, env: { ...process.env, npm_execpath: fakeNpm } + }), /exited unexpectedly \(1\)/); + assert.equal(await readFile(join(config.root, "reset-proof"), "utf8"), "cleaned"); + await assert.rejects(stat(config.state), { code: "ENOENT" }); + } finally { await processes.stop(); } +}); + + +test("macOS node-pty spawn-helper repairs only the checkout dependency execute bit", async (t) => { + const config = await fixture(t); + const dir = join(config.root, "node_modules", "node-pty", "prebuilds", "darwin-x64"); + const helper = join(dir, "spawn-helper"); + await mkdir(dir, { recursive: true }); + await writeFile(helper, "native helper fixture", { mode: 0o644 }); + await chmod(helper, 0o644); + assert.equal(await ensureMacNodePtySpawnHelper(config.root, "linux", "x64"), false); + assert.equal((await stat(helper)).mode & 0o111, 0); + assert.equal(await ensureMacNodePtySpawnHelper(config.root, "darwin", "x64"), true); + assert.equal((await stat(helper)).mode & 0o111, 0o111); + assert.equal(await ensureMacNodePtySpawnHelper(config.root, "darwin", "x64"), false); + await rm(helper); + await symlink(join(config.root, "starter-memory", "space_memory.md"), helper); + await assert.rejects(ensureMacNodePtySpawnHelper(config.root, "darwin", "x64"), /symlinked/); +}); diff --git a/scripts/tests/public-package-metadata.test.mjs b/scripts/tests/public-package-metadata.test.mjs index 4bcafcdc7..d17074e4a 100644 --- a/scripts/tests/public-package-metadata.test.mjs +++ b/scripts/tests/public-package-metadata.test.mjs @@ -11,6 +11,8 @@ const globalFollowUpCommand = /(^|[^\w-])spaceapp (?:doctor|status|uninstall|credentials)\b/; const publicTestModules = [ + "dev-stack.test.mjs", + "dev-stack-vite.test.mjs", "publish-npm-candidate.test.mjs", "container-image-size-budget.test.mjs", "mcp-hono-compatibility.test.mjs", diff --git a/scripts/tests/public-suite.test.mjs b/scripts/tests/public-suite.test.mjs index 1b541bb80..07ed50d88 100644 --- a/scripts/tests/public-suite.test.mjs +++ b/scripts/tests/public-suite.test.mjs @@ -1,3 +1,5 @@ +import "./dev-stack.test.mjs"; +import "./dev-stack-vite.test.mjs"; import "./publish-npm-candidate.test.mjs"; import "./container-image-size-budget.test.mjs"; import "./mcp-hono-compatibility.test.mjs";