diff --git a/README.md b/README.md index e0848ef..3542353 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,23 @@ Start web only: - `bun run dev:web` +Recommended fast iteration loop: + +- `bun run dev:infra` for Docker-backed infra only +- `bun run dev:services` for native Bun backend services +- `bun run dev:web` for the local Next.js UI + +This keeps Docker in the local workflow where it helps most (NATS, ClickHouse, Redis) without forcing the app services themselves into slower container rebuild/restart loops. + +## Deployment Workflow + +- `./deploy main` keeps the current VPS Docker rollout path as the default. +- `./deploy main --runtime native` targets a host-native Bun + systemd deployment. +- `./deploy current-branch` and `./deploy current-branch --runtime native` keep branch deploys available during the transition. +- Partial deploys are supported with `--web-only`, `--api-only`, `--services-only`, and `--no-build`. +- Docker runtime details live in `deployment/docker/README.md`. +- Native runtime expectations live in `deployment/native/README.md`. + ## Desktop Shell Islandflow also includes a thin Electron desktop shell in `apps/desktop`. diff --git a/deployment/docker/README.md b/deployment/docker/README.md index 52e8198..426a006 100644 --- a/deployment/docker/README.md +++ b/deployment/docker/README.md @@ -1,8 +1,13 @@ # Docker Deployment -This directory is the supported VPS deployment path for Islandflow. +This directory contains the Docker runtime for Islandflow VPS deployments. -The repo no longer ships or supports a separate `deployment/npm` stack. Docker Compose is the deployment surface; if you want a reverse proxy, point it at the host ports published by this stack. +Docker remains the default server rollout path, but the repo-root `deploy` helper can now target either: + +- `--runtime docker` for this Docker Compose stack +- `--runtime native` for a host-native Bun + systemd rollout described in `deployment/native/README.md` + +The repo no longer ships or supports a separate `deployment/npm` stack. If you want a reverse proxy, point it at the host ports published by this stack. It is separate from the repo-root `docker-compose.yml`, which remains the lightweight local infra stack for development. @@ -198,6 +203,7 @@ It preserves the current Docker Compose project and avoids destructive cleanup o ```bash ./deploy main +./deploy main --runtime docker ``` This flow: @@ -213,6 +219,7 @@ This flow: ```bash ./deploy current-branch +./deploy current-branch --runtime docker ``` Alias: @@ -229,13 +236,24 @@ This flow: - switches the server checkout to that same branch and keeps it there until you intentionally move it back - runs the same rebuild and verification steps as `main` +### Partial Docker rollouts + +Examples: + +```bash +./deploy main --runtime docker --web-only +./deploy main --runtime docker --api-only +./deploy current-branch --runtime docker --services-only +./deploy main --runtime docker --web-only --no-build +``` + ### Escalation path Use force recreate only when a normal refresh does not update the services cleanly: ```bash -./deploy main --force-recreate -./deploy current-branch --force-recreate +./deploy main --runtime docker --force-recreate +./deploy current-branch --runtime docker --force-recreate ``` ### Return the server to `main` @@ -244,6 +262,7 @@ If the live checkout is on a branch deploy and you want normal production tracki ```bash ./deploy main +./deploy main --runtime docker ``` The helper always does the final public verification against: diff --git a/deployment/native/README.md b/deployment/native/README.md new file mode 100644 index 0000000..fed5b74 --- /dev/null +++ b/deployment/native/README.md @@ -0,0 +1,122 @@ +# Native Deployment + +This directory documents the host-native Islandflow rollout path used by: + +```bash +./deploy main --runtime native +./deploy current-branch --runtime native +``` + +This runtime is intended for faster server iteration during the transition away from Docker-only app rollouts. Local development should still prefer: + +- Docker for infra (`bun run dev:infra`) +- native Bun services (`bun run dev:services`) +- native Next.js web (`bun run dev:web`) + +## What native deploy means here + +The checked-in `deploy` helper assumes: + +- the live repo checkout is still `/home/delta/islandflow` +- Bun is installed on the VPS +- app processes are managed by `systemd` +- infrastructure services such as NATS, ClickHouse, and Redis are already reachable from the host +- the web app runs from `apps/web` and is served with `next start -p 3000` + +The deploy script updates the repo checkout, optionally runs `bun install --frozen-lockfile`, optionally rebuilds the web app, restarts the target systemd units, and then verifies the services locally on the VPS plus through the public app URL. + +## Expected unit names + +Default unit names used by `scripts/deploy.ts`: + +- `islandflow-web` +- `islandflow-api` +- `islandflow-compute` +- `islandflow-candles` +- `islandflow-ingest-options` +- `islandflow-ingest-equities` + +Override them from your local shell before running `./deploy` if the server uses different names: + +```bash +export DEPLOY_NATIVE_WEB_UNIT=my-web-unit +export DEPLOY_NATIVE_API_UNIT=my-api-unit +``` + +Available overrides: + +- `DEPLOY_NATIVE_WEB_UNIT` +- `DEPLOY_NATIVE_API_UNIT` +- `DEPLOY_NATIVE_COMPUTE_UNIT` +- `DEPLOY_NATIVE_CANDLES_UNIT` +- `DEPLOY_NATIVE_INGEST_OPTIONS_UNIT` +- `DEPLOY_NATIVE_INGEST_EQUITIES_UNIT` + +## systemctl invocation + +By default the deploy helper uses: + +```bash +sudo systemctl +``` + +If the server uses user units or another wrapper, override it locally before invoking `./deploy`: + +```bash +export DEPLOY_NATIVE_SYSTEMCTL_PREFIX="systemctl --user" +./deploy main --runtime native +``` + +## Partial native rollouts + +Examples: + +```bash +./deploy main --runtime native --web-only +./deploy main --runtime native --api-only +./deploy current-branch --runtime native --services-only +./deploy main --runtime native --web-only --no-build +``` + +Scope behavior: + +- default: restart web + API + backend services +- `--web-only`: rebuild/restart only the web unit +- `--api-only`: restart only the API unit +- `--services-only`: restart API + backend units without touching the web unit +- `--no-build`: skip `bun install --frozen-lockfile` and skip the web build step + +## Server preparation checklist + +Before the first native rollout, ensure the VPS has: + +1. Bun installed and on `PATH` +2. a working `/home/delta/islandflow/.env` (or unit-managed equivalent env source) +3. systemd units for each target service +4. the web unit configured to serve the built app on port `3000` +5. the API unit configured to serve health checks on port `4000` +6. infrastructure endpoints configured so the native services can reach NATS, ClickHouse, and Redis + +## Verification + +Native deploys verify: + +- target units are active via `systemctl` +- recent unit status and journal output can be collected +- local `http://127.0.0.1:4000/health` when API scope is included +- local `http://127.0.0.1:3000/` when web scope is included +- the public app URL from the local machine after the rollout finishes + +## Rollback + +Rollback remains manual for now: + +1. switch the server checkout back to the last known-good branch or commit +2. rerun the appropriate native deploy command +3. if needed, restart only the affected units with `systemctl` + +Docker remains available as the fallback runtime during the transition: + +```bash +./deploy main --runtime docker +``` diff --git a/docs/turns/2026-05-15-dual-runtime-deploy-workflow.html b/docs/turns/2026-05-15-dual-runtime-deploy-workflow.html new file mode 100644 index 0000000..7fe2a42 --- /dev/null +++ b/docs/turns/2026-05-15-dual-runtime-deploy-workflow.html @@ -0,0 +1,170 @@ + + + + + + 2026-05-15: Dual-runtime deploy workflow + + + +
+
+ Turn document + 2026-05-15 + Issue: islandflow-qh7 +
+

