Files
bitbonsai__mcpvault/scripts/ship.ts
2026-08-26 10:20:21 +02:00

2458 lines
124 KiB
TypeScript

#!/usr/bin/env bun
/**
* Project-owned client for shibumi-server.
*
* `bun ship:setup` connects this repository to one server and registers the
* app. Later, `bun ship` checks local work, pushes one commit, triggers it
* over SSH, and follows status until the app is healthy. `bun ship:webhook`
* is the opt-in for push-to-deploy; `--off` reverses it.
*
* Commit this file and shibumi-server.json. SSH targets stay in machine-local
* Shibumi config. Webhook secrets stay on the server and pass directly to GitHub CLI.
*/
import { cancel, confirm, intro, isCancel, log, outro, select, spinner as animatedSpinner, text } from "@clack/prompts";
import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { createConnection } from "node:net";
import { dirname, join, resolve } from "node:path";
const root = process.cwd();
const configPath = join(root, "shibumi-server.json");
const DOMAIN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const SSH_TARGET = /^(?!-)[A-Za-z0-9_.@:-]+$/;
const SERVER_HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/;
const COMMIT = /^[a-f0-9]{40}$/;
const SERVER_CLI = "~/.local/bin/shibumi-server";
const LATEST_SOURCE = "https://shibumistack.dev/ship/latest.ts";
const CURRENT_SOURCE = "https://shibumistack.dev/ship/v48.ts";
let sshControlDirectory: string | undefined;
let sshControlTarget: string | undefined;
export function supportsTerminalColor(env: NodeJS.ProcessEnv = process.env, isTTY = Boolean(process.stdout.isTTY)): boolean {
return isTTY && !("NO_COLOR" in env) && env.TERM !== "dumb";
}
const accent = (value: string) => supportsTerminalColor()
? `\x1b[38;5;208m${value}\x1b[0m`
: value;
interface ClientConfig {
version: 1;
provider: "shibumi-server";
server: { hostname: string };
domain: string;
appId: string;
repository: `github:${string}`;
branch: string;
webhookUrl: string;
service: string;
port: number;
healthPath: string;
deploymentMode: "build" | "prebuilt";
trigger: "ship" | "github-push";
platform?: `linux/${string}`;
cutoverRequired: boolean;
}
interface Result {
exitCode: number;
stdout: string;
stderr: string;
}
interface DeployStatus {
commit: string;
state: "accepted" | "running" | "succeeded" | "failed";
stage: string;
message?: string;
output?: string;
url?: string;
queuedCommit?: string;
updatedAt: string;
}
interface HistoryEntry {
commit?: string;
kind?: string;
state?: string;
stage?: string;
durationMs?: number;
}
interface ShipOptions {
setup: boolean;
update: boolean;
rollback: boolean;
logs: boolean;
status: boolean;
dev: boolean;
webhook: boolean;
off: boolean;
rebuild: boolean;
yes: boolean;
interactive: boolean;
publicRepo: boolean;
server?: string;
domain?: string;
staticSite: boolean;
outputDir?: string;
buildScript?: string;
spa: boolean;
noSpa?: boolean;
}
export interface StaticSiteConfig {
outputDir: string;
buildScript?: string;
spa: boolean;
}
let options: ShipOptions = { setup: false, update: false, rollback: false, logs: false, status: false, dev: false, webhook: false, off: false, rebuild: false, yes: false, interactive: false, publicRepo: false, staticSite: false, spa: false };
let agentRun = false;
export function isAgentExecution(env: NodeJS.ProcessEnv = process.env, stdinTTY = Boolean(process.stdin.isTTY), stdoutTTY = Boolean(process.stdout.isTTY)): boolean {
return !stdinTTY || !stdoutTTY || env.PI_CODING_AGENT === "true" || env.CLAUDECODE === "1"
|| Object.keys(env).some((key) => /^(?:CODEX_|CURSOR_AGENT|AIDER_)/.test(key));
}
export function shouldAnimateProgress(agentExecution: boolean, stdoutTTY: boolean): boolean {
return !agentExecution && stdoutTTY;
}
type ShipSpinner = {
start(message?: string): void;
message(message?: string): void;
stop(message?: string, code?: number): void;
};
function spinner(): ShipSpinner {
if (shouldAnimateProgress(agentRun, Boolean(process.stdout.isTTY))) {
// Clack 1.x split error stops into error(); 0.7 (pinned by older installers)
// only has stop(message, code). Support both at runtime.
const animated = animatedSpinner() as ShipSpinner & { error?: (message?: string) => void };
return {
start: (message) => animated.start(message),
message: (message) => animated.message(message),
stop: (message, code) => code && typeof animated.error === "function"
? animated.error(message)
: animated.stop(message, code),
};
}
const write = (message?: string, error = false) => {
if (message) (error ? process.stderr : process.stdout).write(`${message}\n`);
};
return {
start: (message) => write(message),
message: (message) => write(message),
stop: (message, code) => write(message, Boolean(code)),
};
}
export function parseShipArgs(args: string[]): ShipOptions {
const parsed: ShipOptions = { setup: false, update: false, rollback: false, logs: false, status: false, dev: false, webhook: false, off: false, rebuild: false, yes: false, interactive: false, publicRepo: false, staticSite: false, spa: false };
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (argument === "--") continue;
if (argument === "--setup") parsed.setup = true;
else if (argument === "--update") parsed.update = true;
else if (argument === "--rollback") parsed.rollback = true;
else if (argument === "--logs") parsed.logs = true;
else if (argument === "--status") parsed.status = true;
else if (argument === "--dev") parsed.dev = true;
else if (argument === "--webhook") parsed.webhook = true;
else if (argument === "--off") parsed.off = true;
else if (argument === "--rebuild") parsed.rebuild = true;
else if (argument === "--yes" || argument === "-y") parsed.yes = true;
else if (argument === "--interactive") parsed.interactive = true;
else if (argument === "--public") parsed.publicRepo = true;
else if (argument === "--static") parsed.staticSite = true;
else if (argument === "--spa") parsed.spa = true;
else if (argument === "--no-spa") parsed.noSpa = true;
else if (argument === "--server" || argument === "--domain" || argument === "--output-dir" || argument === "--build-script") {
const value = args[index + 1];
if (!value || value.startsWith("-")) throw new Error(`${argument} requires a value`);
if (argument === "--server") parsed.server = value;
else if (argument === "--domain") parsed.domain = value;
else if (argument === "--output-dir") parsed.outputDir = value;
else parsed.buildScript = value;
index += 1;
} else throw new Error(`unknown ship option: ${argument}`);
}
if ([parsed.setup, parsed.update, parsed.rollback, parsed.logs, parsed.status, parsed.dev, parsed.webhook].filter(Boolean).length > 1) throw new Error("choose only one ship action");
if (parsed.rebuild && (parsed.setup || parsed.update || parsed.rollback || parsed.logs || parsed.status || parsed.dev || parsed.webhook)) throw new Error("--rebuild applies only to shipping");
if (parsed.off && !parsed.webhook) throw new Error("--off requires --webhook");
if (parsed.interactive && !parsed.setup) throw new Error("--interactive requires --setup");
if (parsed.publicRepo && !parsed.setup) throw new Error("--public requires --setup");
if (parsed.interactive && parsed.yes) throw new Error("--interactive and --yes are mutually exclusive");
if (parsed.spa && parsed.noSpa) throw new Error("--spa and --no-spa are mutually exclusive");
if ((parsed.staticSite || parsed.outputDir || parsed.buildScript || parsed.spa || parsed.noSpa) && !parsed.setup) throw new Error("--static, --output-dir, --build-script, and --spa require --setup");
if ((parsed.outputDir || parsed.buildScript || parsed.spa || parsed.noSpa) && !parsed.staticSite) throw new Error("--output-dir, --build-script, and --spa require --static");
if (parsed.outputDir) {
const problem = staticOutputDirProblem(parsed.outputDir);
if (problem) throw new Error(problem);
}
if (parsed.buildScript && !/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(parsed.buildScript)) throw new Error("--build-script must be a package.json script name");
if (parsed.server && !SSH_TARGET.test(parsed.server)) throw new Error("--server must be an SSH host or user@host without spaces");
if (parsed.domain && !DOMAIN.test(parsed.domain)) throw new Error("--domain must be a lowercase public hostname");
return parsed;
}
export function stripDockerDesktopLinks(value: string): string {
return value.split(/\r?\n/).filter((line) => !/docker-desktop:\/\//i.test(line)).join("\n");
}
export function dockerCredentialHelpers(value: unknown): string[] {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
const config = value as Record<string, unknown>;
const helpers = new Set<string>();
if (typeof config.credsStore === "string" && config.credsStore) helpers.add(config.credsStore);
if (config.credHelpers && typeof config.credHelpers === "object" && !Array.isArray(config.credHelpers)) {
for (const helper of Object.values(config.credHelpers)) if (typeof helper === "string" && helper) helpers.add(helper);
}
return [...helpers];
}
export function removeDockerCredentialHelper(value: Record<string, unknown>, helper: string): void {
if (value.credsStore === helper) delete value.credsStore;
if (!value.credHelpers || typeof value.credHelpers !== "object" || Array.isArray(value.credHelpers)) return;
const helpers = value.credHelpers as Record<string, unknown>;
for (const [registry, configured] of Object.entries(helpers)) if (configured === helper) delete helpers[registry];
if (Object.keys(helpers).length === 0) delete value.credHelpers;
}
export function formatDuration(milliseconds: number): string {
const seconds = Math.max(1, Math.round(milliseconds / 1_000));
if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes} minute${minutes === 1 ? "" : "s"}${remainder ? ` ${remainder} second${remainder === 1 ? "" : "s"}` : ""}`;
}
export function latestDeployDuration(history: HistoryEntry[]): number | undefined {
return [...history].reverse().find((entry) => entry.kind === "webhook" && entry.state === "succeeded"
&& Number.isInteger(entry.durationMs) && entry.durationMs! > 0)?.durationMs;
}
interface ClientSettings {
version: 1;
defaultServer?: string;
servers: Record<string, { sshTarget: string }>;
}
export function clientSettingsPath(env: NodeJS.ProcessEnv = process.env, home = homedir()): string {
return join(env.XDG_CONFIG_HOME || join(home, ".config"), "shibumi", "config.json");
}
async function readClientSettings(): Promise<ClientSettings> {
try {
const value: unknown = JSON.parse(await readFile(clientSettingsPath(), "utf8"));
if (!value || typeof value !== "object" || Array.isArray(value) || (value as { version?: unknown }).version !== 1) throw new Error("invalid client settings");
const servers = (value as { servers?: unknown }).servers;
const defaultServer = (value as { defaultServer?: unknown }).defaultServer;
if (!servers || typeof servers !== "object" || Array.isArray(servers)
|| Object.entries(servers).some(([hostname, entry]) => !SERVER_HOSTNAME.test(hostname)
|| !entry || typeof entry !== "object" || Array.isArray(entry)
|| typeof (entry as { sshTarget?: unknown }).sshTarget !== "string"
|| !SSH_TARGET.test((entry as { sshTarget: string }).sshTarget))
|| (defaultServer !== undefined && (typeof defaultServer !== "string" || !(defaultServer in servers)))) throw new Error("invalid client settings");
return value as ClientSettings;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, servers: {} };
throw new Error(`${clientSettingsPath()} is invalid.\n\nNext: repair or remove this local client config, then retry.`);
}
}
async function savedSshTarget(hostname?: string): Promise<string | undefined> {
const settings = await readClientSettings();
const configured = hostname ? settings.servers[hostname]?.sshTarget : undefined;
if (configured) {
log.info(`Server ${configured}\nConfig ${clientSettingsPath()}`);
return configured;
}
if (hostname) return undefined;
const servers = Object.entries(settings.servers);
if (servers.length === 0) return undefined;
const fallback = settings.defaultServer && settings.servers[settings.defaultServer]
? settings.servers[settings.defaultServer].sshTarget
: servers[0][1].sshTarget;
if (options.yes) {
log.info(`Server ${fallback}\nConfig ${clientSettingsPath()}`);
return fallback;
}
if (agentRun) throw new Error(`Deployment server confirmation required.\n\nAgent: ask user whether to use ${fallback}, then run bun ship:setup --server <target>.`);
const answer = await text({
message: "SSH server",
placeholder: fallback,
validate: (value) => SSH_TARGET.test(value || fallback) ? undefined : "Use an SSH host, user@host, or SSH alias without spaces",
});
return isCancel(answer) ? undefined : answer || fallback;
}
async function rememberSshTarget(hostname: string, sshTarget: string): Promise<void> {
if (!SERVER_HOSTNAME.test(hostname) || !SSH_TARGET.test(sshTarget)) throw new Error("resolved SSH server is unsafe");
const settings = await readClientSettings();
if (settings.servers[hostname]?.sshTarget === sshTarget) return;
settings.servers[hostname] = { sshTarget };
settings.defaultServer = hostname;
const path = clientSettingsPath();
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
const temporary = `${path}.tmp-${process.pid}`;
await writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
await chmod(temporary, 0o600);
await rename(temporary, path);
log.success(`Saved server ${sshTarget} in ${path}`);
}
function planSetup(): boolean {
return !options.interactive && !options.yes && !agentRun
&& Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
}
async function approve(message: string): Promise<boolean> {
if (options.yes || agentRun) return true;
const accepted = await confirm({ message, initialValue: true });
return !isCancel(accepted) && accepted;
}
// Setup asks two questions, shows the plan, and runs it on one confirm. That
// confirm answers exactly the steps the plan enumerated and nothing else:
// anything the plan never named keeps asking for itself, including the GitHub
// sign-in (it opens a browser) and the Caddy cutover (it moves public
// traffic). `--setup --interactive` restores the per-step gates.
let planApproved = false;
async function approvePlanned(message: string): Promise<boolean> {
return planApproved ? true : approve(message);
}
function explain(title: string, message: string): void {
log.info(`${title}\n${message}`);
}
// Run argument arrays directly. Avoiding a shell keeps repository and SSH input
// from becoming executable command text.
async function run(args: string[], options: {
input?: string;
inputFile?: string;
cwd?: string;
inherit?: boolean;
allowFailure?: boolean;
} = {}): Promise<Result> {
const inherit = options.inherit ?? false;
const child = Bun.spawn(args, {
cwd: options.cwd ?? root,
stdin: options.inputFile ? Bun.file(options.inputFile) : options.input === undefined ? (inherit ? "inherit" : "ignore") : "pipe",
stdout: inherit ? "inherit" : "pipe",
stderr: inherit ? "inherit" : "pipe",
});
if (options.input !== undefined) {
if (!child.stdin || typeof child.stdin === "number") throw new Error("cannot open command input");
child.stdin.write(options.input);
child.stdin.end();
}
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
inherit ? Promise.resolve("") : new Response(child.stdout).text(),
inherit ? Promise.resolve("") : new Response(child.stderr).text(),
]);
if (exitCode !== 0 && !options.allowFailure) throw new Error(stripDockerDesktopLinks(stderr).trim() || `${args[0]} exited with ${exitCode}`);
return { exitCode, stdout, stderr };
}
async function git(...args: string[]): Promise<string> {
return (await run(["git", ...args])).stdout.trim();
}
const setupFiles = ["package.json", "bun.lock", "scripts/ship.ts", "shibumi-server.json"];
type SetupCommit = "none" | "committed" | "declined";
async function offerSetupCommit(config: ClientConfig): Promise<SetupCommit> {
const changed: string[] = [];
for (const file of setupFiles) {
const status = await run(["git", "status", "--porcelain", "--", file]);
if (status.stdout.trim()) changed.push(file);
}
if (changed.length === 0) return "none";
const updateOnly = changed.length === 1 && changed[0] === "scripts/ship.ts";
if (await githubBranchIsProtected(config)) {
log.warn(`Branch ${config.branch} is protected. Ship will not commit setup changes directly.\nNext: git switch -c shibumi/setup, commit the Shibumi files, then open a pull request.`);
return "declined";
}
const trackedConfig = (await run(["git", "ls-files", "--error-unmatch", "shibumi-server.json"], { allowFailure: true })).exitCode === 0;
if (!await approvePlanned(updateOnly ? "Commit ship client update now?" : "Commit deployment setup now?")) return "declined";
await run(["git", "add", "--", ...changed]);
await run(["git", "commit", "--only", "-m", trackedConfig ? "Update Shibumi deployment" : "Add Shibumi deployment", "--", ...changed], { inherit: true });
log.success(updateOnly ? "Committed ship client update" : "Committed Shibumi deployment setup");
return "committed";
}
// Double literal hyphens before replacing dots so different domains cannot
// collapse to the same server app ID.
export function appIdForDomain(domain: string): string {
return domain.replaceAll("-", "--").replaceAll(".", "-");
}
export function terminalHistory(entries: HistoryEntry[], commit: string): HistoryEntry | undefined {
return entries.findLast((entry) => entry.commit === commit && ["succeeded", "failed"].includes(entry.state ?? ""));
}
export function canFollowDeployment(status: Pick<DeployStatus, "commit" | "state"> | undefined, commit: string): boolean {
return status?.commit === commit && ["accepted", "running", "succeeded"].includes(status.state ?? "");
}
export function shouldTriggerRedeploy(trigger: ClientConfig["trigger"], ahead: number): boolean {
return trigger === "ship" || ahead === 0;
}
export function protectedPushBlocked(value: unknown, login?: string, admin = false): boolean | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const protection = value as {
enforce_admins?: { enabled?: unknown };
required_pull_request_reviews?: {
bypass_pull_request_allowances?: { users?: Array<{ login?: unknown }> };
} | null;
};
const reviews = protection.required_pull_request_reviews;
if (!reviews) return false;
const bypassed = login && reviews.bypass_pull_request_allowances?.users
?.some((user) => user.login === login);
if (bypassed || (admin && protection.enforce_admins?.enabled === false)) return false;
return true;
}
const branchProtection = new Map<string, boolean | undefined>();
async function githubBranchIsProtected(config: ClientConfig): Promise<boolean> {
const repository = config.repository.slice("github:".length);
const key = `${repository}#${config.branch}`;
if (!branchProtection.has(key)) {
const endpoint = `repos/${repository}/branches/${encodeURIComponent(config.branch)}/protection`;
try {
if (Bun.which("gh")) {
const details = await run(["gh", "api", endpoint], { allowFailure: true });
if (details.exitCode === 0) {
const protection: unknown = JSON.parse(details.stdout);
if (protectedPushBlocked(protection) === false) branchProtection.set(key, false);
else {
const [viewer, repo] = await Promise.all([
run(["gh", "api", "user", "--jq", ".login"], { allowFailure: true }),
run(["gh", "api", `repos/${repository}`, "--jq", ".permissions.admin"], { allowFailure: true }),
]);
branchProtection.set(key, protectedPushBlocked(
protection,
viewer.exitCode === 0 ? viewer.stdout.trim() : undefined,
repo.exitCode === 0 && repo.stdout.trim() === "true",
));
}
}
}
if (!branchProtection.has(key)) {
const response = await fetch(`https://api.github.com/${endpoint}`, {
headers: { Accept: "application/vnd.github+json", "User-Agent": "shibumi-ship" },
signal: AbortSignal.timeout(5_000),
});
branchProtection.set(key, response.status === 404 ? false : response.ok ? protectedPushBlocked(await response.json()) : undefined);
}
} catch {
branchProtection.set(key, undefined);
}
}
return branchProtection.get(key) === true;
}
export function deploymentModeForTrigger(trigger: ClientConfig["trigger"]): ClientConfig["deploymentMode"] {
return trigger === "ship" ? "prebuilt" : "build";
}
export function repositoryFromRemote(remote: string): string {
const match = /github\.com[/:]([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(remote);
if (!match) throw new Error("origin must be a GitHub repository");
return match[1];
}
// Infer safe defaults from owned project files. Interactive server setup still
// previews these values before changing Caddy, systemd, or server config.
export function setupDomain(explicit: string | undefined, configured: string | undefined, inferred: string | undefined): string | undefined {
return explicit ?? configured ?? inferred;
}
export function domainFromProject(packageName: unknown, compose: string): string | undefined {
if (typeof packageName === "string" && DOMAIN.test(packageName)) return packageName;
const siteUrlDomain = /^\s*(?:-\s*)?SITE_URL(?:=|:\s*)["']?https:\/\/([^/:\s"']+)/m.exec(compose)?.[1]?.toLowerCase();
return siteUrlDomain && DOMAIN.test(siteUrlDomain) ? siteUrlDomain : undefined;
}
function composeCandidates(files: string[]): string[] {
return files.filter((file) => /(^|\/)(?:compose\.ya?ml|docker-compose\.ya?ml)$/.test(file));
}
export function deploymentFileTemplates(hasBuildScript: boolean): Record<string, string> {
return {
Dockerfile: `FROM oven/bun:alpine
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
${hasBuildScript ? "RUN bun run build\n\n" : ""}ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
CMD ["bun", "run", "start"]
`,
"compose.yaml": `services:
app:
build: .
ports:
- "127.0.0.1:\${SHIBUMI_PORT:-9001}:3000"
environment:
HOST: 0.0.0.0
PORT: "3000"
restart: unless-stopped
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
`,
".dockerignore": `.git
node_modules
.env
.env.*
!.env.example
data
*.log
`,
};
}
// ── Static output shipping ─────────────────────────────────────────────
// Static params live as labels in the committed compose file, so they ride
// the git archive into every build context and survive the server-downloaded
// shibumi-server.json rewrite. Builds always run on the user's machine (their
// toolchain: Ruby, Node, anything); the image never contains a build stage.
const STATIC_OUTPUT_DIRS = ["dist", "public", "build", "out", "_site"];
const BUSYBOX_IMAGE = "busybox:1.37-musl@sha256:fc6dddc4c44b1bfe37f41cae8e67d1693828e8f42a91862816d7953e2c9d3f23";
const STATIC_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
const BUILD_SCRIPT_NAME = /^[A-Za-z0-9][A-Za-z0-9:_-]*$/;
export function staticOutputDirProblem(value: string): string | undefined {
if (!value) return "output directory is required";
if (value.startsWith("/") || value.includes("\\") || /^[A-Za-z]:/.test(value)) return `output directory must be relative to the project root, got ${value}`;
const segments = value.split("/");
if (segments.some((segment) => !STATIC_SEGMENT.test(segment) || segment.endsWith(".") || segment === ".." )) {
return `output directory segments must be plain names (letters, digits, ".", "_", "-"), got ${value}`;
}
return undefined;
}
// Walk the first service's labels block structurally instead of regex-scanning
// raw YAML, so label-shaped text inside block scalars or other services can
// never masquerade as static configuration.
export function staticConfigFromCompose(compose: string): StaticSiteConfig | undefined {
const values: Record<string, string> = {};
let inServices = false;
let serviceCount = 0;
let inLabels = false;
for (const line of compose.split("\n")) {
if (/^\S/.test(line)) {
inServices = /^services:\s*$/.test(line);
inLabels = false;
continue;
}
if (!inServices) continue;
if (/^ {2}\S/.test(line)) {
serviceCount += 1;
inLabels = false;
continue;
}
if (serviceCount !== 1) continue;
if (/^ {4}labels:\s*$/.test(line)) {
inLabels = true;
continue;
}
if (/^ {4}\S/.test(line)) {
inLabels = false;
continue;
}
if (!inLabels) continue;
const match = /^ {6}dev\.shibumistack\.static\.(output|build|spa): "?([^"]*)"?\s*$/.exec(line);
if (match) values[match[1]!] = match[2]!;
}
if (values.output === undefined) return undefined;
const problem = staticOutputDirProblem(values.output);
if (problem) throw new Error(`compose static labels are invalid: ${problem}`);
if (values.build !== undefined && !BUILD_SCRIPT_NAME.test(values.build)) throw new Error("compose static build label must be a package.json script name");
return { outputDir: values.output, buildScript: values.build || undefined, spa: values.spa === "true" };
}
export function staticHttpdConf(has404: boolean): string {
return [
...(has404 ? ["E404:404.html"] : []),
"I:index.html",
".md:text/plain",
".txt:text/plain",
".json:application/json",
".xml:application/xml",
".svg:image/svg+xml",
".webp:image/webp",
".avif:image/avif",
".woff2:font/woff2",
"",
].join("\n");
}
// SPA fallback needs unknown paths answered with index.html and status 200,
// which BusyBox httpd cannot do; those sites get a small owned Bun server.
export function staticServerSource(outputDir: string): string {
return `// Owned static file server for SPA fallback, generated by ship:setup.
// Serves ${JSON.stringify(outputDir)} and answers unknown paths with index.html (200).
import { join, normalize } from "node:path";
const ROOT = ${JSON.stringify(`/www`)};
const server = Bun.serve({
port: 3000,
async fetch(request) {
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Not found", { status: 404 });
let pathname;
try {
pathname = decodeURIComponent(new URL(request.url).pathname);
} catch {
return new Response("Bad request", { status: 400 });
}
const safe = normalize(pathname).replaceAll("\\\\", "/");
if (safe.includes("..")) return new Response("Not found", { status: 404 });
const candidate = safe.endsWith("/") ? join(ROOT, safe, "index.html") : join(ROOT, safe);
if (!candidate.startsWith(ROOT)) return new Response("Not found", { status: 404 });
const file = Bun.file(candidate);
if (await file.exists()) return new Response(file);
return new Response(Bun.file(join(ROOT, "index.html")));
},
});
console.log(\`Serving \${ROOT} on http://localhost:\${server.port}\`);
process.on("SIGTERM", () => { void server.stop().then(() => process.exit(0)); });
process.on("SIGINT", () => { void server.stop().then(() => process.exit(0)); });
`;
}
export function staticComposeLabels(config: StaticSiteConfig): string {
return [
` dev.shibumistack.static.output: ${JSON.stringify(config.outputDir)}`,
...(config.buildScript ? [` dev.shibumistack.static.build: ${JSON.stringify(config.buildScript)}`] : []),
` dev.shibumistack.static.spa: "${config.spa}"`,
].join("\n");
}
export function staticDeploymentFileTemplates(config: StaticSiteConfig): Record<string, string> {
const dockerfile = config.spa
? `FROM oven/bun:alpine
WORKDIR /app
COPY ${config.outputDir} /www
COPY scripts/static-server.ts ./static-server.ts
USER bun
EXPOSE 3000
CMD ["bun", "static-server.ts"]
`
: `FROM ${BUSYBOX_IMAGE} AS busybox
FROM scratch
COPY --from=busybox /bin/busybox /busybox
COPY --chown=65534:65534 ${config.outputDir} /www
USER 65534:65534
EXPOSE 3000
ENTRYPOINT ["/busybox", "httpd", "-f", "-p", "3000", "-h", "/www", "-c", "/www/httpd.conf"]
`;
const healthcheck = config.spa
? `["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]`
: `["CMD", "/busybox", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://127.0.0.1:3000/"]`;
return {
Dockerfile: dockerfile,
"compose.yaml": `services:
app:
build: .
ports:
- "127.0.0.1:\${SHIBUMI_PORT:-9001}:3000"
labels:
${staticComposeLabels(config)}
restart: unless-stopped
healthcheck:
test: ${healthcheck}
interval: 30s
timeout: 5s
retries: 3
start_period: 5s
deploy:
resources:
limits:
cpus: "0.5"
memory: 128M
`,
".dockerignore": config.spa
? `*\n!${config.outputDir}\n!scripts\nscripts/*\n!scripts/static-server.ts\n`
: `*\n!${config.outputDir}\n`,
};
}
// Runs inside the clean git-archive build context so the image only ever
// contains output derived from the exact committed tree.
export async function prepareStaticContext(context: string, config: StaticSiteConfig, execute: (args: string[], cwd: string) => Promise<Result>): Promise<void> {
const problem = staticOutputDirProblem(config.outputDir);
if (problem) throw new Error(problem);
const contextReal = await realpath(context);
const outputPath = resolve(contextReal, config.outputDir);
if (config.buildScript) {
if (!BUILD_SCRIPT_NAME.test(config.buildScript)) throw new Error("build script must be a package.json script name");
const packageJson = JSON.parse(await readFile(join(contextReal, "package.json"), "utf8")) as { scripts?: Record<string, unknown>; dependencies?: object; devDependencies?: object };
if (typeof packageJson.scripts?.[config.buildScript] !== "string") {
throw new Error(`package.json has no "${config.buildScript}" script.\n\nNext: restore the build script or run bun ship:setup again.`);
}
if (packageJson.dependencies || packageJson.devDependencies) {
const frozen = await Bun.file(join(contextReal, "bun.lock")).exists();
const install = await execute(frozen ? ["bun", "install", "--frozen-lockfile"] : ["bun", "install"], contextReal);
if (install.exitCode !== 0) throw new Error(`dependency install failed in the clean build context.\n\nNext: verify bun install succeeds from a fresh clone, then run bun ship.\n${install.stderr.trim()}`);
}
const build = await execute(["bun", "run", "--", config.buildScript], contextReal);
if (build.exitCode !== 0) throw new Error(`static build failed in the clean build context.\n\nNext: verify bun run ${config.buildScript} succeeds from a fresh clone, then run bun ship.\n${build.stderr.trim()}`);
}
// Ancestors must be real directories: a committed symlink such as
// dist -> /etc would otherwise resolve outside the clean context.
let walk = contextReal;
for (const segment of config.outputDir.split("/")) {
walk = join(walk, segment);
const entry = await lstat(walk).catch(() => undefined);
if (!entry) {
throw new Error(config.buildScript
? `static output ${config.outputDir}/ is missing after the build.\n\nNext: verify the build writes ${config.outputDir}/, or run bun ship:setup with the correct --output-dir.`
: `static output ${config.outputDir}/ is not in the commit.\n\nNext: commit the built output, or run bun ship:setup with a --build-script.`);
}
if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`static output path ${config.outputDir} contains a symlink or non-directory at ${walk}.\n\nNext: use a real directory, then run bun ship.`);
}
if (await realpath(outputPath) !== outputPath || !outputPath.startsWith(contextReal + "/")) {
throw new Error("static output directory escapes the project root");
}
const listing = await execute(["find", outputPath, "-type", "l"], contextReal);
if (listing.exitCode !== 0) throw new Error(`symlink scan failed for ${config.outputDir}/.\n\nNext: verify the output directory is readable, then run bun ship.`);
if (listing.stdout.trim()) throw new Error(`static output contains symlinks, which the image cannot verify:\n${listing.stdout.trim()}\n\nNext: replace symlinks with real files, then run bun ship.`);
if (!await Bun.file(join(outputPath, "index.html")).exists()) {
throw new Error(`static output ${config.outputDir}/ has no index.html.\n\nNext: verify the build output, then run bun ship.`);
}
if (!config.spa) {
const has404 = await Bun.file(join(outputPath, "404.html")).exists();
await writeFile(join(outputPath, "httpd.conf"), staticHttpdConf(has404), { mode: 0o644 });
}
}
export function composeFileFromTracked(files: string[]): string {
const candidates = composeCandidates(files);
for (const preferred of ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]) {
if (candidates.includes(preferred)) return preferred;
}
if (candidates.length === 1) return candidates[0];
if (candidates.length === 0) throw new Error("no Compose file found");
throw new Error(`multiple Compose files found:\n${candidates.map((file) => ` ${file}`).join("\n")}\n\nNext: keep one deployment Compose file or configure the intended path explicitly.`);
}
interface WorktreeCompose {
branch: string;
path: string;
composeFile: string;
}
export function missingComposeMessage(branch: string, alternatives: WorktreeCompose[]): string {
const heading = `no tracked Compose file found on current branch ${branch}.`;
if (alternatives.length === 0) return `${heading}\n\nNext: add compose.yaml, or run ship from a branch containing deployment files.`;
const rows = alternatives.map((item) => ` ${item.branch} → ${join(item.path, item.composeFile)}`).join("\n");
const next = alternatives.length === 1
? `cd '${alternatives[0].path.replaceAll("'", "'\\''")}' and rerun installer.`
: "choose one listed worktree, cd to it, and rerun installer.";
return `${heading}\n\nCompose found in another local worktree:\n${rows}\n\nNext: ${next}`;
}
async function otherWorktreeCompose(): Promise<WorktreeCompose[]> {
const listed = await run(["git", "worktree", "list", "--porcelain", "-z"], { allowFailure: true });
if (listed.exitCode !== 0) return [];
const alternatives: WorktreeCompose[] = [];
for (const record of listed.stdout.split("\0\0")) {
const fields = record.split("\0");
const path = fields.find((field) => field.startsWith("worktree "))?.slice(9);
const branch = fields.find((field) => field.startsWith("branch refs/heads/"))?.slice(18);
if (!path || !branch || resolve(path) === resolve(root)) continue;
const tracked = await run(["git", "-C", path, "ls-files"], { allowFailure: true });
if (tracked.exitCode !== 0) continue;
for (const composeFile of composeCandidates(tracked.stdout.split("\n").filter(Boolean))) {
alternatives.push({ branch, path, composeFile });
}
}
return alternatives;
}
// Deciding what to deploy is separated from writing it, so a plan run can
// state "Generate deployment files (static, dist/)" and mean it: nothing is
// on disk until the plan is approved, and cancelling really changed nothing.
type DeploymentDecision =
| { kind: "tracked" }
| { kind: "untracked"; file: string }
| { kind: "static"; config: StaticSiteConfig }
| { kind: "server"; dockerfileExists: boolean; hasBuildScript: boolean };
export function deploymentPlanLine(decision: { kind: string; config?: StaticSiteConfig }): string | undefined {
if (decision.kind === "static" && decision.config) {
const build = decision.config.buildScript ? `, bun run ${decision.config.buildScript}` : "";
return `Generate deployment files (static, ${decision.config.outputDir}/${build})`;
}
if (decision.kind === "server") return "Generate deployment files (Bun server app)";
return undefined;
}
async function decideDeployment(): Promise<DeploymentDecision> {
const branch = await git("branch", "--show-current");
if (!branch) throw new Error("ship requires a named Git branch");
const tracked = (await git("ls-files")).split("\n").filter(Boolean);
if (composeCandidates(tracked).length > 0) return { kind: "tracked" };
const alternatives = await otherWorktreeCompose();
if (alternatives.length > 0) throw new Error(missingComposeMessage(branch, alternatives));
const names = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"];
const existing = (await Promise.all(names.map(async (name) => await Bun.file(join(root, name)).exists() ? name : undefined))).find(Boolean);
if (existing) return { kind: "untracked", file: existing };
if (agentRun && !options.yes) {
throw new Error("Compose deployment files are missing.\n\nAgent: ask user for permission to generate deployment files, then run bun ship:setup -y (add --static --output-dir <dir> for static output).");
}
let wantStatic = options.staticSite;
if (!options.yes && !options.staticSite) {
const kind = await select({
message: "What are you shipping?",
options: [
{ value: "server", label: "Bun server app: container runs your start script" },
{ value: "static", label: "Static site: prebuilt files from any framework" },
],
});
if (isCancel(kind)) throw new Error(missingComposeMessage(branch, []));
wantStatic = kind === "static";
}
return wantStatic ? { kind: "static", config: await staticDeploymentInputs() } : await serverDeploymentInputs();
}
// Everything that can refuse the deployment runs here, before the plan is
// rendered: a bad output directory, a missing build script, uncommitted output
// with no build, or files that would be overwritten.
async function staticDeploymentInputs(): Promise<StaticSiteConfig> {
const scripts = ((await Bun.file(join(root, "package.json")).json().catch(() => ({}))) as { scripts?: Record<string, unknown> }).scripts;
let buildScript = options.buildScript;
if (buildScript && typeof scripts?.[buildScript] !== "string") {
throw new Error(`package.json has no "${buildScript}" script.\n\nNext: add it (for example "build": "jekyll build"), then run bun ship:setup again.`);
}
if (!buildScript && typeof scripts?.build === "string") buildScript = "build";
let outputDir = options.outputDir;
if (!outputDir) {
const detected: string[] = [];
for (const candidate of STATIC_OUTPUT_DIRS) {
if ((await stat(join(root, candidate)).catch(() => undefined))?.isDirectory()) detected.push(candidate);
}
if (agentRun || options.yes) {
if (detected.length === 1) outputDir = detected[0];
else throw new Error(`Static output directory could not be inferred.\n\nAgent: ask user which directory the build writes (${STATIC_OUTPUT_DIRS.join(", ")}, or custom), then run bun ship:setup -y --static --output-dir <dir>.`);
} else {
const answer = await text({
message: "Which directory holds the built site?",
placeholder: detected[0] ?? "dist",
defaultValue: detected[0] ?? "",
validate: (value) => value ? staticOutputDirProblem(value) : "Enter the build output directory",
});
if (isCancel(answer)) throw new Error("setup cancelled");
outputDir = answer;
}
}
const problem = staticOutputDirProblem(outputDir!);
if (problem) throw new Error(problem);
let spa = options.spa;
if (!spa && !options.noSpa && !agentRun && !options.yes) {
const answer = await confirm({ message: "Single-page app? (unknown paths serve index.html)", initialValue: false });
if (isCancel(answer)) throw new Error("setup cancelled");
spa = answer;
}
if (!buildScript) {
const tracked = await run(["git", "ls-files", "--", outputDir!], { allowFailure: true });
if (!tracked.stdout.trim()) {
throw new Error(`Without a build script, ${outputDir}/ must be committed so shipped images match the exact commit.\n\nNext: commit the built output, or rerun with --build-script <name>.`);
}
}
const config: StaticSiteConfig = { outputDir: outputDir!, buildScript, spa };
const targets = [...Object.keys(staticDeploymentFileTemplates(config)), ...(spa ? ["scripts/static-server.ts"] : [])];
const conflicts: string[] = [];
for (const name of targets) if (await Bun.file(join(root, name)).exists()) conflicts.push(name);
if (conflicts.length > 0) {
throw new Error(`Static setup would generate ${conflicts.join(", ")}, which already exist and may package or run something else.\n\nNext: remove or rename them, then run bun ship:setup again.`);
}
return config;
}
async function serverDeploymentInputs(): Promise<DeploymentDecision> {
const packageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { scripts?: Record<string, unknown> };
const dockerfileExists = await Bun.file(join(root, "Dockerfile")).exists();
if (!dockerfileExists && typeof packageJson.scripts?.start !== "string") {
throw new Error("Dockerfile generation requires a package.json start script.\n\nNext: add a start script that binds to 0.0.0.0 and reads PORT, then run bun ship:setup.");
}
return { kind: "server", dockerfileExists, hasBuildScript: typeof packageJson.scripts?.build === "string" };
}
async function writeDeployment(decision: DeploymentDecision): Promise<string[]> {
const written: string[] = [];
if (decision.kind === "static") {
// Script-less generators (Jekyll) have no package.json; create a minimal
// one so bun ship commands and an optional build script have a home.
const packagePath = join(root, "package.json");
if (!await Bun.file(packagePath).exists()) {
const name = root.split("/").pop()?.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "") || "static-site";
await writeFile(packagePath, `${JSON.stringify({ name, private: true, scripts: { ...SHIP_SCRIPTS } }, null, 2)}\n`, { mode: 0o644 });
written.push("package.json");
log.success("Created minimal package.json");
}
for (const [name, contents] of Object.entries(staticDeploymentFileTemplates(decision.config))) {
await writeFile(join(root, name), contents, { mode: 0o644 });
written.push(name);
}
if (decision.config.spa) {
await mkdir(join(root, "scripts"), { recursive: true });
await writeFile(join(root, "scripts", "static-server.ts"), staticServerSource(decision.config.outputDir), { mode: 0o644 });
written.push("scripts/static-server.ts");
}
} else if (decision.kind === "server") {
for (const [name, contents] of Object.entries(deploymentFileTemplates(decision.hasBuildScript))) {
if (name === "Dockerfile" && decision.dockerfileExists) continue;
if (await Bun.file(join(root, name)).exists()) continue;
await writeFile(join(root, name), contents, { mode: 0o644 });
written.push(name);
}
}
if (written.length > 0) log.success(`Generated ${written.join(", ")}`);
if (decision.kind === "server") log.info("Verify the app binds to 0.0.0.0 and reads PORT before shipping.");
return written;
}
// Plan runs defer every write to plan execution. The other modes keep the
// v47 behavior: generate now, offer to commit, otherwise stop for review.
async function prepareDeployment(): Promise<{ decision: DeploymentDecision; pending: boolean } | undefined> {
const decision = await decideDeployment();
if (decision.kind === "tracked") return { decision, pending: false };
if (planSetup()) return { decision, pending: decision.kind !== "untracked" };
if (decision.kind === "untracked") {
outro(`Found uncommitted ${decision.file}.\n\nNext: review it, commit and push it, then run bun ship:setup.`);
return undefined;
}
const written = await writeDeployment(decision);
if (await offerGeneratedCommit(written)) return { decision, pending: false };
outro(decision.kind === "server"
? "Review generated deployment files and verify app binds to 0.0.0.0 and reads PORT.\n\nNext: commit and push these changes, then run bun ship:setup."
: "Review generated deployment files.\n\nNext: commit and push these changes, then run bun ship:setup.");
return undefined;
}
const SHIP_SCRIPTS = {
ship: "bun scripts/ship.ts",
"ship:setup": "bun scripts/ship.ts --setup",
"ship:update": "bun scripts/ship.ts --update",
"ship:status": "bun scripts/ship.ts --status",
"ship:logs": "bun scripts/ship.ts --logs",
"ship:webhook": "bun scripts/ship.ts --webhook",
};
// After generating deployment files interactively, offer to commit and push
// them in the same run so setup continues without a manual rerun. Returns
// true when the files are committed. A project with no origin yet cannot be
// pushed to; setup creates the repository and pushes a few steps later.
async function offerGeneratedCommit(files: string[]): Promise<boolean> {
if (agentRun || !process.stdin.isTTY || !process.stdout.isTTY) return false;
const accepted = await confirm({ message: "Commit the generated files, then continue setup?", initialValue: true });
if (isCancel(accepted) || !accepted) return false;
const present: string[] = [];
for (const file of files) if (await Bun.file(join(root, file)).exists()) present.push(file);
await run(["git", "add", "--", ...present]);
await run(["git", "commit", "--only", "-m", "Add deployment configuration", "--", ...present], { inherit: true });
if ((await run(["git", "remote", "get-url", "origin"], { allowFailure: true })).exitCode !== 0) {
log.success("Committed deployment files");
return true;
}
await run(["git", "push"], { inherit: true, allowFailure: true });
log.success("Committed and pushed deployment files");
return true;
}
// ── Repository facts ───────────────────────────────────────────────────
// Setup runs before a project necessarily has commits or a GitHub origin, so
// these read the repository defensively. inferredProject below assumes both
// and only runs once the plan has supplied them.
interface ProjectFacts {
name: string;
branch: string;
origin?: string;
committed: boolean;
composeTracked: boolean;
dirty: boolean;
}
export function repositoryNameFromProject(packageName: unknown, directory: string): string {
const candidate = typeof packageName === "string" && packageName ? packageName.replace(/^@[^/]+\//, "") : directory;
return candidate.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "") || "app";
}
async function projectFacts(): Promise<ProjectFacts> {
if ((await run(["git", "rev-parse", "--git-dir"], { allowFailure: true })).exitCode !== 0) {
throw new Error("This project is not a Git repository.\n\nNext: run git init, then bun ship:setup.");
}
const packageJson = await Bun.file(join(root, "package.json")).json().catch(() => ({})) as { name?: unknown };
const remote = await run(["git", "remote", "get-url", "origin"], { allowFailure: true });
let origin: string | undefined;
if (remote.exitCode === 0 && remote.stdout.trim()) {
try {
origin = repositoryFromRemote(remote.stdout.trim());
} catch {
throw new Error(`origin ${remote.stdout.trim()} is not a GitHub repository.\n\nNext: point origin at GitHub, then run bun ship:setup.`);
}
}
const branch = (await run(["git", "branch", "--show-current"], { allowFailure: true })).stdout.trim();
if (!branch) throw new Error("ship requires a named Git branch");
const tracked = (await run(["git", "ls-files"], { allowFailure: true })).stdout.split("\n").filter(Boolean);
return {
name: repositoryNameFromProject(packageJson.name, root.split("/").pop() ?? "app"),
branch,
origin,
committed: (await run(["git", "rev-parse", "--verify", "HEAD"], { allowFailure: true })).exitCode === 0,
composeTracked: composeCandidates(tracked).length > 0,
dirty: Boolean((await run(["git", "status", "--porcelain"], { allowFailure: true })).stdout.trim()),
};
}
// The domain question comes before the plan, so it cannot wait for
// inferredProject (which needs a tracked Compose file and an origin).
async function inferredDomain(): Promise<string | undefined> {
const packageJson = await Bun.file(join(root, "package.json")).json().catch(() => ({})) as { name?: unknown };
let compose = "";
for (const name of ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]) {
const file = Bun.file(join(root, name));
if (await file.exists()) {
compose = await file.text();
break;
}
}
return domainFromProject(packageJson.name, compose);
}
async function resolveDomain(current?: ClientConfig): Promise<string> {
const known = setupDomain(options.domain, current?.domain, await inferredDomain());
if (known) return known;
if (agentRun || options.yes) throw new Error("App domain could not be inferred.\n\nAgent: ask user for the app domain, then run bun ship:setup --domain <domain>");
const answer = await text({
message: "App domain",
placeholder: "example.com",
validate: (value) => value && DOMAIN.test(value) ? undefined : "Use a lowercase public hostname",
});
if (isCancel(answer)) throw new Error("setup cancelled");
return answer;
}
// A local .env holds exactly what must never reach a repository, and the next
// step of this plan pushes. Templates gitignore it, but an adopted project may
// not, so the pathspec keeps it out of the index no matter what git thinks.
// glob magic on purpose: without it git matches the pathspec with slashes
// fair game, so ":(exclude)*.env.*" also drops src/schema.env.ts and
// src/config.env.json out of the commit. With it, "**/" walks directories and
// "*" stops at the separator, so only real dotenv basenames match.
const ENV_EXCLUDES = [":(exclude,glob)**/.env", ":(exclude,glob)**/.env.*"];
const ENV_GLOBS = [":(glob)**/.env", ":(glob)**/.env.*"];
// The three conventional names for the file that lists the variables and none
// of their values.
const ENV_EXAMPLE_NAMES = [".env.example", ".env.sample", ".env.template"];
const ENV_EXAMPLE_GLOBS = ENV_EXAMPLE_NAMES.map((name) => `:(glob)**/${name}`);
function isEnvExample(path: string): boolean {
return ENV_EXAMPLE_NAMES.some((name) => path === name || path.endsWith(`/${name}`));
}
async function untrackedEnvFiles(): Promise<string[]> {
const listed = await run(["git", "ls-files", "--others", "--exclude-standard", "--", ...ENV_GLOBS], { allowFailure: true });
return listed.stdout.split("\n").filter(Boolean).filter((path) => !isEnvExample(path));
}
// The example files document the variable names and hold none of the values,
// so they ride along after the blanket exclude took them out.
async function addEnvExamples(): Promise<void> {
const listed = await run(["git", "ls-files", "--others", "--modified", "--exclude-standard", "--", ...ENV_EXAMPLE_GLOBS], { allowFailure: true });
const examples = [...new Set(listed.stdout.split("\n").filter(Boolean))];
if (examples.length > 0) await run(["git", "add", "--", ...examples], { allowFailure: true });
}
// The first commit belongs to the user, so create-shibumi never makes one;
// approving the plan is where the user makes it.
async function commitEverything(message: string): Promise<boolean> {
const secrets = await untrackedEnvFiles();
await run(["git", "add", "-A", "--", ".", ...ENV_EXCLUDES]);
await addEnvExamples();
if ((await run(["git", "diff", "--cached", "--quiet"], { allowFailure: true })).exitCode === 0) return false;
const commit = await run(["git", "commit", "-m", message], { inherit: true, allowFailure: true });
if (commit.exitCode !== 0) throw new Error("git commit failed.\n\nNext: fix the git error above (identity, hooks), then run bun ship:setup.");
log.success(`Committed: ${message}`);
if (secrets.length > 0) {
log.warn(`Left ${secrets.join(", ")} out of the commit.\nNext: add ${secrets.length === 1 ? "it" : "them"} to .gitignore, and set production values with bun ship:env set KEY=VALUE.`);
}
return true;
}
// A repository with history gets a path-scoped commit instead, so unrelated
// work in progress is never swept into a deployment commit.
const DEPLOYMENT_FILES = ["Dockerfile", "compose.yaml", "compose.yml", ".dockerignore", "scripts/static-server.ts", "package.json", "bun.lock"];
async function commitDeploymentFiles(): Promise<boolean> {
const present: string[] = [];
for (const file of DEPLOYMENT_FILES) if (await Bun.file(join(root, file)).exists()) present.push(file);
if (present.length === 0) return false;
await run(["git", "add", "--", ...present]);
if ((await run(["git", "diff", "--cached", "--quiet", "--", ...present], { allowFailure: true })).exitCode === 0) return false;
const commit = await run(["git", "commit", "--only", "-m", "Add deployment configuration", "--", ...present], { inherit: true, allowFailure: true });
if (commit.exitCode !== 0) throw new Error("git commit failed.\n\nNext: fix the git error above (identity, hooks), then run bun ship:setup.");
log.success("Committed deployment configuration");
return true;
}
// No GitHub origin is not an error: setup offers to create the repository.
// Private by default; --public opts out. There is no visibility question.
async function createGitHubRepository(facts: ProjectFacts): Promise<string> {
if (!Bun.which("gh")) {
throw new Error("Creating the GitHub repository needs the GitHub CLI.\n\nNext: install gh from https://cli.github.com, or add an origin remote yourself, then run bun ship:setup.");
}
await ensureGitHubAuth();
const created = await run([
"gh", "repo", "create", facts.name, options.publicRepo ? "--public" : "--private",
"--source", ".", "--remote", "origin", "--push",
], { allowFailure: true });
if (created.exitCode !== 0) {
throw new Error(`${created.stderr.trim() || "gh repo create failed"}\n\nNext: create the repository yourself, add it as origin, then run bun ship:setup.`);
}
const origin = repositoryFromRemote(await git("remote", "get-url", "origin"));
log.success(`Created ${options.publicRepo ? "public" : "private"} repo ${origin} and pushed ${facts.branch}`);
return origin;
}
async function githubOwner(): Promise<string | undefined> {
if (!Bun.which("gh")) return undefined;
const login = await run(["gh", "api", "user", "--jq", ".login"], { allowFailure: true });
return login.exitCode === 0 && login.stdout.trim() ? login.stdout.trim() : undefined;
}
// One rendered block instead of six questions. Every line names something
// the single "Run setup?" confirm authorises.
export function setupPlanLines(input: {
target: string;
domain: string;
branch: string;
newRepository?: string;
visibility: "private" | "public";
generate?: string;
commit: boolean;
trigger: "ship" | "github-push";
}): string[] {
return [
...(input.generate ? [input.generate] : []),
...(input.newRepository ? [`Create ${input.visibility} repo ${input.newRepository}, push ${input.branch}`] : []),
`Connect to ${input.target}, save target for this project`,
"Install or upgrade shibumi-server (sudo password once)",
`Register ${input.domain}`,
...(input.commit ? ["Commit and push deployment files"] : []),
`Deploys run on: ${input.trigger === "github-push" ? `git push origin ${input.branch}` : "bun ship"}`,
];
}
async function inferredProject() {
const packageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { name?: unknown; version?: unknown; scripts?: Record<string, unknown> };
const repository = repositoryFromRemote(await git("remote", "get-url", "origin"));
const branch = await git("branch", "--show-current");
if (!branch) throw new Error("ship requires a named Git branch");
const tracked = (await git("ls-files")).split("\n").filter(Boolean);
if (composeCandidates(tracked).length === 0) throw new Error(missingComposeMessage(branch, await otherWorktreeCompose()));
const composeFile = composeFileFromTracked(tracked);
const compose = await readFile(join(root, composeFile), "utf8");
const domain = domainFromProject(packageJson.name, compose);
const service = /^services:\s*\n\s{2}([A-Za-z0-9_.-]+):/m.exec(compose)?.[1] ?? "web";
const healthPath = /https?:\/\/(?:127\.0\.0\.1|localhost):\d+(\/[^\s"'\\]*)/.exec(compose)?.[1] ?? "/healthz";
return { packageJson, repository, branch, domain, composeFile, service, healthPath };
}
// Treat downloaded and committed JSON as untrusted input. Derived fields must
// agree with the domain before any value reaches GitHub, Git, or SSH.
export function validateConfig(value: unknown): ClientConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("shibumi-server.json must be an object");
const config = value as Partial<ClientConfig>;
if (config.version !== 1 || config.provider !== "shibumi-server" || typeof config.domain !== "string" || !DOMAIN.test(config.domain)
|| typeof config.appId !== "string" || config.appId !== appIdForDomain(config.domain)
|| typeof config.repository !== "string" || !/^github:[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.repository)
|| typeof config.branch !== "string" || !/^[A-Za-z0-9._/-]+$/.test(config.branch)
|| typeof config.webhookUrl !== "string" || config.webhookUrl !== `https://${config.domain}/hooks/github/${config.appId}`
|| typeof config.service !== "string" || typeof config.port !== "number" || !Number.isInteger(config.port) || config.port < 1024 || config.port > 65_535
|| typeof config.healthPath !== "string" || typeof config.cutoverRequired !== "boolean"
|| (config.deploymentMode !== undefined && config.deploymentMode !== "build" && config.deploymentMode !== "prebuilt")
|| (config.trigger !== undefined && config.trigger !== "ship" && config.trigger !== "github-push")
|| (config.platform !== undefined && !/^linux\/(?:arm64|amd64)$/.test(config.platform))
|| (config.deploymentMode === "prebuilt" && config.platform === undefined)
|| !config.server || typeof config.server.hostname !== "string") {
throw new Error("shibumi-server.json is invalid");
}
return { ...config, deploymentMode: config.deploymentMode ?? "build", trigger: config.trigger ?? "ship" } as ClientConfig;
}
async function readConfig(): Promise<ClientConfig | undefined> {
try {
return validateConfig(JSON.parse(await readFile(configPath, "utf8")));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
}
// Read legacy project-local targets, then migrate them into machine-local
// client settings instead of committed shibumi-server.json.
async function localSshTarget(): Promise<string | undefined> {
const result = await run(["git", "config", "--local", "--get", "shibumi.server"], { allowFailure: true });
return result.exitCode === 0 && result.stdout.trim() ? result.stdout.trim() : undefined;
}
async function configuredSshTarget(hostname?: string): Promise<string | undefined> {
if (options.server) {
log.info(`Server ${options.server}\nSource --server`);
return options.server;
}
const saved = await savedSshTarget(hostname);
if (saved) return saved;
const legacy = await localSshTarget();
if (legacy) log.info(`Server ${legacy}\nSource project .git/config (migrating)`);
return legacy;
}
async function suggestedSshTarget(): Promise<string | undefined> {
try {
const env = await readFile(join(root, ".env"), "utf8");
const host = /^DEPLOY_HOST=(.+)$/m.exec(env)?.[1]?.trim();
const user = /^DEPLOY_USER=(.+)$/m.exec(env)?.[1]?.trim();
if (host && user && SSH_TARGET.test(`${user}@${host}`)) return `${user}@${host}`;
} catch {}
return undefined;
}
async function requestSshTarget(configHostname?: string): Promise<string | undefined> {
if (options.server) return options.server;
const suggestion = await suggestedSshTarget() ?? configHostname;
if (options.yes) {
if (!suggestion) throw new Error("--yes requires a configured server or --server <target>");
return suggestion;
}
if (agentRun) {
if (!suggestion) throw new Error("SSH server could not be inferred.\n\nAgent: ask user for their SSH target (user@host or SSH alias), then run bun ship:setup --server <target>");
return suggestion;
}
// The plan states where the target is saved and that it is not committed,
// so plan mode goes straight to the question.
if (!planSetup()) {
explain(
"Local configuration",
`Use the same user@server target or SSH alias you use in your terminal.\nIt will be saved in ${clientSettingsPath()} on this computer and will not be committed.\nResolved server hostname, app domain, and deploy settings go in committed shibumi-server.json.`,
);
}
const answer = await text({
message: "SSH target (user@server or alias)",
placeholder: suggestion ?? "user@example-vps.com",
validate: (value) => SSH_TARGET.test(value || suggestion || "") ? undefined : "Use an SSH host or user@host without spaces",
});
if (isCancel(answer)) return undefined;
const target = answer || suggestion;
if (!target) return undefined;
if (!planSetup() && !await approve(`Save ${target} locally and connect?`)) return undefined;
return target;
}
async function resolvedSshHostname(target: string): Promise<string> {
if (!SSH_TARGET.test(target)) throw new Error("local SSH target is unsafe");
const result = await run(["ssh", "-G", "--", target]);
const hostname = /^hostname\s+(.+)$/m.exec(result.stdout)?.[1]?.trim();
if (!hostname || !SERVER_HOSTNAME.test(hostname)) {
throw new Error("cannot resolve SSH server hostname");
}
return hostname.toLowerCase();
}
// Pass remote arguments separately and reject control characters. Interactive
// setup gets a TTY so SSH and sudo prompts remain attached to this terminal.
async function sshControlPath(): Promise<string> {
if (!sshControlDirectory) {
sshControlDirectory = await mkdtemp("/tmp/shibumi-ssh-");
await chmod(sshControlDirectory, 0o700);
}
return join(sshControlDirectory, "control");
}
async function closeSshControl(): Promise<void> {
if (!sshControlDirectory) return;
if (sshControlTarget) {
await run(["ssh", "-S", join(sshControlDirectory, "control"), "-O", "exit", "--", sshControlTarget], { allowFailure: true });
}
await rm(sshControlDirectory, { recursive: true, force: true });
sshControlDirectory = undefined;
sshControlTarget = undefined;
}
async function ssh(target: string, remoteArgs: string[], options: { tty?: boolean; allowFailure?: boolean; inputFile?: string } = {}): Promise<Result> {
if (!SSH_TARGET.test(target)) throw new Error("local SSH target is unsafe");
if (sshControlTarget && sshControlTarget !== target) throw new Error("ship supports one SSH target per run");
sshControlTarget = target;
for (const arg of remoteArgs) if (/[\0\r\n]/.test(arg)) throw new Error("remote argument is unsafe");
return run([
"ssh", "-o", "StrictHostKeyChecking=yes", "-o", "ControlMaster=auto", "-o", "ControlPersist=60", "-S", await sshControlPath(),
...(agentRun ? ["-o", "BatchMode=yes"] : []), ...(options.inputFile ? ["-C"] : []), ...(options.tty ? ["-t"] : []), "--", target, ...remoteArgs,
], { inherit: options.tty, allowFailure: options.allowFailure, inputFile: options.inputFile });
}
function versionAtLeast(current: string, minimum: string): boolean {
const left = /^\d+\.\d+\.\d+$/.test(current) ? current.split(".").map(Number) : [];
const right = minimum.split(".").map(Number);
for (let index = 0; index < right.length; index += 1) {
if ((left[index] ?? -1) !== right[index]) return (left[index] ?? -1) > right[index];
}
return left.length === right.length;
}
// Install only through the reviewed, version-pinned website endpoint.
async function ensureServer(target: string): Promise<void> {
const version = await ssh(target, [SERVER_CLI, "--version"], { allowFailure: true });
if (version.exitCode === 0 && versionAtLeast(version.stdout.trim(), "0.8.1")) return;
if (agentRun) throw new Error(version.exitCode === 0
? `shibumi-server ${version.stdout.trim()} needs an upgrade.\n\nAgent: ask user to run bun ship:setup from this project in their terminal, then retry.`
: "shibumi-server is not installed.\n\nAgent: ask user to run bun ship:setup from this project in their terminal, then retry.");
explain(
version.exitCode === 0 ? `shibumi-server ${version.stdout.trim()} needs an upgrade` : "shibumi-server is not installed",
"This runs the reviewed installer on the SSH server. SSH and sudo prompts stay attached directly to your terminal.",
);
if (!await approvePlanned("Install or upgrade shibumi-server now?")) throw new Error("server setup cancelled");
const result = await ssh(target, ["curl -fsSL https://shibumistack.dev/install/server | bash"], { tty: true, allowFailure: true });
if (result.exitCode !== 0) throw new Error("remote shibumi-server installation failed");
const installed = await ssh(target, [SERVER_CLI, "--version"], { allowFailure: true });
if (installed.exitCode !== 0 || !versionAtLeast(installed.stdout.trim(), "0.8.1")) {
throw new Error("shibumi-server 0.8.1 or newer was not installed.\n\nNext: run shis update on the server, then rerun bun ship:setup.");
}
}
// Reuse an existing registration silently. New apps retain interactive SSH so
// server and sudo prompts stay attached to the local terminal.
async function remoteSetup(target: string, domain: string): Promise<ClientConfig> {
const project = await inferredProject();
const serverHostname = await resolvedSshHostname(target);
await rememberSshTarget(serverHostname, target);
await ensureServer(target);
const appId = appIdForDomain(domain);
let downloaded = await ssh(target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "client-config", appId, "--server-hostname", serverHostname,
], { allowFailure: true });
if (downloaded.exitCode !== 0) {
if (agentRun) throw new Error("Server app registration needs interactive SSH and sudo.\n\nAgent: ask user to run bun ship:setup from this project in their terminal, then retry.");
explain(
"Server setup required",
`SSH target ${target}\nDomain ${domain}\nRepository github:${project.repository}\n\nSSH and sudo prompts stay attached to this terminal.`,
);
if (!await approvePlanned("Continue through SSH?")) throw new Error("server setup cancelled");
const setup = await ssh(target, [
"env", "SHIBUMI_SHIP_SETUP=1", SERVER_CLI, "add", domain,
"--repository", `github:${project.repository}`,
"--ref", `refs/heads/${project.branch}`,
"--compose-file", project.composeFile,
"--service", project.service,
"--health-path", project.healthPath,
"--deployment-mode", "prebuilt",
...(options.yes ? ["--yes"] : []),
], { tty: true, allowFailure: true });
if (setup.exitCode !== 0) throw new Error("server app setup is incomplete.\n\nNext: complete the DNS or server action above, then run bun ship:setup.");
downloaded = await ssh(target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "client-config", appId, "--server-hostname", serverHostname,
], { allowFailure: true });
}
if (downloaded.exitCode !== 0) throw new Error("server app registration is incomplete.\n\nNext: complete the DNS or server action above, then run bun ship:setup.");
const config = validateConfig(JSON.parse(downloaded.stdout));
if (config.repository !== `github:${project.repository}`) throw new Error(`registered domain belongs to ${config.repository}\n\nNext: use the matching project or remove the conflicting server registration.`);
if (config.branch !== project.branch) throw new Error(`registered domain deploys ${config.branch}, but current branch is ${project.branch}.\n\nNext: check out ${config.branch}, or register another domain for ${project.branch}.`);
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
log.success(`Found ${domain} on ${serverHostname}`);
log.success("Wrote shibumi-server.json");
return config;
}
interface GitHubWebhook { id: number; active: boolean; needsRepair: boolean }
export function matchingWebhook(value: unknown, webhookUrl: string): GitHubWebhook | undefined {
if (!Array.isArray(value)) return undefined;
const hook = value.find((item) => item && typeof item === "object"
&& (item as { config?: { url?: unknown } }).config?.url === webhookUrl) as { id?: unknown; active?: unknown; last_response?: { code?: unknown } } | undefined;
if (!hook || typeof hook.id !== "number" || typeof hook.active !== "boolean") return undefined;
const code = hook.last_response?.code;
return { id: hook.id, active: hook.active, needsRepair: !hook.active || (typeof code === "number" && code !== 0 && (code < 200 || code >= 300)) };
}
async function ensureGitHubAuth(): Promise<void> {
const status = await run(["gh", "auth", "status", "-h", "github.com"], { allowFailure: true });
if (status.exitCode === 0) return;
explain("GitHub sign-in required", "GitHub CLI stores your credentials. Shibumi never reads them.");
if (agentRun || options.yes) throw new Error("GitHub sign-in required.\n\nAgent: ask user to run gh auth login -h github.com -p https -w, then retry.");
if (!await approve("Sign in to GitHub now?")) throw new Error("Next: run gh auth login -h github.com -p https -w, then rerun this command.");
const login = await run(["gh", "auth", "login", "-h", "github.com", "-p", "https", "-w"], { inherit: true, allowFailure: true });
if (login.exitCode !== 0 || (await run(["gh", "auth", "status", "-h", "github.com"], { allowFailure: true })).exitCode !== 0) {
throw new Error("GitHub sign-in did not complete.\n\nNext: run gh auth login -h github.com -p https -w, then rerun this command.");
}
}
async function authorizeWebhookAccess(): Promise<void> {
explain("GitHub webhook access required", "GitHub CLI needs admin:repo_hook to create or repair this repository webhook.");
if (agentRun || options.yes) throw new Error("GitHub webhook access required.\n\nAgent: ask user to run gh auth refresh -h github.com -s admin:repo_hook, then retry.");
if (!await approve("Authorize webhook access now?")) throw new Error("Next: run gh auth refresh -h github.com -s admin:repo_hook, then rerun bun ship:webhook.");
const refresh = await run(["gh", "auth", "refresh", "-h", "github.com", "-s", "admin:repo_hook"], { inherit: true, allowFailure: true });
if (refresh.exitCode !== 0) throw new Error("GitHub webhook authorization did not complete.\n\nNext: run gh auth refresh -h github.com -s admin:repo_hook, then rerun bun ship:webhook.");
}
async function findWebhook(config: ClientConfig): Promise<GitHubWebhook | undefined> {
const repository = config.repository.slice("github:".length);
await ensureGitHubAuth();
let hooks = await run(["gh", "api", `repos/${repository}/hooks?per_page=100`], { allowFailure: true });
if (hooks.exitCode !== 0) {
await authorizeWebhookAccess();
hooks = await run(["gh", "api", `repos/${repository}/hooks?per_page=100`], { allowFailure: true });
}
if (hooks.exitCode !== 0) throw new Error(`${hooks.stderr.trim() || "GitHub CLI could not read repository webhooks"}\n\nNext: confirm repository admin access, then rerun bun ship:webhook.`);
return matchingWebhook(JSON.parse(hooks.stdout), config.webhookUrl);
}
// Fetch the secret only when GitHub needs it. It moves through process memory
// from server output to `gh` input and is never printed or written locally.
// Returns true when this run created the hook, which is the only case where
// a later failure may take it back down.
async function ensureWebhook(config: ClientConfig, target: string, assumeApproved = false): Promise<boolean> {
const existing = await findWebhook(config);
if (existing && !existing.needsRepair) {
log.success("GitHub webhook is active");
return false;
}
const repository = config.repository.slice("github:".length);
explain(
existing ? "GitHub webhook needs repair" : "GitHub webhook is missing",
`Repository ${repository}\nPayload URL ${config.webhookUrl}\nEvents push\n\nThe secret travels from server to GitHub CLI through memory only.`,
);
if (!existing && !assumeApproved && !await approve("Create webhook with GitHub CLI?")) throw new Error(`Next: review ${config.webhookUrl} at https://github.com/${repository}/settings/hooks`);
if (existing) log.info("Refreshing webhook secret from server configuration");
const secretResult = await ssh(target, ["env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "webhook-secret", config.appId]);
const secretValue: unknown = JSON.parse(secretResult.stdout);
const secret = secretValue && typeof secretValue === "object" ? (secretValue as { secret?: unknown }).secret : undefined;
if (typeof secret !== "string" || !/^[a-f0-9]{64}$/.test(secret)) throw new Error("server returned an invalid webhook secret");
const args = existing
? ["gh", "api", "-X", "PATCH", `repos/${repository}/hooks/${existing.id}/config`, "--input", "-"]
: ["gh", "api", "-X", "POST", `repos/${repository}/hooks`, "--input", "-"];
const input = existing
? JSON.stringify({ secret })
: JSON.stringify({ name: "web", active: true, events: ["push"], config: { url: config.webhookUrl, content_type: "json", insecure_ssl: "0", secret } });
let result = await run(args, { input, allowFailure: true });
if (result.exitCode !== 0) {
await authorizeWebhookAccess();
result = await run(args, { input, allowFailure: true });
}
if (result.exitCode !== 0) throw new Error(`${result.stderr.trim() || "GitHub CLI could not configure webhook"}\n\nNext: confirm repository admin access, then rerun bun ship:webhook.`);
const hookId = existing?.id ?? (JSON.parse(result.stdout) as { id?: unknown }).id;
if (typeof hookId !== "number") throw new Error("GitHub returned an invalid webhook");
if (existing && !existing.active) {
result = await run(["gh", "api", "-X", "PATCH", `repos/${repository}/hooks/${hookId}`, "--input", "-"], {
input: JSON.stringify({ active: true, events: ["push"] }), allowFailure: true,
});
if (result.exitCode !== 0) throw new Error(`${result.stderr.trim() || "GitHub CLI could not enable webhook"}\n\nNext: confirm repository admin access, then rerun bun ship:webhook.`);
}
const ping = await run(["gh", "api", "-X", "POST", `repos/${repository}/hooks/${hookId}/pings`], { allowFailure: true });
if (ping.exitCode !== 0) throw new Error(`${ping.stderr.trim() || "GitHub CLI could not test webhook"}\n\nNext: review https://github.com/${repository}/settings/hooks.`);
for (let attempt = 0; attempt < 10; attempt += 1) {
await Bun.sleep(1_000);
const checked = await run(["gh", "api", `repos/${repository}/hooks/${hookId}`], { allowFailure: true });
if (checked.exitCode === 0 && (JSON.parse(checked.stdout) as { last_response?: { code?: unknown } }).last_response?.code === 200) {
log.success(existing ? "GitHub webhook repaired and tested" : "GitHub webhook created and tested");
return !existing;
}
}
throw new Error(`GitHub webhook is configured but not reachable yet.\n\nNext: confirm ${config.domain} DNS and TLS, then run bun ship:webhook. For proxied Cloudflare domains, use Full (strict) SSL/TLS mode. Prefer deploying with bun ship? Run bun ship:webhook --off.\n\nGitHub: https://github.com/${repository}/settings/hooks`);
}
async function disableWebhook(config: ClientConfig, assumeApproved = false): Promise<void> {
const repository = config.repository.slice("github:".length);
const settings = `https://github.com/${repository}/settings/hooks`;
if (!Bun.which("gh") || (await run(["gh", "auth", "status", "-h", "github.com"], { allowFailure: true })).exitCode !== 0) {
log.warn(`Direct shipping enabled. GitHub webhook cleanup skipped because GitHub CLI is not authenticated.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:webhook --off after GitHub sign-in.`);
return;
}
const hooks = await run(["gh", "api", `repos/${repository}/hooks?per_page=100`], { allowFailure: true });
if (hooks.exitCode !== 0) {
log.warn(`Direct shipping enabled. GitHub webhook cleanup could not reach GitHub.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:webhook --off later.`);
return;
}
const existing = matchingWebhook(JSON.parse(hooks.stdout), config.webhookUrl);
if (!existing || !existing.active) {
log.success("GitHub webhook is disabled");
return;
}
if (!assumeApproved) {
explain("Disable deploy-on-push", `Repository ${repository}\nPayload URL ${config.webhookUrl}\n\nGit pushes will stop changing production. Run bun ship to deploy.`);
if (!await approve("Disable GitHub webhook?")) throw new Error("webhook change cancelled");
}
const result = await run(["gh", "api", "-X", "PATCH", `repos/${repository}/hooks/${existing.id}`, "--input", "-"], {
input: JSON.stringify({ active: false }), allowFailure: true,
});
if (result.exitCode !== 0) {
log.warn(`Direct shipping enabled. GitHub webhook cleanup failed.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:webhook --off later.`);
return;
}
log.success("GitHub webhook disabled");
}
async function setDeploymentMode(config: ClientConfig, target: string, trigger: ClientConfig["trigger"]): Promise<ClientConfig> {
const mode = deploymentModeForTrigger(trigger);
if (config.deploymentMode === mode) return config;
const changed = await ssh(target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "deployment-mode", config.appId, mode,
], { allowFailure: true });
if (changed.exitCode !== 0) throw new Error(`${changed.stderr.trim() || `could not enable ${mode} deployments`}\n\nNext: update shibumi-server, then rerun bun ship:setup.`);
const downloaded = await ssh(target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "client-config", config.appId, "--server-hostname", config.server.hostname,
]);
return { ...validateConfig(JSON.parse(downloaded.stdout)), trigger };
}
// Opt-in push-to-deploy. Setup never creates a webhook: with the default
// `bun ship` trigger it buys nothing, and it costs a GitHub sign-in plus an
// admin:repo_hook grant. This command pays that cost only when asked, and
// --off reverses both halves (webhook and trigger).
async function runWebhook(): Promise<void> {
intro(`渋み ship webhook${options.off ? " --off" : ""}`);
try {
const config = await readConfig();
if (!config) throw new Error("Shibumi setup is missing.\n\nNext: run bun ship:setup.");
const target = await projectTarget(config);
if (options.off) {
// Runs whatever the recorded trigger says: a hook can outlive the
// trigger that installed it, and that hook is the thing to switch off.
const updated = config.trigger === "github-push"
? await setDeploymentMode({ ...config, trigger: "ship" }, target, "ship")
: config;
await writeFile(configPath, `${JSON.stringify(updated, null, 2)}\n`);
await disableWebhook(updated, true);
await offerSetupCommit(updated);
outro("Pushes no longer deploy. Deploys run on: bun ship");
return;
}
if (!Bun.which("gh")) throw new Error("Push-to-deploy needs the GitHub CLI.\n\nNext: install gh from https://cli.github.com, then run bun ship:webhook.");
const already = config.trigger === "github-push";
explain(
already ? "Push-to-deploy: repair" : "Push-to-deploy",
`Every push to ${config.branch} deploys ${config.domain} automatically.\nThe webhook secret travels from server to GitHub CLI through memory only.`,
);
await ensureGitHubAuth();
if (!await approve(already ? "Repair the webhook and keep push-to-deploy?" : "Install webhook and switch to push-to-deploy?")) {
throw new Error("Next: run bun ship:webhook when you want pushes to deploy.");
}
// Hook first, then the trigger: if the trigger switch fails, the hook is
// taken back down, so an active hook always means trigger github-push.
const created = await ensureWebhook(config, target, true);
let updated: ClientConfig;
try {
updated = await setDeploymentMode({ ...config, trigger: "github-push" }, target, "github-push");
} catch (error) {
// Only undo what this run did: a hook that was already there (repair
// path) stays, and its project keeps deploying on push.
if (created) await disableWebhook(config, true);
throw error;
}
await writeFile(configPath, `${JSON.stringify(updated, null, 2)}\n`);
await offerSetupCommit(updated);
outro(`git push origin ${updated.branch} now deploys. Undo: bun ship:webhook --off`);
} finally {
await closeSshControl();
}
}
interface SetupResult {
config: ClientConfig;
target: string;
changed: boolean;
setupCommit?: SetupCommit;
}
async function setup(force: boolean): Promise<SetupResult | undefined> {
let config = await readConfig();
const first = force || !config;
const previous = config;
// Projects set up before ship:webhook existed keep their github-push
// trigger; new ones deploy on bun ship until ship:webhook says otherwise.
const trigger = previous?.trigger ?? "ship";
let deployment: { decision: DeploymentDecision; pending: boolean } | undefined;
if (first) {
deployment = await prepareDeployment();
if (!deployment) return undefined;
}
let target = await configuredSshTarget(config?.server.hostname);
if (!target) target = await requestSshTarget(config?.server.hostname);
if (!target) throw new Error("SSH server is required");
if (first && deployment) {
// Question two of two. Everything after this is plan, confirm, run.
const domain = await resolveDomain(config);
const facts = await projectFacts();
if (!facts.origin && agentRun) {
throw new Error(`This project has no GitHub origin.\n\nAgent: ask user whether to create a repository for ${facts.name}, then run bun ship:setup -y (add --public for a public repo).`);
}
const owner = facts.origin ? undefined : await githubOwner();
const willCommit = !facts.committed || !facts.composeTracked || facts.dirty || deployment.pending || !previous;
// Rendered in every mode, prompted in none but a plan run: even under
// --yes the transcript has to say what this run is about to do.
explain("Plan", setupPlanLines({
target,
domain,
branch: facts.branch,
newRepository: facts.origin ? undefined : owner ? `${owner}/${facts.name}` : facts.name,
visibility: options.publicRepo ? "public" : "private",
generate: deploymentPlanLine(deployment.decision),
commit: willCommit,
trigger,
}).join("\n"));
if (planSetup()) {
const accepted = await confirm({ message: "Run setup?", initialValue: true });
if (isCancel(accepted) || !accepted) {
cancel("Setup cancelled. Nothing was changed.");
return undefined;
}
planApproved = true;
}
if (deployment.pending) await writeDeployment(deployment.decision);
// A repository needs a commit before it can be pushed, and registration
// reads the Compose file out of the committed tree. Each approvePlanned()
// here is answered by the plan confirm; only --interactive asks again.
if (!facts.committed) {
if (!await approvePlanned("Commit this project now?")) throw new Error("Next: commit your project, then run bun ship:setup.");
await commitEverything("Initial commit");
} else if (!facts.composeTracked || deployment.pending) {
if (!await approvePlanned("Commit the deployment files now?")) throw new Error("Next: commit the deployment files, then run bun ship:setup.");
await commitDeploymentFiles();
}
if (!facts.origin) {
if (!await approvePlanned(`Create ${options.publicRepo ? "public" : "private"} repo ${facts.name} and push ${facts.branch}?`)) {
throw new Error("Next: create the repository, add it as origin, then run bun ship:setup.");
}
await createGitHubRepository(facts);
}
config = await remoteSetup(target, domain);
}
if (!config) throw new Error("deployment setup did not return client configuration");
await rememberSshTarget(config.server.hostname, target);
config = await setDeploymentMode({ ...config, trigger }, target, trigger);
// Persisting is ship:setup's job. A bare `bun ship` that had to run setup
// leaves the commit to runShip below, exactly as it did before v48.
let setupCommit: SetupCommit | undefined;
if (force) {
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
log.success(trigger === "ship"
? "Deployments run through bun ship"
: `Deployments run on every push to ${config.branch}`);
// The plan said commit and push, so this run does both, and runShip is
// told the outcome so it never asks the same question twice.
setupCommit = await offerSetupCommit(config);
if (setupCommit !== "declined") await pushSetupCommit(config.branch);
}
return {
config,
target,
setupCommit,
changed: !previous || JSON.stringify(previous) !== JSON.stringify(config),
};
}
async function pushSetupCommit(branch: string): Promise<void> {
if ((await run(["git", "remote", "get-url", "origin"], { allowFailure: true })).exitCode !== 0) return;
const ahead = await run(["git", "rev-list", "--count", `origin/${branch}..HEAD`], { allowFailure: true });
if (ahead.exitCode === 0 && ahead.stdout.trim() === "0") return;
const push = await run(["git", "push", "origin", branch], { inherit: true, allowFailure: true });
if (push.exitCode !== 0) {
log.warn(`Could not push ${branch}.\nNext: git push origin ${branch}, then run bun ship.`);
return;
}
log.success(`Pushed ${branch} to origin`);
}
// Refuse ambiguous deploys: wrong origin, wrong branch, dirty work, or remote
// work not present locally. Run project-owned checks before every deployment.
async function preflight(config: ClientConfig): Promise<number> {
const project = await inferredProject();
if (`github:${project.repository}` !== config.repository) throw new Error(`origin does not match ${config.repository}`);
if (project.branch !== config.branch) throw new Error(`current branch must be ${config.branch}`);
const status = await git("status", "--short");
if (status) throw new Error(`Ship paused: working tree has uncommitted changes.\n\n${status}\n\nNext: commit or stash these changes, then run bun ship.`);
const progress = spinner();
progress.start(`Fetching origin/${config.branch}`);
await run(["git", "fetch", "origin", config.branch]);
const counts = (await git("rev-list", "--left-right", "--count", `HEAD...origin/${config.branch}`)).split(/\s+/).map(Number);
if (counts[1] > 0) {
progress.stop("Branch is behind or diverged", 1);
throw new Error(`pull origin/${config.branch} before shipping`);
}
if (counts[0] > 0 && await githubBranchIsProtected(config)) {
progress.stop(`${config.branch} is protected`, 1);
throw new Error(`Ship cannot push ${counts[0]} local commit${counts[0] === 1 ? "" : "s"} directly to protected branch ${config.branch}.\n\nNext: push a feature branch, merge its pull request, update local ${config.branch}, then run bun ship.`);
}
progress.stop(counts[0] > 0
? `${counts[0]} commit${counts[0] === 1 ? "" : "s"} ready to push`
: "Current commit already pushed and ready to deploy");
const scripts = project.packageJson.scripts ?? {};
for (const name of ["test", "check"]) {
if (typeof scripts[name] !== "string") continue;
const check = spinner();
check.start(`Running bun run ${name}`);
const result = await run(["bun", "run", name], { allowFailure: true });
if (result.exitCode !== 0) {
check.stop(`${name} failed`, 1);
process.stderr.write(result.stderr || result.stdout);
throw new Error(`bun run ${name} failed`);
}
check.stop(`${name} passed`);
}
return counts[0];
}
export function prebuiltImage(appId: string, commit: string): string {
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(appId) || !COMMIT.test(commit)) throw new Error("invalid prebuilt image identity");
return `localhost/shibumi-server/upload/${appId}:${commit}`;
}
export function prebuiltLabels(appId: string, commit: string, repository: string, sourceTree: string, version?: unknown): Record<string, string> {
prebuiltImage(appId, commit);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || !COMMIT.test(sourceTree)) throw new Error("invalid prebuilt image source");
return {
"dev.shibumistack.app-id": appId,
"dev.shibumistack.source-tree": sourceTree,
"org.opencontainers.image.revision": commit,
"org.opencontainers.image.source": `https://github.com/${repository}`,
...(typeof version === "string" && version.length <= 128 ? { "org.opencontainers.image.version": version } : {}),
};
}
export function composeFrontend(plugin: boolean, standalone: boolean): string[] | undefined {
if (plugin) return ["docker", "compose"];
if (standalone) return ["docker-compose"];
return undefined;
}
async function verifyDockerCredentialHelpers(): Promise<void> {
const path = join(process.env.DOCKER_CONFIG || join(homedir(), ".docker"), "config.json");
let value: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("expected object");
value = parsed as Record<string, unknown>;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
throw new Error(`Docker CLI config is invalid: ${path}\n\nNext: fix its JSON, verify docker info, then run bun ship.`);
}
for (const helper of dockerCredentialHelpers(value)) {
const executable = `docker-credential-${helper}`;
if (Bun.which(executable)) continue;
const canOfferRepair = !agentRun && !options.yes && process.stdin.isTTY && process.stdout.isTTY;
const accepted = canOfferRepair && await confirm({
message: `${executable} is configured but unavailable. Remove its stale entries from ${path}?`,
initialValue: true,
});
if (accepted && !isCancel(accepted)) {
const backup = `${path}.shibumi-backup`;
const temporary = `${path}.tmp-${process.pid}`;
removeDockerCredentialHelper(value, helper);
await copyFile(path, backup);
await chmod(backup, 0o600);
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
await chmod(temporary, 0o600);
await rename(temporary, path);
log.success(`Removed stale ${executable} entries; backup saved to ${backup}`);
continue;
}
const next = helper === "desktop"
? `edit ${path}, remove \"credsStore\": \"desktop\" and any credHelpers entries set to \"desktop\"`
: `install ${executable} or remove its stale entry from ${path}`;
throw new Error(`Docker credential helper ${executable} is configured but unavailable.\n\nNext: ${next}; verify docker pull oven/bun:alpine, then run bun ship.`);
}
}
async function localBuildFrontend(config: ClientConfig): Promise<string[] | undefined> {
if (config.deploymentMode !== "prebuilt") return undefined;
if (!config.platform) throw new Error("server image platform is missing.\n\nNext: run bun ship:setup.");
const docker = await run(["docker", "info"], { allowFailure: true });
if (docker.exitCode !== 0) throw new Error("Docker CLI cannot reach a compatible engine.\n\nNext: start Colima with colima start, verify docker info, then run bun ship.");
const plugin = await run(["docker", "compose", "version"], { allowFailure: true });
const standalone = plugin.exitCode === 0
? undefined
: await run(["docker-compose", "version"], { allowFailure: true });
const compose = composeFrontend(plugin.exitCode === 0, standalone?.exitCode === 0);
if (!compose) throw new Error("Docker Compose is unavailable.\n\nNext: install docker compose or docker-compose, verify its version, then run bun ship.");
await verifyDockerCredentialHelpers();
const buildx = await run(["docker", "buildx", "version"], { allowFailure: true });
if (buildx.exitCode !== 0) {
const detail = buildx.stderr.trim() || buildx.stdout.trim() || "docker buildx version failed";
const next = process.platform === "darwin"
? 'brew install docker-buildx; mkdir -p "${DOCKER_CONFIG:-$HOME/.docker}/cli-plugins"; ln -sfn "$(brew --prefix docker-buildx)/bin/docker-buildx" "${DOCKER_CONFIG:-$HOME/.docker}/cli-plugins/docker-buildx"'
: "install Docker Buildx plugin for your Docker CLI";
throw new Error(`Buildx is unavailable: ${detail}\n\nNext: ${next}; verify docker buildx version, then run bun ship.`);
}
return compose;
}
async function buildAndUpload(config: ClientConfig, target: string, commit: string, compose?: string[]): Promise<void> {
if (config.deploymentMode !== "prebuilt") return;
if (!compose) throw new Error("Docker Compose preflight was not completed");
const submodules = (await git("ls-files", "--stage")).split("\n").filter((line) => line.startsWith("160000 "));
if (submodules.length > 0) throw new Error("Prebuilt shipping does not support Git submodules yet.\n\nNext: remove the submodule dependency or use server build mode.");
const project = await inferredProject();
const temporary = await mkdtemp("/tmp/shibumi-build-");
const context = join(temporary, "context");
const sourceArchive = join(temporary, "source.tar");
const imageArchive = join(temporary, "image.tar");
const override = join(temporary, "compose.prebuilt.yaml");
const image = prebuiltImage(config.appId, commit);
const progress = spinner();
try {
progress.start(`Building ${config.platform} image from ${commit.slice(0, 7)}`);
await mkdir(context);
await run(["git", "archive", "--format=tar", "--output", sourceArchive, commit]);
await run(["tar", "-xf", sourceArchive, "-C", context]);
const staticConfig = staticConfigFromCompose(await readFile(join(context, project.composeFile), "utf8"));
if (staticConfig) {
progress.message(staticConfig.buildScript ? `Building static output with bun run ${staticConfig.buildScript}` : `Verifying committed ${staticConfig.outputDir}/`);
await prepareStaticContext(context, staticConfig, (args, cwd) => run(args, { cwd, allowFailure: true }));
progress.message(`Verified static output in ${staticConfig.outputDir}/`);
}
const sourceTree = (await git("rev-parse", `${commit}^{tree}`)).toLowerCase();
const labels = prebuiltLabels(config.appId, commit, project.repository, sourceTree, project.packageJson.version);
const buildLabels = Object.entries(labels).map(([name, value]) => ` ${JSON.stringify(name)}: ${JSON.stringify(value)}`).join("\n");
await writeFile(override, `services:\n ${JSON.stringify(config.service)}:\n image: ${JSON.stringify(image)}\n platform: ${JSON.stringify(config.platform)}\n build:\n labels:\n${buildLabels}\n`);
// An older ship client may have left this exact tag pointing at an unlabeled image.
// Remove only the temporary upload tag so Compose must export current identity labels;
// BuildKit layer cache remains available unless --rebuild was requested.
await run(["docker", "image", "rm", image], { allowFailure: true });
await run([
...compose,
"--project-name", `shibumi-build-${config.appId}`,
"--file", join(context, project.composeFile),
"--file", override,
"build", ...(options.rebuild ? ["--no-cache"] : []), config.service,
], { cwd: context });
const inspected = await run(["docker", "image", "inspect", "--format", "{{.Id}} {{.Os}}/{{.Architecture}}", image]);
const [imageId, platform] = inspected.stdout.trim().split(/\s+/, 2);
if (platform !== config.platform) throw new Error(`built image platform is ${platform || "unknown"}; ${config.platform} required`);
progress.message("Packing image for SSH upload");
await run(["docker", "image", "save", "--output", imageArchive, image]);
progress.message(`Uploading image to ${target}`);
const archiveBytes = (await stat(imageArchive)).size;
const loaded = await ssh(target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "image-load", config.appId, commit, String(archiveBytes),
], { inputFile: imageArchive, allowFailure: true });
if (loaded.exitCode !== 0) throw new Error(loaded.stderr.trim() || loaded.stdout.trim() || "server rejected prebuilt image");
progress.stop(`Built and uploaded ${commit.slice(0, 7)} (${Math.ceil(archiveBytes / 1024 ** 2)} MiB, ${imageId?.replace(/^sha256:/, "").slice(0, 12) || "unknown digest"})`);
await run(["docker", "image", "rm", image], { allowFailure: true });
} catch (error) {
progress.stop("Prebuilt image failed", 1);
const message = error instanceof Error ? error.message : String(error);
throw new Error(message.includes("\n\nNext:")
? message
: `${message}\n\nNext: fix the local Docker or Compose error above, verify docker compose build, then run bun ship.`);
} finally {
await rm(temporary, { recursive: true, force: true });
}
}
async function estimatedDeployDuration(config: ClientConfig, target: string): Promise<number | undefined> {
const result = await ssh(target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "history", config.appId, "--json",
], { allowFailure: true });
if (result.exitCode !== 0 || !result.stdout.trim()) return undefined;
try {
const history: unknown = JSON.parse(result.stdout);
return Array.isArray(history) ? latestDeployDuration(history as HistoryEntry[]) : undefined;
} catch {
return undefined;
}
}
// Follow status for the exact pushed commit. This prevents an older or parallel
// deployment from being reported as success for current ship.
async function followStatus(config: ClientConfig, target: string, commit: string, estimateMs?: number): Promise<void> {
const progress = spinner();
const fit = (value: string) => {
const width = Math.max(24, (process.stdout.columns ?? 80) - 8);
return value.length > width ? `${value.slice(0, width - 1)}…` : value;
};
progress.start(estimateMs ? `Waiting for deployment (ETA: ${formatDuration(estimateMs)})` : "Waiting for deployment");
const startedAt = Date.now();
const deadline = startedAt + 12 * 60_000;
const webhookDeadline = startedAt + 45_000;
let lastStage = "";
let lastOutput = "";
let sawQueued = false;
while (Date.now() < deadline) {
const result = await ssh(target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "status", config.appId, "--commit", commit, "--json",
], { allowFailure: true });
if (result.exitCode === 0 && result.stdout.trim() && result.stdout.trim() !== "null") {
const status = parseDeployStatus(JSON.parse(result.stdout))!;
const queued = status.commit !== commit && status.queuedCommit === commit;
sawQueued ||= queued;
const displayStage = queued ? `Queued ${commit.slice(0, 7)} next. Current ${status.commit!.slice(0, 7)} ${status.stage}` : status.stage;
if (status.stage && (`${status.commit}:${status.stage}` !== lastStage || (status.output ?? "") !== lastOutput)) {
lastStage = `${status.commit}:${status.stage}`;
lastOutput = status.output ?? "";
progress.message(status.stage === "shipped" && !queued
? "Deployment complete"
: status.output ? fit(`${displayStage}: ${status.output}`) : fit(`${displayStage}…`));
}
if (!queued && status.state === "succeeded") {
progress.stop(config.cutoverRequired
? "New upstream healthy at 127.0.0.1 (Caddy cutover pending)"
: "Deployment complete");
return;
}
if (!queued && status.state === "failed") {
progress.stop(`Deployment failed during ${status.stage ?? "unknown"}`, 1);
throw new Error(`${[status.message ?? "deployment failed", status.output].filter(Boolean).join("\n")}\n\nNext: run bun ship --logs.`);
}
} else if (lastStage) {
const history = await ssh(target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "history", config.appId, "--json",
], { allowFailure: true });
if (history.exitCode === 0 && history.stdout.trim()) {
const terminal = terminalHistory(JSON.parse(history.stdout) as HistoryEntry[], commit);
if (terminal?.state === "succeeded") {
progress.stop(config.cutoverRequired
? "New upstream healthy at 127.0.0.1 (Caddy cutover pending)"
: "Deployment complete");
return;
}
if (terminal?.state === "failed") {
progress.stop(`Deployment failed during ${terminal.stage ?? "unknown"}`, 1);
throw new Error(`deployment failed during ${terminal.stage ?? "unknown"}.\n\nNext: run bun ship --logs.`);
}
}
}
if (sawQueued) {
const current = await ssh(target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "status", config.appId, "--json",
], { allowFailure: true });
if (current.exitCode === 0 && current.stdout.trim() && current.stdout.trim() !== "null") {
const status = parseDeployStatus(JSON.parse(current.stdout))!;
if (status.queuedCommit && status.queuedCommit !== commit) {
progress.stop(`Queued commit replaced by ${status.queuedCommit.slice(0, 7)}`, 1);
throw new Error(`deployment ${commit.slice(0, 7)} was superseded by newer commit ${status.queuedCommit.slice(0, 7)}.\n\nNext: pull latest changes before shipping again.`);
}
}
}
if (!lastStage && Date.now() >= webhookDeadline) {
progress.stop("Webhook did not start deployment", 1);
throw new Error(`GitHub webhook did not reach shibumi-server.\n\nNext: run bun ship:webhook to repair delivery (or bun ship:webhook --off to deploy with bun ship instead).\n\nGitHub: https://github.com/${config.repository.slice("github:".length)}/settings/hooks`);
}
await Bun.sleep(2_000);
}
progress.stop("Deployment status timed out", 1);
throw new Error(`deployment may still be running. Check: ssh ${target} shibumi-server status ${config.appId}`);
}
// Existing domains keep serving their previous upstream until new app is
// healthy and user explicitly approves Caddy cutover.
async function completeCutover(config: ClientConfig, target: string): Promise<boolean> {
if (!config.cutoverRequired) return false;
if (agentRun) throw new Error("Existing-domain cutover needs interactive SSH and sudo.\n\nAgent: ask user to run bun ship from this project in their terminal.");
explain(
"Existing-domain cutover",
`The new app is healthy, but ${config.domain} still serves its previous upstream.\nCaddy cutover validates and reloads configuration without stopping active connections.`,
);
if (!await approve("Switch public traffic to the new upstream through SSH?")) {
log.warn("Cutover skipped. Previous upstream remains public.");
return false;
}
const result = await ssh(target, [SERVER_CLI, "caddy-cutover", config.appId], { tty: true, allowFailure: true });
if (result.exitCode !== 0) throw new Error("Caddy cutover failed; previous upstream remains active");
const hostname = await resolvedSshHostname(target);
const downloaded = await ssh(target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "client-config", config.appId, "--server-hostname", hostname,
]);
const next = validateConfig(JSON.parse(downloaded.stdout));
await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`);
Object.assign(config, next);
log.success(`Caddy now serves ${config.domain} from the Shibumi upstream`);
return true;
}
async function projectTarget(config: ClientConfig): Promise<string> {
const target = await configuredSshTarget(config.server.hostname) ?? await requestSshTarget(config.server.hostname);
if (!target) throw new Error("SSH server is required");
await rememberSshTarget(config.server.hostname, target);
return target;
}
async function showLogs(): Promise<void> {
try {
const config = await readConfig();
if (!config) throw new Error("Shibumi setup is missing.\n\nNext: run bun ship:setup.");
const result = await ssh(await projectTarget(config), [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "logs", config.appId,
], { allowFailure: true });
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "deployment log is unavailable");
process.stdout.write(result.stdout);
} finally {
await closeSshControl();
}
}
export function parseDeployStatus(value: unknown): DeployStatus | undefined {
if (value === null) return undefined;
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("deployment status is invalid");
const status = value as Partial<DeployStatus>;
if (typeof status.commit !== "string" || !COMMIT.test(status.commit)
|| !["accepted", "running", "succeeded", "failed"].includes(status.state ?? "")
|| typeof status.stage !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(status.stage)
|| typeof status.updatedAt !== "string" || Number.isNaN(Date.parse(status.updatedAt))
|| (status.message !== undefined && (typeof status.message !== "string" || status.message.length > 256 || /[\r\n\0]/.test(status.message)))
|| (status.output !== undefined && (typeof status.output !== "string" || status.output.length > 512 || /[\r\n\0\x1b]/.test(status.output)))
|| (status.url !== undefined && (typeof status.url !== "string" || status.url.length > 512 || !status.url.startsWith("https://")))
|| (status.queuedCommit !== undefined && (typeof status.queuedCommit !== "string" || !COMMIT.test(status.queuedCommit)))) {
throw new Error("deployment status is invalid");
}
return status as DeployStatus;
}
export function deploymentStatusSummary(status: DeployStatus | undefined, localCommit: string, config: Pick<ClientConfig, "domain" | "cutoverRequired">): string {
if (!status) return `No deployment status for https://${config.domain}`;
return [
`Status ${status.state}`,
`Commit ${status.commit}${status.commit === localCommit ? " (matches HEAD)" : ""}`,
status.commit === localCommit ? undefined : `HEAD ${localCommit}`,
`Stage ${status.stage}${status.message ? `: ${status.message}` : ""}`,
status.queuedCommit ? `Queued ${status.queuedCommit}` : undefined,
`Updated ${status.updatedAt}`,
config.cutoverRequired && status.state === "succeeded"
? "Traffic previous upstream (Caddy cutover pending)"
: `URL ${status.url ?? `https://${config.domain}`}`,
].filter(Boolean).join("\n");
}
async function showStatus(): Promise<void> {
try {
const config = await readConfig();
if (!config) throw new Error("Shibumi setup is missing.\n\nNext: run bun ship:setup.");
const result = await ssh(await projectTarget(config), [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "status", config.appId, "--json",
], { allowFailure: true });
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "deployment status is unavailable");
const status = parseDeployStatus(JSON.parse(result.stdout));
process.stdout.write(`${deploymentStatusSummary(status, await git("rev-parse", "HEAD"), config)}\n`);
} finally {
await closeSshControl();
}
}
function portIsBusy(port: number): Promise<boolean> {
return new Promise((resolveBusy) => {
const socket = createConnection({ host: "127.0.0.1", port });
socket.setTimeout(500);
socket.once("connect", () => { socket.destroy(); resolveBusy(true); });
socket.once("timeout", () => { socket.destroy(); resolveBusy(false); });
socket.once("error", () => resolveBusy(false));
});
}
export function formatDevStartup(port: number, domain: string | undefined, time: string, color = false): string {
const paint = (code: string, value: string) => color ? `\x1b[${code}m${value}\x1b[0m` : value;
const row = (label: string, url: string) => `${paint("2", "┃")} ${label.padEnd(8)} ${paint("34", url)}`;
return [
`${paint("38;5;208", "渋み")} ship dev`,
row("Local", `http://localhost:${port}/`),
...(domain ? [row("Remote", `https://${domain}`)] : []),
`${paint("2", time)} starting app dev server...`,
].join("\n");
}
function localTime(date = new Date()): string {
return [date.getHours(), date.getMinutes(), date.getSeconds()].map((value) => String(value).padStart(2, "0")).join(":");
}
async function runDev(): Promise<void> {
// Dev must work on a fresh scaffold, before any server exists: without
// setup, fall back to the Shibumi port convention (registered apps get the
// first free port above 9000) and skip the Remote row.
const config = await readConfig();
const port = config?.port ?? 9000;
if (await portIsBusy(port)) {
const lsof = Bun.which("lsof");
const fuser = Bun.which("fuser");
if (!lsof && !fuser) throw new Error(`Port ${port} is already in use.\n\nNext: stop that process, then run bun dev again.`);
const found = lsof
? await run([lsof, "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { allowFailure: true })
: await run([fuser!, "-n", "tcp", String(port)], { allowFailure: true });
const pids = [...new Set(`${found.stdout} ${found.stderr}`.split(/\s+/).filter((value) => /^\d+$/.test(value)).map(Number))];
if (pids.length === 0) throw new Error(`Port ${port} is already in use.\n\nNext: stop that process, then run bun dev again.`);
const details = await run(["ps", "-o", "pid=,comm=", "-p", pids.join(",")], { allowFailure: true });
log.warn(`Port ${port} is in use${details.stdout.trim() ? `:\n${details.stdout.trim()}` : ""}`);
const accepted = await confirm({ message: "Stop it and start this project?", initialValue: false });
if (isCancel(accepted) || !accepted) return;
for (const pid of pids) process.kill(pid, "SIGTERM");
const deadline = Date.now() + 5_000;
while (await portIsBusy(port) && Date.now() < deadline) await Bun.sleep(100);
if (await portIsBusy(port)) throw new Error(`Port ${port} did not stop.\n\nNext: stop PID ${pids.join(", ")} manually, then run bun dev again.`);
}
process.stdout.write(`${formatDevStartup(port, config?.domain, localTime(), supportsTerminalColor())}\n`);
const child = Bun.spawn([process.execPath, "run", "dev:app"], {
cwd: root,
env: { ...process.env, PORT: String(port), SHIBUMI_PORT: String(port) },
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
process.exitCode = await child.exited;
}
async function rollbackShip(): Promise<void> {
intro("渋み ship rollback");
try {
const config = await readConfig();
if (!config) throw new Error("Shibumi setup is missing.\n\nNext: run bun ship:setup.");
const target = await projectTarget(config);
const status = await ssh(target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "status", config.appId, "--json",
], { allowFailure: true });
if (status.exitCode === 0 && status.stdout.trim() && status.stdout.trim() !== "null") {
const current = parseDeployStatus(JSON.parse(status.stdout))!;
if (current.state === "accepted" || current.state === "running") {
throw new Error(`Deployment ${current.commit?.slice(0, 7) ?? ""} is still ${current.state}.\n\nNext: wait for it to finish, then retry bun ship --rollback.`);
}
}
if (!await approve(`Restore the previous retained image for ${config.domain}?`)) {
cancel("Rollback cancelled");
return;
}
const startedAt = Date.now();
const progress = spinner();
progress.start("Restoring previous image");
const rollback = await ssh(target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "rollback", config.appId, "--yes",
], { allowFailure: true });
if (rollback.exitCode !== 0) {
progress.stop("Rollback failed", 1);
throw new Error(rollback.stderr.trim() || rollback.stdout.trim() || "server rollback failed");
}
progress.stop(`Rolled back in ${formatDuration(Date.now() - startedAt)}`);
outro(`https://${config.domain}`);
} finally {
await closeSshControl();
}
}
// Setup exits after configuration changes so user can review and commit them.
// Normal ship reaches push only when configuration is already stable.
export async function runShip(): Promise<void> {
intro("渋み ship");
try {
const forceSetup = options.setup;
const result = await setup(forceSetup);
if (!result) return;
// ship:setup already committed and pushed; asking again would prompt
// twice and, on a decline, commit without pushing.
const setupCommit = result.setupCommit ?? await offerSetupCommit(result.config);
if (setupCommit === "declined") {
outro(`${accent("Next:")} review and commit Shibumi setup files, then run bun ship.`);
return;
}
const firstRun = forceSetup || result.changed;
if (firstRun) {
// Setup succeeded with everything committed, so the first deploy is one
// Enter away. Offer it here instead of ending on "Next: bun ship".
// Only for direct-ship triggers in an interactive run: github-push
// deploys on push, and agent runs never reach setup.
const shipNow = result.config.trigger === "ship" && !agentRun && process.stdin.isTTY && process.stdout.isTTY
? await confirm({ message: "Ship now?", initialValue: true })
: false;
if (shipNow !== true || isCancel(shipNow)) {
// A commit made just above still has to reach origin; leaving here
// must not leave "commit and push" half done.
if (setupCommit === "committed") await pushSetupCommit(result.config.branch);
outro(result.config.trigger === "github-push"
? `${accent("Next:")} git push origin ${result.config.branch} to deploy`
: `${accent("Next:")} bun ship\n Prefer push-to-deploy? bun ship:webhook`);
return;
}
}
const compose = await localBuildFrontend(result.config);
const estimateMs = await estimatedDeployDuration(result.config, result.target);
const startedAt = Date.now();
const ahead = await preflight(result.config);
const commit = await git("rev-parse", "HEAD");
if (!COMMIT.test(commit)) throw new Error("cannot determine shipped commit");
await buildAndUpload(result.config, result.target, commit, compose);
if (ahead > 0) await run(["git", "push", "origin", result.config.branch], { inherit: true });
if (shouldTriggerRedeploy(result.config.trigger, ahead)) {
const redeploy = await ssh(result.target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "redeploy", result.config.appId, commit,
], { allowFailure: true });
if (redeploy.exitCode !== 0) {
const current = await ssh(result.target, [
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "status", result.config.appId, "--commit", commit, "--json",
], { allowFailure: true });
const status = current.exitCode === 0 && current.stdout.trim() && current.stdout.trim() !== "null"
? parseDeployStatus(JSON.parse(current.stdout))
: undefined;
if (!canFollowDeployment(status, commit)) throw new Error(redeploy.stderr.trim() || "redeploy request failed");
log.info("Deployment already running for this commit. Following its progress.");
}
}
await followStatus(result.config, result.target, commit, estimateMs);
const changed = await completeCutover(result.config, result.target);
if (changed) log.info("Updated shibumi-server.json after Caddy cutover");
const complete = spinner();
complete.start("Finishing ship");
complete.stop(`Shipped in ${formatDuration(Date.now() - startedAt)} (--rollback if needed)`);
outro(firstRun && result.config.trigger === "ship"
? `Live at https://${result.config.domain}\n Deploys run on: bun ship. Prefer push-to-deploy? bun ship:webhook`
: `https://${result.config.domain}`);
} finally {
await closeSshControl();
}
}
export function immutableShipSource(source: string): string | undefined {
return /const CURRENT_SOURCE = "(https:\/\/shibumistack\.dev\/ship\/v\d+\.ts)";/.exec(source)?.[1];
}
export function shouldCheckForShipUpdate(value: ShipOptions): boolean {
return !(value.setup || value.update || value.rollback || value.logs || value.status || value.dev || value.webhook);
}
async function runLatestShipClient(args: string[]): Promise<boolean> {
if (process.env.SHIBUMI_SHIP_LATEST === "1") return false;
let parsed: ShipOptions;
try {
parsed = parseShipArgs(args);
} catch {
return false;
}
if (!shouldCheckForShipUpdate(parsed)) return false;
const sourcePath = join(root, "scripts/ship.ts");
let current: string;
let latest: string;
try {
current = await readFile(sourcePath, "utf8");
const [latestResponse, reviewedResponse] = await Promise.all([
fetch(LATEST_SOURCE, { headers: { accept: "text/plain" } }),
fetch(CURRENT_SOURCE, { headers: { accept: "text/plain" } }),
]);
if (!latestResponse.ok || !reviewedResponse.ok || await reviewedResponse.text() !== current) return false;
latest = await latestResponse.text();
if (latest === current || !latest.startsWith("#!/usr/bin/env bun") || !latest.includes("export function runShipCli")) return false;
const immutable = immutableShipSource(latest);
if (!immutable) return false;
const immutableResponse = await fetch(immutable, { headers: { accept: "text/plain" } });
if (!immutableResponse.ok || await immutableResponse.text() !== latest) return false;
} catch {
return false;
}
const latestVersion = immutableShipSource(latest)?.match(/v(\d+)\.ts$/)?.[1] ?? "latest";
if (!(parsed.yes || isAgentExecution())) {
const accepted = await confirm({
message: `Ship client v${latestVersion} is available. Use it now and save it after a successful deployment?`,
initialValue: true,
});
if (isCancel(accepted) || !accepted) return false;
}
log.info(`Using ship client v${latestVersion} for this deployment`);
const temporaryDirectory = await mkdtemp("/tmp/shibumi-ship-client-");
const temporarySource = join(temporaryDirectory, "ship.ts");
try {
await writeFile(temporarySource, latest, { mode: 0o644 });
const child = Bun.spawn([process.execPath, temporarySource, ...args], {
cwd: root,
env: { ...process.env, SHIBUMI_SHIP_LATEST: "1" },
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
const exitCode = await child.exited;
if (exitCode !== 0) {
process.exitCode = exitCode;
return true;
}
let sourceUnchanged = false;
try {
sourceUnchanged = await readFile(sourcePath, "utf8") === current;
} catch {}
if (sourceUnchanged) {
const temporaryUpdate = `${sourcePath}.tmp-${process.pid}`;
await writeFile(temporaryUpdate, latest, { mode: 0o644 });
await chmod(temporaryUpdate, 0o644);
await rename(temporaryUpdate, sourcePath);
log.success(`Updated scripts/ship.ts to v${latestVersion}. Review and commit it.`);
}
return true;
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
async function updateShipClient(): Promise<void> {
intro("渋み ship update");
const response = await fetch(LATEST_SOURCE, { headers: { accept: "text/plain" } });
if (!response.ok) throw new Error(`ship client returned HTTP ${response.status}`);
const source = await response.text();
if (!source.startsWith("#!/usr/bin/env bun") || !source.includes("export function runShipCli")) {
throw new Error("downloaded ship client is invalid");
}
const current = await readFile(import.meta.path, "utf8");
if (source === current) {
outro("Ship client is current");
return;
}
const reviewed = await fetch(CURRENT_SOURCE, { headers: { accept: "text/plain" } });
if (!reviewed.ok || await reviewed.text() !== current) {
throw new Error(`scripts/ship.ts contains owned changes.\n\nNext: review and merge ${LATEST_SOURCE} manually.`);
}
const temporary = `${import.meta.path}.tmp-${process.pid}`;
await writeFile(temporary, source, { mode: 0o644 });
await chmod(temporary, 0o644);
await rename(temporary, import.meta.path);
outro("Ship client updated. Review and commit scripts/ship.ts.");
}
// Manage per-app environment variables on the server (secrets and per-deploy
// config). Values travel over the existing SSH channel to `shis env`, which
// persists them server-side and injects them at deploy. Not part of
// parseShipArgs: `env` has its own positional grammar.
//
// bun ship:env set KEY=VALUE [KEY=VALUE...] set individual variables
// bun ship:env import [file] import a .env file (default .env.production)
// bun ship:env list list variable names (never values)
// bun ship:env rm KEY [KEY...] remove variables
async function runEnv(args: string[]): Promise<void> {
const [sub, ...rest] = args;
const config = await readConfig();
if (!config) throw new Error("Shibumi setup is missing.\n\nNext: run bun ship:setup.");
const base = ["env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "env"];
const applyNote = "Redeploy to apply: bun ship.";
try {
const target = await projectTarget(config);
if (sub === "list") {
const result = await ssh(target, [...base, "list", config.appId], { allowFailure: true });
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "could not list environment variables");
process.stdout.write(result.stdout.trim() ? `${result.stdout.trim()}\n` : "No variables set.\n");
return;
}
if (sub === "rm") {
if (rest.length === 0) throw new Error("usage: bun ship:env rm <KEY...>");
const result = await ssh(target, [...base, "rm", config.appId, ...rest], { allowFailure: true });
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "could not remove variables");
process.stdout.write(`${result.stdout.trim()}\n${applyNote}\n`);
return;
}
if (sub === "set" || sub === "import") {
let content: string;
if (sub === "import") {
const file = rest[0] ?? ".env.production";
content = await readFile(join(root, file), "utf8").catch(() => {
throw new Error(`Cannot read ${file}. Pass a path: bun ship:env import <file>.`);
});
} else {
if (rest.length === 0) throw new Error("usage: bun ship:env set KEY=VALUE [KEY=VALUE...]");
for (const pair of rest) {
if (!/^[A-Z_][A-Z0-9_]*=/.test(pair)) throw new Error(`not KEY=VALUE (KEY must be UPPER_SNAKE): ${pair}`);
}
content = `${rest.join("\n")}\n`;
}
// Stage in a 0600 temp file and pipe it over SSH stdin, so values never
// appear in the process list on either machine.
const dir = await mkdtemp(join(tmpdir(), "shibumi-env-"));
const tmp = join(dir, "env");
await writeFile(tmp, content, { mode: 0o600 });
try {
const result = await ssh(target, [...base, "set", config.appId], { inputFile: tmp, allowFailure: true });
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "could not set variables");
process.stdout.write(`${result.stdout.trim()}\n${applyNote}\n`);
} finally {
await rm(dir, { recursive: true, force: true });
}
return;
}
throw new Error("usage: bun ship:env set KEY=VALUE | import [file] | list | rm <KEY...>");
} finally {
await closeSshControl();
}
}
export function runShipCli(): void {
const rawArgs = process.argv.slice(2);
if (rawArgs[0] === "--env") {
agentRun = isAgentExecution();
runEnv(rawArgs.slice(1)).catch((error) => {
cancel(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
return;
}
try {
options = parseShipArgs(process.argv.slice(2));
agentRun = isAgentExecution();
} catch (error) {
cancel(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
return;
}
const action = options.update ? updateShipClient()
: options.rollback ? rollbackShip()
: options.logs ? showLogs()
: options.status ? showStatus()
: options.dev ? runDev()
: options.webhook ? runWebhook()
: runShip();
action.catch((error) => {
cancel(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
if (import.meta.main) {
runLatestShipClient(process.argv.slice(2)).then((ranLatest) => {
if (!ranLatest) runShipCli();
}).catch((error) => {
cancel(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}