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
+
+ - Refactored
scripts/deploy.ts into shared git/publish logic plus runtime-specific precheck, rollout, and verification paths.
+ - Removed Docker verification’s dependence on hardcoded container names and switched to
docker compose exec.
+ - Added native deployment support that assumes Bun plus systemd-managed units on the VPS.
+ - Added a new operator guide at
deployment/native/README.md.
+ - Updated
README.md to emphasize the preferred fast local loop: Docker infra only, native Bun services, native web dev.
+ - Updated
deployment/docker/README.md to document Docker as the default runtime and show new partial rollout examples.
+
+
+
+
+ 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
+
+ - Docker remains the default runtime, so
./deploy main still works without extra flags.
+ - Native rollouts are invoked with
./deploy main --runtime native or ./deploy current-branch --runtime native.
+ - Partial scopes are mutually exclusive and intentionally simple:
+
+ ./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
+
+ - Docker workspace snapshot validation now runs only when a Docker rollout will build images.
+ - Native rollouts assume systemd unit names like
islandflow-web and islandflow-api, but those names can be overridden with environment variables such as DEPLOY_NATIVE_WEB_UNIT.
+ - The native path also allows overriding the systemctl wrapper via
DEPLOY_NATIVE_SYSTEMCTL_PREFIX, which is useful for systemctl --user setups.
+
+
+
+
+ Validation
+
+ - Passed:
bun run scripts/deploy.ts --help
+ - Passed:
bun run check:docker-workspace
+ - Passed: invalid-flag guard for
--runtime native --force-recreate
+ - Passed: conflicting-scope guard for
--web-only --api-only
+
+ 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
+
+ - Native deploys assume server-side systemd units already exist. Mitigation: added
deployment/native/README.md documenting expected unit names and override variables.
+ - Rollback is still manual. Mitigation: both Docker and native docs now frame runtime selection as a transition path, with Docker preserved as a fallback.
+ - No native service unit files were added in this change. This keeps the scope focused on the deploy workflow itself.
+ - Public verification still centers on the hosted app URL. API verification remains local-to-runtime unless
DEPLOY_PUBLIC_API_HEALTH_URL is configured.
+
+
+
+
+ Follow-up Work
+
+ - Implementation tracked in
islandflow-qh7 is complete for the deploy helper itself.
+ - Open follow-up:
islandflow-38p, add checked-in native deployment unit templates and rollback helpers.
+
+
+
+
+
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();