Dual-runtime deploy workflow

+

+ Updated the root deploy flow so it can target either the existing Docker Compose VPS runtime or a new host-native Bun + systemd runtime, while also adding partial deploy scopes for faster iteration. +

+ +
+

Summary

+

+ The deploy helper now supports --runtime docker and --runtime native, keeps Docker as the default, and adds --web-only, --api-only, --services-only, and --no-build. Documentation now clearly separates fast local development from VPS rollout options. +

+
+ +
+

Changes Made

+ +
+ +
+

Context

+

+ The repo already separated local infra from application processes: the root docker-compose.yml is infra-only, while services and the web app run through Bun scripts. The old deploy helper still assumed every server rollout was Docker-only. This change makes the deploy workflow match the new operating model: fast native iteration locally, Docker still available in production, and a native VPS path available during transition. +

+
+ +
+

Important Implementation Details

+ +
./deploy main --runtime docker --web-only
+./deploy main --runtime native --api-only
+./deploy current-branch --runtime docker --services-only
+./deploy main --runtime native --web-only --no-build
+ +
+ +
+

Validation

+ +
bun run scripts/deploy.ts --help
+bun run check:docker-workspace
+bun run scripts/deploy.ts main --runtime native --force-recreate
+bun run scripts/deploy.ts main --web-only --api-only
+
+ +
+

