create-shibumi 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +44 -2
- package/package.json +57 -5
- package/scripts/shibumi.lock.json +22 -0
- package/scripts/ship.lock.json +4 -0
- package/src/args.ts +162 -0
- package/src/cli.ts +232 -0
- package/src/create.ts +272 -0
- package/src/templates/blog/README.md +10 -0
- package/src/templates/blog/agents.md +36 -0
- package/src/templates/blog/astro.config.mjs +13 -0
- package/src/templates/blog/bun.lock +787 -0
- package/src/templates/blog/gitignore +4 -0
- package/src/templates/blog/package.json +29 -0
- package/src/templates/blog/public/og-default.png +0 -0
- package/src/templates/blog/public/style.css +147 -0
- package/src/templates/blog/src/components/BaseHead.astro +37 -0
- package/src/templates/blog/src/content/blog/one-vps-is-plenty.md +13 -0
- package/src/templates/blog/src/content/blog/own-your-source.md +13 -0
- package/src/templates/blog/src/content/blog/writing-for-agents.md +13 -0
- package/src/templates/blog/src/content.config.ts +17 -0
- package/src/templates/blog/src/layouts/Base.astro +26 -0
- package/src/templates/blog/src/pages/404.astro +11 -0
- package/src/templates/blog/src/pages/index.astro +30 -0
- package/src/templates/blog/src/pages/llms.txt.ts +29 -0
- package/src/templates/blog/src/pages/posts/[id].astro +31 -0
- package/src/templates/blog/src/pages/posts/[id].md.ts +18 -0
- package/src/templates/blog/src/pages/robots.txt.ts +6 -0
- package/src/templates/blog/src/pages/rss.xml.ts +21 -0
- package/src/templates/blog/src/site.ts +7 -0
- package/src/templates/blog/tsconfig.json +5 -0
- package/src/templates/full-stack/.dockerignore +14 -0
- package/src/templates/full-stack/Dockerfile +23 -0
- package/src/templates/full-stack/README.md +10 -0
- package/src/templates/full-stack/agents.md +60 -0
- package/src/templates/full-stack/bun.lock +225 -0
- package/src/templates/full-stack/compose.yaml +24 -0
- package/src/templates/full-stack/drizzle.config.ts +11 -0
- package/src/templates/full-stack/gitignore +6 -0
- package/src/templates/full-stack/package.json +39 -0
- package/src/templates/full-stack/public/app.js +11 -0
- package/src/templates/full-stack/public/style.css +60 -0
- package/src/templates/full-stack/public/vendor/alpine-csp-3.16.2.min.js +23 -0
- package/src/templates/full-stack/scripts/db.ts +39 -0
- package/src/templates/full-stack/src/app.ts +106 -0
- package/src/templates/full-stack/src/db/index.ts +11 -0
- package/src/templates/full-stack/src/db/lifecycle.ts +214 -0
- package/src/templates/full-stack/src/db/migrations/0001_notes.sql +9 -0
- package/src/templates/full-stack/src/db/schema.ts +13 -0
- package/src/templates/full-stack/src/env.ts +24 -0
- package/src/templates/full-stack/src/server.ts +30 -0
- package/src/templates/full-stack/test/app.test.ts +144 -0
- package/src/templates/full-stack/test/db.test.ts +169 -0
- package/src/templates/full-stack/tsconfig.json +19 -0
- package/src/templates/shibumi.ts +809 -0
- package/src/templates/ship.ts +2056 -0
- package/src/templates/static/README.md +10 -0
- package/src/templates/static/agents.md +31 -0
- package/src/templates/static/gitignore +2 -0
- package/src/templates/static/package.json +17 -0
- package/src/templates/static/public/404.html +15 -0
- package/src/templates/static/public/index.html +16 -0
- package/src/templates/static/public/style.css +50 -0
- package/src/templates/static/scripts/preview.ts +42 -0
- package/src/templates/web/.dockerignore +12 -0
- package/src/templates/web/Dockerfile +24 -0
- package/src/templates/web/README.md +10 -0
- package/src/templates/web/agents.md +54 -0
- package/src/templates/web/bun.lock +45 -0
- package/src/templates/web/compose.yaml +17 -0
- package/src/templates/web/gitignore +5 -0
- package/src/templates/web/package.json +33 -0
- package/src/templates/web/public/app.js +11 -0
- package/src/templates/web/public/style.css +60 -0
- package/src/templates/web/public/vendor/alpine-csp-3.16.2.min.js +23 -0
- package/src/templates/web/src/app.ts +96 -0
- package/src/templates/web/src/env.ts +21 -0
- package/src/templates/web/src/server.ts +23 -0
- package/src/templates/web/test/app.test.ts +137 -0
- package/src/templates/web/tsconfig.json +13 -0
|
@@ -0,0 +1,2056 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Project-owned client for shibumi-server.
|
|
5
|
+
*
|
|
6
|
+
* `bun ship:setup` connects this repository to one server and creates its
|
|
7
|
+
* deployment trigger. Later, `bun ship` checks local work, pushes one commit,
|
|
8
|
+
* triggers it over SSH by default, and follows status until the app is healthy.
|
|
9
|
+
*
|
|
10
|
+
* Commit this file and shibumi-server.json. SSH targets stay in machine-local
|
|
11
|
+
* Shibumi config. Webhook secrets stay on the server and pass directly to GitHub CLI.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { cancel, confirm, intro, isCancel, log, outro, select, spinner as animatedSpinner, text } from "@clack/prompts";
|
|
15
|
+
import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
16
|
+
import { homedir, tmpdir } from "node:os";
|
|
17
|
+
import { createConnection } from "node:net";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
const root = process.cwd();
|
|
21
|
+
const configPath = join(root, "shibumi-server.json");
|
|
22
|
+
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])?$/;
|
|
23
|
+
const SSH_TARGET = /^(?!-)[A-Za-z0-9_.@:-]+$/;
|
|
24
|
+
const SERVER_HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/;
|
|
25
|
+
const COMMIT = /^[a-f0-9]{40}$/;
|
|
26
|
+
const SERVER_CLI = "~/.local/bin/shibumi-server";
|
|
27
|
+
const LATEST_SOURCE = "https://shibumistack.dev/ship/latest.ts";
|
|
28
|
+
const CURRENT_SOURCE = "https://shibumistack.dev/ship/v45.ts";
|
|
29
|
+
let sshControlDirectory: string | undefined;
|
|
30
|
+
let sshControlTarget: string | undefined;
|
|
31
|
+
|
|
32
|
+
export function supportsTerminalColor(env: NodeJS.ProcessEnv = process.env, isTTY = Boolean(process.stdout.isTTY)): boolean {
|
|
33
|
+
return isTTY && !("NO_COLOR" in env) && env.TERM !== "dumb";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const accent = (value: string) => supportsTerminalColor()
|
|
37
|
+
? `\x1b[38;5;208m${value}\x1b[0m`
|
|
38
|
+
: value;
|
|
39
|
+
|
|
40
|
+
interface ClientConfig {
|
|
41
|
+
version: 1;
|
|
42
|
+
provider: "shibumi-server";
|
|
43
|
+
server: { hostname: string };
|
|
44
|
+
domain: string;
|
|
45
|
+
appId: string;
|
|
46
|
+
repository: `github:${string}`;
|
|
47
|
+
branch: string;
|
|
48
|
+
webhookUrl: string;
|
|
49
|
+
service: string;
|
|
50
|
+
port: number;
|
|
51
|
+
healthPath: string;
|
|
52
|
+
deploymentMode: "build" | "prebuilt";
|
|
53
|
+
trigger: "ship" | "github-push";
|
|
54
|
+
platform?: `linux/${string}`;
|
|
55
|
+
cutoverRequired: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Result {
|
|
59
|
+
exitCode: number;
|
|
60
|
+
stdout: string;
|
|
61
|
+
stderr: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface DeployStatus {
|
|
65
|
+
commit: string;
|
|
66
|
+
state: "accepted" | "running" | "succeeded" | "failed";
|
|
67
|
+
stage: string;
|
|
68
|
+
message?: string;
|
|
69
|
+
output?: string;
|
|
70
|
+
url?: string;
|
|
71
|
+
queuedCommit?: string;
|
|
72
|
+
updatedAt: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface HistoryEntry {
|
|
76
|
+
commit?: string;
|
|
77
|
+
kind?: string;
|
|
78
|
+
state?: string;
|
|
79
|
+
stage?: string;
|
|
80
|
+
durationMs?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ShipOptions {
|
|
84
|
+
setup: boolean;
|
|
85
|
+
update: boolean;
|
|
86
|
+
rollback: boolean;
|
|
87
|
+
logs: boolean;
|
|
88
|
+
status: boolean;
|
|
89
|
+
dev: boolean;
|
|
90
|
+
rebuild: boolean;
|
|
91
|
+
yes: boolean;
|
|
92
|
+
server?: string;
|
|
93
|
+
domain?: string;
|
|
94
|
+
trigger?: "ship" | "github-push";
|
|
95
|
+
staticSite: boolean;
|
|
96
|
+
outputDir?: string;
|
|
97
|
+
buildScript?: string;
|
|
98
|
+
spa: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface StaticSiteConfig {
|
|
102
|
+
outputDir: string;
|
|
103
|
+
buildScript?: string;
|
|
104
|
+
spa: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let options: ShipOptions = { setup: false, update: false, rollback: false, logs: false, status: false, dev: false, rebuild: false, yes: false, staticSite: false, spa: false };
|
|
108
|
+
let agentRun = false;
|
|
109
|
+
|
|
110
|
+
export function isAgentExecution(env: NodeJS.ProcessEnv = process.env, stdinTTY = Boolean(process.stdin.isTTY), stdoutTTY = Boolean(process.stdout.isTTY)): boolean {
|
|
111
|
+
return !stdinTTY || !stdoutTTY || env.PI_CODING_AGENT === "true" || env.CLAUDECODE === "1"
|
|
112
|
+
|| Object.keys(env).some((key) => /^(?:CODEX_|CURSOR_AGENT|AIDER_)/.test(key));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function shouldAnimateProgress(agentExecution: boolean, stdoutTTY: boolean): boolean {
|
|
116
|
+
return !agentExecution && stdoutTTY;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type ShipSpinner = {
|
|
120
|
+
start(message?: string): void;
|
|
121
|
+
message(message?: string): void;
|
|
122
|
+
stop(message?: string, code?: number): void;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function spinner(): ShipSpinner {
|
|
126
|
+
if (shouldAnimateProgress(agentRun, Boolean(process.stdout.isTTY))) {
|
|
127
|
+
// Clack 1.x split error stops into error(); 0.7 (pinned by older installers)
|
|
128
|
+
// only has stop(message, code). Support both at runtime.
|
|
129
|
+
const animated = animatedSpinner() as ShipSpinner & { error?: (message?: string) => void };
|
|
130
|
+
return {
|
|
131
|
+
start: (message) => animated.start(message),
|
|
132
|
+
message: (message) => animated.message(message),
|
|
133
|
+
stop: (message, code) => code && typeof animated.error === "function"
|
|
134
|
+
? animated.error(message)
|
|
135
|
+
: animated.stop(message, code),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const write = (message?: string, error = false) => {
|
|
139
|
+
if (message) (error ? process.stderr : process.stdout).write(`${message}\n`);
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
start: (message) => write(message),
|
|
143
|
+
message: (message) => write(message),
|
|
144
|
+
stop: (message, code) => write(message, Boolean(code)),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function parseShipArgs(args: string[]): ShipOptions {
|
|
149
|
+
const parsed: ShipOptions = { setup: false, update: false, rollback: false, logs: false, status: false, dev: false, rebuild: false, yes: false, staticSite: false, spa: false };
|
|
150
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
151
|
+
const argument = args[index];
|
|
152
|
+
if (argument === "--") continue;
|
|
153
|
+
if (argument === "--setup") parsed.setup = true;
|
|
154
|
+
else if (argument === "--update") parsed.update = true;
|
|
155
|
+
else if (argument === "--rollback") parsed.rollback = true;
|
|
156
|
+
else if (argument === "--logs") parsed.logs = true;
|
|
157
|
+
else if (argument === "--status") parsed.status = true;
|
|
158
|
+
else if (argument === "--dev") parsed.dev = true;
|
|
159
|
+
else if (argument === "--rebuild") parsed.rebuild = true;
|
|
160
|
+
else if (argument === "--yes" || argument === "-y") parsed.yes = true;
|
|
161
|
+
else if (argument === "--static") parsed.staticSite = true;
|
|
162
|
+
else if (argument === "--spa") parsed.spa = true;
|
|
163
|
+
else if (argument === "--server" || argument === "--domain" || argument === "--trigger" || argument === "--output-dir" || argument === "--build-script") {
|
|
164
|
+
const value = args[index + 1];
|
|
165
|
+
if (!value || value.startsWith("-")) throw new Error(`${argument} requires a value`);
|
|
166
|
+
if (argument === "--server") parsed.server = value;
|
|
167
|
+
else if (argument === "--domain") parsed.domain = value;
|
|
168
|
+
else if (argument === "--output-dir") parsed.outputDir = value;
|
|
169
|
+
else if (argument === "--build-script") parsed.buildScript = value;
|
|
170
|
+
else if (value === "ship" || value === "github-push") parsed.trigger = value;
|
|
171
|
+
else throw new Error("--trigger must be ship or github-push");
|
|
172
|
+
index += 1;
|
|
173
|
+
} else throw new Error(`unknown ship option: ${argument}`);
|
|
174
|
+
}
|
|
175
|
+
if ([parsed.setup, parsed.update, parsed.rollback, parsed.logs, parsed.status, parsed.dev].filter(Boolean).length > 1) throw new Error("choose only one ship action");
|
|
176
|
+
if (parsed.rebuild && (parsed.setup || parsed.update || parsed.rollback || parsed.logs || parsed.status || parsed.dev)) throw new Error("--rebuild applies only to shipping");
|
|
177
|
+
if (parsed.trigger && !parsed.setup) throw new Error("--trigger requires --setup");
|
|
178
|
+
if ((parsed.staticSite || parsed.outputDir || parsed.buildScript || parsed.spa) && !parsed.setup) throw new Error("--static, --output-dir, --build-script, and --spa require --setup");
|
|
179
|
+
if ((parsed.outputDir || parsed.buildScript || parsed.spa) && !parsed.staticSite) throw new Error("--output-dir, --build-script, and --spa require --static");
|
|
180
|
+
if (parsed.outputDir) {
|
|
181
|
+
const problem = staticOutputDirProblem(parsed.outputDir);
|
|
182
|
+
if (problem) throw new Error(problem);
|
|
183
|
+
}
|
|
184
|
+
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");
|
|
185
|
+
if (parsed.server && !SSH_TARGET.test(parsed.server)) throw new Error("--server must be an SSH host or user@host without spaces");
|
|
186
|
+
if (parsed.domain && !DOMAIN.test(parsed.domain)) throw new Error("--domain must be a lowercase public hostname");
|
|
187
|
+
return parsed;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function stripDockerDesktopLinks(value: string): string {
|
|
191
|
+
return value.split(/\r?\n/).filter((line) => !/docker-desktop:\/\//i.test(line)).join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function dockerCredentialHelpers(value: unknown): string[] {
|
|
195
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
196
|
+
const config = value as Record<string, unknown>;
|
|
197
|
+
const helpers = new Set<string>();
|
|
198
|
+
if (typeof config.credsStore === "string" && config.credsStore) helpers.add(config.credsStore);
|
|
199
|
+
if (config.credHelpers && typeof config.credHelpers === "object" && !Array.isArray(config.credHelpers)) {
|
|
200
|
+
for (const helper of Object.values(config.credHelpers)) if (typeof helper === "string" && helper) helpers.add(helper);
|
|
201
|
+
}
|
|
202
|
+
return [...helpers];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function removeDockerCredentialHelper(value: Record<string, unknown>, helper: string): void {
|
|
206
|
+
if (value.credsStore === helper) delete value.credsStore;
|
|
207
|
+
if (!value.credHelpers || typeof value.credHelpers !== "object" || Array.isArray(value.credHelpers)) return;
|
|
208
|
+
const helpers = value.credHelpers as Record<string, unknown>;
|
|
209
|
+
for (const [registry, configured] of Object.entries(helpers)) if (configured === helper) delete helpers[registry];
|
|
210
|
+
if (Object.keys(helpers).length === 0) delete value.credHelpers;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function formatDuration(milliseconds: number): string {
|
|
214
|
+
const seconds = Math.max(1, Math.round(milliseconds / 1_000));
|
|
215
|
+
if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
|
|
216
|
+
const minutes = Math.floor(seconds / 60);
|
|
217
|
+
const remainder = seconds % 60;
|
|
218
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}${remainder ? ` ${remainder} second${remainder === 1 ? "" : "s"}` : ""}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function latestDeployDuration(history: HistoryEntry[]): number | undefined {
|
|
222
|
+
return [...history].reverse().find((entry) => entry.kind === "webhook" && entry.state === "succeeded"
|
|
223
|
+
&& Number.isInteger(entry.durationMs) && entry.durationMs! > 0)?.durationMs;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface ClientSettings {
|
|
227
|
+
version: 1;
|
|
228
|
+
defaultServer?: string;
|
|
229
|
+
servers: Record<string, { sshTarget: string }>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function clientSettingsPath(env: NodeJS.ProcessEnv = process.env, home = homedir()): string {
|
|
233
|
+
return join(env.XDG_CONFIG_HOME || join(home, ".config"), "shibumi", "config.json");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function readClientSettings(): Promise<ClientSettings> {
|
|
237
|
+
try {
|
|
238
|
+
const value: unknown = JSON.parse(await readFile(clientSettingsPath(), "utf8"));
|
|
239
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || (value as { version?: unknown }).version !== 1) throw new Error("invalid client settings");
|
|
240
|
+
const servers = (value as { servers?: unknown }).servers;
|
|
241
|
+
const defaultServer = (value as { defaultServer?: unknown }).defaultServer;
|
|
242
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)
|
|
243
|
+
|| Object.entries(servers).some(([hostname, entry]) => !SERVER_HOSTNAME.test(hostname)
|
|
244
|
+
|| !entry || typeof entry !== "object" || Array.isArray(entry)
|
|
245
|
+
|| typeof (entry as { sshTarget?: unknown }).sshTarget !== "string"
|
|
246
|
+
|| !SSH_TARGET.test((entry as { sshTarget: string }).sshTarget))
|
|
247
|
+
|| (defaultServer !== undefined && (typeof defaultServer !== "string" || !(defaultServer in servers)))) throw new Error("invalid client settings");
|
|
248
|
+
return value as ClientSettings;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, servers: {} };
|
|
251
|
+
throw new Error(`${clientSettingsPath()} is invalid.\n\nNext: repair or remove this local client config, then retry.`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function savedSshTarget(hostname?: string): Promise<string | undefined> {
|
|
256
|
+
const settings = await readClientSettings();
|
|
257
|
+
const configured = hostname ? settings.servers[hostname]?.sshTarget : undefined;
|
|
258
|
+
if (configured) {
|
|
259
|
+
log.info(`Server ${configured}\nConfig ${clientSettingsPath()}`);
|
|
260
|
+
return configured;
|
|
261
|
+
}
|
|
262
|
+
if (hostname) return undefined;
|
|
263
|
+
const servers = Object.entries(settings.servers);
|
|
264
|
+
if (servers.length === 0) return undefined;
|
|
265
|
+
const fallback = settings.defaultServer && settings.servers[settings.defaultServer]
|
|
266
|
+
? settings.servers[settings.defaultServer].sshTarget
|
|
267
|
+
: servers[0][1].sshTarget;
|
|
268
|
+
if (options.yes) {
|
|
269
|
+
log.info(`Server ${fallback}\nConfig ${clientSettingsPath()}`);
|
|
270
|
+
return fallback;
|
|
271
|
+
}
|
|
272
|
+
if (agentRun) throw new Error(`Deployment server confirmation required.\n\nAgent: ask user whether to use ${fallback}, then run bun ship:setup --server <target>.`);
|
|
273
|
+
const answer = await text({
|
|
274
|
+
message: "SSH server",
|
|
275
|
+
placeholder: fallback,
|
|
276
|
+
validate: (value) => SSH_TARGET.test(value || fallback) ? undefined : "Use an SSH host, user@host, or SSH alias without spaces",
|
|
277
|
+
});
|
|
278
|
+
return isCancel(answer) ? undefined : answer || fallback;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function rememberSshTarget(hostname: string, sshTarget: string): Promise<void> {
|
|
282
|
+
if (!SERVER_HOSTNAME.test(hostname) || !SSH_TARGET.test(sshTarget)) throw new Error("resolved SSH server is unsafe");
|
|
283
|
+
const settings = await readClientSettings();
|
|
284
|
+
if (settings.servers[hostname]?.sshTarget === sshTarget) return;
|
|
285
|
+
settings.servers[hostname] = { sshTarget };
|
|
286
|
+
settings.defaultServer = hostname;
|
|
287
|
+
const path = clientSettingsPath();
|
|
288
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
289
|
+
const temporary = `${path}.tmp-${process.pid}`;
|
|
290
|
+
await writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
|
|
291
|
+
await chmod(temporary, 0o600);
|
|
292
|
+
await rename(temporary, path);
|
|
293
|
+
log.success(`Saved server ${sshTarget} in ${path}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function approve(message: string): Promise<boolean> {
|
|
297
|
+
if (options.yes || agentRun) return true;
|
|
298
|
+
const accepted = await confirm({ message, initialValue: true });
|
|
299
|
+
return !isCancel(accepted) && accepted;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function explain(title: string, message: string): void {
|
|
303
|
+
log.info(`${title}\n${message}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Run argument arrays directly. Avoiding a shell keeps repository and SSH input
|
|
307
|
+
// from becoming executable command text.
|
|
308
|
+
async function run(args: string[], options: {
|
|
309
|
+
input?: string;
|
|
310
|
+
inputFile?: string;
|
|
311
|
+
cwd?: string;
|
|
312
|
+
inherit?: boolean;
|
|
313
|
+
allowFailure?: boolean;
|
|
314
|
+
} = {}): Promise<Result> {
|
|
315
|
+
const inherit = options.inherit ?? false;
|
|
316
|
+
const child = Bun.spawn(args, {
|
|
317
|
+
cwd: options.cwd ?? root,
|
|
318
|
+
stdin: options.inputFile ? Bun.file(options.inputFile) : options.input === undefined ? (inherit ? "inherit" : "ignore") : "pipe",
|
|
319
|
+
stdout: inherit ? "inherit" : "pipe",
|
|
320
|
+
stderr: inherit ? "inherit" : "pipe",
|
|
321
|
+
});
|
|
322
|
+
if (options.input !== undefined) {
|
|
323
|
+
if (!child.stdin || typeof child.stdin === "number") throw new Error("cannot open command input");
|
|
324
|
+
child.stdin.write(options.input);
|
|
325
|
+
child.stdin.end();
|
|
326
|
+
}
|
|
327
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
328
|
+
child.exited,
|
|
329
|
+
inherit ? Promise.resolve("") : new Response(child.stdout).text(),
|
|
330
|
+
inherit ? Promise.resolve("") : new Response(child.stderr).text(),
|
|
331
|
+
]);
|
|
332
|
+
if (exitCode !== 0 && !options.allowFailure) throw new Error(stripDockerDesktopLinks(stderr).trim() || `${args[0]} exited with ${exitCode}`);
|
|
333
|
+
return { exitCode, stdout, stderr };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function git(...args: string[]): Promise<string> {
|
|
337
|
+
return (await run(["git", ...args])).stdout.trim();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const setupFiles = ["package.json", "bun.lock", "scripts/ship.ts", "shibumi-server.json"];
|
|
341
|
+
|
|
342
|
+
async function offerSetupCommit(config: ClientConfig): Promise<"none" | "committed" | "declined"> {
|
|
343
|
+
const changed: string[] = [];
|
|
344
|
+
for (const file of setupFiles) {
|
|
345
|
+
const status = await run(["git", "status", "--porcelain", "--", file]);
|
|
346
|
+
if (status.stdout.trim()) changed.push(file);
|
|
347
|
+
}
|
|
348
|
+
if (changed.length === 0) return "none";
|
|
349
|
+
const updateOnly = changed.length === 1 && changed[0] === "scripts/ship.ts";
|
|
350
|
+
if (await githubBranchIsProtected(config)) {
|
|
351
|
+
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.`);
|
|
352
|
+
return "declined";
|
|
353
|
+
}
|
|
354
|
+
const trackedConfig = (await run(["git", "ls-files", "--error-unmatch", "shibumi-server.json"], { allowFailure: true })).exitCode === 0;
|
|
355
|
+
if (!await approve(updateOnly ? "Commit ship client update now?" : "Commit deployment setup now?")) return "declined";
|
|
356
|
+
await run(["git", "add", "--", ...changed]);
|
|
357
|
+
await run(["git", "commit", "--only", "-m", trackedConfig ? "Update Shibumi deployment" : "Add Shibumi deployment", "--", ...changed], { inherit: true });
|
|
358
|
+
log.success(updateOnly ? "Committed ship client update" : "Committed Shibumi deployment setup");
|
|
359
|
+
return "committed";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Double literal hyphens before replacing dots so different domains cannot
|
|
363
|
+
// collapse to the same server app ID.
|
|
364
|
+
export function appIdForDomain(domain: string): string {
|
|
365
|
+
return domain.replaceAll("-", "--").replaceAll(".", "-");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function terminalHistory(entries: HistoryEntry[], commit: string): HistoryEntry | undefined {
|
|
369
|
+
return entries.findLast((entry) => entry.commit === commit && ["succeeded", "failed"].includes(entry.state ?? ""));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function canFollowDeployment(status: Pick<DeployStatus, "commit" | "state"> | undefined, commit: string): boolean {
|
|
373
|
+
return status?.commit === commit && ["accepted", "running", "succeeded"].includes(status.state ?? "");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function shouldTriggerRedeploy(trigger: ClientConfig["trigger"], ahead: number): boolean {
|
|
377
|
+
return trigger === "ship" || ahead === 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function protectedPushBlocked(value: unknown, login?: string, admin = false): boolean | undefined {
|
|
381
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
382
|
+
const protection = value as {
|
|
383
|
+
enforce_admins?: { enabled?: unknown };
|
|
384
|
+
required_pull_request_reviews?: {
|
|
385
|
+
bypass_pull_request_allowances?: { users?: Array<{ login?: unknown }> };
|
|
386
|
+
} | null;
|
|
387
|
+
};
|
|
388
|
+
const reviews = protection.required_pull_request_reviews;
|
|
389
|
+
if (!reviews) return false;
|
|
390
|
+
const bypassed = login && reviews.bypass_pull_request_allowances?.users
|
|
391
|
+
?.some((user) => user.login === login);
|
|
392
|
+
if (bypassed || (admin && protection.enforce_admins?.enabled === false)) return false;
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const branchProtection = new Map<string, boolean | undefined>();
|
|
397
|
+
|
|
398
|
+
async function githubBranchIsProtected(config: ClientConfig): Promise<boolean> {
|
|
399
|
+
const repository = config.repository.slice("github:".length);
|
|
400
|
+
const key = `${repository}#${config.branch}`;
|
|
401
|
+
if (!branchProtection.has(key)) {
|
|
402
|
+
const endpoint = `repos/${repository}/branches/${encodeURIComponent(config.branch)}/protection`;
|
|
403
|
+
try {
|
|
404
|
+
if (Bun.which("gh")) {
|
|
405
|
+
const details = await run(["gh", "api", endpoint], { allowFailure: true });
|
|
406
|
+
if (details.exitCode === 0) {
|
|
407
|
+
const protection: unknown = JSON.parse(details.stdout);
|
|
408
|
+
if (protectedPushBlocked(protection) === false) branchProtection.set(key, false);
|
|
409
|
+
else {
|
|
410
|
+
const [viewer, repo] = await Promise.all([
|
|
411
|
+
run(["gh", "api", "user", "--jq", ".login"], { allowFailure: true }),
|
|
412
|
+
run(["gh", "api", `repos/${repository}`, "--jq", ".permissions.admin"], { allowFailure: true }),
|
|
413
|
+
]);
|
|
414
|
+
branchProtection.set(key, protectedPushBlocked(
|
|
415
|
+
protection,
|
|
416
|
+
viewer.exitCode === 0 ? viewer.stdout.trim() : undefined,
|
|
417
|
+
repo.exitCode === 0 && repo.stdout.trim() === "true",
|
|
418
|
+
));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (!branchProtection.has(key)) {
|
|
423
|
+
const response = await fetch(`https://api.github.com/${endpoint}`, {
|
|
424
|
+
headers: { Accept: "application/vnd.github+json", "User-Agent": "shibumi-ship" },
|
|
425
|
+
signal: AbortSignal.timeout(5_000),
|
|
426
|
+
});
|
|
427
|
+
branchProtection.set(key, response.status === 404 ? false : response.ok ? protectedPushBlocked(await response.json()) : undefined);
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
branchProtection.set(key, undefined);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return branchProtection.get(key) === true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function deploymentModeForTrigger(trigger: ClientConfig["trigger"]): ClientConfig["deploymentMode"] {
|
|
437
|
+
return trigger === "ship" ? "prebuilt" : "build";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function repositoryFromRemote(remote: string): string {
|
|
441
|
+
const match = /github\.com[/:]([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(remote);
|
|
442
|
+
if (!match) throw new Error("origin must be a GitHub repository");
|
|
443
|
+
return match[1];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Infer safe defaults from owned project files. Interactive server setup still
|
|
447
|
+
// previews these values before changing Caddy, systemd, or server config.
|
|
448
|
+
export function setupDomain(explicit: string | undefined, configured: string | undefined, inferred: string | undefined): string | undefined {
|
|
449
|
+
return explicit ?? configured ?? inferred;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function domainFromProject(packageName: unknown, compose: string): string | undefined {
|
|
453
|
+
if (typeof packageName === "string" && DOMAIN.test(packageName)) return packageName;
|
|
454
|
+
const siteUrlDomain = /^\s*(?:-\s*)?SITE_URL(?:=|:\s*)["']?https:\/\/([^/:\s"']+)/m.exec(compose)?.[1]?.toLowerCase();
|
|
455
|
+
return siteUrlDomain && DOMAIN.test(siteUrlDomain) ? siteUrlDomain : undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function composeCandidates(files: string[]): string[] {
|
|
459
|
+
return files.filter((file) => /(^|\/)(?:compose\.ya?ml|docker-compose\.ya?ml)$/.test(file));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function deploymentFileTemplates(hasBuildScript: boolean): Record<string, string> {
|
|
463
|
+
return {
|
|
464
|
+
Dockerfile: `FROM oven/bun:alpine
|
|
465
|
+
|
|
466
|
+
WORKDIR /app
|
|
467
|
+
|
|
468
|
+
COPY package.json bun.lock* ./
|
|
469
|
+
RUN bun install --frozen-lockfile
|
|
470
|
+
|
|
471
|
+
COPY . .
|
|
472
|
+
${hasBuildScript ? "RUN bun run build\n\n" : ""}ENV HOST=0.0.0.0
|
|
473
|
+
ENV PORT=3000
|
|
474
|
+
EXPOSE 3000
|
|
475
|
+
|
|
476
|
+
CMD ["bun", "run", "start"]
|
|
477
|
+
`,
|
|
478
|
+
"compose.yaml": `services:
|
|
479
|
+
app:
|
|
480
|
+
build: .
|
|
481
|
+
ports:
|
|
482
|
+
- "127.0.0.1:\${SHIBUMI_PORT:-9001}:3000"
|
|
483
|
+
environment:
|
|
484
|
+
HOST: 0.0.0.0
|
|
485
|
+
PORT: "3000"
|
|
486
|
+
init: true
|
|
487
|
+
restart: unless-stopped
|
|
488
|
+
healthcheck:
|
|
489
|
+
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
|
490
|
+
interval: 30s
|
|
491
|
+
timeout: 5s
|
|
492
|
+
retries: 3
|
|
493
|
+
start_period: 10s
|
|
494
|
+
deploy:
|
|
495
|
+
resources:
|
|
496
|
+
limits:
|
|
497
|
+
cpus: "1.0"
|
|
498
|
+
memory: 512M
|
|
499
|
+
`,
|
|
500
|
+
".dockerignore": `.git
|
|
501
|
+
node_modules
|
|
502
|
+
.env
|
|
503
|
+
.env.*
|
|
504
|
+
!.env.example
|
|
505
|
+
data
|
|
506
|
+
*.log
|
|
507
|
+
`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ── Static output shipping ─────────────────────────────────────────────
|
|
512
|
+
// Static params live as labels in the committed compose file, so they ride
|
|
513
|
+
// the git archive into every build context and survive the server-downloaded
|
|
514
|
+
// shibumi-server.json rewrite. Builds always run on the user's machine (their
|
|
515
|
+
// toolchain: Ruby, Node, anything); the image never contains a build stage.
|
|
516
|
+
|
|
517
|
+
const STATIC_OUTPUT_DIRS = ["dist", "public", "build", "out", "_site"];
|
|
518
|
+
const BUSYBOX_IMAGE = "busybox:1.37-musl@sha256:fc6dddc4c44b1bfe37f41cae8e67d1693828e8f42a91862816d7953e2c9d3f23";
|
|
519
|
+
const STATIC_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
520
|
+
const BUILD_SCRIPT_NAME = /^[A-Za-z0-9][A-Za-z0-9:_-]*$/;
|
|
521
|
+
|
|
522
|
+
export function staticOutputDirProblem(value: string): string | undefined {
|
|
523
|
+
if (!value) return "output directory is required";
|
|
524
|
+
if (value.startsWith("/") || value.includes("\\") || /^[A-Za-z]:/.test(value)) return `output directory must be relative to the project root, got ${value}`;
|
|
525
|
+
const segments = value.split("/");
|
|
526
|
+
if (segments.some((segment) => !STATIC_SEGMENT.test(segment) || segment.endsWith(".") || segment === ".." )) {
|
|
527
|
+
return `output directory segments must be plain names (letters, digits, ".", "_", "-"), got ${value}`;
|
|
528
|
+
}
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Walk the first service's labels block structurally instead of regex-scanning
|
|
533
|
+
// raw YAML, so label-shaped text inside block scalars or other services can
|
|
534
|
+
// never masquerade as static configuration.
|
|
535
|
+
export function staticConfigFromCompose(compose: string): StaticSiteConfig | undefined {
|
|
536
|
+
const values: Record<string, string> = {};
|
|
537
|
+
let inServices = false;
|
|
538
|
+
let serviceCount = 0;
|
|
539
|
+
let inLabels = false;
|
|
540
|
+
for (const line of compose.split("\n")) {
|
|
541
|
+
if (/^\S/.test(line)) {
|
|
542
|
+
inServices = /^services:\s*$/.test(line);
|
|
543
|
+
inLabels = false;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (!inServices) continue;
|
|
547
|
+
if (/^ {2}\S/.test(line)) {
|
|
548
|
+
serviceCount += 1;
|
|
549
|
+
inLabels = false;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (serviceCount !== 1) continue;
|
|
553
|
+
if (/^ {4}labels:\s*$/.test(line)) {
|
|
554
|
+
inLabels = true;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (/^ {4}\S/.test(line)) {
|
|
558
|
+
inLabels = false;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (!inLabels) continue;
|
|
562
|
+
const match = /^ {6}dev\.shibumistack\.static\.(output|build|spa): "?([^"]*)"?\s*$/.exec(line);
|
|
563
|
+
if (match) values[match[1]!] = match[2]!;
|
|
564
|
+
}
|
|
565
|
+
if (values.output === undefined) return undefined;
|
|
566
|
+
const problem = staticOutputDirProblem(values.output);
|
|
567
|
+
if (problem) throw new Error(`compose static labels are invalid: ${problem}`);
|
|
568
|
+
if (values.build !== undefined && !BUILD_SCRIPT_NAME.test(values.build)) throw new Error("compose static build label must be a package.json script name");
|
|
569
|
+
return { outputDir: values.output, buildScript: values.build || undefined, spa: values.spa === "true" };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function staticHttpdConf(has404: boolean): string {
|
|
573
|
+
return [
|
|
574
|
+
...(has404 ? ["E404:404.html"] : []),
|
|
575
|
+
"I:index.html",
|
|
576
|
+
".md:text/plain",
|
|
577
|
+
".txt:text/plain",
|
|
578
|
+
".json:application/json",
|
|
579
|
+
".xml:application/xml",
|
|
580
|
+
".svg:image/svg+xml",
|
|
581
|
+
".webp:image/webp",
|
|
582
|
+
".avif:image/avif",
|
|
583
|
+
".woff2:font/woff2",
|
|
584
|
+
"",
|
|
585
|
+
].join("\n");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// SPA fallback needs unknown paths answered with index.html and status 200,
|
|
589
|
+
// which BusyBox httpd cannot do; those sites get a small owned Bun server.
|
|
590
|
+
export function staticServerSource(outputDir: string): string {
|
|
591
|
+
return `// Owned static file server for SPA fallback, generated by ship:setup.
|
|
592
|
+
// Serves ${JSON.stringify(outputDir)} and answers unknown paths with index.html (200).
|
|
593
|
+
import { join, normalize } from "node:path";
|
|
594
|
+
|
|
595
|
+
const ROOT = ${JSON.stringify(`/www`)};
|
|
596
|
+
const server = Bun.serve({
|
|
597
|
+
port: 3000,
|
|
598
|
+
async fetch(request) {
|
|
599
|
+
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Not found", { status: 404 });
|
|
600
|
+
let pathname;
|
|
601
|
+
try {
|
|
602
|
+
pathname = decodeURIComponent(new URL(request.url).pathname);
|
|
603
|
+
} catch {
|
|
604
|
+
return new Response("Bad request", { status: 400 });
|
|
605
|
+
}
|
|
606
|
+
const safe = normalize(pathname).replaceAll("\\\\", "/");
|
|
607
|
+
if (safe.includes("..")) return new Response("Not found", { status: 404 });
|
|
608
|
+
const candidate = safe.endsWith("/") ? join(ROOT, safe, "index.html") : join(ROOT, safe);
|
|
609
|
+
if (!candidate.startsWith(ROOT)) return new Response("Not found", { status: 404 });
|
|
610
|
+
const file = Bun.file(candidate);
|
|
611
|
+
if (await file.exists()) return new Response(file);
|
|
612
|
+
return new Response(Bun.file(join(ROOT, "index.html")));
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
console.log(\`Serving \${ROOT} on http://localhost:\${server.port}\`);
|
|
616
|
+
process.on("SIGTERM", () => { void server.stop().then(() => process.exit(0)); });
|
|
617
|
+
process.on("SIGINT", () => { void server.stop().then(() => process.exit(0)); });
|
|
618
|
+
`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function staticComposeLabels(config: StaticSiteConfig): string {
|
|
622
|
+
return [
|
|
623
|
+
` dev.shibumistack.static.output: ${JSON.stringify(config.outputDir)}`,
|
|
624
|
+
...(config.buildScript ? [` dev.shibumistack.static.build: ${JSON.stringify(config.buildScript)}`] : []),
|
|
625
|
+
` dev.shibumistack.static.spa: "${config.spa}"`,
|
|
626
|
+
].join("\n");
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function staticDeploymentFileTemplates(config: StaticSiteConfig): Record<string, string> {
|
|
630
|
+
const dockerfile = config.spa
|
|
631
|
+
? `FROM oven/bun:alpine
|
|
632
|
+
WORKDIR /app
|
|
633
|
+
COPY ${config.outputDir} /www
|
|
634
|
+
COPY scripts/static-server.ts ./static-server.ts
|
|
635
|
+
USER bun
|
|
636
|
+
EXPOSE 3000
|
|
637
|
+
CMD ["bun", "static-server.ts"]
|
|
638
|
+
`
|
|
639
|
+
: `FROM ${BUSYBOX_IMAGE} AS busybox
|
|
640
|
+
|
|
641
|
+
FROM scratch
|
|
642
|
+
COPY --from=busybox /bin/busybox /busybox
|
|
643
|
+
COPY --chown=65534:65534 ${config.outputDir} /www
|
|
644
|
+
USER 65534:65534
|
|
645
|
+
EXPOSE 3000
|
|
646
|
+
ENTRYPOINT ["/busybox", "httpd", "-f", "-p", "3000", "-h", "/www", "-c", "/www/httpd.conf"]
|
|
647
|
+
`;
|
|
648
|
+
const healthcheck = config.spa
|
|
649
|
+
? `["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]`
|
|
650
|
+
: `["CMD", "/busybox", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://127.0.0.1:3000/"]`;
|
|
651
|
+
return {
|
|
652
|
+
Dockerfile: dockerfile,
|
|
653
|
+
"compose.yaml": `services:
|
|
654
|
+
app:
|
|
655
|
+
build: .
|
|
656
|
+
ports:
|
|
657
|
+
- "127.0.0.1:\${SHIBUMI_PORT:-9001}:3000"
|
|
658
|
+
labels:
|
|
659
|
+
${staticComposeLabels(config)}
|
|
660
|
+
init: true
|
|
661
|
+
restart: unless-stopped
|
|
662
|
+
healthcheck:
|
|
663
|
+
test: ${healthcheck}
|
|
664
|
+
interval: 30s
|
|
665
|
+
timeout: 5s
|
|
666
|
+
retries: 3
|
|
667
|
+
start_period: 5s
|
|
668
|
+
deploy:
|
|
669
|
+
resources:
|
|
670
|
+
limits:
|
|
671
|
+
cpus: "0.5"
|
|
672
|
+
memory: 128M
|
|
673
|
+
`,
|
|
674
|
+
".dockerignore": config.spa
|
|
675
|
+
? `*\n!${config.outputDir}\n!scripts\nscripts/*\n!scripts/static-server.ts\n`
|
|
676
|
+
: `*\n!${config.outputDir}\n`,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Runs inside the clean git-archive build context so the image only ever
|
|
681
|
+
// contains output derived from the exact committed tree.
|
|
682
|
+
export async function prepareStaticContext(context: string, config: StaticSiteConfig, execute: (args: string[], cwd: string) => Promise<Result>): Promise<void> {
|
|
683
|
+
const problem = staticOutputDirProblem(config.outputDir);
|
|
684
|
+
if (problem) throw new Error(problem);
|
|
685
|
+
const contextReal = await realpath(context);
|
|
686
|
+
const outputPath = resolve(contextReal, config.outputDir);
|
|
687
|
+
|
|
688
|
+
if (config.buildScript) {
|
|
689
|
+
if (!BUILD_SCRIPT_NAME.test(config.buildScript)) throw new Error("build script must be a package.json script name");
|
|
690
|
+
const packageJson = JSON.parse(await readFile(join(contextReal, "package.json"), "utf8")) as { scripts?: Record<string, unknown>; dependencies?: object; devDependencies?: object };
|
|
691
|
+
if (typeof packageJson.scripts?.[config.buildScript] !== "string") {
|
|
692
|
+
throw new Error(`package.json has no "${config.buildScript}" script.\n\nNext: restore the build script or run bun ship:setup again.`);
|
|
693
|
+
}
|
|
694
|
+
if (packageJson.dependencies || packageJson.devDependencies) {
|
|
695
|
+
const frozen = await Bun.file(join(contextReal, "bun.lock")).exists();
|
|
696
|
+
const install = await execute(frozen ? ["bun", "install", "--frozen-lockfile"] : ["bun", "install"], contextReal);
|
|
697
|
+
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()}`);
|
|
698
|
+
}
|
|
699
|
+
const build = await execute(["bun", "run", "--", config.buildScript], contextReal);
|
|
700
|
+
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()}`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Ancestors must be real directories: a committed symlink such as
|
|
704
|
+
// dist -> /etc would otherwise resolve outside the clean context.
|
|
705
|
+
let walk = contextReal;
|
|
706
|
+
for (const segment of config.outputDir.split("/")) {
|
|
707
|
+
walk = join(walk, segment);
|
|
708
|
+
const entry = await lstat(walk).catch(() => undefined);
|
|
709
|
+
if (!entry) {
|
|
710
|
+
throw new Error(config.buildScript
|
|
711
|
+
? `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.`
|
|
712
|
+
: `static output ${config.outputDir}/ is not in the commit.\n\nNext: commit the built output, or run bun ship:setup with a --build-script.`);
|
|
713
|
+
}
|
|
714
|
+
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.`);
|
|
715
|
+
}
|
|
716
|
+
if (await realpath(outputPath) !== outputPath || !outputPath.startsWith(contextReal + "/")) {
|
|
717
|
+
throw new Error("static output directory escapes the project root");
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const listing = await execute(["find", outputPath, "-type", "l"], contextReal);
|
|
721
|
+
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.`);
|
|
722
|
+
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.`);
|
|
723
|
+
if (!await Bun.file(join(outputPath, "index.html")).exists()) {
|
|
724
|
+
throw new Error(`static output ${config.outputDir}/ has no index.html.\n\nNext: verify the build output, then run bun ship.`);
|
|
725
|
+
}
|
|
726
|
+
if (!config.spa) {
|
|
727
|
+
const has404 = await Bun.file(join(outputPath, "404.html")).exists();
|
|
728
|
+
await writeFile(join(outputPath, "httpd.conf"), staticHttpdConf(has404), { mode: 0o644 });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function composeFileFromTracked(files: string[]): string {
|
|
733
|
+
const candidates = composeCandidates(files);
|
|
734
|
+
for (const preferred of ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]) {
|
|
735
|
+
if (candidates.includes(preferred)) return preferred;
|
|
736
|
+
}
|
|
737
|
+
if (candidates.length === 1) return candidates[0];
|
|
738
|
+
if (candidates.length === 0) throw new Error("no Compose file found");
|
|
739
|
+
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.`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
interface WorktreeCompose {
|
|
743
|
+
branch: string;
|
|
744
|
+
path: string;
|
|
745
|
+
composeFile: string;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export function missingComposeMessage(branch: string, alternatives: WorktreeCompose[]): string {
|
|
749
|
+
const heading = `no tracked Compose file found on current branch ${branch}.`;
|
|
750
|
+
if (alternatives.length === 0) return `${heading}\n\nNext: add compose.yaml, or run ship from a branch containing deployment files.`;
|
|
751
|
+
const rows = alternatives.map((item) => ` ${item.branch} → ${join(item.path, item.composeFile)}`).join("\n");
|
|
752
|
+
const next = alternatives.length === 1
|
|
753
|
+
? `cd '${alternatives[0].path.replaceAll("'", "'\\''")}' and rerun installer.`
|
|
754
|
+
: "choose one listed worktree, cd to it, and rerun installer.";
|
|
755
|
+
return `${heading}\n\nCompose found in another local worktree:\n${rows}\n\nNext: ${next}`;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function otherWorktreeCompose(): Promise<WorktreeCompose[]> {
|
|
759
|
+
const listed = await run(["git", "worktree", "list", "--porcelain", "-z"], { allowFailure: true });
|
|
760
|
+
if (listed.exitCode !== 0) return [];
|
|
761
|
+
const alternatives: WorktreeCompose[] = [];
|
|
762
|
+
for (const record of listed.stdout.split("\0\0")) {
|
|
763
|
+
const fields = record.split("\0");
|
|
764
|
+
const path = fields.find((field) => field.startsWith("worktree "))?.slice(9);
|
|
765
|
+
const branch = fields.find((field) => field.startsWith("branch refs/heads/"))?.slice(18);
|
|
766
|
+
if (!path || !branch || resolve(path) === resolve(root)) continue;
|
|
767
|
+
const tracked = await run(["git", "-C", path, "ls-files"], { allowFailure: true });
|
|
768
|
+
if (tracked.exitCode !== 0) continue;
|
|
769
|
+
for (const composeFile of composeCandidates(tracked.stdout.split("\n").filter(Boolean))) {
|
|
770
|
+
alternatives.push({ branch, path, composeFile });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return alternatives;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function prepareCompose(): Promise<boolean> {
|
|
777
|
+
const branch = await git("branch", "--show-current");
|
|
778
|
+
if (!branch) throw new Error("ship requires a named Git branch");
|
|
779
|
+
const tracked = (await git("ls-files")).split("\n").filter(Boolean);
|
|
780
|
+
if (composeCandidates(tracked).length > 0) return false;
|
|
781
|
+
const alternatives = await otherWorktreeCompose();
|
|
782
|
+
if (alternatives.length > 0) throw new Error(missingComposeMessage(branch, alternatives));
|
|
783
|
+
|
|
784
|
+
const names = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"];
|
|
785
|
+
const existingCompose = (await Promise.all(names.map(async (name) => await Bun.file(join(root, name)).exists() ? name : undefined))).find(Boolean);
|
|
786
|
+
if (existingCompose) {
|
|
787
|
+
outro(`Found uncommitted ${existingCompose}.\n\nNext: review it, commit and push it, then run bun ship:setup.`);
|
|
788
|
+
return true;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (agentRun && !options.yes) {
|
|
792
|
+
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).");
|
|
793
|
+
}
|
|
794
|
+
let wantStatic = options.staticSite;
|
|
795
|
+
if (!options.yes && !options.staticSite) {
|
|
796
|
+
const kind = await select({
|
|
797
|
+
message: "What are you shipping?",
|
|
798
|
+
options: [
|
|
799
|
+
{ value: "server", label: "Bun server app: container runs your start script" },
|
|
800
|
+
{ value: "static", label: "Static site: prebuilt files from any framework" },
|
|
801
|
+
],
|
|
802
|
+
});
|
|
803
|
+
if (isCancel(kind)) throw new Error(missingComposeMessage(branch, []));
|
|
804
|
+
wantStatic = kind === "static";
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (wantStatic) {
|
|
808
|
+
await generateStaticDeployment();
|
|
809
|
+
outro("Review generated deployment files.\n\nNext: commit and push these changes, then run bun ship:setup.");
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const packageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { scripts?: Record<string, unknown> };
|
|
814
|
+
const dockerfileExists = await Bun.file(join(root, "Dockerfile")).exists();
|
|
815
|
+
if (!dockerfileExists && typeof packageJson.scripts?.start !== "string") {
|
|
816
|
+
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.");
|
|
817
|
+
}
|
|
818
|
+
const templates = deploymentFileTemplates(typeof packageJson.scripts?.build === "string");
|
|
819
|
+
const written: string[] = [];
|
|
820
|
+
for (const [name, contents] of Object.entries(templates)) {
|
|
821
|
+
if (name === "Dockerfile" && dockerfileExists) continue;
|
|
822
|
+
if (await Bun.file(join(root, name)).exists()) continue;
|
|
823
|
+
await writeFile(join(root, name), contents, { mode: 0o644 });
|
|
824
|
+
written.push(name);
|
|
825
|
+
}
|
|
826
|
+
log.success(`Generated ${written.join(", ")}`);
|
|
827
|
+
outro("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.");
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const SHIP_SCRIPTS = {
|
|
832
|
+
ship: "bun scripts/ship.ts",
|
|
833
|
+
"ship:setup": "bun scripts/ship.ts --setup",
|
|
834
|
+
"ship:update": "bun scripts/ship.ts --update",
|
|
835
|
+
"ship:status": "bun scripts/ship.ts --status",
|
|
836
|
+
"ship:logs": "bun scripts/ship.ts --logs",
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
async function generateStaticDeployment(): Promise<void> {
|
|
840
|
+
// Script-less generators (Jekyll) have no package.json; create a minimal one
|
|
841
|
+
// so bun ship commands and an optional build script have a home.
|
|
842
|
+
const packagePath = join(root, "package.json");
|
|
843
|
+
let packageJson: { name?: unknown; scripts?: Record<string, unknown> };
|
|
844
|
+
if (await Bun.file(packagePath).exists()) {
|
|
845
|
+
packageJson = JSON.parse(await readFile(packagePath, "utf8")) as typeof packageJson;
|
|
846
|
+
} else {
|
|
847
|
+
const name = root.split("/").pop()?.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "") || "static-site";
|
|
848
|
+
packageJson = { name, private: true, scripts: { ...SHIP_SCRIPTS } } as typeof packageJson;
|
|
849
|
+
await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, { mode: 0o644 });
|
|
850
|
+
log.success("Created minimal package.json");
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
let buildScript = options.buildScript;
|
|
854
|
+
if (buildScript && typeof packageJson.scripts?.[buildScript] !== "string") {
|
|
855
|
+
throw new Error(`package.json has no "${buildScript}" script.\n\nNext: add it (for example "build": "jekyll build"), then run bun ship:setup again.`);
|
|
856
|
+
}
|
|
857
|
+
if (!buildScript && typeof packageJson.scripts?.build === "string") buildScript = "build";
|
|
858
|
+
|
|
859
|
+
let outputDir = options.outputDir;
|
|
860
|
+
if (!outputDir) {
|
|
861
|
+
const detected: string[] = [];
|
|
862
|
+
for (const candidate of STATIC_OUTPUT_DIRS) {
|
|
863
|
+
if ((await stat(join(root, candidate)).catch(() => undefined))?.isDirectory()) detected.push(candidate);
|
|
864
|
+
}
|
|
865
|
+
if (agentRun || options.yes) {
|
|
866
|
+
if (detected.length === 1) outputDir = detected[0];
|
|
867
|
+
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>.`);
|
|
868
|
+
} else {
|
|
869
|
+
const answer = await text({
|
|
870
|
+
message: "Which directory holds the built site?",
|
|
871
|
+
placeholder: detected[0] ?? "dist",
|
|
872
|
+
defaultValue: detected[0] ?? "",
|
|
873
|
+
validate: (value) => value ? staticOutputDirProblem(value) : "Enter the build output directory",
|
|
874
|
+
});
|
|
875
|
+
if (isCancel(answer)) throw new Error("setup cancelled");
|
|
876
|
+
outputDir = answer;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const problem = staticOutputDirProblem(outputDir!);
|
|
880
|
+
if (problem) throw new Error(problem);
|
|
881
|
+
|
|
882
|
+
let spa = options.spa;
|
|
883
|
+
if (!spa && !agentRun && !options.yes) {
|
|
884
|
+
const answer = await confirm({ message: "Single-page app? (unknown paths serve index.html)", initialValue: false });
|
|
885
|
+
if (isCancel(answer)) throw new Error("setup cancelled");
|
|
886
|
+
spa = answer;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (!buildScript) {
|
|
890
|
+
const tracked = await run(["git", "ls-files", "--", outputDir!], { allowFailure: true });
|
|
891
|
+
if (!tracked.stdout.trim()) {
|
|
892
|
+
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>.`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const staticConfig: StaticSiteConfig = { outputDir: outputDir!, buildScript, spa };
|
|
897
|
+
const templates = staticDeploymentFileTemplates(staticConfig);
|
|
898
|
+
const targets = [...Object.keys(templates), ...(spa ? ["scripts/static-server.ts"] : [])];
|
|
899
|
+
const conflicts: string[] = [];
|
|
900
|
+
for (const name of targets) if (await Bun.file(join(root, name)).exists()) conflicts.push(name);
|
|
901
|
+
if (conflicts.length > 0) {
|
|
902
|
+
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.`);
|
|
903
|
+
}
|
|
904
|
+
const written: string[] = [];
|
|
905
|
+
for (const [name, contents] of Object.entries(templates)) {
|
|
906
|
+
await writeFile(join(root, name), contents, { mode: 0o644 });
|
|
907
|
+
written.push(name);
|
|
908
|
+
}
|
|
909
|
+
if (spa) {
|
|
910
|
+
await mkdir(join(root, "scripts"), { recursive: true });
|
|
911
|
+
await writeFile(join(root, "scripts", "static-server.ts"), staticServerSource(staticConfig.outputDir), { mode: 0o644 });
|
|
912
|
+
written.push("scripts/static-server.ts");
|
|
913
|
+
}
|
|
914
|
+
log.success(`Generated ${written.join(", ")}`);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function inferredProject() {
|
|
918
|
+
const packageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { name?: unknown; version?: unknown; scripts?: Record<string, unknown> };
|
|
919
|
+
const repository = repositoryFromRemote(await git("remote", "get-url", "origin"));
|
|
920
|
+
const branch = await git("branch", "--show-current");
|
|
921
|
+
if (!branch) throw new Error("ship requires a named Git branch");
|
|
922
|
+
const tracked = (await git("ls-files")).split("\n").filter(Boolean);
|
|
923
|
+
if (composeCandidates(tracked).length === 0) throw new Error(missingComposeMessage(branch, await otherWorktreeCompose()));
|
|
924
|
+
const composeFile = composeFileFromTracked(tracked);
|
|
925
|
+
const compose = await readFile(join(root, composeFile), "utf8");
|
|
926
|
+
const domain = domainFromProject(packageJson.name, compose);
|
|
927
|
+
const service = /^services:\s*\n\s{2}([A-Za-z0-9_.-]+):/m.exec(compose)?.[1] ?? "web";
|
|
928
|
+
const healthPath = /https?:\/\/(?:127\.0\.0\.1|localhost):\d+(\/[^\s"'\\]*)/.exec(compose)?.[1] ?? "/healthz";
|
|
929
|
+
return { packageJson, repository, branch, domain, composeFile, service, healthPath };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Treat downloaded and committed JSON as untrusted input. Derived fields must
|
|
933
|
+
// agree with the domain before any value reaches GitHub, Git, or SSH.
|
|
934
|
+
export function validateConfig(value: unknown): ClientConfig {
|
|
935
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("shibumi-server.json must be an object");
|
|
936
|
+
const config = value as Partial<ClientConfig>;
|
|
937
|
+
if (config.version !== 1 || config.provider !== "shibumi-server" || typeof config.domain !== "string" || !DOMAIN.test(config.domain)
|
|
938
|
+
|| typeof config.appId !== "string" || config.appId !== appIdForDomain(config.domain)
|
|
939
|
+
|| typeof config.repository !== "string" || !/^github:[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.repository)
|
|
940
|
+
|| typeof config.branch !== "string" || !/^[A-Za-z0-9._/-]+$/.test(config.branch)
|
|
941
|
+
|| typeof config.webhookUrl !== "string" || config.webhookUrl !== `https://${config.domain}/hooks/github/${config.appId}`
|
|
942
|
+
|| typeof config.service !== "string" || typeof config.port !== "number" || !Number.isInteger(config.port) || config.port < 1024 || config.port > 65_535
|
|
943
|
+
|| typeof config.healthPath !== "string" || typeof config.cutoverRequired !== "boolean"
|
|
944
|
+
|| (config.deploymentMode !== undefined && config.deploymentMode !== "build" && config.deploymentMode !== "prebuilt")
|
|
945
|
+
|| (config.trigger !== undefined && config.trigger !== "ship" && config.trigger !== "github-push")
|
|
946
|
+
|| (config.platform !== undefined && !/^linux\/(?:arm64|amd64)$/.test(config.platform))
|
|
947
|
+
|| (config.deploymentMode === "prebuilt" && config.platform === undefined)
|
|
948
|
+
|| !config.server || typeof config.server.hostname !== "string") {
|
|
949
|
+
throw new Error("shibumi-server.json is invalid");
|
|
950
|
+
}
|
|
951
|
+
return { ...config, deploymentMode: config.deploymentMode ?? "build", trigger: config.trigger ?? "ship" } as ClientConfig;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
async function readConfig(): Promise<ClientConfig | undefined> {
|
|
955
|
+
try {
|
|
956
|
+
return validateConfig(JSON.parse(await readFile(configPath, "utf8")));
|
|
957
|
+
} catch (error) {
|
|
958
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
959
|
+
throw error;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Read legacy project-local targets, then migrate them into machine-local
|
|
964
|
+
// client settings instead of committed shibumi-server.json.
|
|
965
|
+
async function localSshTarget(): Promise<string | undefined> {
|
|
966
|
+
const result = await run(["git", "config", "--local", "--get", "shibumi.server"], { allowFailure: true });
|
|
967
|
+
return result.exitCode === 0 && result.stdout.trim() ? result.stdout.trim() : undefined;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async function configuredSshTarget(hostname?: string): Promise<string | undefined> {
|
|
971
|
+
if (options.server) {
|
|
972
|
+
log.info(`Server ${options.server}\nSource --server`);
|
|
973
|
+
return options.server;
|
|
974
|
+
}
|
|
975
|
+
const saved = await savedSshTarget(hostname);
|
|
976
|
+
if (saved) return saved;
|
|
977
|
+
const legacy = await localSshTarget();
|
|
978
|
+
if (legacy) log.info(`Server ${legacy}\nSource project .git/config (migrating)`);
|
|
979
|
+
return legacy;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async function suggestedSshTarget(): Promise<string | undefined> {
|
|
983
|
+
try {
|
|
984
|
+
const env = await readFile(join(root, ".env"), "utf8");
|
|
985
|
+
const host = /^DEPLOY_HOST=(.+)$/m.exec(env)?.[1]?.trim();
|
|
986
|
+
const user = /^DEPLOY_USER=(.+)$/m.exec(env)?.[1]?.trim();
|
|
987
|
+
if (host && user && SSH_TARGET.test(`${user}@${host}`)) return `${user}@${host}`;
|
|
988
|
+
} catch {}
|
|
989
|
+
return undefined;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
async function requestSshTarget(configHostname?: string): Promise<string | undefined> {
|
|
993
|
+
if (options.server) return options.server;
|
|
994
|
+
const suggestion = await suggestedSshTarget() ?? configHostname;
|
|
995
|
+
if (options.yes) {
|
|
996
|
+
if (!suggestion) throw new Error("--yes requires a configured server or --server <target>");
|
|
997
|
+
return suggestion;
|
|
998
|
+
}
|
|
999
|
+
if (agentRun) {
|
|
1000
|
+
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>");
|
|
1001
|
+
return suggestion;
|
|
1002
|
+
}
|
|
1003
|
+
explain(
|
|
1004
|
+
"Local configuration",
|
|
1005
|
+
`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.`,
|
|
1006
|
+
);
|
|
1007
|
+
const answer = await text({
|
|
1008
|
+
message: "SSH target (user@server or alias)",
|
|
1009
|
+
placeholder: suggestion ?? "user@example-vps.com",
|
|
1010
|
+
validate: (value) => SSH_TARGET.test(value || suggestion || "") ? undefined : "Use an SSH host or user@host without spaces",
|
|
1011
|
+
});
|
|
1012
|
+
if (isCancel(answer)) return undefined;
|
|
1013
|
+
const target = answer || suggestion;
|
|
1014
|
+
if (!target) return undefined;
|
|
1015
|
+
if (!await approve(`Save ${target} locally and connect?`)) return undefined;
|
|
1016
|
+
return target;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async function resolvedSshHostname(target: string): Promise<string> {
|
|
1020
|
+
if (!SSH_TARGET.test(target)) throw new Error("local SSH target is unsafe");
|
|
1021
|
+
const result = await run(["ssh", "-G", "--", target]);
|
|
1022
|
+
const hostname = /^hostname\s+(.+)$/m.exec(result.stdout)?.[1]?.trim();
|
|
1023
|
+
if (!hostname || !SERVER_HOSTNAME.test(hostname)) {
|
|
1024
|
+
throw new Error("cannot resolve SSH server hostname");
|
|
1025
|
+
}
|
|
1026
|
+
return hostname.toLowerCase();
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Pass remote arguments separately and reject control characters. Interactive
|
|
1030
|
+
// setup gets a TTY so SSH and sudo prompts remain attached to this terminal.
|
|
1031
|
+
async function sshControlPath(): Promise<string> {
|
|
1032
|
+
if (!sshControlDirectory) {
|
|
1033
|
+
sshControlDirectory = await mkdtemp("/tmp/shibumi-ssh-");
|
|
1034
|
+
await chmod(sshControlDirectory, 0o700);
|
|
1035
|
+
}
|
|
1036
|
+
return join(sshControlDirectory, "control");
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function closeSshControl(): Promise<void> {
|
|
1040
|
+
if (!sshControlDirectory) return;
|
|
1041
|
+
if (sshControlTarget) {
|
|
1042
|
+
await run(["ssh", "-S", join(sshControlDirectory, "control"), "-O", "exit", "--", sshControlTarget], { allowFailure: true });
|
|
1043
|
+
}
|
|
1044
|
+
await rm(sshControlDirectory, { recursive: true, force: true });
|
|
1045
|
+
sshControlDirectory = undefined;
|
|
1046
|
+
sshControlTarget = undefined;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async function ssh(target: string, remoteArgs: string[], options: { tty?: boolean; allowFailure?: boolean; inputFile?: string } = {}): Promise<Result> {
|
|
1050
|
+
if (!SSH_TARGET.test(target)) throw new Error("local SSH target is unsafe");
|
|
1051
|
+
if (sshControlTarget && sshControlTarget !== target) throw new Error("ship supports one SSH target per run");
|
|
1052
|
+
sshControlTarget = target;
|
|
1053
|
+
for (const arg of remoteArgs) if (/[\0\r\n]/.test(arg)) throw new Error("remote argument is unsafe");
|
|
1054
|
+
return run([
|
|
1055
|
+
"ssh", "-o", "StrictHostKeyChecking=yes", "-o", "ControlMaster=auto", "-o", "ControlPersist=60", "-S", await sshControlPath(),
|
|
1056
|
+
...(agentRun ? ["-o", "BatchMode=yes"] : []), ...(options.inputFile ? ["-C"] : []), ...(options.tty ? ["-t"] : []), "--", target, ...remoteArgs,
|
|
1057
|
+
], { inherit: options.tty, allowFailure: options.allowFailure, inputFile: options.inputFile });
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function versionAtLeast(current: string, minimum: string): boolean {
|
|
1061
|
+
const left = /^\d+\.\d+\.\d+$/.test(current) ? current.split(".").map(Number) : [];
|
|
1062
|
+
const right = minimum.split(".").map(Number);
|
|
1063
|
+
for (let index = 0; index < right.length; index += 1) {
|
|
1064
|
+
if ((left[index] ?? -1) !== right[index]) return (left[index] ?? -1) > right[index];
|
|
1065
|
+
}
|
|
1066
|
+
return left.length === right.length;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Install only through the reviewed, version-pinned website endpoint.
|
|
1070
|
+
async function ensureServer(target: string): Promise<void> {
|
|
1071
|
+
const version = await ssh(target, [SERVER_CLI, "--version"], { allowFailure: true });
|
|
1072
|
+
if (version.exitCode === 0 && versionAtLeast(version.stdout.trim(), "0.8.1")) return;
|
|
1073
|
+
if (agentRun) throw new Error(version.exitCode === 0
|
|
1074
|
+
? `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.`
|
|
1075
|
+
: "shibumi-server is not installed.\n\nAgent: ask user to run bun ship:setup from this project in their terminal, then retry.");
|
|
1076
|
+
explain(
|
|
1077
|
+
version.exitCode === 0 ? `shibumi-server ${version.stdout.trim()} needs an upgrade` : "shibumi-server is not installed",
|
|
1078
|
+
"This runs the reviewed installer on the SSH server. SSH and sudo prompts stay attached directly to your terminal.",
|
|
1079
|
+
);
|
|
1080
|
+
if (!await approve("Install or upgrade shibumi-server now?")) throw new Error("server setup cancelled");
|
|
1081
|
+
const result = await ssh(target, ["curl -fsSL https://shibumistack.dev/install/server | bash"], { tty: true, allowFailure: true });
|
|
1082
|
+
if (result.exitCode !== 0) throw new Error("remote shibumi-server installation failed");
|
|
1083
|
+
const installed = await ssh(target, [SERVER_CLI, "--version"], { allowFailure: true });
|
|
1084
|
+
if (installed.exitCode !== 0 || !versionAtLeast(installed.stdout.trim(), "0.8.1")) {
|
|
1085
|
+
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.");
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Reuse an existing registration silently. New apps retain interactive SSH so
|
|
1090
|
+
// server and sudo prompts stay attached to the local terminal.
|
|
1091
|
+
async function remoteSetup(target: string, _force: boolean, current?: ClientConfig): Promise<ClientConfig> {
|
|
1092
|
+
const project = await inferredProject();
|
|
1093
|
+
let domain = setupDomain(options.domain, current?.domain, project.domain);
|
|
1094
|
+
if (!domain && (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>");
|
|
1095
|
+
if (!domain) {
|
|
1096
|
+
const answer = await text({
|
|
1097
|
+
message: "App domain",
|
|
1098
|
+
placeholder: "example.com",
|
|
1099
|
+
validate: (value) => value && DOMAIN.test(value) ? undefined : "Use a lowercase public hostname",
|
|
1100
|
+
});
|
|
1101
|
+
if (isCancel(answer)) throw new Error("setup cancelled");
|
|
1102
|
+
domain = answer;
|
|
1103
|
+
}
|
|
1104
|
+
const serverHostname = await resolvedSshHostname(target);
|
|
1105
|
+
await rememberSshTarget(serverHostname, target);
|
|
1106
|
+
await ensureServer(target);
|
|
1107
|
+
const appId = appIdForDomain(domain);
|
|
1108
|
+
let downloaded = await ssh(target, [
|
|
1109
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "client-config", appId, "--server-hostname", serverHostname,
|
|
1110
|
+
], { allowFailure: true });
|
|
1111
|
+
|
|
1112
|
+
if (downloaded.exitCode !== 0) {
|
|
1113
|
+
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.");
|
|
1114
|
+
explain(
|
|
1115
|
+
"Server setup required",
|
|
1116
|
+
`SSH target ${target}\nDomain ${domain}\nRepository github:${project.repository}\n\nSSH and sudo prompts stay attached to this terminal.`,
|
|
1117
|
+
);
|
|
1118
|
+
if (!await approve("Continue through SSH?")) throw new Error("server setup cancelled");
|
|
1119
|
+
const setup = await ssh(target, [
|
|
1120
|
+
"env", "SHIBUMI_SHIP_SETUP=1", SERVER_CLI, "add", domain,
|
|
1121
|
+
"--repository", `github:${project.repository}`,
|
|
1122
|
+
"--ref", `refs/heads/${project.branch}`,
|
|
1123
|
+
"--compose-file", project.composeFile,
|
|
1124
|
+
"--service", project.service,
|
|
1125
|
+
"--health-path", project.healthPath,
|
|
1126
|
+
"--deployment-mode", "prebuilt",
|
|
1127
|
+
...(options.yes ? ["--yes"] : []),
|
|
1128
|
+
], { tty: true, allowFailure: true });
|
|
1129
|
+
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.");
|
|
1130
|
+
downloaded = await ssh(target, [
|
|
1131
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "client-config", appId, "--server-hostname", serverHostname,
|
|
1132
|
+
], { allowFailure: true });
|
|
1133
|
+
}
|
|
1134
|
+
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.");
|
|
1135
|
+
const config = validateConfig(JSON.parse(downloaded.stdout));
|
|
1136
|
+
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.`);
|
|
1137
|
+
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}.`);
|
|
1138
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
1139
|
+
log.success(`Found ${domain} on ${serverHostname}`);
|
|
1140
|
+
log.success("Wrote shibumi-server.json");
|
|
1141
|
+
return config;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
interface GitHubWebhook { id: number; active: boolean; needsRepair: boolean }
|
|
1145
|
+
|
|
1146
|
+
export function matchingWebhook(value: unknown, webhookUrl: string): GitHubWebhook | undefined {
|
|
1147
|
+
if (!Array.isArray(value)) return undefined;
|
|
1148
|
+
const hook = value.find((item) => item && typeof item === "object"
|
|
1149
|
+
&& (item as { config?: { url?: unknown } }).config?.url === webhookUrl) as { id?: unknown; active?: unknown; last_response?: { code?: unknown } } | undefined;
|
|
1150
|
+
if (!hook || typeof hook.id !== "number" || typeof hook.active !== "boolean") return undefined;
|
|
1151
|
+
const code = hook.last_response?.code;
|
|
1152
|
+
return { id: hook.id, active: hook.active, needsRepair: !hook.active || (typeof code === "number" && code !== 0 && (code < 200 || code >= 300)) };
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
async function ensureGitHubAuth(): Promise<void> {
|
|
1156
|
+
const status = await run(["gh", "auth", "status", "-h", "github.com"], { allowFailure: true });
|
|
1157
|
+
if (status.exitCode === 0) return;
|
|
1158
|
+
explain("GitHub sign-in required", "GitHub CLI stores your credentials. Shibumi never reads them.");
|
|
1159
|
+
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.");
|
|
1160
|
+
if (!await approve("Sign in to GitHub now?")) throw new Error("Next: run gh auth login -h github.com -p https -w, then rerun bun ship.");
|
|
1161
|
+
const login = await run(["gh", "auth", "login", "-h", "github.com", "-p", "https", "-w"], { inherit: true, allowFailure: true });
|
|
1162
|
+
if (login.exitCode !== 0 || (await run(["gh", "auth", "status", "-h", "github.com"], { allowFailure: true })).exitCode !== 0) {
|
|
1163
|
+
throw new Error("GitHub sign-in did not complete.\n\nNext: run gh auth login -h github.com -p https -w, then rerun bun ship.");
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
async function authorizeWebhookAccess(): Promise<void> {
|
|
1168
|
+
explain("GitHub webhook access required", "GitHub CLI needs admin:repo_hook to create or repair this repository webhook.");
|
|
1169
|
+
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.");
|
|
1170
|
+
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.");
|
|
1171
|
+
const refresh = await run(["gh", "auth", "refresh", "-h", "github.com", "-s", "admin:repo_hook"], { inherit: true, allowFailure: true });
|
|
1172
|
+
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.");
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
async function findWebhook(config: ClientConfig): Promise<GitHubWebhook | undefined> {
|
|
1176
|
+
const repository = config.repository.slice("github:".length);
|
|
1177
|
+
await ensureGitHubAuth();
|
|
1178
|
+
let hooks = await run(["gh", "api", `repos/${repository}/hooks?per_page=100`], { allowFailure: true });
|
|
1179
|
+
if (hooks.exitCode !== 0) {
|
|
1180
|
+
await authorizeWebhookAccess();
|
|
1181
|
+
hooks = await run(["gh", "api", `repos/${repository}/hooks?per_page=100`], { allowFailure: true });
|
|
1182
|
+
}
|
|
1183
|
+
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.`);
|
|
1184
|
+
return matchingWebhook(JSON.parse(hooks.stdout), config.webhookUrl);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Fetch the secret only when GitHub needs it. It moves through process memory
|
|
1188
|
+
// from server output to `gh` input and is never printed or written locally.
|
|
1189
|
+
async function ensureWebhook(config: ClientConfig, target: string): Promise<void> {
|
|
1190
|
+
const existing = await findWebhook(config);
|
|
1191
|
+
if (existing && !existing.needsRepair) {
|
|
1192
|
+
log.success("GitHub webhook is active");
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const repository = config.repository.slice("github:".length);
|
|
1196
|
+
explain(
|
|
1197
|
+
existing ? "GitHub webhook needs repair" : "GitHub webhook is missing",
|
|
1198
|
+
`Repository ${repository}\nPayload URL ${config.webhookUrl}\nEvents push\n\nThe secret travels from server to GitHub CLI through memory only.`,
|
|
1199
|
+
);
|
|
1200
|
+
if (!existing && !await approve("Create webhook with GitHub CLI?")) throw new Error(`Next: review ${config.webhookUrl} at https://github.com/${repository}/settings/hooks`);
|
|
1201
|
+
if (existing) log.info("Refreshing webhook secret from server configuration");
|
|
1202
|
+
const secretResult = await ssh(target, ["env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "webhook-secret", config.appId]);
|
|
1203
|
+
const secretValue: unknown = JSON.parse(secretResult.stdout);
|
|
1204
|
+
const secret = secretValue && typeof secretValue === "object" ? (secretValue as { secret?: unknown }).secret : undefined;
|
|
1205
|
+
if (typeof secret !== "string" || !/^[a-f0-9]{64}$/.test(secret)) throw new Error("server returned an invalid webhook secret");
|
|
1206
|
+
const args = existing
|
|
1207
|
+
? ["gh", "api", "-X", "PATCH", `repos/${repository}/hooks/${existing.id}/config`, "--input", "-"]
|
|
1208
|
+
: ["gh", "api", "-X", "POST", `repos/${repository}/hooks`, "--input", "-"];
|
|
1209
|
+
const input = existing
|
|
1210
|
+
? JSON.stringify({ secret })
|
|
1211
|
+
: JSON.stringify({ name: "web", active: true, events: ["push"], config: { url: config.webhookUrl, content_type: "json", insecure_ssl: "0", secret } });
|
|
1212
|
+
let result = await run(args, { input, allowFailure: true });
|
|
1213
|
+
if (result.exitCode !== 0) {
|
|
1214
|
+
await authorizeWebhookAccess();
|
|
1215
|
+
result = await run(args, { input, allowFailure: true });
|
|
1216
|
+
}
|
|
1217
|
+
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.`);
|
|
1218
|
+
const hookId = existing?.id ?? (JSON.parse(result.stdout) as { id?: unknown }).id;
|
|
1219
|
+
if (typeof hookId !== "number") throw new Error("GitHub returned an invalid webhook");
|
|
1220
|
+
if (existing && !existing.active) {
|
|
1221
|
+
result = await run(["gh", "api", "-X", "PATCH", `repos/${repository}/hooks/${hookId}`, "--input", "-"], {
|
|
1222
|
+
input: JSON.stringify({ active: true, events: ["push"] }), allowFailure: true,
|
|
1223
|
+
});
|
|
1224
|
+
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:setup.`);
|
|
1225
|
+
}
|
|
1226
|
+
const ping = await run(["gh", "api", "-X", "POST", `repos/${repository}/hooks/${hookId}/pings`], { allowFailure: true });
|
|
1227
|
+
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.`);
|
|
1228
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
1229
|
+
await Bun.sleep(1_000);
|
|
1230
|
+
const checked = await run(["gh", "api", `repos/${repository}/hooks/${hookId}`], { allowFailure: true });
|
|
1231
|
+
if (checked.exitCode === 0 && (JSON.parse(checked.stdout) as { last_response?: { code?: unknown } }).last_response?.code === 200) {
|
|
1232
|
+
log.success(existing ? "GitHub webhook repaired and tested" : "GitHub webhook created and tested");
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
throw new Error(`GitHub webhook is configured but not reachable yet.\n\nNext: confirm ${config.domain} DNS and TLS, then run bun ship:setup. For proxied Cloudflare domains, use Full (strict) SSL/TLS mode.\n\nGitHub: https://github.com/${repository}/settings/hooks`);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function disableWebhook(config: ClientConfig): Promise<void> {
|
|
1240
|
+
const repository = config.repository.slice("github:".length);
|
|
1241
|
+
const settings = `https://github.com/${repository}/settings/hooks`;
|
|
1242
|
+
if (!Bun.which("gh") || (await run(["gh", "auth", "status", "-h", "github.com"], { allowFailure: true })).exitCode !== 0) {
|
|
1243
|
+
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:setup after GitHub sign-in.`);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const hooks = await run(["gh", "api", `repos/${repository}/hooks?per_page=100`], { allowFailure: true });
|
|
1247
|
+
if (hooks.exitCode !== 0) {
|
|
1248
|
+
log.warn(`Direct shipping enabled. GitHub webhook cleanup could not reach GitHub.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:setup later.`);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
const existing = matchingWebhook(JSON.parse(hooks.stdout), config.webhookUrl);
|
|
1252
|
+
if (!existing || !existing.active) {
|
|
1253
|
+
log.success("GitHub webhook is disabled");
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
explain("Disable deploy-on-push", `Repository ${repository}\nPayload URL ${config.webhookUrl}\n\nGit pushes will stop changing production. Run bun ship to deploy.`);
|
|
1257
|
+
if (!await approve("Disable GitHub webhook?")) throw new Error("webhook change cancelled");
|
|
1258
|
+
const result = await run(["gh", "api", "-X", "PATCH", `repos/${repository}/hooks/${existing.id}`, "--input", "-"], {
|
|
1259
|
+
input: JSON.stringify({ active: false }), allowFailure: true,
|
|
1260
|
+
});
|
|
1261
|
+
if (result.exitCode !== 0) {
|
|
1262
|
+
log.warn(`Direct shipping enabled. GitHub webhook cleanup failed.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:setup later.`);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
log.success("GitHub webhook disabled");
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async function setDeploymentMode(config: ClientConfig, target: string, trigger: ClientConfig["trigger"]): Promise<ClientConfig> {
|
|
1269
|
+
const mode = deploymentModeForTrigger(trigger);
|
|
1270
|
+
if (config.deploymentMode === mode) return config;
|
|
1271
|
+
const changed = await ssh(target, [
|
|
1272
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "deployment-mode", config.appId, mode,
|
|
1273
|
+
], { allowFailure: true });
|
|
1274
|
+
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.`);
|
|
1275
|
+
const downloaded = await ssh(target, [
|
|
1276
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "client-config", config.appId, "--server-hostname", config.server.hostname,
|
|
1277
|
+
]);
|
|
1278
|
+
return { ...validateConfig(JSON.parse(downloaded.stdout)), trigger };
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async function selectTrigger(current: ClientConfig["trigger"], force: boolean): Promise<ClientConfig["trigger"]> {
|
|
1282
|
+
if (options.trigger) return options.trigger;
|
|
1283
|
+
if (!force || options.yes || agentRun) return current;
|
|
1284
|
+
log.info(`Current deployment: ${current === "ship" ? "Run bun ship" : "Every GitHub push"}`);
|
|
1285
|
+
const answer = await select({
|
|
1286
|
+
message: "How do you want to deploy?",
|
|
1287
|
+
initialValue: current,
|
|
1288
|
+
options: [
|
|
1289
|
+
{ value: "ship", label: "Run bun ship", hint: "recommended" },
|
|
1290
|
+
{ value: "github-push", label: "Deploy every GitHub push" },
|
|
1291
|
+
],
|
|
1292
|
+
});
|
|
1293
|
+
if (isCancel(answer)) throw new Error("setup cancelled");
|
|
1294
|
+
return answer as ClientConfig["trigger"];
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
async function setup(force: boolean): Promise<{ config: ClientConfig; target: string; changed: boolean } | undefined> {
|
|
1298
|
+
let config = await readConfig();
|
|
1299
|
+
if ((force || !config) && await prepareCompose()) return undefined;
|
|
1300
|
+
if (force || !config) await inferredProject();
|
|
1301
|
+
let target = await configuredSshTarget(config?.server.hostname);
|
|
1302
|
+
if (!target) target = await requestSshTarget(config?.server.hostname);
|
|
1303
|
+
if (!target) throw new Error("SSH server is required");
|
|
1304
|
+
const previous = config;
|
|
1305
|
+
if (force || !config) config = await remoteSetup(target, force, config);
|
|
1306
|
+
if (!config) throw new Error("deployment setup did not return client configuration");
|
|
1307
|
+
await rememberSshTarget(config.server.hostname, target);
|
|
1308
|
+
const trigger = await selectTrigger(previous?.trigger ?? config.trigger, force);
|
|
1309
|
+
config = await setDeploymentMode({ ...config, trigger }, target, trigger);
|
|
1310
|
+
if (force) {
|
|
1311
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
1312
|
+
if (trigger === "github-push") await ensureWebhook(config, target);
|
|
1313
|
+
else if (previous) await disableWebhook(config);
|
|
1314
|
+
else log.success("Deployments run through bun ship");
|
|
1315
|
+
}
|
|
1316
|
+
return { config, target, changed: !previous || JSON.stringify(previous) !== JSON.stringify(config) };
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Refuse ambiguous deploys: wrong origin, wrong branch, dirty work, or remote
|
|
1320
|
+
// work not present locally. Run project-owned checks before every deployment.
|
|
1321
|
+
async function preflight(config: ClientConfig): Promise<number> {
|
|
1322
|
+
const project = await inferredProject();
|
|
1323
|
+
if (`github:${project.repository}` !== config.repository) throw new Error(`origin does not match ${config.repository}`);
|
|
1324
|
+
if (project.branch !== config.branch) throw new Error(`current branch must be ${config.branch}`);
|
|
1325
|
+
const status = await git("status", "--short");
|
|
1326
|
+
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.`);
|
|
1327
|
+
|
|
1328
|
+
const progress = spinner();
|
|
1329
|
+
progress.start(`Fetching origin/${config.branch}`);
|
|
1330
|
+
await run(["git", "fetch", "origin", config.branch]);
|
|
1331
|
+
const counts = (await git("rev-list", "--left-right", "--count", `HEAD...origin/${config.branch}`)).split(/\s+/).map(Number);
|
|
1332
|
+
if (counts[1] > 0) {
|
|
1333
|
+
progress.stop("Branch is behind or diverged", 1);
|
|
1334
|
+
throw new Error(`pull origin/${config.branch} before shipping`);
|
|
1335
|
+
}
|
|
1336
|
+
if (counts[0] > 0 && await githubBranchIsProtected(config)) {
|
|
1337
|
+
progress.stop(`${config.branch} is protected`, 1);
|
|
1338
|
+
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.`);
|
|
1339
|
+
}
|
|
1340
|
+
progress.stop(counts[0] > 0
|
|
1341
|
+
? `${counts[0]} commit${counts[0] === 1 ? "" : "s"} ready to push`
|
|
1342
|
+
: "Current commit already pushed and ready to deploy");
|
|
1343
|
+
|
|
1344
|
+
const scripts = project.packageJson.scripts ?? {};
|
|
1345
|
+
for (const name of ["test", "check"]) {
|
|
1346
|
+
if (typeof scripts[name] !== "string") continue;
|
|
1347
|
+
const check = spinner();
|
|
1348
|
+
check.start(`Running bun run ${name}`);
|
|
1349
|
+
const result = await run(["bun", "run", name], { allowFailure: true });
|
|
1350
|
+
if (result.exitCode !== 0) {
|
|
1351
|
+
check.stop(`${name} failed`, 1);
|
|
1352
|
+
process.stderr.write(result.stderr || result.stdout);
|
|
1353
|
+
throw new Error(`bun run ${name} failed`);
|
|
1354
|
+
}
|
|
1355
|
+
check.stop(`${name} passed`);
|
|
1356
|
+
}
|
|
1357
|
+
return counts[0];
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
export function prebuiltImage(appId: string, commit: string): string {
|
|
1361
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(appId) || !COMMIT.test(commit)) throw new Error("invalid prebuilt image identity");
|
|
1362
|
+
return `localhost/shibumi-server/upload/${appId}:${commit}`;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
export function prebuiltLabels(appId: string, commit: string, repository: string, sourceTree: string, version?: unknown): Record<string, string> {
|
|
1366
|
+
prebuiltImage(appId, commit);
|
|
1367
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || !COMMIT.test(sourceTree)) throw new Error("invalid prebuilt image source");
|
|
1368
|
+
return {
|
|
1369
|
+
"dev.shibumistack.app-id": appId,
|
|
1370
|
+
"dev.shibumistack.source-tree": sourceTree,
|
|
1371
|
+
"org.opencontainers.image.revision": commit,
|
|
1372
|
+
"org.opencontainers.image.source": `https://github.com/${repository}`,
|
|
1373
|
+
...(typeof version === "string" && version.length <= 128 ? { "org.opencontainers.image.version": version } : {}),
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
export function composeFrontend(plugin: boolean, standalone: boolean): string[] | undefined {
|
|
1378
|
+
if (plugin) return ["docker", "compose"];
|
|
1379
|
+
if (standalone) return ["docker-compose"];
|
|
1380
|
+
return undefined;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
async function verifyDockerCredentialHelpers(): Promise<void> {
|
|
1384
|
+
const path = join(process.env.DOCKER_CONFIG || join(homedir(), ".docker"), "config.json");
|
|
1385
|
+
let value: Record<string, unknown>;
|
|
1386
|
+
try {
|
|
1387
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
1388
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("expected object");
|
|
1389
|
+
value = parsed as Record<string, unknown>;
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
1392
|
+
throw new Error(`Docker CLI config is invalid: ${path}\n\nNext: fix its JSON, verify docker info, then run bun ship.`);
|
|
1393
|
+
}
|
|
1394
|
+
for (const helper of dockerCredentialHelpers(value)) {
|
|
1395
|
+
const executable = `docker-credential-${helper}`;
|
|
1396
|
+
if (Bun.which(executable)) continue;
|
|
1397
|
+
const canOfferRepair = !agentRun && !options.yes && process.stdin.isTTY && process.stdout.isTTY;
|
|
1398
|
+
const accepted = canOfferRepair && await confirm({
|
|
1399
|
+
message: `${executable} is configured but unavailable. Remove its stale entries from ${path}?`,
|
|
1400
|
+
initialValue: true,
|
|
1401
|
+
});
|
|
1402
|
+
if (accepted && !isCancel(accepted)) {
|
|
1403
|
+
const backup = `${path}.shibumi-backup`;
|
|
1404
|
+
const temporary = `${path}.tmp-${process.pid}`;
|
|
1405
|
+
removeDockerCredentialHelper(value, helper);
|
|
1406
|
+
await copyFile(path, backup);
|
|
1407
|
+
await chmod(backup, 0o600);
|
|
1408
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
1409
|
+
await chmod(temporary, 0o600);
|
|
1410
|
+
await rename(temporary, path);
|
|
1411
|
+
log.success(`Removed stale ${executable} entries; backup saved to ${backup}`);
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
const next = helper === "desktop"
|
|
1415
|
+
? `edit ${path}, remove \"credsStore\": \"desktop\" and any credHelpers entries set to \"desktop\"`
|
|
1416
|
+
: `install ${executable} or remove its stale entry from ${path}`;
|
|
1417
|
+
throw new Error(`Docker credential helper ${executable} is configured but unavailable.\n\nNext: ${next}; verify docker pull oven/bun:alpine, then run bun ship.`);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
async function localBuildFrontend(config: ClientConfig): Promise<string[] | undefined> {
|
|
1422
|
+
if (config.deploymentMode !== "prebuilt") return undefined;
|
|
1423
|
+
if (!config.platform) throw new Error("server image platform is missing.\n\nNext: run bun ship:setup.");
|
|
1424
|
+
const docker = await run(["docker", "info"], { allowFailure: true });
|
|
1425
|
+
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.");
|
|
1426
|
+
const plugin = await run(["docker", "compose", "version"], { allowFailure: true });
|
|
1427
|
+
const standalone = plugin.exitCode === 0
|
|
1428
|
+
? undefined
|
|
1429
|
+
: await run(["docker-compose", "version"], { allowFailure: true });
|
|
1430
|
+
const compose = composeFrontend(plugin.exitCode === 0, standalone?.exitCode === 0);
|
|
1431
|
+
if (!compose) throw new Error("Docker Compose is unavailable.\n\nNext: install docker compose or docker-compose, verify its version, then run bun ship.");
|
|
1432
|
+
await verifyDockerCredentialHelpers();
|
|
1433
|
+
const buildx = await run(["docker", "buildx", "version"], { allowFailure: true });
|
|
1434
|
+
if (buildx.exitCode !== 0) {
|
|
1435
|
+
const detail = buildx.stderr.trim() || buildx.stdout.trim() || "docker buildx version failed";
|
|
1436
|
+
const next = process.platform === "darwin"
|
|
1437
|
+
? '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"'
|
|
1438
|
+
: "install Docker Buildx plugin for your Docker CLI";
|
|
1439
|
+
throw new Error(`Buildx is unavailable: ${detail}\n\nNext: ${next}; verify docker buildx version, then run bun ship.`);
|
|
1440
|
+
}
|
|
1441
|
+
return compose;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async function buildAndUpload(config: ClientConfig, target: string, commit: string, compose?: string[]): Promise<void> {
|
|
1445
|
+
if (config.deploymentMode !== "prebuilt") return;
|
|
1446
|
+
if (!compose) throw new Error("Docker Compose preflight was not completed");
|
|
1447
|
+
const submodules = (await git("ls-files", "--stage")).split("\n").filter((line) => line.startsWith("160000 "));
|
|
1448
|
+
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.");
|
|
1449
|
+
|
|
1450
|
+
const project = await inferredProject();
|
|
1451
|
+
const temporary = await mkdtemp("/tmp/shibumi-build-");
|
|
1452
|
+
const context = join(temporary, "context");
|
|
1453
|
+
const sourceArchive = join(temporary, "source.tar");
|
|
1454
|
+
const imageArchive = join(temporary, "image.tar");
|
|
1455
|
+
const override = join(temporary, "compose.prebuilt.yaml");
|
|
1456
|
+
const image = prebuiltImage(config.appId, commit);
|
|
1457
|
+
const progress = spinner();
|
|
1458
|
+
try {
|
|
1459
|
+
progress.start(`Building ${config.platform} image from ${commit.slice(0, 7)}`);
|
|
1460
|
+
await mkdir(context);
|
|
1461
|
+
await run(["git", "archive", "--format=tar", "--output", sourceArchive, commit]);
|
|
1462
|
+
await run(["tar", "-xf", sourceArchive, "-C", context]);
|
|
1463
|
+
const staticConfig = staticConfigFromCompose(await readFile(join(context, project.composeFile), "utf8"));
|
|
1464
|
+
if (staticConfig) {
|
|
1465
|
+
progress.message(staticConfig.buildScript ? `Building static output with bun run ${staticConfig.buildScript}` : `Verifying committed ${staticConfig.outputDir}/`);
|
|
1466
|
+
await prepareStaticContext(context, staticConfig, (args, cwd) => run(args, { cwd, allowFailure: true }));
|
|
1467
|
+
progress.message(`Verified static output in ${staticConfig.outputDir}/`);
|
|
1468
|
+
}
|
|
1469
|
+
const sourceTree = (await git("rev-parse", `${commit}^{tree}`)).toLowerCase();
|
|
1470
|
+
const labels = prebuiltLabels(config.appId, commit, project.repository, sourceTree, project.packageJson.version);
|
|
1471
|
+
const buildLabels = Object.entries(labels).map(([name, value]) => ` ${JSON.stringify(name)}: ${JSON.stringify(value)}`).join("\n");
|
|
1472
|
+
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`);
|
|
1473
|
+
// An older ship client may have left this exact tag pointing at an unlabeled image.
|
|
1474
|
+
// Remove only the temporary upload tag so Compose must export current identity labels;
|
|
1475
|
+
// BuildKit layer cache remains available unless --rebuild was requested.
|
|
1476
|
+
await run(["docker", "image", "rm", image], { allowFailure: true });
|
|
1477
|
+
await run([
|
|
1478
|
+
...compose,
|
|
1479
|
+
"--project-name", `shibumi-build-${config.appId}`,
|
|
1480
|
+
"--file", join(context, project.composeFile),
|
|
1481
|
+
"--file", override,
|
|
1482
|
+
"build", ...(options.rebuild ? ["--no-cache"] : []), config.service,
|
|
1483
|
+
], { cwd: context });
|
|
1484
|
+
const inspected = await run(["docker", "image", "inspect", "--format", "{{.Id}} {{.Os}}/{{.Architecture}}", image]);
|
|
1485
|
+
const [imageId, platform] = inspected.stdout.trim().split(/\s+/, 2);
|
|
1486
|
+
if (platform !== config.platform) throw new Error(`built image platform is ${platform || "unknown"}; ${config.platform} required`);
|
|
1487
|
+
progress.message("Packing image for SSH upload");
|
|
1488
|
+
await run(["docker", "image", "save", "--output", imageArchive, image]);
|
|
1489
|
+
progress.message(`Uploading image to ${target}`);
|
|
1490
|
+
const archiveBytes = (await stat(imageArchive)).size;
|
|
1491
|
+
const loaded = await ssh(target, [
|
|
1492
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "image-load", config.appId, commit, String(archiveBytes),
|
|
1493
|
+
], { inputFile: imageArchive, allowFailure: true });
|
|
1494
|
+
if (loaded.exitCode !== 0) throw new Error(loaded.stderr.trim() || loaded.stdout.trim() || "server rejected prebuilt image");
|
|
1495
|
+
progress.stop(`Built and uploaded ${commit.slice(0, 7)} (${Math.ceil(archiveBytes / 1024 ** 2)} MiB, ${imageId?.replace(/^sha256:/, "").slice(0, 12) || "unknown digest"})`);
|
|
1496
|
+
await run(["docker", "image", "rm", image], { allowFailure: true });
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
progress.stop("Prebuilt image failed", 1);
|
|
1499
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1500
|
+
throw new Error(message.includes("\n\nNext:")
|
|
1501
|
+
? message
|
|
1502
|
+
: `${message}\n\nNext: fix the local Docker or Compose error above, verify docker compose build, then run bun ship.`);
|
|
1503
|
+
} finally {
|
|
1504
|
+
await rm(temporary, { recursive: true, force: true });
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
async function estimatedDeployDuration(config: ClientConfig, target: string): Promise<number | undefined> {
|
|
1509
|
+
const result = await ssh(target, [
|
|
1510
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "history", config.appId, "--json",
|
|
1511
|
+
], { allowFailure: true });
|
|
1512
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) return undefined;
|
|
1513
|
+
try {
|
|
1514
|
+
const history: unknown = JSON.parse(result.stdout);
|
|
1515
|
+
return Array.isArray(history) ? latestDeployDuration(history as HistoryEntry[]) : undefined;
|
|
1516
|
+
} catch {
|
|
1517
|
+
return undefined;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Follow status for the exact pushed commit. This prevents an older or parallel
|
|
1522
|
+
// deployment from being reported as success for current ship.
|
|
1523
|
+
async function followStatus(config: ClientConfig, target: string, commit: string, estimateMs?: number): Promise<void> {
|
|
1524
|
+
const progress = spinner();
|
|
1525
|
+
const fit = (value: string) => {
|
|
1526
|
+
const width = Math.max(24, (process.stdout.columns ?? 80) - 8);
|
|
1527
|
+
return value.length > width ? `${value.slice(0, width - 1)}…` : value;
|
|
1528
|
+
};
|
|
1529
|
+
progress.start(estimateMs ? `Waiting for deployment (ETA: ${formatDuration(estimateMs)})` : "Waiting for deployment");
|
|
1530
|
+
const startedAt = Date.now();
|
|
1531
|
+
const deadline = startedAt + 12 * 60_000;
|
|
1532
|
+
const webhookDeadline = startedAt + 45_000;
|
|
1533
|
+
let lastStage = "";
|
|
1534
|
+
let lastOutput = "";
|
|
1535
|
+
let sawQueued = false;
|
|
1536
|
+
while (Date.now() < deadline) {
|
|
1537
|
+
const result = await ssh(target, [
|
|
1538
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "status", config.appId, "--commit", commit, "--json",
|
|
1539
|
+
], { allowFailure: true });
|
|
1540
|
+
if (result.exitCode === 0 && result.stdout.trim() && result.stdout.trim() !== "null") {
|
|
1541
|
+
const status = parseDeployStatus(JSON.parse(result.stdout))!;
|
|
1542
|
+
const queued = status.commit !== commit && status.queuedCommit === commit;
|
|
1543
|
+
sawQueued ||= queued;
|
|
1544
|
+
const displayStage = queued ? `Queued ${commit.slice(0, 7)} next. Current ${status.commit!.slice(0, 7)} ${status.stage}` : status.stage;
|
|
1545
|
+
if (status.stage && (`${status.commit}:${status.stage}` !== lastStage || (status.output ?? "") !== lastOutput)) {
|
|
1546
|
+
lastStage = `${status.commit}:${status.stage}`;
|
|
1547
|
+
lastOutput = status.output ?? "";
|
|
1548
|
+
progress.message(status.stage === "shipped" && !queued
|
|
1549
|
+
? "Deployment complete"
|
|
1550
|
+
: status.output ? fit(`${displayStage}: ${status.output}`) : fit(`${displayStage}…`));
|
|
1551
|
+
}
|
|
1552
|
+
if (!queued && status.state === "succeeded") {
|
|
1553
|
+
progress.stop(config.cutoverRequired
|
|
1554
|
+
? "New upstream healthy at 127.0.0.1 (Caddy cutover pending)"
|
|
1555
|
+
: "Deployment complete");
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
if (!queued && status.state === "failed") {
|
|
1559
|
+
progress.stop(`Deployment failed during ${status.stage ?? "unknown"}`, 1);
|
|
1560
|
+
throw new Error(`${[status.message ?? "deployment failed", status.output].filter(Boolean).join("\n")}\n\nNext: run bun ship --logs.`);
|
|
1561
|
+
}
|
|
1562
|
+
} else if (lastStage) {
|
|
1563
|
+
const history = await ssh(target, [
|
|
1564
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "history", config.appId, "--json",
|
|
1565
|
+
], { allowFailure: true });
|
|
1566
|
+
if (history.exitCode === 0 && history.stdout.trim()) {
|
|
1567
|
+
const terminal = terminalHistory(JSON.parse(history.stdout) as HistoryEntry[], commit);
|
|
1568
|
+
if (terminal?.state === "succeeded") {
|
|
1569
|
+
progress.stop(config.cutoverRequired
|
|
1570
|
+
? "New upstream healthy at 127.0.0.1 (Caddy cutover pending)"
|
|
1571
|
+
: "Deployment complete");
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
if (terminal?.state === "failed") {
|
|
1575
|
+
progress.stop(`Deployment failed during ${terminal.stage ?? "unknown"}`, 1);
|
|
1576
|
+
throw new Error(`deployment failed during ${terminal.stage ?? "unknown"}.\n\nNext: run bun ship --logs.`);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
if (sawQueued) {
|
|
1581
|
+
const current = await ssh(target, [
|
|
1582
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "status", config.appId, "--json",
|
|
1583
|
+
], { allowFailure: true });
|
|
1584
|
+
if (current.exitCode === 0 && current.stdout.trim() && current.stdout.trim() !== "null") {
|
|
1585
|
+
const status = parseDeployStatus(JSON.parse(current.stdout))!;
|
|
1586
|
+
if (status.queuedCommit && status.queuedCommit !== commit) {
|
|
1587
|
+
progress.stop(`Queued commit replaced by ${status.queuedCommit.slice(0, 7)}`, 1);
|
|
1588
|
+
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.`);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
if (!lastStage && Date.now() >= webhookDeadline) {
|
|
1593
|
+
progress.stop("Webhook did not start deployment", 1);
|
|
1594
|
+
throw new Error(`GitHub webhook did not reach shibumi-server.\n\nNext: check https://github.com/${config.repository.slice("github:".length)}/settings/hooks, then rerun bun ship after repairing delivery.`);
|
|
1595
|
+
}
|
|
1596
|
+
await Bun.sleep(2_000);
|
|
1597
|
+
}
|
|
1598
|
+
progress.stop("Deployment status timed out", 1);
|
|
1599
|
+
throw new Error(`deployment may still be running. Check: ssh ${target} shibumi-server status ${config.appId}`);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// Existing domains keep serving their previous upstream until new app is
|
|
1603
|
+
// healthy and user explicitly approves Caddy cutover.
|
|
1604
|
+
async function completeCutover(config: ClientConfig, target: string): Promise<boolean> {
|
|
1605
|
+
if (!config.cutoverRequired) return false;
|
|
1606
|
+
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.");
|
|
1607
|
+
explain(
|
|
1608
|
+
"Existing-domain cutover",
|
|
1609
|
+
`The new app is healthy, but ${config.domain} still serves its previous upstream.\nCaddy cutover validates and reloads configuration without stopping active connections.`,
|
|
1610
|
+
);
|
|
1611
|
+
if (!await approve("Switch public traffic to the new upstream through SSH?")) {
|
|
1612
|
+
log.warn("Cutover skipped. Previous upstream remains public.");
|
|
1613
|
+
return false;
|
|
1614
|
+
}
|
|
1615
|
+
const result = await ssh(target, [SERVER_CLI, "caddy-cutover", config.appId], { tty: true, allowFailure: true });
|
|
1616
|
+
if (result.exitCode !== 0) throw new Error("Caddy cutover failed; previous upstream remains active");
|
|
1617
|
+
const hostname = await resolvedSshHostname(target);
|
|
1618
|
+
const downloaded = await ssh(target, [
|
|
1619
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "client-config", config.appId, "--server-hostname", hostname,
|
|
1620
|
+
]);
|
|
1621
|
+
const next = validateConfig(JSON.parse(downloaded.stdout));
|
|
1622
|
+
await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
1623
|
+
Object.assign(config, next);
|
|
1624
|
+
log.success(`Caddy now serves ${config.domain} from the Shibumi upstream`);
|
|
1625
|
+
return true;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
async function projectTarget(config: ClientConfig): Promise<string> {
|
|
1629
|
+
const target = await configuredSshTarget(config.server.hostname) ?? await requestSshTarget(config.server.hostname);
|
|
1630
|
+
if (!target) throw new Error("SSH server is required");
|
|
1631
|
+
await rememberSshTarget(config.server.hostname, target);
|
|
1632
|
+
return target;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
async function showLogs(): Promise<void> {
|
|
1636
|
+
try {
|
|
1637
|
+
const config = await readConfig();
|
|
1638
|
+
if (!config) throw new Error("Shibumi setup is missing.\n\nNext: run bun ship:setup.");
|
|
1639
|
+
const result = await ssh(await projectTarget(config), [
|
|
1640
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "logs", config.appId,
|
|
1641
|
+
], { allowFailure: true });
|
|
1642
|
+
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "deployment log is unavailable");
|
|
1643
|
+
process.stdout.write(result.stdout);
|
|
1644
|
+
} finally {
|
|
1645
|
+
await closeSshControl();
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
export function parseDeployStatus(value: unknown): DeployStatus | undefined {
|
|
1650
|
+
if (value === null) return undefined;
|
|
1651
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("deployment status is invalid");
|
|
1652
|
+
const status = value as Partial<DeployStatus>;
|
|
1653
|
+
if (typeof status.commit !== "string" || !COMMIT.test(status.commit)
|
|
1654
|
+
|| !["accepted", "running", "succeeded", "failed"].includes(status.state ?? "")
|
|
1655
|
+
|| typeof status.stage !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(status.stage)
|
|
1656
|
+
|| typeof status.updatedAt !== "string" || Number.isNaN(Date.parse(status.updatedAt))
|
|
1657
|
+
|| (status.message !== undefined && (typeof status.message !== "string" || status.message.length > 256 || /[\r\n\0]/.test(status.message)))
|
|
1658
|
+
|| (status.output !== undefined && (typeof status.output !== "string" || status.output.length > 512 || /[\r\n\0\x1b]/.test(status.output)))
|
|
1659
|
+
|| (status.url !== undefined && (typeof status.url !== "string" || status.url.length > 512 || !status.url.startsWith("https://")))
|
|
1660
|
+
|| (status.queuedCommit !== undefined && (typeof status.queuedCommit !== "string" || !COMMIT.test(status.queuedCommit)))) {
|
|
1661
|
+
throw new Error("deployment status is invalid");
|
|
1662
|
+
}
|
|
1663
|
+
return status as DeployStatus;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
export function deploymentStatusSummary(status: DeployStatus | undefined, localCommit: string, config: Pick<ClientConfig, "domain" | "cutoverRequired">): string {
|
|
1667
|
+
if (!status) return `No deployment status for https://${config.domain}`;
|
|
1668
|
+
return [
|
|
1669
|
+
`Status ${status.state}`,
|
|
1670
|
+
`Commit ${status.commit}${status.commit === localCommit ? " (matches HEAD)" : ""}`,
|
|
1671
|
+
status.commit === localCommit ? undefined : `HEAD ${localCommit}`,
|
|
1672
|
+
`Stage ${status.stage}${status.message ? `: ${status.message}` : ""}`,
|
|
1673
|
+
status.queuedCommit ? `Queued ${status.queuedCommit}` : undefined,
|
|
1674
|
+
`Updated ${status.updatedAt}`,
|
|
1675
|
+
config.cutoverRequired && status.state === "succeeded"
|
|
1676
|
+
? "Traffic previous upstream (Caddy cutover pending)"
|
|
1677
|
+
: `URL ${status.url ?? `https://${config.domain}`}`,
|
|
1678
|
+
].filter(Boolean).join("\n");
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async function showStatus(): Promise<void> {
|
|
1682
|
+
try {
|
|
1683
|
+
const config = await readConfig();
|
|
1684
|
+
if (!config) throw new Error("Shibumi setup is missing.\n\nNext: run bun ship:setup.");
|
|
1685
|
+
const result = await ssh(await projectTarget(config), [
|
|
1686
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "status", config.appId, "--json",
|
|
1687
|
+
], { allowFailure: true });
|
|
1688
|
+
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "deployment status is unavailable");
|
|
1689
|
+
const status = parseDeployStatus(JSON.parse(result.stdout));
|
|
1690
|
+
process.stdout.write(`${deploymentStatusSummary(status, await git("rev-parse", "HEAD"), config)}\n`);
|
|
1691
|
+
} finally {
|
|
1692
|
+
await closeSshControl();
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function portIsBusy(port: number): Promise<boolean> {
|
|
1697
|
+
return new Promise((resolveBusy) => {
|
|
1698
|
+
const socket = createConnection({ host: "127.0.0.1", port });
|
|
1699
|
+
socket.setTimeout(500);
|
|
1700
|
+
socket.once("connect", () => { socket.destroy(); resolveBusy(true); });
|
|
1701
|
+
socket.once("timeout", () => { socket.destroy(); resolveBusy(false); });
|
|
1702
|
+
socket.once("error", () => resolveBusy(false));
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
export function formatDevStartup(port: number, domain: string, time: string, color = false): string {
|
|
1707
|
+
const paint = (code: string, value: string) => color ? `\x1b[${code}m${value}\x1b[0m` : value;
|
|
1708
|
+
const row = (label: string, url: string) => `${paint("2", "┃")} ${label.padEnd(8)} ${paint("34", url)}`;
|
|
1709
|
+
return [
|
|
1710
|
+
`${paint("38;5;208", "渋み")} ship dev`,
|
|
1711
|
+
row("Local", `http://localhost:${port}/`),
|
|
1712
|
+
row("Remote", `https://${domain}`),
|
|
1713
|
+
`${paint("2", time)} starting app dev server...`,
|
|
1714
|
+
].join("\n");
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function localTime(date = new Date()): string {
|
|
1718
|
+
return [date.getHours(), date.getMinutes(), date.getSeconds()].map((value) => String(value).padStart(2, "0")).join(":");
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
async function runDev(): Promise<void> {
|
|
1722
|
+
const config = await readConfig();
|
|
1723
|
+
if (!config) throw new Error("Shibumi setup is missing.\n\nNext: run bun ship:setup.");
|
|
1724
|
+
if (await portIsBusy(config.port)) {
|
|
1725
|
+
const lsof = Bun.which("lsof");
|
|
1726
|
+
const fuser = Bun.which("fuser");
|
|
1727
|
+
if (!lsof && !fuser) throw new Error(`Port ${config.port} is already in use.\n\nNext: stop that process, then run bun dev again.`);
|
|
1728
|
+
const found = lsof
|
|
1729
|
+
? await run([lsof, "-nP", `-iTCP:${config.port}`, "-sTCP:LISTEN", "-t"], { allowFailure: true })
|
|
1730
|
+
: await run([fuser!, "-n", "tcp", String(config.port)], { allowFailure: true });
|
|
1731
|
+
const pids = [...new Set(`${found.stdout} ${found.stderr}`.split(/\s+/).filter((value) => /^\d+$/.test(value)).map(Number))];
|
|
1732
|
+
if (pids.length === 0) throw new Error(`Port ${config.port} is already in use.\n\nNext: stop that process, then run bun dev again.`);
|
|
1733
|
+
const details = await run(["ps", "-o", "pid=,comm=", "-p", pids.join(",")], { allowFailure: true });
|
|
1734
|
+
log.warn(`Port ${config.port} is in use${details.stdout.trim() ? `:\n${details.stdout.trim()}` : ""}`);
|
|
1735
|
+
const accepted = await confirm({ message: "Stop it and start this project?", initialValue: false });
|
|
1736
|
+
if (isCancel(accepted) || !accepted) return;
|
|
1737
|
+
for (const pid of pids) process.kill(pid, "SIGTERM");
|
|
1738
|
+
const deadline = Date.now() + 5_000;
|
|
1739
|
+
while (await portIsBusy(config.port) && Date.now() < deadline) await Bun.sleep(100);
|
|
1740
|
+
if (await portIsBusy(config.port)) throw new Error(`Port ${config.port} did not stop.\n\nNext: stop PID ${pids.join(", ")} manually, then run bun dev again.`);
|
|
1741
|
+
}
|
|
1742
|
+
process.stdout.write(`${formatDevStartup(config.port, config.domain, localTime(), supportsTerminalColor())}\n`);
|
|
1743
|
+
const child = Bun.spawn([process.execPath, "run", "dev:app"], {
|
|
1744
|
+
cwd: root,
|
|
1745
|
+
env: { ...process.env, PORT: String(config.port), SHIBUMI_PORT: String(config.port) },
|
|
1746
|
+
stdin: "inherit",
|
|
1747
|
+
stdout: "inherit",
|
|
1748
|
+
stderr: "inherit",
|
|
1749
|
+
});
|
|
1750
|
+
process.exitCode = await child.exited;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
async function rollbackShip(): Promise<void> {
|
|
1754
|
+
intro("渋み ship rollback");
|
|
1755
|
+
try {
|
|
1756
|
+
const config = await readConfig();
|
|
1757
|
+
if (!config) throw new Error("Shibumi setup is missing.\n\nNext: run bun ship:setup.");
|
|
1758
|
+
const target = await projectTarget(config);
|
|
1759
|
+
const status = await ssh(target, [
|
|
1760
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "status", config.appId, "--json",
|
|
1761
|
+
], { allowFailure: true });
|
|
1762
|
+
if (status.exitCode === 0 && status.stdout.trim() && status.stdout.trim() !== "null") {
|
|
1763
|
+
const current = parseDeployStatus(JSON.parse(status.stdout))!;
|
|
1764
|
+
if (current.state === "accepted" || current.state === "running") {
|
|
1765
|
+
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.`);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
if (!await approve(`Restore the previous retained image for ${config.domain}?`)) {
|
|
1769
|
+
cancel("Rollback cancelled");
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
const startedAt = Date.now();
|
|
1773
|
+
const progress = spinner();
|
|
1774
|
+
progress.start("Restoring previous image");
|
|
1775
|
+
const rollback = await ssh(target, [
|
|
1776
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "rollback", config.appId, "--yes",
|
|
1777
|
+
], { allowFailure: true });
|
|
1778
|
+
if (rollback.exitCode !== 0) {
|
|
1779
|
+
progress.stop("Rollback failed", 1);
|
|
1780
|
+
throw new Error(rollback.stderr.trim() || rollback.stdout.trim() || "server rollback failed");
|
|
1781
|
+
}
|
|
1782
|
+
progress.stop(`Rolled back in ${formatDuration(Date.now() - startedAt)}`);
|
|
1783
|
+
outro(`https://${config.domain}`);
|
|
1784
|
+
} finally {
|
|
1785
|
+
await closeSshControl();
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// Setup exits after configuration changes so user can review and commit them.
|
|
1790
|
+
// Normal ship reaches push only when configuration is already stable.
|
|
1791
|
+
export async function runShip(): Promise<void> {
|
|
1792
|
+
intro("渋み ship");
|
|
1793
|
+
try {
|
|
1794
|
+
const forceSetup = options.setup;
|
|
1795
|
+
const result = await setup(forceSetup);
|
|
1796
|
+
if (!result) return;
|
|
1797
|
+
const setupCommit = await offerSetupCommit(result.config);
|
|
1798
|
+
if (setupCommit === "declined") {
|
|
1799
|
+
outro(`${accent("Next:")} review and commit Shibumi setup files, then run bun ship.`);
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
if (forceSetup || result.changed) {
|
|
1803
|
+
// Setup succeeded with everything committed, so the first deploy is one
|
|
1804
|
+
// Enter away. Offer it here instead of ending on "Next: bun ship".
|
|
1805
|
+
// Only for direct-ship triggers in an interactive run: github-push
|
|
1806
|
+
// deploys on push, and agent runs never reach setup.
|
|
1807
|
+
const shipNow = result.config.trigger === "ship" && !agentRun && process.stdin.isTTY && process.stdout.isTTY
|
|
1808
|
+
? await confirm({ message: "Ship now?", initialValue: true })
|
|
1809
|
+
: false;
|
|
1810
|
+
if (shipNow !== true || isCancel(shipNow)) {
|
|
1811
|
+
outro(`${accent("Next:")} ${result.config.trigger === "github-push" ? "git push deploys automatically" : "bun ship"}`);
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
const compose = await localBuildFrontend(result.config);
|
|
1816
|
+
const estimateMs = await estimatedDeployDuration(result.config, result.target);
|
|
1817
|
+
const startedAt = Date.now();
|
|
1818
|
+
const ahead = await preflight(result.config);
|
|
1819
|
+
const commit = await git("rev-parse", "HEAD");
|
|
1820
|
+
if (!COMMIT.test(commit)) throw new Error("cannot determine shipped commit");
|
|
1821
|
+
await buildAndUpload(result.config, result.target, commit, compose);
|
|
1822
|
+
if (ahead > 0) await run(["git", "push", "origin", result.config.branch], { inherit: true });
|
|
1823
|
+
if (shouldTriggerRedeploy(result.config.trigger, ahead)) {
|
|
1824
|
+
const redeploy = await ssh(result.target, [
|
|
1825
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "redeploy", result.config.appId, commit,
|
|
1826
|
+
], { allowFailure: true });
|
|
1827
|
+
if (redeploy.exitCode !== 0) {
|
|
1828
|
+
const current = await ssh(result.target, [
|
|
1829
|
+
"env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "status", result.config.appId, "--commit", commit, "--json",
|
|
1830
|
+
], { allowFailure: true });
|
|
1831
|
+
const status = current.exitCode === 0 && current.stdout.trim() && current.stdout.trim() !== "null"
|
|
1832
|
+
? parseDeployStatus(JSON.parse(current.stdout))
|
|
1833
|
+
: undefined;
|
|
1834
|
+
if (!canFollowDeployment(status, commit)) throw new Error(redeploy.stderr.trim() || "redeploy request failed");
|
|
1835
|
+
log.info("Deployment already running for this commit. Following its progress.");
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
await followStatus(result.config, result.target, commit, estimateMs);
|
|
1839
|
+
const changed = await completeCutover(result.config, result.target);
|
|
1840
|
+
if (changed) log.info("Updated shibumi-server.json after Caddy cutover");
|
|
1841
|
+
const complete = spinner();
|
|
1842
|
+
complete.start("Finishing ship");
|
|
1843
|
+
complete.stop(`Shipped in ${formatDuration(Date.now() - startedAt)} (--rollback if needed)`);
|
|
1844
|
+
outro(`https://${result.config.domain}`);
|
|
1845
|
+
} finally {
|
|
1846
|
+
await closeSshControl();
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
export function immutableShipSource(source: string): string | undefined {
|
|
1851
|
+
return /const CURRENT_SOURCE = "(https:\/\/shibumistack\.dev\/ship\/v\d+\.ts)";/.exec(source)?.[1];
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
export function shouldCheckForShipUpdate(value: ShipOptions): boolean {
|
|
1855
|
+
return !(value.setup || value.update || value.rollback || value.logs || value.status || value.dev);
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
async function runLatestShipClient(args: string[]): Promise<boolean> {
|
|
1859
|
+
if (process.env.SHIBUMI_SHIP_LATEST === "1") return false;
|
|
1860
|
+
let parsed: ShipOptions;
|
|
1861
|
+
try {
|
|
1862
|
+
parsed = parseShipArgs(args);
|
|
1863
|
+
} catch {
|
|
1864
|
+
return false;
|
|
1865
|
+
}
|
|
1866
|
+
if (!shouldCheckForShipUpdate(parsed)) return false;
|
|
1867
|
+
|
|
1868
|
+
const sourcePath = join(root, "scripts/ship.ts");
|
|
1869
|
+
let current: string;
|
|
1870
|
+
let latest: string;
|
|
1871
|
+
try {
|
|
1872
|
+
current = await readFile(sourcePath, "utf8");
|
|
1873
|
+
const [latestResponse, reviewedResponse] = await Promise.all([
|
|
1874
|
+
fetch(LATEST_SOURCE, { headers: { accept: "text/plain" } }),
|
|
1875
|
+
fetch(CURRENT_SOURCE, { headers: { accept: "text/plain" } }),
|
|
1876
|
+
]);
|
|
1877
|
+
if (!latestResponse.ok || !reviewedResponse.ok || await reviewedResponse.text() !== current) return false;
|
|
1878
|
+
latest = await latestResponse.text();
|
|
1879
|
+
if (latest === current || !latest.startsWith("#!/usr/bin/env bun") || !latest.includes("export function runShipCli")) return false;
|
|
1880
|
+
const immutable = immutableShipSource(latest);
|
|
1881
|
+
if (!immutable) return false;
|
|
1882
|
+
const immutableResponse = await fetch(immutable, { headers: { accept: "text/plain" } });
|
|
1883
|
+
if (!immutableResponse.ok || await immutableResponse.text() !== latest) return false;
|
|
1884
|
+
} catch {
|
|
1885
|
+
return false;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
const latestVersion = immutableShipSource(latest)?.match(/v(\d+)\.ts$/)?.[1] ?? "latest";
|
|
1889
|
+
if (!(parsed.yes || isAgentExecution())) {
|
|
1890
|
+
const accepted = await confirm({
|
|
1891
|
+
message: `Ship client v${latestVersion} is available. Use it now and save it after a successful deployment?`,
|
|
1892
|
+
initialValue: true,
|
|
1893
|
+
});
|
|
1894
|
+
if (isCancel(accepted) || !accepted) return false;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
log.info(`Using ship client v${latestVersion} for this deployment`);
|
|
1898
|
+
const temporaryDirectory = await mkdtemp("/tmp/shibumi-ship-client-");
|
|
1899
|
+
const temporarySource = join(temporaryDirectory, "ship.ts");
|
|
1900
|
+
try {
|
|
1901
|
+
await writeFile(temporarySource, latest, { mode: 0o644 });
|
|
1902
|
+
const child = Bun.spawn([process.execPath, temporarySource, ...args], {
|
|
1903
|
+
cwd: root,
|
|
1904
|
+
env: { ...process.env, SHIBUMI_SHIP_LATEST: "1" },
|
|
1905
|
+
stdin: "inherit",
|
|
1906
|
+
stdout: "inherit",
|
|
1907
|
+
stderr: "inherit",
|
|
1908
|
+
});
|
|
1909
|
+
const exitCode = await child.exited;
|
|
1910
|
+
if (exitCode !== 0) {
|
|
1911
|
+
process.exitCode = exitCode;
|
|
1912
|
+
return true;
|
|
1913
|
+
}
|
|
1914
|
+
let sourceUnchanged = false;
|
|
1915
|
+
try {
|
|
1916
|
+
sourceUnchanged = await readFile(sourcePath, "utf8") === current;
|
|
1917
|
+
} catch {}
|
|
1918
|
+
if (sourceUnchanged) {
|
|
1919
|
+
const temporaryUpdate = `${sourcePath}.tmp-${process.pid}`;
|
|
1920
|
+
await writeFile(temporaryUpdate, latest, { mode: 0o644 });
|
|
1921
|
+
await chmod(temporaryUpdate, 0o644);
|
|
1922
|
+
await rename(temporaryUpdate, sourcePath);
|
|
1923
|
+
log.success(`Updated scripts/ship.ts to v${latestVersion}. Review and commit it.`);
|
|
1924
|
+
}
|
|
1925
|
+
return true;
|
|
1926
|
+
} finally {
|
|
1927
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
async function updateShipClient(): Promise<void> {
|
|
1932
|
+
intro("渋み ship update");
|
|
1933
|
+
const response = await fetch(LATEST_SOURCE, { headers: { accept: "text/plain" } });
|
|
1934
|
+
if (!response.ok) throw new Error(`ship client returned HTTP ${response.status}`);
|
|
1935
|
+
const source = await response.text();
|
|
1936
|
+
if (!source.startsWith("#!/usr/bin/env bun") || !source.includes("export function runShipCli")) {
|
|
1937
|
+
throw new Error("downloaded ship client is invalid");
|
|
1938
|
+
}
|
|
1939
|
+
const current = await readFile(import.meta.path, "utf8");
|
|
1940
|
+
if (source === current) {
|
|
1941
|
+
outro("Ship client is current");
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
const reviewed = await fetch(CURRENT_SOURCE, { headers: { accept: "text/plain" } });
|
|
1945
|
+
if (!reviewed.ok || await reviewed.text() !== current) {
|
|
1946
|
+
throw new Error(`scripts/ship.ts contains owned changes.\n\nNext: review and merge ${LATEST_SOURCE} manually.`);
|
|
1947
|
+
}
|
|
1948
|
+
const temporary = `${import.meta.path}.tmp-${process.pid}`;
|
|
1949
|
+
await writeFile(temporary, source, { mode: 0o644 });
|
|
1950
|
+
await chmod(temporary, 0o644);
|
|
1951
|
+
await rename(temporary, import.meta.path);
|
|
1952
|
+
outro("Ship client updated. Review and commit scripts/ship.ts.");
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// Manage per-app environment variables on the server (secrets and per-deploy
|
|
1956
|
+
// config). Values travel over the existing SSH channel to `shis env`, which
|
|
1957
|
+
// persists them server-side and injects them at deploy. Not part of
|
|
1958
|
+
// parseShipArgs: `env` has its own positional grammar.
|
|
1959
|
+
//
|
|
1960
|
+
// bun ship:env set KEY=VALUE [KEY=VALUE...] set individual variables
|
|
1961
|
+
// bun ship:env import [file] import a .env file (default .env.production)
|
|
1962
|
+
// bun ship:env list list variable names (never values)
|
|
1963
|
+
// bun ship:env rm KEY [KEY...] remove variables
|
|
1964
|
+
async function runEnv(args: string[]): Promise<void> {
|
|
1965
|
+
const [sub, ...rest] = args;
|
|
1966
|
+
const config = await readConfig();
|
|
1967
|
+
if (!config) throw new Error("Shibumi setup is missing.\n\nNext: run bun ship:setup.");
|
|
1968
|
+
const base = ["env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "env"];
|
|
1969
|
+
const applyNote = "Redeploy to apply: bun ship.";
|
|
1970
|
+
try {
|
|
1971
|
+
const target = await projectTarget(config);
|
|
1972
|
+
if (sub === "list") {
|
|
1973
|
+
const result = await ssh(target, [...base, "list", config.appId], { allowFailure: true });
|
|
1974
|
+
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "could not list environment variables");
|
|
1975
|
+
process.stdout.write(result.stdout.trim() ? `${result.stdout.trim()}\n` : "No variables set.\n");
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
if (sub === "rm") {
|
|
1979
|
+
if (rest.length === 0) throw new Error("usage: bun ship:env rm <KEY...>");
|
|
1980
|
+
const result = await ssh(target, [...base, "rm", config.appId, ...rest], { allowFailure: true });
|
|
1981
|
+
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "could not remove variables");
|
|
1982
|
+
process.stdout.write(`${result.stdout.trim()}\n${applyNote}\n`);
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
if (sub === "set" || sub === "import") {
|
|
1986
|
+
let content: string;
|
|
1987
|
+
if (sub === "import") {
|
|
1988
|
+
const file = rest[0] ?? ".env.production";
|
|
1989
|
+
content = await readFile(join(root, file), "utf8").catch(() => {
|
|
1990
|
+
throw new Error(`Cannot read ${file}. Pass a path: bun ship:env import <file>.`);
|
|
1991
|
+
});
|
|
1992
|
+
} else {
|
|
1993
|
+
if (rest.length === 0) throw new Error("usage: bun ship:env set KEY=VALUE [KEY=VALUE...]");
|
|
1994
|
+
for (const pair of rest) {
|
|
1995
|
+
if (!/^[A-Z_][A-Z0-9_]*=/.test(pair)) throw new Error(`not KEY=VALUE (KEY must be UPPER_SNAKE): ${pair}`);
|
|
1996
|
+
}
|
|
1997
|
+
content = `${rest.join("\n")}\n`;
|
|
1998
|
+
}
|
|
1999
|
+
// Stage in a 0600 temp file and pipe it over SSH stdin, so values never
|
|
2000
|
+
// appear in the process list on either machine.
|
|
2001
|
+
const dir = await mkdtemp(join(tmpdir(), "shibumi-env-"));
|
|
2002
|
+
const tmp = join(dir, "env");
|
|
2003
|
+
await writeFile(tmp, content, { mode: 0o600 });
|
|
2004
|
+
try {
|
|
2005
|
+
const result = await ssh(target, [...base, "set", config.appId], { inputFile: tmp, allowFailure: true });
|
|
2006
|
+
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "could not set variables");
|
|
2007
|
+
process.stdout.write(`${result.stdout.trim()}\n${applyNote}\n`);
|
|
2008
|
+
} finally {
|
|
2009
|
+
await rm(dir, { recursive: true, force: true });
|
|
2010
|
+
}
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
throw new Error("usage: bun ship:env set KEY=VALUE | import [file] | list | rm <KEY...>");
|
|
2014
|
+
} finally {
|
|
2015
|
+
await closeSshControl();
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
export function runShipCli(): void {
|
|
2020
|
+
const rawArgs = process.argv.slice(2);
|
|
2021
|
+
if (rawArgs[0] === "--env") {
|
|
2022
|
+
agentRun = isAgentExecution();
|
|
2023
|
+
runEnv(rawArgs.slice(1)).catch((error) => {
|
|
2024
|
+
cancel(error instanceof Error ? error.message : String(error));
|
|
2025
|
+
process.exitCode = 1;
|
|
2026
|
+
});
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
try {
|
|
2030
|
+
options = parseShipArgs(process.argv.slice(2));
|
|
2031
|
+
agentRun = isAgentExecution();
|
|
2032
|
+
} catch (error) {
|
|
2033
|
+
cancel(error instanceof Error ? error.message : String(error));
|
|
2034
|
+
process.exitCode = 1;
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
const action = options.update ? updateShipClient()
|
|
2038
|
+
: options.rollback ? rollbackShip()
|
|
2039
|
+
: options.logs ? showLogs()
|
|
2040
|
+
: options.status ? showStatus()
|
|
2041
|
+
: options.dev ? runDev()
|
|
2042
|
+
: runShip();
|
|
2043
|
+
action.catch((error) => {
|
|
2044
|
+
cancel(error instanceof Error ? error.message : String(error));
|
|
2045
|
+
process.exitCode = 1;
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
if (import.meta.main) {
|
|
2050
|
+
runLatestShipClient(process.argv.slice(2)).then((ranLatest) => {
|
|
2051
|
+
if (!ranLatest) runShipCli();
|
|
2052
|
+
}).catch((error) => {
|
|
2053
|
+
cancel(error instanceof Error ? error.message : String(error));
|
|
2054
|
+
process.exitCode = 1;
|
|
2055
|
+
});
|
|
2056
|
+
}
|