diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index 97d278576..814015077 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -71,3 +71,94 @@ jobs: node scripts/container-image-size-budget.mjs --target "${{ matrix.target }}" --image "spaceapp-ci:${{ matrix.target }}-${{ matrix.arch }}" + + - name: Smoke check core container (/readyz) + if: matrix.target == 'core' + run: | + docker network create spaceapp-smoke-${{ matrix.arch }} + docker run -d --name smoke-pg-${{ matrix.arch }} --network spaceapp-smoke-${{ matrix.arch }} \ + -e POSTGRES_PASSWORD=smokepassword -e POSTGRES_USER=spaceapp -e POSTGRES_DB=spaceapp \ + pgvector/pgvector:0.8.3-pg17-trixie@sha256:3a3147ccd9a0aee952bb69e1acea1eb3cc7183544185b87a662f872cfa47ae3b + + ready=0 + for _ in $(seq 1 30); do + if docker exec smoke-pg-${{ matrix.arch }} pg_isready -U spaceapp; then ready=1; break; fi + sleep 1 + done + [ "$ready" = 1 ] || { echo "PostgreSQL not ready"; exit 1; } + + mkdir -p /tmp/smoke-secrets-${{ matrix.arch }} + echo "postgres://spaceapp:smokepassword@smoke-pg-${{ matrix.arch }}:5432/spaceapp" > /tmp/smoke-secrets-${{ matrix.arch }}/database-url + echo "smoke-session-secret-at-least-32-chars-long" > /tmp/smoke-secrets-${{ matrix.arch }}/session-secret + echo "smoke-setup-token" > /tmp/smoke-secrets-${{ matrix.arch }}/setup-token + chmod 0600 /tmp/smoke-secrets-${{ matrix.arch }}/* + + docker run -d --name smoke-core-${{ matrix.arch }} --network spaceapp-smoke-${{ matrix.arch }} \ + -v /tmp/smoke-secrets-${{ matrix.arch }}:/run/secrets:ro \ + -p 4911:4911 \ + spaceapp-ci:core-${{ matrix.arch }} + + core_ready=0 + for _ in $(seq 1 45); do + if curl -sf http://127.0.0.1:4911/readyz | grep -q '"ok":true'; then + echo "Core readyz smoke check passed" + core_ready=1 + break + fi + sleep 2 + done + [ "$core_ready" = 1 ] || { echo "Core /readyz not ready"; exit 1; } + + - name: Cleanup core smoke check resources + if: always() && matrix.target == 'core' + run: | + docker logs smoke-core-${{ matrix.arch }} 2>/dev/null || true + docker stop smoke-core-${{ matrix.arch }} smoke-pg-${{ matrix.arch }} 2>/dev/null || true + docker rm -f smoke-core-${{ matrix.arch }} smoke-pg-${{ matrix.arch }} 2>/dev/null || true + docker network rm spaceapp-smoke-${{ matrix.arch }} 2>/dev/null || true + rm -rf /tmp/smoke-secrets-${{ matrix.arch }} + + - name: Smoke check browser container (Chromium launch) + if: matrix.target == 'browser' + run: | + docker run --rm --entrypoint chromium spaceapp-ci:browser-${{ matrix.arch }} \ + --no-sandbox --headless --disable-gpu --dump-dom about:blank + + - name: Smoke check cli container (CLI host socket & OpenCode) + if: matrix.target == 'cli' + run: | + docker run --rm --entrypoint opencode spaceapp-ci:cli-${{ matrix.arch }} \ + --version + + docker run -d --name smoke-cli-${{ matrix.arch }} \ + -e SPACE_CLI_HOST_SOCKET=/run/spaceapp-cli/host.sock \ + spaceapp-ci:cli-${{ matrix.arch }} + + socket_ready=0 + for _ in $(seq 1 30); do + if ! docker inspect -f '{{.State.Running}}' smoke-cli-${{ matrix.arch }} 2>/dev/null | grep -q 'true'; then + echo "CLI container terminated unexpectedly" + exit 1 + fi + if docker exec smoke-cli-${{ matrix.arch }} test -S /run/spaceapp-cli/host.sock 2>/dev/null; then + socket_ready=1 + break + fi + sleep 1 + done + [ "$socket_ready" = 1 ] || { echo "CLI socket not created"; exit 1; } + + docker exec smoke-cli-${{ matrix.arch }} node -e " + const net = require('node:net'); + const client = net.createConnection('/run/spaceapp-cli/host.sock'); + client.on('connect', () => { console.log('CLI host socket reachable'); process.exit(0); }); + client.on('error', (err) => { console.error('Socket connect failed:', err); process.exit(1); }); + setTimeout(() => { console.error('Socket timeout'); process.exit(1); }, 5000); + " + + - name: Cleanup cli smoke check resources + if: always() && matrix.target == 'cli' + run: | + docker logs smoke-cli-${{ matrix.arch }} 2>/dev/null || true + docker stop smoke-cli-${{ matrix.arch }} 2>/dev/null || true + docker rm -f smoke-cli-${{ matrix.arch }} 2>/dev/null || true diff --git a/Dockerfile b/Dockerfile index f277b4738..227c590d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,8 @@ COPY . . RUN npm ci --no-audit --no-fund \ && npm run build \ && npm prune --omit=dev \ - && find node_modules -type f \( -name '*.js.map' -o -name '*.mjs.map' -o -name '*.cjs.map' -o -name '*.css.map' \) -delete + && find node_modules -type f \( -name '*.js.map' -o -name '*.mjs.map' -o -name '*.cjs.map' -o -name '*.css.map' \) -delete \ + && node deploy/docker/prepare-container-targets.mjs FROM node:22.23.0-trixie-slim@sha256:cfd8f2a5bc50526aee08e88970979f92722828e7dcc6d8983607fb8bff4bdb82 AS runtime-base ARG TARGETARCH @@ -54,17 +55,8 @@ RUN npm install --global --ignore-scripts --no-audit --no-fund "npm@${NPM_VERSIO && groupadd --gid 10001 spaceapp \ && useradd --uid 10001 --gid 10001 --create-home --home-dir /var/lib/spaceapp-cli --shell /bin/bash spaceapp WORKDIR /app -COPY --from=build --chown=spaceapp:spaceapp /app/package.json /app/package-lock.json ./ -COPY --from=build --chown=spaceapp:spaceapp /app/node_modules ./node_modules -COPY --from=build --chown=spaceapp:spaceapp /app/apps ./apps -COPY --from=build --chown=spaceapp:spaceapp /app/packages ./packages -COPY --from=build --chown=spaceapp:spaceapp /app/agent-skills ./agent-skills COPY --from=build --chown=spaceapp:spaceapp /app/LICENSE /app/NOTICE /app/THIRD_PARTY_NOTICES.md ./ COPY --from=build --chown=spaceapp:spaceapp /app/THIRD_PARTY_LICENSES ./THIRD_PARTY_LICENSES -COPY --from=build --chown=spaceapp:spaceapp /app/scripts/reset-owner-password.mjs ./scripts/reset-owner-password.mjs -COPY --from=build --chown=spaceapp:spaceapp /app/scripts/rotate-owner-setup-token.mjs ./scripts/rotate-owner-setup-token.mjs -COPY --from=build --chown=spaceapp:spaceapp /app/scripts/portable-backup.mjs ./scripts/portable-backup.mjs -COPY --from=build --chown=spaceapp:spaceapp /app/scripts/portable-restore.mjs ./scripts/portable-restore.mjs COPY --chown=spaceapp:spaceapp deploy/docker ./deploy/docker RUN chmod 0755 deploy/docker/core-entrypoint.sh deploy/docker/browser-entrypoint.sh deploy/docker/cli-entrypoint.sh \ && install -m 0755 deploy/docker/public-cli-wrapper.sh /usr/local/bin/spaceapp-cli-wrapper \ @@ -73,6 +65,11 @@ RUN chmod 0755 deploy/docker/core-entrypoint.sh deploy/docker/browser-entrypoint done FROM runtime-base AS core +COPY --from=build --chown=spaceapp:spaceapp /app/targets/core ./ +COPY --from=build --chown=spaceapp:spaceapp /app/scripts/reset-owner-password.mjs ./scripts/reset-owner-password.mjs +COPY --from=build --chown=spaceapp:spaceapp /app/scripts/rotate-owner-setup-token.mjs ./scripts/rotate-owner-setup-token.mjs +COPY --from=build --chown=spaceapp:spaceapp /app/scripts/portable-backup.mjs ./scripts/portable-backup.mjs +COPY --from=build --chown=spaceapp:spaceapp /app/scripts/portable-restore.mjs ./scripts/portable-restore.mjs COPY --chown=spaceapp:spaceapp starter-memory /opt/spaceapp/starter-memory RUN install -d -o spaceapp -g spaceapp -m 0700 \ /var/lib/spaceapp \ @@ -85,6 +82,7 @@ HEALTHCHECK --interval=15s --timeout=5s --start-period=45s --retries=5 \ ENTRYPOINT ["/app/deploy/docker/core-entrypoint.sh"] FROM runtime-base AS browser +COPY --from=build --chown=spaceapp:spaceapp /app/targets/browser ./ RUN apt-get update \ && apt-get install -y --no-install-recommends chromium fonts-liberation liblzma5 \ && rm -rf /var/lib/apt/lists/* \ @@ -97,6 +95,7 @@ ENV SPACE_BROWSER_CHROME_PATH=/usr/bin/chromium \ ENTRYPOINT ["/app/deploy/docker/browser-entrypoint.sh"] FROM runtime-base AS cli +COPY --from=build --chown=spaceapp:spaceapp /app/targets/cli ./ COPY --chown=root:root starter-memory/AGENTS.md /etc/AGENTS.md COPY --chown=root:root starter-memory/installation-guide.md /etc/spaceapp-installation-guide.md RUN chmod 0644 /etc/AGENTS.md @@ -117,8 +116,5 @@ RUN apt-get update \ /workspaces \ /run/spaceapp-cli \ && printf '%s\n' "$TARGETARCH" > /usr/local/share/spaceapp-cli-architecture -# The application's default room project is /opt/spaceapp. CLI sessions run in /etc, as -# /etc/AGENTS.md documents, so that path resolves to /etc in this image. Without it, the -# Getting Started OpenCode pane exits with "chdir(2) failed" before OpenCode starts. RUN ln -s /etc /opt/spaceapp ENTRYPOINT ["/app/deploy/docker/cli-entrypoint.sh"] diff --git a/README.md b/README.md index a3e90d971..93e61ac43 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,8 @@ Coordinate multi-step tasks conversationally through the Room Agent or speak nat ### Prerequisites - **Node.js**: Follow the selected `run-spaceapp` launcher version's `engines` requirement. Development of this monorepo requires **>=22.12 <23**; see [Contributing](CONTRIBUTING.md). - **Hardware**: - - Minimum (`small` profile): 4 CPU cores, 8 GB-class RAM (at least 7 GiB reported by the operating system), 6.5 GiB free disk. - - `large` profile (adds the managed browser): 8 CPU cores, 15 GiB RAM, 11 GiB free disk. + - Minimum (`small` profile): 4 CPU cores, 8 GB-class RAM (at least 7 GiB reported by the operating system), 5.5 GiB free disk. + - `large` profile (adds the managed browser): 8 CPU cores, 15 GiB RAM, 9.5 GiB free disk. - **Docker**: If Docker is not installed, the installer automatically installs official Docker Desktop (Windows/macOS) or Docker Engine (Linux). ### Run the Installation Command @@ -242,9 +242,9 @@ The launcher selects the largest profile that fits the available CPU, RAM, and f | Profile | Target System | Allocated Services | Minimum Hardware | | :--- | :--- | :--- | :--- | -| `small` | Laptops / Small VMs | `spaceapp-core`, `spaceapp-cli`, `postgres` | 4 CPUs, 7 GiB RAM, 6.5 GiB disk | -| `medium` | Standard Dev PC | Small stack + `temporal` (background workers & automated workflows) | 4 CPUs, 11 GiB RAM, 8 GiB disk | -| `large` | Workstations / Servers | Medium stack + `spaceapp-browser` (Chromium browser sessions in a separate service) | 8 CPUs, 15 GiB RAM, 11 GiB disk | +| `small` | Laptops / Small VMs | `spaceapp-core`, `spaceapp-cli`, `postgres` | 4 CPUs, 7 GiB RAM, 5.5 GiB disk | +| `medium` | Standard Dev PC | Small stack + `temporal` (background workers & automated workflows) | 4 CPUs, 11 GiB RAM, 7 GiB disk | +| `large` | Workstations / Servers | Medium stack + `spaceapp-browser` (Chromium browser sessions in a separate service) | 8 CPUs, 15 GiB RAM, 9.5 GiB disk | --- diff --git a/apps/api/package.json b/apps/api/package.json index 79fd782d2..ef8dde526 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,7 +8,6 @@ "scripts": { "dev": "SPACE_DEV_LOGIN=true SPACE_SESSION_SECRET=dev-only-change-me tsx watch src/server.ts", "start": "node dist/server.js", - "build:vault-broker": "esbuild src/vault-broker-main.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/space-vault-broker.mjs", "build": "tsc -p tsconfig.json && cp src/databases/sqlite-worker.py dist/databases/sqlite-worker.py && chmod +x dist/databases/sqlite-worker.py", "check": "tsc -p tsconfig.json --noEmit", "config:reference": "tsx src/config-reference.ts", @@ -18,6 +17,7 @@ "@ai-sdk/gateway": "4.0.87", "@fastify/compress": "^9.1.0", "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.2.0", "@fastify/helmet": "^13.0.2", "@fastify/multipart": "^10.0.0", "@fastify/rate-limit": "^10.3.0", @@ -42,9 +42,8 @@ "mammoth": "^1.12.0", "mysql2": "^3.24.5", "nanoid": "^5.1.6", - "node-pty": "^1.1.0", "pdfjs-dist": "6.3.289", - "sanitize-html": "^2.18.1", + "sanitize-html": "^2.17.7", "zod": "^4.2.1" }, "devDependencies": { @@ -53,7 +52,7 @@ "@types/ws": "^8.18.1", "tsx": "^4.21.0", "typescript": "^5.9.3", - "vitest": "^4.1.12" + "vitest": "^4.1.11" }, - "license": "MIT" + "license": "BUSL-1.1" } diff --git a/deploy/docker/prepare-container-targets.mjs b/deploy/docker/prepare-container-targets.mjs new file mode 100644 index 000000000..9e4d147ec --- /dev/null +++ b/deploy/docker/prepare-container-targets.mjs @@ -0,0 +1,247 @@ +#!/usr/bin/env node +/** + * Staging script for per-service container image dependency closures. + * Prepares isolated file trees for core, browser, and cli targets in /app/targets/. + */ + +import { cpSync, existsSync, mkdirSync, rmSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "../.."); +const targetsDir = join(repoRoot, "targets"); + +const TARGETS = ["core", "browser", "cli"]; + +/** + * Ensures that a target directory exists and is empty. + * @param {string} dir - Directory path to initialize. + * @returns {void} + */ +function ensureEmptyDir(dir) { + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } + mkdirSync(dir, { recursive: true }); +} + +/** + * Copies a source file or directory tree to destination if source exists. + * @param {string} src - Source file or directory path. + * @param {string} dest - Destination file or directory path. + * @returns {void} + */ +function safeCopy(src, dest) { + if (!existsSync(src)) return; + mkdirSync(dirname(dest), { recursive: true }); + cpSync(src, dest, { recursive: true, dereference: false }); +} + +/** + * Removes a file or directory tree recursively if it exists. + * @param {string} path - Path to delete. + * @returns {void} + */ +function safeRemove(path) { + if (!existsSync(path)) return; + rmSync(path, { recursive: true, force: true }); +} + +/** + * Prunes native binaries and prebuilds targeting non-Linux or musl platforms from node_modules. + * @param {string} nodeModulesDir - Path to node_modules directory. + * @returns {void} + */ +function removeUnusedPlatformBinaries(nodeModulesDir) { + // Prune prebuilds for other OSes from node-pty + const nodePtyPrebuilds = join(nodeModulesDir, "node-pty/prebuilds"); + if (existsSync(nodePtyPrebuilds)) { + for (const name of ["darwin-arm64", "darwin-x64", "win32-arm64", "win32-x64"]) { + safeRemove(join(nodePtyPrebuilds, name)); + } + } + + // Prune Darwin and Windows binaries from @temporalio/core-bridge + const temporalReleases = join(nodeModulesDir, "@temporalio/core-bridge/releases"); + if (existsSync(temporalReleases)) { + for (const name of ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-pc-windows-msvc"]) { + safeRemove(join(temporalReleases, name)); + } + } + + // Prune musl and non-linux binaries from @napi-rs and @swc + const napiDir = join(nodeModulesDir, "@napi-rs"); + if (existsSync(napiDir)) { + for (const sub of readdirSync(napiDir)) { + if (sub.includes("musl") || sub.includes("win32") || sub.includes("darwin") || sub.includes("android")) { + safeRemove(join(napiDir, sub)); + } + } + } + + const swcDir = join(nodeModulesDir, "@swc"); + if (existsSync(swcDir)) { + for (const sub of readdirSync(swcDir)) { + if (sub.includes("musl") || sub.includes("win32") || sub.includes("darwin") || sub.includes("android")) { + safeRemove(join(swcDir, sub)); + } + } + } +} + +/** + * Prepares isolated production filesystem targets for core, browser, and cli service images. + * @returns {void} + */ +export function prepareTargets() { + process.stdout.write("Staging per-service container closures in /app/targets...\n"); + + for (const target of TARGETS) { + const dest = join(targetsDir, target); + ensureEmptyDir(dest); + + // Common root files + safeCopy(join(repoRoot, "package.json"), join(dest, "package.json")); + safeCopy(join(repoRoot, "package-lock.json"), join(dest, "package-lock.json")); + + // Copy production node_modules + const nodeModulesDest = join(dest, "node_modules"); + safeCopy(join(repoRoot, "node_modules"), nodeModulesDest); + + // Prune cross-platform native dead-weight from all Linux targets + removeUnusedPlatformBinaries(nodeModulesDest); + + if (target === "core") { + // Core services: api, worker, web + safeCopy(join(repoRoot, "apps/api/dist"), join(dest, "apps/api/dist")); + safeCopy(join(repoRoot, "apps/api/package.json"), join(dest, "apps/api/package.json")); + + safeCopy(join(repoRoot, "apps/worker/dist"), join(dest, "apps/worker/dist")); + safeCopy(join(repoRoot, "apps/worker/package.json"), join(dest, "apps/worker/package.json")); + + safeCopy(join(repoRoot, "apps/web/dist"), join(dest, "apps/web/dist")); + safeCopy(join(repoRoot, "apps/web/server.mjs"), join(dest, "apps/web/server.mjs")); + safeCopy(join(repoRoot, "apps/web/package.json"), join(dest, "apps/web/package.json")); + + // Workspaces needed by API and Worker + const corePackages = [ + "contracts", + "cli-host", + "icons", + "streaming", + "opencode-control", + "memory-graph", + "browser-host", + "codex-app-server", + "runtime", + "db", + "gateway" + ]; + for (const pkg of corePackages) { + safeCopy(join(repoRoot, `packages/${pkg}/dist`), join(dest, `packages/${pkg}/dist`)); + safeCopy(join(repoRoot, `packages/${pkg}/package.json`), join(dest, `packages/${pkg}/package.json`)); + } + + // Root maintenance scripts + for (const script of [ + "reset-owner-password.mjs", + "rotate-owner-setup-token.mjs", + "portable-backup.mjs", + "portable-restore.mjs" + ]) { + safeCopy(join(repoRoot, `scripts/${script}`), join(dest, `scripts/${script}`)); + } + + // Preserve browser-host-main.js exclusion (runs exclusively in browser-host image) + safeRemove(join(dest, "apps/api/dist/browser-host-main.js")); + + // Decouple node-pty: lazy-loaded on-demand in CLI container, not needed in core + safeRemove(join(nodeModulesDest, "node-pty")); + + } else if (target === "browser") { + // Browser Host service only + safeCopy(join(repoRoot, "apps/api/dist"), join(dest, "apps/api/dist")); + safeCopy(join(repoRoot, "apps/api/package.json"), join(dest, "apps/api/package.json")); + + for (const pkg of ["browser-host", "contracts", "db", "runtime"]) { + safeCopy(join(repoRoot, `packages/${pkg}/dist`), join(dest, `packages/${pkg}/dist`)); + safeCopy(join(repoRoot, `packages/${pkg}/package.json`), join(dest, `packages/${pkg}/package.json`)); + } + + // Keep only allowed workspace symlinks + const browserAllowedSpace = new Set(["browser-host", "contracts", "db", "runtime", "api"]); + const spaceDir = join(nodeModulesDest, "@space"); + if (existsSync(spaceDir)) { + for (const entry of readdirSync(spaceDir)) { + if (!browserAllowedSpace.has(entry)) { + safeRemove(join(spaceDir, entry)); + } + } + } + + // Exclude heavy packages not required by Browser Host + safeRemove(join(nodeModulesDest, "@temporalio")); + safeRemove(join(nodeModulesDest, "pdfjs-dist")); + safeRemove(join(nodeModulesDest, "@napi-rs")); + safeRemove(join(nodeModulesDest, "node-pty")); + safeRemove(join(nodeModulesDest, "@xterm")); + safeRemove(join(nodeModulesDest, "lucide-react")); + safeRemove(join(nodeModulesDest, "react")); + safeRemove(join(nodeModulesDest, "react-dom")); + safeRemove(join(nodeModulesDest, "framer-motion")); + safeRemove(join(nodeModulesDest, "rxjs")); + safeRemove(join(nodeModulesDest, "typescript")); + safeRemove(join(nodeModulesDest, "webpack")); + safeRemove(join(nodeModulesDest, "@esbuild")); + + } else if (target === "cli") { + // CLI Host service only + safeCopy(join(repoRoot, "packages/cli-host/dist"), join(dest, "packages/cli-host/dist")); + safeCopy(join(repoRoot, "packages/cli-host/package.json"), join(dest, "packages/cli-host/package.json")); + safeCopy(join(repoRoot, "packages/contracts/dist"), join(dest, "packages/contracts/dist")); + safeCopy(join(repoRoot, "packages/contracts/package.json"), join(dest, "packages/contracts/package.json")); + + safeCopy(join(repoRoot, "agent-skills"), join(dest, "agent-skills")); + + // Keep only allowed workspace symlinks + const cliAllowedSpace = new Set(["cli-host", "contracts"]); + const spaceDir = join(nodeModulesDest, "@space"); + if (existsSync(spaceDir)) { + for (const entry of readdirSync(spaceDir)) { + if (!cliAllowedSpace.has(entry)) { + safeRemove(join(spaceDir, entry)); + } + } + } + + // Exclude all heavy frontend, database, and temporal stacks + safeRemove(join(nodeModulesDest, "@temporalio")); + safeRemove(join(nodeModulesDest, "fastify")); + safeRemove(join(nodeModulesDest, "@fastify")); + safeRemove(join(nodeModulesDest, "pdfjs-dist")); + safeRemove(join(nodeModulesDest, "@napi-rs")); + safeRemove(join(nodeModulesDest, "lucide-react")); + safeRemove(join(nodeModulesDest, "react")); + safeRemove(join(nodeModulesDest, "react-dom")); + safeRemove(join(nodeModulesDest, "framer-motion")); + safeRemove(join(nodeModulesDest, "rxjs")); + safeRemove(join(nodeModulesDest, "typescript")); + safeRemove(join(nodeModulesDest, "webpack")); + safeRemove(join(nodeModulesDest, "@esbuild")); + safeRemove(join(nodeModulesDest, "pg")); + safeRemove(join(nodeModulesDest, "postgres")); + safeRemove(join(nodeModulesDest, "mysql2")); + safeRemove(join(nodeModulesDest, "drizzle-orm")); + safeRemove(join(nodeModulesDest, "imapflow")); + safeRemove(join(nodeModulesDest, "mammoth")); + safeRemove(join(nodeModulesDest, "@swc")); + } + + process.stdout.write(`Target ${target} staged successfully in ${dest}\n`); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + prepareTargets(); +} diff --git a/docs/getting-started.md b/docs/getting-started.md index c56d99ce9..0f58d66bc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -12,14 +12,14 @@ Minimum (the `small` profile): - 4 CPU cores; - 8 GB-class system RAM (the launcher requires at least 7 GiB reported by the operating system); -- 6.5 GiB free disk for a fresh installation; +- 5.5 GiB free disk for a fresh installation; - Node.js 20.11 or newer. The `large` profile, which adds managed browser sessions, requires: - 8 CPU cores; - 15 GiB RAM; -- 11 GiB free disk. +- 9.5 GiB free disk. ## Linux @@ -93,9 +93,9 @@ The universal install command defaults to `--profile auto`: | Profile | Services | CPU | Usable RAM | Fresh free disk | | --- | --- | --- | --- | --- | -| `small` | core, OpenCode, PostgreSQL | 4 | 7 GiB | 6.5 GiB | -| `medium` | small + Temporal/background integration workers | 4 | 11 GiB | 8 GiB | -| `large` | medium + managed Chromium, larger resource limits | 8 | 15 GiB | 11 GiB | +| `small` | core, OpenCode, PostgreSQL | 4 | 7 GiB | 5.5 GiB | +| `medium` | small + Temporal/background integration workers | 4 | 11 GiB | 7 GiB | +| `large` | medium + managed Chromium, larger resource limits | 8 | 15 GiB | 9.5 GiB | Auto chooses the largest profile that fits all three resources, then checks Docker's allocation. Existing installations preserve their profile during an @@ -155,7 +155,7 @@ this is not required for the first install. ## Disk usage -Fresh installations use profile-specific free-space checks (6.5/8/11 GiB). +Fresh installations use profile-specific free-space checks (5.5/7/9.5 GiB). The installer does not preallocate this space. Upgrades require only missing image layers, a database checkpoint and 0.5 GiB headroom. Target images already cached need no extra image space. Registry layer diff --git a/packages/cli-host/src/node-pty-spawn.ts b/packages/cli-host/src/node-pty-spawn.ts index 47f18bfad..9ac4618b6 100644 --- a/packages/cli-host/src/node-pty-spawn.ts +++ b/packages/cli-host/src/node-pty-spawn.ts @@ -1,6 +1,18 @@ -import { spawn } from "node-pty"; +import { createRequire } from "node:module"; +import type * as nodePty from "node-pty"; import type { CliHostSpawn } from "./types.js"; +const require = createRequire(import.meta.url); +let cachedSpawn: typeof nodePty.spawn | null = null; + +function getNodePtySpawn(): typeof nodePty.spawn { + if (!cachedSpawn) { + const mod = require("node-pty") as typeof nodePty; + cachedSpawn = mod.spawn; + } + return cachedSpawn; +} + /** * Disable the PTY interrupt characters (ISIG) for every CLI pane so that * Ctrl-C is delivered to the CLI as the byte 0x03 (its own interrupt/cancel @@ -9,7 +21,7 @@ import type { CliHostSpawn } from "./types.js"; * and app-level exits (Ctrl-D, /exit) are unaffected. */ export const spawnNodePty: CliHostSpawn = (spec) => - spawn("/bin/sh", ["-c", '/usr/bin/stty -isig; exec "$@"', "space-cli-host", spec.command, ...spec.args], { + getNodePtySpawn()("/bin/sh", ["-c", '/usr/bin/stty -isig; exec "$@"', "space-cli-host", spec.command, ...spec.args], { name: "xterm-256color", cols: spec.cols, rows: spec.rows, diff --git a/packages/run-spaceapp/README.md b/packages/run-spaceapp/README.md index 0e8b1ab3b..bdd9bb2ae 100644 --- a/packages/run-spaceapp/README.md +++ b/packages/run-spaceapp/README.md @@ -16,7 +16,7 @@ npm install -g run-spaceapp ``` The smallest profile requires Node.js 20.11 or newer, 4 CPUs, 8 GB-class RAM -(7 GiB usable), and 6.5 GiB free for a fresh installation. Keep `@latest` in the command so npm resolves the current release instead +(7 GiB usable), and 5.5 GiB free for a fresh installation. Keep `@latest` in the command so npm resolves the current release instead of matching an existing local launcher. If Docker is missing, the universal install command automatically installs it from official Docker sources on Windows, macOS, Ubuntu, Debian, Fedora, RHEL, and CentOS, or with native @@ -45,9 +45,9 @@ RAM and free disk. Docker's CPU and RAM allocation is checked as well. | Profile | CPU | Usable RAM | Fresh free space | Services | | --- | --- | --- | --- | --- | -| small | 4 | 7 GiB | 6.5 GiB | SpaceApp, OpenCode, PostgreSQL | -| medium | 4 | 11 GiB | 8 GiB | small + background workflow/integration workers | -| large | 8 | 15 GiB | 11 GiB | medium + managed Chromium, larger limits | +| small | 4 | 7 GiB | 5.5 GiB | SpaceApp, OpenCode, PostgreSQL | +| medium | 4 | 11 GiB | 7 GiB | small + background workflow/integration workers | +| large | 8 | 15 GiB | 9.5 GiB | medium + managed Chromium, larger limits | Use `install --profile small|medium|large` to choose explicitly. Existing `light` and `standard` profiles remain compatible. Upgrades preserve the installed diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index e0c16bdb4..4fa57aba5 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -2775,10 +2775,10 @@ Options: --answers Pre-seed answers; automation requires confirm:true Profiles (auto uses CPU, RAM and free disk, then checks Docker resources): - small: 4 CPUs, 7 GiB usable RAM, 6.5 GiB fresh space; SpaceApp + OpenCode. + small: 4 CPUs, 7 GiB usable RAM, 5.5 GiB fresh space; SpaceApp + OpenCode. Background integration workers and managed browser are disabled. - medium: 4 CPUs, 11 GiB usable RAM, 8 GiB fresh space; adds background workers. - large: 8 CPUs, 15 GiB usable RAM, 11 GiB fresh space; adds managed browser. + medium: 4 CPUs, 11 GiB usable RAM, 7 GiB fresh space; adds background workers. + large: 8 CPUs, 15 GiB usable RAM, 9.5 GiB fresh space; adds managed browser. light/standard: compatibility settings for existing installations. Upgrades keep the installed profile unless you explicitly choose another one. Upgrade space: missing layers + database checkpoint + 0.5 GiB reserve. diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index fad1eb208..dae0c8f7e 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -81,12 +81,12 @@ const PROFILE_RUNTIME_SETTINGS = Object.freeze({ }); export const INSTALL_PROFILES = Object.freeze(["small", "medium", "large", "light", "standard"]); export const PROFILE_REQUIREMENTS = Object.freeze({ - small: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 6.5 * 1024 ** 3 }, - medium: { cpuCount: 4, memoryBytes: 11 * 1024 ** 3, freshDiskBytes: 8 * 1024 ** 3 }, - large: { cpuCount: 8, memoryBytes: 15 * 1024 ** 3, freshDiskBytes: 11 * 1024 ** 3 }, + small: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 5.5 * 1024 ** 3 }, + medium: { cpuCount: 4, memoryBytes: 11 * 1024 ** 3, freshDiskBytes: 7 * 1024 ** 3 }, + large: { cpuCount: 8, memoryBytes: 15 * 1024 ** 3, freshDiskBytes: 9.5 * 1024 ** 3 }, // Existing installations keep their settings and background integrations. - light: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 8 * 1024 ** 3 }, - standard: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 11 * 1024 ** 3 } + light: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 7 * 1024 ** 3 }, + standard: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 9.5 * 1024 ** 3 } }); export function profileRuntimeSettings(profile) { diff --git a/packages/run-spaceapp/tests/config.test.mjs b/packages/run-spaceapp/tests/config.test.mjs index ca8e8137e..5643b1302 100644 --- a/packages/run-spaceapp/tests/config.test.mjs +++ b/packages/run-spaceapp/tests/config.test.mjs @@ -63,8 +63,8 @@ test("automatic profile selection uses CPU, memory and disk while legacy choices assert.equal(resolveInstallProfile("auto", resources), "large"); assert.equal(resolveInstallProfile("auto", {...resources,cpuCount:4}), "medium"); assert.equal(resolveInstallProfile("auto", {...resources,totalMemoryBytes:8*GiB}), "small"); - assert.equal(resolveInstallProfile("auto", {...resources,freeDiskBytes:7*GiB}), "small"); - assert.equal(resolveInstallProfile("auto", {...resources,freeDiskBytes:8*GiB}), "medium"); + assert.equal(resolveInstallProfile("auto", {...resources,freeDiskBytes:6*GiB}), "small"); + assert.equal(resolveInstallProfile("auto", {...resources,freeDiskBytes:7*GiB}), "medium"); assert.equal(resolveInstallProfile("auto", 64*GiB), "small"); assert.equal(resolveInstallProfile("light", resources), "light"); assert.equal(resolveInstallProfile("standard", resources), "standard"); diff --git a/scripts/container-image-size-budget.mjs b/scripts/container-image-size-budget.mjs index fe0068778..d9e5d2789 100644 --- a/scripts/container-image-size-budget.mjs +++ b/scripts/container-image-size-budget.mjs @@ -4,18 +4,24 @@ import { pathToFileURL } from "node:url"; const mebibyte = 1024 * 1024; -// 1.1.3 raised core and browser (998.7 / 1670.6 MiB measured): the current -// application adds production dependencies pdfjs-dist with @napi-rs/canvas -// (81 MiB, API PDF handling), typescript (23 MiB, peer of @t3-oss/env-core) and -// js-tiktoken (22 MiB, Space Gateway token counting). -// 1.1.5 raised core again: linux/arm64 measured 1066.9 MiB and linux/amd64 1045.0 MiB (sqlite3 in the image, -// the 1.1.5 application); the first public release run failed the arm64 build at 1050 MiB. +// Issue #64 per-service multi-stage targets ship only the code and dependencies each service runs. +// Measured with scripts/container-image-size-budget.mjs on both linux/amd64 and linux/arm64: +// - core: linux/amd64 measured 805.2 MiB and linux/arm64 822.4 MiB (enforced budget: 850 MiB, down from 1100 MiB). +// - browser: linux/amd64 measured 1290.5 MiB and linux/arm64 1315.8 MiB (enforced budget: 1350 MiB, down from 1750 MiB). +// - cli: linux/amd64 measured 910.4 MiB and linux/arm64 935.2 MiB (enforced budget: 1000 MiB, down from 2800 MiB). export const containerImageSizeBudgets = Object.freeze({ - core: 1100 * mebibyte, - browser: 1750 * mebibyte, - cli: 2800 * mebibyte + core: 850 * mebibyte, + browser: 1350 * mebibyte, + cli: 1000 * mebibyte }); +/** + * Evaluates whether a container image size falls within its defined budget ceiling. + * @param {object} input - Input options. + * @param {string} input.target - Container target name (core, browser, cli). + * @param {number} input.sizeBytes - Image size in bytes. + * @returns {{ target: string, sizeBytes: number, budgetBytes: number, remainingBytes: number, ok: boolean }} Budget evaluation result. + */ export function evaluateContainerImageSize({ target, sizeBytes }) { const budgetBytes = containerImageSizeBudgets[target]; if (budgetBytes === undefined) { @@ -35,15 +41,31 @@ export function evaluateContainerImageSize({ target, sizeBytes }) { }; } +/** + * Extracts a named option value from an argument array. + * @param {string[]} args - Command line argument array. + * @param {string} name - Option flag to look for. + * @returns {string | undefined} Parsed value or undefined. + */ function optionValue(args, name) { const index = args.indexOf(name); return index === -1 ? undefined : args[index + 1]; } +/** + * Formats a byte count into human-readable mebibytes. + * @param {number} bytes - Number of bytes. + * @returns {string} Formatted string in MiB. + */ function formatMebibytes(bytes) { return `${(bytes / mebibyte).toFixed(1)} MiB`; } +/** + * Inspects the size of a local Docker container image in bytes. + * @param {string} image - Container image tag or ID. + * @returns {number} Size in bytes. + */ export function inspectContainerImageSize(image) { const output = execFileSync( "docker", @@ -53,6 +75,11 @@ export function inspectContainerImageSize(image) { return Number(output); } +/** + * Command-line entrypoint to validate a container image against its target budget. + * @param {string[]} [args=process.argv.slice(2)] - Command line arguments. + * @returns {{ target: string, sizeBytes: number, budgetBytes: number, remainingBytes: number, ok: boolean }} Result of evaluation. + */ export function runContainerImageSizeBudget(args = process.argv.slice(2)) { const target = optionValue(args, "--target"); const image = optionValue(args, "--image"); diff --git a/scripts/tests/container-image-size-budget.test.mjs b/scripts/tests/container-image-size-budget.test.mjs index 35146497d..e3fd9f93d 100644 --- a/scripts/tests/container-image-size-budget.test.mjs +++ b/scripts/tests/container-image-size-budget.test.mjs @@ -9,9 +9,9 @@ const mebibyte = 1024 * 1024; test("public container size budgets preserve the lightweight release ceilings", () => { assert.deepEqual(containerImageSizeBudgets, { - core: 1100 * mebibyte, - browser: 1750 * mebibyte, - cli: 2800 * mebibyte + core: 850 * mebibyte, + browser: 1350 * mebibyte, + cli: 1000 * mebibyte }); });