Issues, Limitations, and Mitigations

+ +
+ +
+

Follow-up Work

+ +
+
+ + diff --git a/scripts/deploy.ts b/scripts/deploy.ts index b76a393..f56598d 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -6,10 +6,20 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; type DeployMode = "main" | "current-branch"; +type DeployRuntime = "docker" | "native"; +type DeployScope = "full" | "web" | "api" | "services"; + +type DeployOptions = { + mode: DeployMode; + runtime: DeployRuntime; + scope: DeployScope; + forceRecreate: boolean; + noBuild: boolean; +}; const REMOTE_HOST = "delta@152.53.80.229"; const REMOTE_REPO = "/home/delta/islandflow"; -const REMOTE_DEPLOYMENT = "/home/delta/islandflow/deployment/docker"; +const REMOTE_DOCKER_DEPLOYMENT = "/home/delta/islandflow/deployment/docker"; const SSH_KEY = path.join(process.env.HOME ?? "", ".ssh", "delta_ed25519"); const SSH_OPTIONS = [ "-i", @@ -17,47 +27,83 @@ const SSH_OPTIONS = [ "-o", "IdentitiesOnly=yes", "-o", - "BatchMode=yes", + "BatchMode=yes" ]; const ALLOWED_REMOTE_UNTRACKED = new Set([ "deployment/docker/signal-cli-0.14.3-Linux-native.tar.gz", - "deployment/npm/", + "deployment/npm/" ]); -const API_CONTAINER = "islandflow-vps-api-1"; -const WEB_CONTAINER = "islandflow-vps-web-1"; const PUBLIC_APP_URL = process.env.DEPLOY_PUBLIC_APP_URL?.trim() || "https://flow.deltaisland.io"; const PUBLIC_API_HEALTH_URL = process.env.DEPLOY_PUBLIC_API_HEALTH_URL?.trim() || null; -const LOG_SERVICES = [ +const NATIVE_SYSTEMCTL_PREFIX = + process.env.DEPLOY_NATIVE_SYSTEMCTL_PREFIX?.trim() || "sudo systemctl"; +const NATIVE_UNITS = { + web: process.env.DEPLOY_NATIVE_WEB_UNIT?.trim() || "islandflow-web", + api: process.env.DEPLOY_NATIVE_API_UNIT?.trim() || "islandflow-api", + compute: process.env.DEPLOY_NATIVE_COMPUTE_UNIT?.trim() || "islandflow-compute", + candles: process.env.DEPLOY_NATIVE_CANDLES_UNIT?.trim() || "islandflow-candles", + ingestOptions: + process.env.DEPLOY_NATIVE_INGEST_OPTIONS_UNIT?.trim() || "islandflow-ingest-options", + ingestEquities: + process.env.DEPLOY_NATIVE_INGEST_EQUITIES_UNIT?.trim() || "islandflow-ingest-equities" +} as const; +const DOCKER_CORE_SERVICES = [ "api", "web", "compute", "candles", "ingest-options", - "ingest-equities", -]; + "ingest-equities" +] as const; +const DOCKER_BACKEND_SERVICES = [ + "api", + "compute", + "candles", + "ingest-options", + "ingest-equities" +] as const; const scriptPath = fileURLToPath(import.meta.url); const repoRoot = path.resolve(path.dirname(scriptPath), ".."); function usage(exitCode = 1): never { console.error(`Usage: - ./deploy main [--force-recreate] - ./deploy current-branch [--force-recreate] - ./deploy current branch [--force-recreate] + ./deploy main [--runtime docker|native] [--web-only|--api-only|--services-only] [--no-build] [--force-recreate] + ./deploy current-branch [--runtime docker|native] [--web-only|--api-only|--services-only] [--no-build] [--force-recreate] + ./deploy current branch [--runtime docker|native] [--web-only|--api-only|--services-only] [--no-build] [--force-recreate] Modes: main Deploy origin/main to the live server checkout. current-branch Push the current local branch, switch the server to it, and deploy it. +Runtimes: + docker Roll out from deployment/docker with Docker Compose (default). + native Roll out host-native Bun services managed by systemd. + +Scopes: + default Full rollout (web + API + backend services). + --web-only Deploy only the Next.js web surface. + --api-only Deploy only the API service. + --services-only Deploy API + backend services without the web service. + Options: - --force-recreate Escalation path for docker compose when a normal refresh is not enough. - --help Show this help text. + --runtime Explicit runtime selector (docker or native). + --no-build Skip docker image builds or native bun install/web build steps. + --force-recreate Docker-only escalation path for docker compose when a normal refresh is not enough. + --help Show this help text. Environment: - DEPLOY_PUBLIC_APP_URL Override the public app URL (default: https://flow.deltaisland.io). - DEPLOY_PUBLIC_API_HEALTH_URL Optional separate public API health URL for two-origin deployments.`); + DEPLOY_PUBLIC_APP_URL Override the public app URL (default: https://flow.deltaisland.io). + DEPLOY_PUBLIC_API_HEALTH_URL Optional separate public API health URL for two-origin deployments. + DEPLOY_NATIVE_SYSTEMCTL_PREFIX Override systemctl invocation for native rollouts (default: sudo systemctl). + DEPLOY_NATIVE_WEB_UNIT Override native web systemd unit name. + DEPLOY_NATIVE_API_UNIT Override native api systemd unit name. + DEPLOY_NATIVE_COMPUTE_UNIT Override native compute systemd unit name. + DEPLOY_NATIVE_CANDLES_UNIT Override native candles systemd unit name. + DEPLOY_NATIVE_INGEST_OPTIONS_UNIT Override native ingest-options systemd unit name. + DEPLOY_NATIVE_INGEST_EQUITIES_UNIT Override native ingest-equities systemd unit name.`); process.exit(exitCode); } @@ -74,13 +120,13 @@ function formatCommand(command: string, args: string[]): string { function runChecked( command: string, args: string[], - options: SpawnSyncOptions = {}, + options: SpawnSyncOptions = {} ): void { console.log(`$ ${formatCommand(command, args)}`); const result = spawnSync(command, args, { cwd: repoRoot, stdio: "inherit", - ...options, + ...options }); if (result.status !== 0) { @@ -91,13 +137,13 @@ function runChecked( function captureChecked( command: string, args: string[], - options: SpawnSyncOptions = {}, + options: SpawnSyncOptions = {} ): string { const result = spawnSync(command, args, { cwd: repoRoot, encoding: "utf8", stdio: ["inherit", "pipe", "pipe"], - ...options, + ...options }); if (result.status !== 0) { @@ -111,7 +157,7 @@ function captureChecked( function runRemoteScript( title: string, script: string, - args: string[] = [], + args: string[] = [] ): void { section(title); const sshArgs = [...SSH_OPTIONS, REMOTE_HOST, "bash", "-s", "--", ...args]; @@ -120,7 +166,7 @@ function runRemoteScript( cwd: repoRoot, input: script, encoding: "utf8", - stdio: ["pipe", "inherit", "inherit"], + stdio: ["pipe", "inherit", "inherit"] }); if (result.status !== 0) { @@ -128,28 +174,85 @@ function runRemoteScript( } } -function parseArgs(rawArgs: string[]): { - mode: DeployMode; - forceRecreate: boolean; -} { +function parseRuntime(rawArgs: string[]): DeployRuntime { + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === "--runtime") { + const value = rawArgs[index + 1]; + if (value === "docker" || value === "native") { + return value; + } + usage(); + } + + if (arg.startsWith("--runtime=")) { + const value = arg.slice("--runtime=".length); + if (value === "docker" || value === "native") { + return value; + } + usage(); + } + } + + return "docker"; +} + +function parseScope(rawArgs: string[]): DeployScope { + const scopes = [ + rawArgs.includes("--web-only") ? "web" : null, + rawArgs.includes("--api-only") ? "api" : null, + rawArgs.includes("--services-only") ? "services" : null + ].filter((value): value is Exclude => value !== null); + + if (scopes.length > 1) { + console.error("Choose only one deploy scope flag: --web-only, --api-only, or --services-only."); + process.exit(1); + } + + return scopes[0] ?? "full"; +} + +function parseArgs(rawArgs: string[]): DeployOptions { if (rawArgs.includes("--help") || rawArgs.includes("-h")) { usage(0); } + const runtime = parseRuntime(rawArgs); + const scope = parseScope(rawArgs); const forceRecreate = rawArgs.includes("--force-recreate"); - const positional = rawArgs.filter((arg) => arg !== "--force-recreate"); + const noBuild = rawArgs.includes("--no-build"); + const positional = rawArgs.filter( + (arg, index) => + arg !== "--force-recreate" && + arg !== "--no-build" && + arg !== "--web-only" && + arg !== "--api-only" && + arg !== "--services-only" && + arg !== "--runtime" && + rawArgs[index - 1] !== "--runtime" && + !arg.startsWith("--runtime=") + ); + + if (forceRecreate && runtime !== "docker") { + console.error("--force-recreate is only supported with --runtime docker."); + process.exit(1); + } if (positional.length === 1 && positional[0] === "main") { - return { mode: "main", forceRecreate }; + return { mode: "main", runtime, scope, forceRecreate, noBuild }; } if ( (positional.length === 1 && positional[0] === "current-branch") || - (positional.length === 2 && - positional[0] === "current" && - positional[1] === "branch") + (positional.length === 2 && positional[0] === "current" && positional[1] === "branch") ) { - return { mode: "current-branch", forceRecreate }; + return { + mode: "current-branch", + runtime, + scope, + forceRecreate, + noBuild + }; } usage(); @@ -162,28 +265,122 @@ function assertSshKeyExists(): void { } } -function localWorkspaceSnapshotPrecheck(): void { +function shellEscape(value: string): string { + if (value.length === 0) { + return "''"; + } + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function shellPattern(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function describeRuntime(runtime: DeployRuntime): string { + return runtime === "docker" ? "Docker Compose" : "native systemd/Bun"; +} + +function describeScope(scope: DeployScope): string { + switch (scope) { + case "web": + return "web only"; + case "api": + return "api only"; + case "services": + return "api + backend services"; + default: + return "full stack"; + } +} + +function scopeIncludesWeb(scope: DeployScope): boolean { + return scope === "full" || scope === "web"; +} + +function scopeIncludesApi(scope: DeployScope): boolean { + return scope === "full" || scope === "api" || scope === "services"; +} + +function dockerServicesForScope(scope: DeployScope): string[] { + switch (scope) { + case "web": + return ["web"]; + case "api": + return ["api"]; + case "services": + return [...DOCKER_BACKEND_SERVICES]; + default: + return []; + } +} + +function dockerLogServicesForScope(scope: DeployScope): string[] { + switch (scope) { + case "web": + return ["web"]; + case "api": + return ["api"]; + case "services": + return [...DOCKER_BACKEND_SERVICES]; + default: + return [...DOCKER_CORE_SERVICES]; + } +} + +function nativeUnitsForScope(scope: DeployScope): string[] { + switch (scope) { + case "web": + return [NATIVE_UNITS.web]; + case "api": + return [NATIVE_UNITS.api]; + case "services": + return [ + NATIVE_UNITS.api, + NATIVE_UNITS.compute, + NATIVE_UNITS.candles, + NATIVE_UNITS.ingestOptions, + NATIVE_UNITS.ingestEquities + ]; + default: + return [ + NATIVE_UNITS.web, + NATIVE_UNITS.api, + NATIVE_UNITS.compute, + NATIVE_UNITS.candles, + NATIVE_UNITS.ingestOptions, + NATIVE_UNITS.ingestEquities + ]; + } +} + +function localDockerWorkspaceSnapshotPrecheck(): void { console.log("$ bun run check:docker-workspace"); const result = spawnSync("bun", ["run", "check:docker-workspace"], { cwd: repoRoot, - stdio: "inherit", + stdio: "inherit" }); if (result.status !== 0) { console.error( - "Refusing deploy: deployment/docker/workspace-root is out of sync. Run `bun run sync:docker-workspace`, commit updated snapshot files, then retry deploy.", + "Refusing docker deploy: deployment/docker/workspace-root is out of sync. Run `bun run sync:docker-workspace`, commit updated snapshot files, then retry deploy." ); process.exit(result.status ?? 1); } } -function localMainPrecheck(): void { +function localRuntimePrecheck(runtime: DeployRuntime, noBuild: boolean): void { + if (runtime === "docker" && !noBuild) { + localDockerWorkspaceSnapshotPrecheck(); + } +} + +function localMainPrecheck(runtime: DeployRuntime, noBuild: boolean): void { section("Local Precheck"); runChecked("git", ["fetch", "origin"]); runChecked("git", ["status", "--short", "--branch"]); runChecked("git", ["rev-parse", "--verify", "HEAD"]); runChecked("git", ["rev-parse", "origin/main"]); - localWorkspaceSnapshotPrecheck(); + localRuntimePrecheck(runtime, noBuild); } function currentBranchName(): string { @@ -195,7 +392,11 @@ function currentBranchName(): string { return branch; } -function localBranchPrecheck(branch: string): void { +function localBranchPrecheck( + branch: string, + runtime: DeployRuntime, + noBuild: boolean +): void { section("Local Precheck"); runChecked("git", ["branch", "--show-current"]); runChecked("git", ["status", "--short", "--branch"]); @@ -204,12 +405,12 @@ function localBranchPrecheck(branch: string): void { const porcelain = captureChecked("git", ["status", "--porcelain=v1"]).trim(); if (porcelain) { console.error( - `Refusing to deploy ${branch} with uncommitted local changes. Commit the intended state first.`, + `Refusing to deploy ${branch} with uncommitted local changes. Commit the intended state first.` ); process.exit(1); } - localWorkspaceSnapshotPrecheck(); + localRuntimePrecheck(runtime, noBuild); } function publishCurrentBranch(branch: string): void { @@ -220,8 +421,8 @@ function publishCurrentBranch(branch: string): void { { cwd: repoRoot, encoding: "utf8", - stdio: ["inherit", "pipe", "pipe"], - }, + stdio: ["inherit", "pipe", "pipe"] + } ); if (upstreamResult.status === 0) { @@ -232,9 +433,9 @@ function publishCurrentBranch(branch: string): void { runChecked("git", ["push", "-u", "origin", branch]); } -function remotePrecheck(): void { +function remoteGitPrecheck(): void { const allowedRemoteUntrackedPattern = Array.from(ALLOWED_REMOTE_UNTRACKED) - .map((path) => shellPattern(path)) + .map((value) => shellPattern(value)) .join("|"); runRemoteScript( @@ -242,7 +443,7 @@ function remotePrecheck(): void { `#!/usr/bin/env bash set -euo pipefail -cd "${REMOTE_REPO}" +cd ${shellEscape(REMOTE_REPO)} status="$(git status --porcelain=v1 --branch)" git status --short --branch git branch --show-current @@ -269,104 +470,268 @@ while IFS= read -r line; do ;; esac done <<< "$status" -`, +` ); } -function remoteRollout( - mode: DeployMode, - branch: string | null, - forceRecreate: boolean, -): void { - const composeArgs = forceRecreate - ? "up -d --build --force-recreate" - : "up -d --build"; +function remoteRuntimePrecheck(runtime: DeployRuntime, scope: DeployScope): void { + if (runtime === "docker") { + runRemoteScript( + "Remote Runtime Precheck", + `#!/usr/bin/env bash +set -euo pipefail + +cd ${shellEscape(REMOTE_DOCKER_DEPLOYMENT)} +command -v docker >/dev/null 2>&1 + +docker compose version >/dev/null +` + ); + return; + } + + const units = nativeUnitsForScope(scope).map((value) => shellEscape(value)).join(" "); + runRemoteScript( + "Remote Runtime Precheck", + `#!/usr/bin/env bash +set -euo pipefail + +cd ${shellEscape(REMOTE_REPO)} +command -v bun >/dev/null 2>&1 +command -v systemctl >/dev/null 2>&1 + +declare -a units=(${units}) +for unit in "\${units[@]}"; do + load_state="$(${NATIVE_SYSTEMCTL_PREFIX} show --property=LoadState --value "$unit" 2>/dev/null || true)" + if [[ -z "$load_state" || "$load_state" == "not-found" ]]; then + echo "Refusing native rollout: missing systemd unit $unit" >&2 + echo "See deployment/native/README.md for expected unit names and overrides." >&2 + exit 1 + fi +done +` + ); +} + +function remoteGitUpdateScript(mode: DeployMode, branch: string | null): string { + const escapedBranch = branch ? shellEscape(branch) : null; const switchCommand = mode === "main" - ? `git switch main -git pull --ff-only origin main` - : `git switch ${shellEscape(branch!)} || git switch -c ${shellEscape(branch!)} --track origin/${shellEscape(branch!)} -git pull --ff-only origin ${shellEscape(branch!)}`; + ? `git switch main\ngit pull --ff-only origin main` + : `git switch ${escapedBranch} || git switch -c ${escapedBranch} --track origin/${escapedBranch}\ngit pull --ff-only origin ${escapedBranch}`; + + return `cd ${shellEscape(REMOTE_REPO)}\ngit fetch origin\n${switchCommand}`; +} + +function remoteDockerRollout( + mode: DeployMode, + branch: string | null, + scope: DeployScope, + forceRecreate: boolean, + noBuild: boolean +): void { + const services = dockerServicesForScope(scope); + const args = ["up", "-d"]; + if (!noBuild) { + args.push("--build"); + } + if (forceRecreate) { + args.push("--force-recreate"); + } + const command = `docker compose ${[...args, ...services].join(" ")}`; runRemoteScript( "Remote Rollout", `#!/usr/bin/env bash set -euo pipefail -cd "${REMOTE_REPO}" -git fetch origin -${switchCommand} +${remoteGitUpdateScript(mode, branch)} -cd "${REMOTE_DEPLOYMENT}" -docker compose ${composeArgs} -`, +cd ${shellEscape(REMOTE_DOCKER_DEPLOYMENT)} +${command} +` ); } -function remoteVerification(): void { +function remoteNativeRollout( + mode: DeployMode, + branch: string | null, + scope: DeployScope, + noBuild: boolean +): void { + const units = nativeUnitsForScope(scope).map((value) => shellEscape(value)).join(" "); + const buildSteps: string[] = []; + + if (!noBuild) { + buildSteps.push("bun install --frozen-lockfile"); + if (scopeIncludesWeb(scope)) { + buildSteps.push("bun --cwd=apps/web run build"); + } + } + + buildSteps.push(`${NATIVE_SYSTEMCTL_PREFIX} restart ${nativeUnitsForScope(scope).map((value) => shellEscape(value)).join(" ")}`); + + runRemoteScript( + "Remote Rollout", + `#!/usr/bin/env bash +set -euo pipefail + +${remoteGitUpdateScript(mode, branch)} + +cd ${shellEscape(REMOTE_REPO)} +${buildSteps.join("\n")} + +declare -a units=(${units}) +for unit in "\${units[@]}"; do + ${NATIVE_SYSTEMCTL_PREFIX} is-active --quiet "$unit" +done +` + ); +} + +function remoteRollout( + mode: DeployMode, + runtime: DeployRuntime, + branch: string | null, + scope: DeployScope, + forceRecreate: boolean, + noBuild: boolean +): void { + if (runtime === "docker") { + remoteDockerRollout(mode, branch, scope, forceRecreate, noBuild); + return; + } + + remoteNativeRollout(mode, branch, scope, noBuild); +} + +function remoteDockerVerification(scope: DeployScope): void { + const psServices = dockerServicesForScope(scope); + const logServices = dockerLogServicesForScope(scope); + const psCommand = + psServices.length > 0 + ? `docker compose ps ${psServices.join(" ")}` + : "docker compose ps"; + const logCommand = `docker compose logs --tail=100 ${logServices.join(" ")}`; + const checks: string[] = []; + + if (scopeIncludesApi(scope)) { + checks.push( + `docker compose exec -T api bun -e 'const r = await fetch("http://127.0.0.1:4000/health"); if (!r.ok) throw new Error("api healthcheck failed: " + r.status); console.log(await r.text())'` + ); + } + + if (scopeIncludesWeb(scope)) { + checks.push( + `docker compose exec -T web bun -e 'const r = await fetch("http://127.0.0.1:3000/"); if (!r.ok) throw new Error("web healthcheck failed: " + r.status); console.log(r.status)'` + ); + } + runRemoteScript( "Remote Verification", `#!/usr/bin/env bash set -euo pipefail -cd "${REMOTE_DEPLOYMENT}" -docker compose ps -docker compose logs --tail=100 ${LOG_SERVICES.join(" ")} -docker exec ${API_CONTAINER} bun -e 'const r = await fetch("http://127.0.0.1:4000/health"); console.log(await r.text())' -docker exec ${WEB_CONTAINER} bun -e 'const r = await fetch("http://127.0.0.1:3000/"); console.log(r.status)' -`, +cd ${shellEscape(REMOTE_DOCKER_DEPLOYMENT)} +${psCommand} +${logCommand} +${checks.join("\n")} +` ); } -function publicVerification(): void { +function remoteNativeVerification(scope: DeployScope): void { + const units = nativeUnitsForScope(scope).map((value) => shellEscape(value)).join(" "); + const checks: string[] = []; + + if (scopeIncludesApi(scope)) { + checks.push('curl -fksS http://127.0.0.1:4000/health'); + } + + if (scopeIncludesWeb(scope)) { + checks.push('curl -I -fksS http://127.0.0.1:3000/'); + } + + runRemoteScript( + "Remote Verification", + `#!/usr/bin/env bash +set -euo pipefail + +declare -a units=(${units}) +for unit in "\${units[@]}"; do + ${NATIVE_SYSTEMCTL_PREFIX} is-active --quiet "$unit" + ${NATIVE_SYSTEMCTL_PREFIX} status --no-pager "$unit" || true + journalctl -u "$unit" -n 50 --no-pager || true +done +${checks.join("\n")} +` + ); +} + +function remoteVerification(runtime: DeployRuntime, scope: DeployScope): void { + if (runtime === "docker") { + remoteDockerVerification(scope); + return; + } + + remoteNativeVerification(scope); +} + +function publicVerification(scope: DeployScope): void { section("Public Verification"); runChecked("curl", ["-I", "-fksS", PUBLIC_APP_URL]); - if (PUBLIC_API_HEALTH_URL) { + if (scopeIncludesApi(scope) && PUBLIC_API_HEALTH_URL) { runChecked("curl", ["-fksS", PUBLIC_API_HEALTH_URL]); return; } - console.log( - "Skipping separate public API health check; same-origin mode relies on the public app check plus container-local API verification.", - ); -} - -function shellEscape(value: string): string { - if (value.length === 0) { - return "''"; + if (scopeIncludesApi(scope)) { + console.log( + "Skipping separate public API health check; same-origin mode relies on the public app check plus runtime-local API verification." + ); } - return `'${value.replace(/'/g, `'\"'\"'`)}'`; -} - -function shellPattern(value: string): string { - return `'${value.replace(/'/g, `'\"'\"'`)}'`; } function main(): void { - const { mode, forceRecreate } = parseArgs(process.argv.slice(2)); + const options = parseArgs(process.argv.slice(2)); assertSshKeyExists(); console.log( - mode === "main" - ? "Deploying origin/main to the existing Islandflow VPS checkout." - : "Deploying the current local branch to the existing Islandflow VPS checkout.", + `Deploying ${options.mode === "main" ? "origin/main" : "the current local branch"} ` + + `via ${describeRuntime(options.runtime)} (${describeScope(options.scope)}).` ); - if (mode === "main") { - localMainPrecheck(); - remotePrecheck(); - remoteRollout(mode, null, forceRecreate); + if (options.mode === "main") { + localMainPrecheck(options.runtime, options.noBuild); + remoteGitPrecheck(); + remoteRuntimePrecheck(options.runtime, options.scope); + remoteRollout( + options.mode, + options.runtime, + null, + options.scope, + options.forceRecreate, + options.noBuild + ); } else { const branch = currentBranchName(); - localBranchPrecheck(branch); + localBranchPrecheck(branch, options.runtime, options.noBuild); publishCurrentBranch(branch); - remotePrecheck(); - remoteRollout(mode, branch, forceRecreate); + remoteGitPrecheck(); + remoteRuntimePrecheck(options.runtime, options.scope); + remoteRollout( + options.mode, + options.runtime, + branch, + options.scope, + options.forceRecreate, + options.noBuild + ); } - remoteVerification(); - publicVerification(); + remoteVerification(options.runtime, options.scope); + publicVerification(options.scope); } main();