Repository navigation
Add npm run dev:stack — one command to run the full stack from source (closes #65) #281
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b12364a
90e07c6
f9dcd4f
d8d8e41
a921224
d8f99e3
48e6f2d
cf99f3a
9e2f2fd
4d7403a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| name: Dev stack | ||
|
|
||
| # End-to-end check for `npm run dev:stack` (issue: one command to run the | ||
| # full SpaceApp stack from source). Runs the stack without a TTY, completes | ||
| # owner setup over HTTP, checks the starter room's CLI pane, then resets | ||
| # and verifies nothing is left behind. | ||
| on: | ||
| pull_request: | ||
| paths: | ||
| - "scripts/dev-stack.mjs" | ||
| - "scripts/dev-stack-ci.mjs" | ||
| - "scripts/dev-stack.compose.yml" | ||
| - "package.json" | ||
| - ".github/workflows/dev-stack.yml" | ||
| workflow_dispatch: | ||
|
|
||
| jobs: | ||
| dev-stack: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 45 | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 22 | ||
| cache: npm | ||
|
|
||
| - name: Install dependencies | ||
| run: ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm ci | ||
|
|
||
| - name: Run the dev stack end to end | ||
| run: node scripts/dev-stack-ci.mjs | ||
|
|
||
| # Same end-to-end check on macOS (acceptance asks for Linux and | ||
| # macOS/WSL2 run outputs). GitHub's macOS runners ship no Docker, so | ||
| # Colima provides the daemon; the stack itself is unchanged. | ||
| dev-stack-macos: | ||
| runs-on: macos-14 | ||
| timeout-minutes: 45 | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 22 | ||
| cache: npm | ||
|
|
||
| - name: Install Docker via Colima | ||
| run: | | ||
| brew install colima docker docker-compose | ||
| mkdir -p ~/.docker/cli-plugins | ||
| ln -sfn "$(brew --prefix docker-compose)/bin/docker-compose" ~/.docker/cli-plugins/docker-compose | ||
| colima start --cpu 4 --memory 6 | ||
|
|
||
| - name: Install dependencies | ||
| run: ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm ci | ||
|
|
||
| - name: Run the dev stack end to end | ||
| run: node scripts/dev-stack-ci.mjs | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * CI driver for the dev stack (.github/workflows/dev-stack.yml): | ||
| * start `npm run dev:stack` without a TTY, wait for /readyz, complete the | ||
| * owner setup over HTTP, ensure the starter room and its CLI pane exist, | ||
| * then run `reset` and verify nothing is left behind (no container, no | ||
| * volume, no state directory, no supervised process). | ||
| */ | ||
| import { spawn, execFile } from "node:child_process"; | ||
| import { existsSync, readFileSync } from "node:fs"; | ||
| import { dirname, join, resolve } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { promisify } from "node:util"; | ||
| import { buildConfig } from "./dev-stack.mjs"; | ||
|
|
||
| const execFileAsync = promisify(execFile); | ||
| const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | ||
| const config = buildConfig(); | ||
| const webOrigin = `http://127.0.0.1:${config.webPort}`; | ||
| const apiOrigin = `http://127.0.0.1:${config.apiPort}`; | ||
|
|
||
| function step(message) { | ||
| process.stdout.write(`\n=== ${message}\n`); | ||
| } | ||
|
|
||
| async function sleep(ms) { | ||
| return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); | ||
| } | ||
|
|
||
| async function waitForReadyz(timeoutMs = 300_000) { | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (Date.now() < deadline) { | ||
| try { | ||
| const response = await fetch(`${webOrigin}/readyz`); | ||
| const body = await response.json().catch(() => ({})); | ||
| if (body && body.ok === true) return body; | ||
| } catch { /* not up yet */ } | ||
| await sleep(3_000); | ||
| } | ||
|
Comment on lines
+32
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis
Fail fast when the stack process exits during the readiness wait.
Suggested fix
🤖 Prompt for AI Agents
|
||
| throw new Error(`dev stack did not become ready within ${timeoutMs}ms`); | ||
| } | ||
|
|
||
| async function main() { | ||
| step("Starting the dev stack (background, no TTY)"); | ||
| const stack = spawn("npm", ["run", "dev:stack"], { | ||
| cwd: repoRoot, | ||
| env: process.env, | ||
| shell: process.platform === "win32", | ||
| detached: process.platform !== "win32", | ||
| stdio: "inherit" | ||
| }); | ||
| let stackExit = null; | ||
| stack.on("exit", (code) => { stackExit = code; }); | ||
|
|
||
| try { | ||
| step("Waiting for /readyz through the web port"); | ||
| const ready = await waitForReadyz(); | ||
| if (stackExit !== null) throw new Error(`dev stack exited early with code ${stackExit}`); | ||
| process.stdout.write(`readyz: ${JSON.stringify(ready)}\n`); | ||
|
|
||
| step("Completing owner setup over HTTP"); | ||
| const setupToken = readFileSync(join(config.secretsDir, "setup-token"), "utf8").trim(); | ||
| const claimResponse = await fetch(`${apiOrigin}/api/setup/claim`, { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| token: setupToken, | ||
| email: "dev-stack-ci@example.com", | ||
| password: "dev-stack-ci-password-1" | ||
| }) | ||
| }); | ||
| if (!claimResponse.ok) { | ||
| throw new Error(`Owner claim failed: ${claimResponse.status} ${await claimResponse.text()}`); | ||
| } | ||
| const claim = await claimResponse.json(); | ||
| const cookie = claimResponse.headers.get("set-cookie"); | ||
| if (!cookie) throw new Error("Owner claim did not return a session cookie."); | ||
| const authHeaders = { cookie, "content-type": "application/json" }; | ||
| process.stdout.write(`Owner claimed; starter room: ${claim.starterRoomId}\n`); | ||
|
|
||
| step("Ensuring the starter room and its CLI pane"); | ||
| const starterResponse = await fetch(`${apiOrigin}/api/setup/starter-room`, { | ||
| method: "POST", | ||
| headers: authHeaders, | ||
| body: "{}" | ||
| }); | ||
| if (!starterResponse.ok) { | ||
| throw new Error(`Starter room failed: ${starterResponse.status} ${await starterResponse.text()}`); | ||
| } | ||
| const starter = await starterResponse.json(); | ||
| const roomId = starter.room?.id ?? claim.starterRoomId; | ||
| const roomResponse = await fetch(`${apiOrigin}/api/rooms/${roomId}`, { headers: authHeaders }); | ||
| if (!roomResponse.ok) { | ||
| throw new Error(`Room fetch failed: ${roomResponse.status} ${await roomResponse.text()}`); | ||
| } | ||
| const room = await roomResponse.json(); | ||
| const panes = room.panes ?? room.room?.panes ?? []; | ||
| const cliPane = panes.find((pane) => pane.mode === "TERMINAL" || pane.terminalRuntimeId); | ||
| if (!cliPane) { | ||
| throw new Error(`Starter room ${roomId} has no CLI pane. Panes: ${JSON.stringify(panes).slice(0, 500)}`); | ||
|
Comment on lines
+96
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis
Require an active OpenCode session for the selected pane. The current predicate accepts any terminal pane. It can pass when the pane has no active process. Suggested fix
🤖 Prompt for AI Agents
|
||
| } | ||
| process.stdout.write(`CLI pane present: ${cliPane.id} (mode ${cliPane.mode ?? "?"}, runtime ${cliPane.terminalRuntimeId ?? "?"})\n`); | ||
|
|
||
| const tasksResponse = await fetch(`${apiOrigin}/api/cli/tasks`, { headers: authHeaders }); | ||
| if (!tasksResponse.ok) { | ||
| throw new Error(`CLI tasks endpoint failed: ${tasksResponse.status}`); | ||
| } | ||
| process.stdout.write("CLI host answers through the API.\n"); | ||
| } finally { | ||
| step("Resetting the dev stack and verifying nothing is left"); | ||
| try { process.kill(process.platform === "win32" ? stack.pid : -stack.pid, "SIGTERM"); } catch { /* already down */ } | ||
| await sleep(2_000); | ||
| const reset = spawn("npm", ["run", "dev:stack", "--", "reset"], { | ||
| cwd: repoRoot, env: process.env, shell: process.platform === "win32", stdio: "inherit" | ||
| }); | ||
| await new Promise((resolvePromise) => reset.on("exit", resolvePromise)); | ||
|
|
||
| const { stdout: containers } = await execFileAsync("docker", ["ps", "-aq", "--filter", "name=spaceapp-dev"]).catch(() => ({ stdout: "" })); | ||
| if (containers.trim().length > 0) throw new Error(`Containers left behind: ${containers.trim()}`); | ||
| const { stdout: volumes } = await execFileAsync("docker", ["volume", "ls", "-q", "--filter", "name=spaceapp-dev"]).catch(() => ({ stdout: "" })); | ||
| if (volumes.trim().length > 0) throw new Error(`Volumes left behind: ${volumes.trim()}`); | ||
| if (existsSync(config.stateDir)) throw new Error(`State directory left behind: ${config.stateDir}`); | ||
|
Comment on lines
+119
to
+122
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis
Do not throw cleanup failures directly from A failure in the Collect cleanup errors and capture the primary error. After The applicable repository rule is ESLint’s 🧰 Tools
🤖 Prompt for AI Agents
|
||
| process.stdout.write("Reset verified: no container, volume, process or state left.\n"); | ||
| } | ||
| } | ||
|
|
||
| main().catch((error) => { | ||
| process.stderr.write(`dev-stack CI failed: ${error.message}\n`); | ||
| process.exit(1); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| # Dev-stack services for `npm run dev:stack` (scripts/dev-stack.mjs). | ||
| # Only PostgreSQL and Temporal run in Docker; everything else runs from source. | ||
| # The ALL-CAPS placeholder tokens are substituted by scripts/dev-stack.mjs: | ||
| # the images (with digests) are parsed out of | ||
| # packages/run-spaceapp/templates/compose.yml so they are never copied by hand. | ||
| name: spaceapp-dev | ||
|
|
||
| services: | ||
| postgres: | ||
| image: __POSTGRES_IMAGE__ | ||
| restart: "no" | ||
| environment: | ||
| POSTGRES_DB: spaceapp | ||
| POSTGRES_USER: spaceapp | ||
| POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password | ||
| ports: | ||
| - "127.0.0.1:__POSTGRES_PORT__:5432" | ||
| volumes: | ||
| - spaceapp-dev-postgres:/var/lib/postgresql/data | ||
| secrets: | ||
| - postgres-password | ||
| healthcheck: | ||
| test: ["CMD-SHELL", "pg_isready -U spaceapp -d spaceapp"] | ||
| interval: 5s | ||
| timeout: 3s | ||
| retries: 20 | ||
|
|
||
| temporal: | ||
| image: __TEMPORAL_IMAGE__ | ||
| restart: "no" | ||
| depends_on: | ||
| postgres: | ||
| condition: service_healthy | ||
| 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:__TEMPORAL_PORT__:7233" | ||
| secrets: | ||
| - postgres-password | ||
| 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: 10s | ||
|
Comment on lines
+32
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis
Make the Temporal secret readable by its container user. When the host UID is not 🤖 Prompt for AI Agents
|
||
| timeout: 5s | ||
| retries: 20 | ||
|
|
||
| secrets: | ||
| postgres-password: | ||
| file: __SECRETS_DIR__/postgres-password | ||
|
|
||
| volumes: | ||
| spaceapp-dev-postgres: | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Restrict token permissions and credential persistence.
The workflow has no
permissions:block, so the jobs use the repository default token scope. Bothactions/checkout@v4steps keep credentials in the local git config (zizmorartipacked). The jobs only read the repository.Add
permissions: contents: readat the top level. Setpersist-credentials: falseon both checkout steps.Proposed fix
Also applies to: 42-42
🧰 Tools
🪛 zizmor (1.30.1)
[warning] 22-22: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
[warning] 1-61: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block
(excessive-permissions)
[warning] 18-37: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block
(excessive-permissions)
🤖 Prompt for AI Agents
Source: Linters/SAST tools