create-shibumi 0.2.9 → 0.3.2
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/README.md +17 -4
- package/package.json +2 -2
- package/scripts/ship.lock.json +2 -2
- package/src/adopt.ts +250 -0
- package/src/args.ts +32 -6
- package/src/cli.ts +232 -43
- package/src/templates/blog/agents.md +1 -0
- package/src/templates/blog/gitignore +2 -0
- package/src/templates/blog/package.json +2 -1
- package/src/templates/full-stack/agents.md +1 -0
- package/src/templates/full-stack/package.json +1 -0
- package/src/templates/ship.ts +545 -168
- package/src/templates/static/agents.md +1 -0
- package/src/templates/static/gitignore +2 -0
- package/src/templates/static/package.json +2 -1
- package/src/templates/web/.dockerignore +0 -12
- package/src/templates/web/Dockerfile +0 -24
- package/src/templates/web/README.md +0 -10
- package/src/templates/web/agents.md +0 -54
- package/src/templates/web/bun.lock +0 -45
- package/src/templates/web/compose.yaml +0 -17
- package/src/templates/web/gitignore +0 -5
- package/src/templates/web/package.json +0 -34
- package/src/templates/web/public/app.js +0 -11
- package/src/templates/web/public/favicon.svg +0 -1
- package/src/templates/web/public/style.css +0 -149
- package/src/templates/web/public/vendor/alpine-csp-3.16.2.min.js +0 -23
- package/src/templates/web/public/vendor/shibumi.css +0 -422
- package/src/templates/web/src/app.ts +0 -108
- package/src/templates/web/src/env.ts +0 -21
- package/src/templates/web/src/server.ts +0 -23
- package/src/templates/web/test/app.test.ts +0 -145
- package/src/templates/web/tsconfig.json +0 -13
package/src/templates/ship.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Project-owned client for shibumi-server.
|
|
5
5
|
*
|
|
6
|
-
* `bun ship:setup` connects this repository to one server and
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* `bun ship:setup` connects this repository to one server and registers the
|
|
7
|
+
* app. Later, `bun ship` checks local work, pushes one commit, triggers it
|
|
8
|
+
* over SSH, and follows status until the app is healthy. `bun ship:webhook`
|
|
9
|
+
* is the opt-in for push-to-deploy; `--off` reverses it.
|
|
9
10
|
*
|
|
10
11
|
* Commit this file and shibumi-server.json. SSH targets stay in machine-local
|
|
11
12
|
* Shibumi config. Webhook secrets stay on the server and pass directly to GitHub CLI.
|
|
@@ -25,7 +26,7 @@ const SERVER_HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/;
|
|
|
25
26
|
const COMMIT = /^[a-f0-9]{40}$/;
|
|
26
27
|
const SERVER_CLI = "~/.local/bin/shibumi-server";
|
|
27
28
|
const LATEST_SOURCE = "https://shibumistack.dev/ship/latest.ts";
|
|
28
|
-
const CURRENT_SOURCE = "https://shibumistack.dev/ship/
|
|
29
|
+
const CURRENT_SOURCE = "https://shibumistack.dev/ship/v48.ts";
|
|
29
30
|
let sshControlDirectory: string | undefined;
|
|
30
31
|
let sshControlTarget: string | undefined;
|
|
31
32
|
|
|
@@ -87,11 +88,14 @@ interface ShipOptions {
|
|
|
87
88
|
logs: boolean;
|
|
88
89
|
status: boolean;
|
|
89
90
|
dev: boolean;
|
|
91
|
+
webhook: boolean;
|
|
92
|
+
off: boolean;
|
|
90
93
|
rebuild: boolean;
|
|
91
94
|
yes: boolean;
|
|
95
|
+
interactive: boolean;
|
|
96
|
+
publicRepo: boolean;
|
|
92
97
|
server?: string;
|
|
93
98
|
domain?: string;
|
|
94
|
-
trigger?: "ship" | "github-push";
|
|
95
99
|
staticSite: boolean;
|
|
96
100
|
outputDir?: string;
|
|
97
101
|
buildScript?: string;
|
|
@@ -105,7 +109,7 @@ export interface StaticSiteConfig {
|
|
|
105
109
|
spa: boolean;
|
|
106
110
|
}
|
|
107
111
|
|
|
108
|
-
let options: ShipOptions = { setup: false, update: false, rollback: false, logs: false, status: false, dev: false, rebuild: false, yes: false, staticSite: false, spa: false };
|
|
112
|
+
let options: ShipOptions = { setup: false, update: false, rollback: false, logs: false, status: false, dev: false, webhook: false, off: false, rebuild: false, yes: false, interactive: false, publicRepo: false, staticSite: false, spa: false };
|
|
109
113
|
let agentRun = false;
|
|
110
114
|
|
|
111
115
|
export function isAgentExecution(env: NodeJS.ProcessEnv = process.env, stdinTTY = Boolean(process.stdin.isTTY), stdoutTTY = Boolean(process.stdout.isTTY)): boolean {
|
|
@@ -147,7 +151,7 @@ function spinner(): ShipSpinner {
|
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
export function parseShipArgs(args: string[]): ShipOptions {
|
|
150
|
-
const parsed: ShipOptions = { setup: false, update: false, rollback: false, logs: false, status: false, dev: false, rebuild: false, yes: false, staticSite: false, spa: false };
|
|
154
|
+
const parsed: ShipOptions = { setup: false, update: false, rollback: false, logs: false, status: false, dev: false, webhook: false, off: false, rebuild: false, yes: false, interactive: false, publicRepo: false, staticSite: false, spa: false };
|
|
151
155
|
for (let index = 0; index < args.length; index += 1) {
|
|
152
156
|
const argument = args[index];
|
|
153
157
|
if (argument === "--") continue;
|
|
@@ -157,26 +161,31 @@ export function parseShipArgs(args: string[]): ShipOptions {
|
|
|
157
161
|
else if (argument === "--logs") parsed.logs = true;
|
|
158
162
|
else if (argument === "--status") parsed.status = true;
|
|
159
163
|
else if (argument === "--dev") parsed.dev = true;
|
|
164
|
+
else if (argument === "--webhook") parsed.webhook = true;
|
|
165
|
+
else if (argument === "--off") parsed.off = true;
|
|
160
166
|
else if (argument === "--rebuild") parsed.rebuild = true;
|
|
161
167
|
else if (argument === "--yes" || argument === "-y") parsed.yes = true;
|
|
168
|
+
else if (argument === "--interactive") parsed.interactive = true;
|
|
169
|
+
else if (argument === "--public") parsed.publicRepo = true;
|
|
162
170
|
else if (argument === "--static") parsed.staticSite = true;
|
|
163
171
|
else if (argument === "--spa") parsed.spa = true;
|
|
164
172
|
else if (argument === "--no-spa") parsed.noSpa = true;
|
|
165
|
-
else if (argument === "--server" || argument === "--domain" || argument === "--
|
|
173
|
+
else if (argument === "--server" || argument === "--domain" || argument === "--output-dir" || argument === "--build-script") {
|
|
166
174
|
const value = args[index + 1];
|
|
167
175
|
if (!value || value.startsWith("-")) throw new Error(`${argument} requires a value`);
|
|
168
176
|
if (argument === "--server") parsed.server = value;
|
|
169
177
|
else if (argument === "--domain") parsed.domain = value;
|
|
170
178
|
else if (argument === "--output-dir") parsed.outputDir = value;
|
|
171
|
-
else
|
|
172
|
-
else if (value === "ship" || value === "github-push") parsed.trigger = value;
|
|
173
|
-
else throw new Error("--trigger must be ship or github-push");
|
|
179
|
+
else parsed.buildScript = value;
|
|
174
180
|
index += 1;
|
|
175
181
|
} else throw new Error(`unknown ship option: ${argument}`);
|
|
176
182
|
}
|
|
177
|
-
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");
|
|
178
|
-
if (parsed.rebuild && (parsed.setup || parsed.update || parsed.rollback || parsed.logs || parsed.status || parsed.dev)) throw new Error("--rebuild applies only to shipping");
|
|
179
|
-
if (parsed.
|
|
183
|
+
if ([parsed.setup, parsed.update, parsed.rollback, parsed.logs, parsed.status, parsed.dev, parsed.webhook].filter(Boolean).length > 1) throw new Error("choose only one ship action");
|
|
184
|
+
if (parsed.rebuild && (parsed.setup || parsed.update || parsed.rollback || parsed.logs || parsed.status || parsed.dev || parsed.webhook)) throw new Error("--rebuild applies only to shipping");
|
|
185
|
+
if (parsed.off && !parsed.webhook) throw new Error("--off requires --webhook");
|
|
186
|
+
if (parsed.interactive && !parsed.setup) throw new Error("--interactive requires --setup");
|
|
187
|
+
if (parsed.publicRepo && !parsed.setup) throw new Error("--public requires --setup");
|
|
188
|
+
if (parsed.interactive && parsed.yes) throw new Error("--interactive and --yes are mutually exclusive");
|
|
180
189
|
if (parsed.spa && parsed.noSpa) throw new Error("--spa and --no-spa are mutually exclusive");
|
|
181
190
|
if ((parsed.staticSite || parsed.outputDir || parsed.buildScript || parsed.spa || parsed.noSpa) && !parsed.setup) throw new Error("--static, --output-dir, --build-script, and --spa require --setup");
|
|
182
191
|
if ((parsed.outputDir || parsed.buildScript || parsed.spa || parsed.noSpa) && !parsed.staticSite) throw new Error("--output-dir, --build-script, and --spa require --static");
|
|
@@ -296,12 +305,28 @@ async function rememberSshTarget(hostname: string, sshTarget: string): Promise<v
|
|
|
296
305
|
log.success(`Saved server ${sshTarget} in ${path}`);
|
|
297
306
|
}
|
|
298
307
|
|
|
308
|
+
function planSetup(): boolean {
|
|
309
|
+
return !options.interactive && !options.yes && !agentRun
|
|
310
|
+
&& Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
311
|
+
}
|
|
312
|
+
|
|
299
313
|
async function approve(message: string): Promise<boolean> {
|
|
300
314
|
if (options.yes || agentRun) return true;
|
|
301
315
|
const accepted = await confirm({ message, initialValue: true });
|
|
302
316
|
return !isCancel(accepted) && accepted;
|
|
303
317
|
}
|
|
304
318
|
|
|
319
|
+
// Setup asks two questions, shows the plan, and runs it on one confirm. That
|
|
320
|
+
// confirm answers exactly the steps the plan enumerated and nothing else:
|
|
321
|
+
// anything the plan never named keeps asking for itself, including the GitHub
|
|
322
|
+
// sign-in (it opens a browser) and the Caddy cutover (it moves public
|
|
323
|
+
// traffic). `--setup --interactive` restores the per-step gates.
|
|
324
|
+
let planApproved = false;
|
|
325
|
+
|
|
326
|
+
async function approvePlanned(message: string): Promise<boolean> {
|
|
327
|
+
return planApproved ? true : approve(message);
|
|
328
|
+
}
|
|
329
|
+
|
|
305
330
|
function explain(title: string, message: string): void {
|
|
306
331
|
log.info(`${title}\n${message}`);
|
|
307
332
|
}
|
|
@@ -342,7 +367,9 @@ async function git(...args: string[]): Promise<string> {
|
|
|
342
367
|
|
|
343
368
|
const setupFiles = ["package.json", "bun.lock", "scripts/ship.ts", "shibumi-server.json"];
|
|
344
369
|
|
|
345
|
-
|
|
370
|
+
type SetupCommit = "none" | "committed" | "declined";
|
|
371
|
+
|
|
372
|
+
async function offerSetupCommit(config: ClientConfig): Promise<SetupCommit> {
|
|
346
373
|
const changed: string[] = [];
|
|
347
374
|
for (const file of setupFiles) {
|
|
348
375
|
const status = await run(["git", "status", "--porcelain", "--", file]);
|
|
@@ -355,7 +382,7 @@ async function offerSetupCommit(config: ClientConfig): Promise<"none" | "committ
|
|
|
355
382
|
return "declined";
|
|
356
383
|
}
|
|
357
384
|
const trackedConfig = (await run(["git", "ls-files", "--error-unmatch", "shibumi-server.json"], { allowFailure: true })).exitCode === 0;
|
|
358
|
-
if (!await
|
|
385
|
+
if (!await approvePlanned(updateOnly ? "Commit ship client update now?" : "Commit deployment setup now?")) return "declined";
|
|
359
386
|
await run(["git", "add", "--", ...changed]);
|
|
360
387
|
await run(["git", "commit", "--only", "-m", trackedConfig ? "Update Shibumi deployment" : "Add Shibumi deployment", "--", ...changed], { inherit: true });
|
|
361
388
|
log.success(updateOnly ? "Committed ship client update" : "Committed Shibumi deployment setup");
|
|
@@ -774,20 +801,35 @@ async function otherWorktreeCompose(): Promise<WorktreeCompose[]> {
|
|
|
774
801
|
return alternatives;
|
|
775
802
|
}
|
|
776
803
|
|
|
777
|
-
|
|
804
|
+
// Deciding what to deploy is separated from writing it, so a plan run can
|
|
805
|
+
// state "Generate deployment files (static, dist/)" and mean it: nothing is
|
|
806
|
+
// on disk until the plan is approved, and cancelling really changed nothing.
|
|
807
|
+
type DeploymentDecision =
|
|
808
|
+
| { kind: "tracked" }
|
|
809
|
+
| { kind: "untracked"; file: string }
|
|
810
|
+
| { kind: "static"; config: StaticSiteConfig }
|
|
811
|
+
| { kind: "server"; dockerfileExists: boolean; hasBuildScript: boolean };
|
|
812
|
+
|
|
813
|
+
export function deploymentPlanLine(decision: { kind: string; config?: StaticSiteConfig }): string | undefined {
|
|
814
|
+
if (decision.kind === "static" && decision.config) {
|
|
815
|
+
const build = decision.config.buildScript ? `, bun run ${decision.config.buildScript}` : "";
|
|
816
|
+
return `Generate deployment files (static, ${decision.config.outputDir}/${build})`;
|
|
817
|
+
}
|
|
818
|
+
if (decision.kind === "server") return "Generate deployment files (Bun server app)";
|
|
819
|
+
return undefined;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function decideDeployment(): Promise<DeploymentDecision> {
|
|
778
823
|
const branch = await git("branch", "--show-current");
|
|
779
824
|
if (!branch) throw new Error("ship requires a named Git branch");
|
|
780
825
|
const tracked = (await git("ls-files")).split("\n").filter(Boolean);
|
|
781
|
-
if (composeCandidates(tracked).length > 0) return
|
|
826
|
+
if (composeCandidates(tracked).length > 0) return { kind: "tracked" };
|
|
782
827
|
const alternatives = await otherWorktreeCompose();
|
|
783
828
|
if (alternatives.length > 0) throw new Error(missingComposeMessage(branch, alternatives));
|
|
784
829
|
|
|
785
830
|
const names = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"];
|
|
786
|
-
const
|
|
787
|
-
if (
|
|
788
|
-
outro(`Found uncommitted ${existingCompose}.\n\nNext: review it, commit and push it, then run bun ship:setup.`);
|
|
789
|
-
return true;
|
|
790
|
-
}
|
|
831
|
+
const existing = (await Promise.all(names.map(async (name) => await Bun.file(join(root, name)).exists() ? name : undefined))).find(Boolean);
|
|
832
|
+
if (existing) return { kind: "untracked", file: existing };
|
|
791
833
|
|
|
792
834
|
if (agentRun && !options.yes) {
|
|
793
835
|
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).");
|
|
@@ -804,78 +846,19 @@ async function prepareCompose(): Promise<boolean> {
|
|
|
804
846
|
if (isCancel(kind)) throw new Error(missingComposeMessage(branch, []));
|
|
805
847
|
wantStatic = kind === "static";
|
|
806
848
|
}
|
|
807
|
-
|
|
808
|
-
if (wantStatic) {
|
|
809
|
-
await generateStaticDeployment();
|
|
810
|
-
if (await offerGeneratedCommit(["Dockerfile", "compose.yaml", ".dockerignore", "scripts/static-server.ts", "package.json", "bun.lock"])) return false;
|
|
811
|
-
outro("Review generated deployment files.\n\nNext: commit and push these changes, then run bun ship:setup.");
|
|
812
|
-
return true;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
const packageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { scripts?: Record<string, unknown> };
|
|
816
|
-
const dockerfileExists = await Bun.file(join(root, "Dockerfile")).exists();
|
|
817
|
-
if (!dockerfileExists && typeof packageJson.scripts?.start !== "string") {
|
|
818
|
-
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.");
|
|
819
|
-
}
|
|
820
|
-
const templates = deploymentFileTemplates(typeof packageJson.scripts?.build === "string");
|
|
821
|
-
const written: string[] = [];
|
|
822
|
-
for (const [name, contents] of Object.entries(templates)) {
|
|
823
|
-
if (name === "Dockerfile" && dockerfileExists) continue;
|
|
824
|
-
if (await Bun.file(join(root, name)).exists()) continue;
|
|
825
|
-
await writeFile(join(root, name), contents, { mode: 0o644 });
|
|
826
|
-
written.push(name);
|
|
827
|
-
}
|
|
828
|
-
log.success(`Generated ${written.join(", ")}`);
|
|
829
|
-
log.info("Verify the app binds to 0.0.0.0 and reads PORT before shipping.");
|
|
830
|
-
if (await offerGeneratedCommit(written)) return false;
|
|
831
|
-
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.");
|
|
832
|
-
return true;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
const SHIP_SCRIPTS = {
|
|
836
|
-
ship: "bun scripts/ship.ts",
|
|
837
|
-
"ship:setup": "bun scripts/ship.ts --setup",
|
|
838
|
-
"ship:update": "bun scripts/ship.ts --update",
|
|
839
|
-
"ship:status": "bun scripts/ship.ts --status",
|
|
840
|
-
"ship:logs": "bun scripts/ship.ts --logs",
|
|
841
|
-
};
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
// After generating deployment files interactively, offer to commit and push
|
|
845
|
-
// them in the same run so setup continues without a manual rerun. Returns
|
|
846
|
-
// true when the files are committed and pushed.
|
|
847
|
-
async function offerGeneratedCommit(files: string[]): Promise<boolean> {
|
|
848
|
-
if (agentRun || !process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
849
|
-
const accepted = await confirm({ message: "Commit and push the generated files, then continue setup?", initialValue: true });
|
|
850
|
-
if (isCancel(accepted) || !accepted) return false;
|
|
851
|
-
const present: string[] = [];
|
|
852
|
-
for (const file of files) if (await Bun.file(join(root, file)).exists()) present.push(file);
|
|
853
|
-
await run(["git", "add", "--", ...present]);
|
|
854
|
-
await run(["git", "commit", "--only", "-m", "Add deployment configuration", "--", ...present], { inherit: true });
|
|
855
|
-
await run(["git", "push"], { inherit: true });
|
|
856
|
-
log.success("Committed and pushed deployment files");
|
|
857
|
-
return true;
|
|
849
|
+
return wantStatic ? { kind: "static", config: await staticDeploymentInputs() } : await serverDeploymentInputs();
|
|
858
850
|
}
|
|
859
851
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
if (await Bun.file(packagePath).exists()) {
|
|
866
|
-
packageJson = JSON.parse(await readFile(packagePath, "utf8")) as typeof packageJson;
|
|
867
|
-
} else {
|
|
868
|
-
const name = root.split("/").pop()?.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "") || "static-site";
|
|
869
|
-
packageJson = { name, private: true, scripts: { ...SHIP_SCRIPTS } } as typeof packageJson;
|
|
870
|
-
await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, { mode: 0o644 });
|
|
871
|
-
log.success("Created minimal package.json");
|
|
872
|
-
}
|
|
873
|
-
|
|
852
|
+
// Everything that can refuse the deployment runs here, before the plan is
|
|
853
|
+
// rendered: a bad output directory, a missing build script, uncommitted output
|
|
854
|
+
// with no build, or files that would be overwritten.
|
|
855
|
+
async function staticDeploymentInputs(): Promise<StaticSiteConfig> {
|
|
856
|
+
const scripts = ((await Bun.file(join(root, "package.json")).json().catch(() => ({}))) as { scripts?: Record<string, unknown> }).scripts;
|
|
874
857
|
let buildScript = options.buildScript;
|
|
875
|
-
if (buildScript && typeof
|
|
858
|
+
if (buildScript && typeof scripts?.[buildScript] !== "string") {
|
|
876
859
|
throw new Error(`package.json has no "${buildScript}" script.\n\nNext: add it (for example "build": "jekyll build"), then run bun ship:setup again.`);
|
|
877
860
|
}
|
|
878
|
-
if (!buildScript && typeof
|
|
861
|
+
if (!buildScript && typeof scripts?.build === "string") buildScript = "build";
|
|
879
862
|
|
|
880
863
|
let outputDir = options.outputDir;
|
|
881
864
|
if (!outputDir) {
|
|
@@ -914,25 +897,291 @@ async function generateStaticDeployment(): Promise<void> {
|
|
|
914
897
|
}
|
|
915
898
|
}
|
|
916
899
|
|
|
917
|
-
const
|
|
918
|
-
const
|
|
919
|
-
const targets = [...Object.keys(templates), ...(spa ? ["scripts/static-server.ts"] : [])];
|
|
900
|
+
const config: StaticSiteConfig = { outputDir: outputDir!, buildScript, spa };
|
|
901
|
+
const targets = [...Object.keys(staticDeploymentFileTemplates(config)), ...(spa ? ["scripts/static-server.ts"] : [])];
|
|
920
902
|
const conflicts: string[] = [];
|
|
921
903
|
for (const name of targets) if (await Bun.file(join(root, name)).exists()) conflicts.push(name);
|
|
922
904
|
if (conflicts.length > 0) {
|
|
923
905
|
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.`);
|
|
924
906
|
}
|
|
907
|
+
return config;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async function serverDeploymentInputs(): Promise<DeploymentDecision> {
|
|
911
|
+
const packageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { scripts?: Record<string, unknown> };
|
|
912
|
+
const dockerfileExists = await Bun.file(join(root, "Dockerfile")).exists();
|
|
913
|
+
if (!dockerfileExists && typeof packageJson.scripts?.start !== "string") {
|
|
914
|
+
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.");
|
|
915
|
+
}
|
|
916
|
+
return { kind: "server", dockerfileExists, hasBuildScript: typeof packageJson.scripts?.build === "string" };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async function writeDeployment(decision: DeploymentDecision): Promise<string[]> {
|
|
925
920
|
const written: string[] = [];
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
921
|
+
if (decision.kind === "static") {
|
|
922
|
+
// Script-less generators (Jekyll) have no package.json; create a minimal
|
|
923
|
+
// one so bun ship commands and an optional build script have a home.
|
|
924
|
+
const packagePath = join(root, "package.json");
|
|
925
|
+
if (!await Bun.file(packagePath).exists()) {
|
|
926
|
+
const name = root.split("/").pop()?.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "") || "static-site";
|
|
927
|
+
await writeFile(packagePath, `${JSON.stringify({ name, private: true, scripts: { ...SHIP_SCRIPTS } }, null, 2)}\n`, { mode: 0o644 });
|
|
928
|
+
written.push("package.json");
|
|
929
|
+
log.success("Created minimal package.json");
|
|
930
|
+
}
|
|
931
|
+
for (const [name, contents] of Object.entries(staticDeploymentFileTemplates(decision.config))) {
|
|
932
|
+
await writeFile(join(root, name), contents, { mode: 0o644 });
|
|
933
|
+
written.push(name);
|
|
934
|
+
}
|
|
935
|
+
if (decision.config.spa) {
|
|
936
|
+
await mkdir(join(root, "scripts"), { recursive: true });
|
|
937
|
+
await writeFile(join(root, "scripts", "static-server.ts"), staticServerSource(decision.config.outputDir), { mode: 0o644 });
|
|
938
|
+
written.push("scripts/static-server.ts");
|
|
939
|
+
}
|
|
940
|
+
} else if (decision.kind === "server") {
|
|
941
|
+
for (const [name, contents] of Object.entries(deploymentFileTemplates(decision.hasBuildScript))) {
|
|
942
|
+
if (name === "Dockerfile" && decision.dockerfileExists) continue;
|
|
943
|
+
if (await Bun.file(join(root, name)).exists()) continue;
|
|
944
|
+
await writeFile(join(root, name), contents, { mode: 0o644 });
|
|
945
|
+
written.push(name);
|
|
946
|
+
}
|
|
929
947
|
}
|
|
930
|
-
if (
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
948
|
+
if (written.length > 0) log.success(`Generated ${written.join(", ")}`);
|
|
949
|
+
if (decision.kind === "server") log.info("Verify the app binds to 0.0.0.0 and reads PORT before shipping.");
|
|
950
|
+
return written;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Plan runs defer every write to plan execution. The other modes keep the
|
|
954
|
+
// v47 behavior: generate now, offer to commit, otherwise stop for review.
|
|
955
|
+
async function prepareDeployment(): Promise<{ decision: DeploymentDecision; pending: boolean } | undefined> {
|
|
956
|
+
const decision = await decideDeployment();
|
|
957
|
+
if (decision.kind === "tracked") return { decision, pending: false };
|
|
958
|
+
if (planSetup()) return { decision, pending: decision.kind !== "untracked" };
|
|
959
|
+
if (decision.kind === "untracked") {
|
|
960
|
+
outro(`Found uncommitted ${decision.file}.\n\nNext: review it, commit and push it, then run bun ship:setup.`);
|
|
961
|
+
return undefined;
|
|
962
|
+
}
|
|
963
|
+
const written = await writeDeployment(decision);
|
|
964
|
+
if (await offerGeneratedCommit(written)) return { decision, pending: false };
|
|
965
|
+
outro(decision.kind === "server"
|
|
966
|
+
? "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."
|
|
967
|
+
: "Review generated deployment files.\n\nNext: commit and push these changes, then run bun ship:setup.");
|
|
968
|
+
return undefined;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const SHIP_SCRIPTS = {
|
|
972
|
+
ship: "bun scripts/ship.ts",
|
|
973
|
+
"ship:setup": "bun scripts/ship.ts --setup",
|
|
974
|
+
"ship:update": "bun scripts/ship.ts --update",
|
|
975
|
+
"ship:status": "bun scripts/ship.ts --status",
|
|
976
|
+
"ship:logs": "bun scripts/ship.ts --logs",
|
|
977
|
+
"ship:webhook": "bun scripts/ship.ts --webhook",
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
// After generating deployment files interactively, offer to commit and push
|
|
982
|
+
// them in the same run so setup continues without a manual rerun. Returns
|
|
983
|
+
// true when the files are committed. A project with no origin yet cannot be
|
|
984
|
+
// pushed to; setup creates the repository and pushes a few steps later.
|
|
985
|
+
async function offerGeneratedCommit(files: string[]): Promise<boolean> {
|
|
986
|
+
if (agentRun || !process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
987
|
+
const accepted = await confirm({ message: "Commit the generated files, then continue setup?", initialValue: true });
|
|
988
|
+
if (isCancel(accepted) || !accepted) return false;
|
|
989
|
+
const present: string[] = [];
|
|
990
|
+
for (const file of files) if (await Bun.file(join(root, file)).exists()) present.push(file);
|
|
991
|
+
await run(["git", "add", "--", ...present]);
|
|
992
|
+
await run(["git", "commit", "--only", "-m", "Add deployment configuration", "--", ...present], { inherit: true });
|
|
993
|
+
if ((await run(["git", "remote", "get-url", "origin"], { allowFailure: true })).exitCode !== 0) {
|
|
994
|
+
log.success("Committed deployment files");
|
|
995
|
+
return true;
|
|
934
996
|
}
|
|
935
|
-
|
|
997
|
+
await run(["git", "push"], { inherit: true, allowFailure: true });
|
|
998
|
+
log.success("Committed and pushed deployment files");
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ── Repository facts ───────────────────────────────────────────────────
|
|
1003
|
+
// Setup runs before a project necessarily has commits or a GitHub origin, so
|
|
1004
|
+
// these read the repository defensively. inferredProject below assumes both
|
|
1005
|
+
// and only runs once the plan has supplied them.
|
|
1006
|
+
|
|
1007
|
+
interface ProjectFacts {
|
|
1008
|
+
name: string;
|
|
1009
|
+
branch: string;
|
|
1010
|
+
origin?: string;
|
|
1011
|
+
committed: boolean;
|
|
1012
|
+
composeTracked: boolean;
|
|
1013
|
+
dirty: boolean;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
export function repositoryNameFromProject(packageName: unknown, directory: string): string {
|
|
1017
|
+
const candidate = typeof packageName === "string" && packageName ? packageName.replace(/^@[^/]+\//, "") : directory;
|
|
1018
|
+
return candidate.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "") || "app";
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function projectFacts(): Promise<ProjectFacts> {
|
|
1022
|
+
if ((await run(["git", "rev-parse", "--git-dir"], { allowFailure: true })).exitCode !== 0) {
|
|
1023
|
+
throw new Error("This project is not a Git repository.\n\nNext: run git init, then bun ship:setup.");
|
|
1024
|
+
}
|
|
1025
|
+
const packageJson = await Bun.file(join(root, "package.json")).json().catch(() => ({})) as { name?: unknown };
|
|
1026
|
+
const remote = await run(["git", "remote", "get-url", "origin"], { allowFailure: true });
|
|
1027
|
+
let origin: string | undefined;
|
|
1028
|
+
if (remote.exitCode === 0 && remote.stdout.trim()) {
|
|
1029
|
+
try {
|
|
1030
|
+
origin = repositoryFromRemote(remote.stdout.trim());
|
|
1031
|
+
} catch {
|
|
1032
|
+
throw new Error(`origin ${remote.stdout.trim()} is not a GitHub repository.\n\nNext: point origin at GitHub, then run bun ship:setup.`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
const branch = (await run(["git", "branch", "--show-current"], { allowFailure: true })).stdout.trim();
|
|
1036
|
+
if (!branch) throw new Error("ship requires a named Git branch");
|
|
1037
|
+
const tracked = (await run(["git", "ls-files"], { allowFailure: true })).stdout.split("\n").filter(Boolean);
|
|
1038
|
+
return {
|
|
1039
|
+
name: repositoryNameFromProject(packageJson.name, root.split("/").pop() ?? "app"),
|
|
1040
|
+
branch,
|
|
1041
|
+
origin,
|
|
1042
|
+
committed: (await run(["git", "rev-parse", "--verify", "HEAD"], { allowFailure: true })).exitCode === 0,
|
|
1043
|
+
composeTracked: composeCandidates(tracked).length > 0,
|
|
1044
|
+
dirty: Boolean((await run(["git", "status", "--porcelain"], { allowFailure: true })).stdout.trim()),
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// The domain question comes before the plan, so it cannot wait for
|
|
1049
|
+
// inferredProject (which needs a tracked Compose file and an origin).
|
|
1050
|
+
async function inferredDomain(): Promise<string | undefined> {
|
|
1051
|
+
const packageJson = await Bun.file(join(root, "package.json")).json().catch(() => ({})) as { name?: unknown };
|
|
1052
|
+
let compose = "";
|
|
1053
|
+
for (const name of ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]) {
|
|
1054
|
+
const file = Bun.file(join(root, name));
|
|
1055
|
+
if (await file.exists()) {
|
|
1056
|
+
compose = await file.text();
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return domainFromProject(packageJson.name, compose);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async function resolveDomain(current?: ClientConfig): Promise<string> {
|
|
1064
|
+
const known = setupDomain(options.domain, current?.domain, await inferredDomain());
|
|
1065
|
+
if (known) return known;
|
|
1066
|
+
if (agentRun || options.yes) throw new Error("App domain could not be inferred.\n\nAgent: ask user for the app domain, then run bun ship:setup --domain <domain>");
|
|
1067
|
+
const answer = await text({
|
|
1068
|
+
message: "App domain",
|
|
1069
|
+
placeholder: "example.com",
|
|
1070
|
+
validate: (value) => value && DOMAIN.test(value) ? undefined : "Use a lowercase public hostname",
|
|
1071
|
+
});
|
|
1072
|
+
if (isCancel(answer)) throw new Error("setup cancelled");
|
|
1073
|
+
return answer;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// A local .env holds exactly what must never reach a repository, and the next
|
|
1077
|
+
// step of this plan pushes. Templates gitignore it, but an adopted project may
|
|
1078
|
+
// not, so the pathspec keeps it out of the index no matter what git thinks.
|
|
1079
|
+
// glob magic on purpose: without it git matches the pathspec with slashes
|
|
1080
|
+
// fair game, so ":(exclude)*.env.*" also drops src/schema.env.ts and
|
|
1081
|
+
// src/config.env.json out of the commit. With it, "**/" walks directories and
|
|
1082
|
+
// "*" stops at the separator, so only real dotenv basenames match.
|
|
1083
|
+
const ENV_EXCLUDES = [":(exclude,glob)**/.env", ":(exclude,glob)**/.env.*"];
|
|
1084
|
+
const ENV_GLOBS = [":(glob)**/.env", ":(glob)**/.env.*"];
|
|
1085
|
+
// The three conventional names for the file that lists the variables and none
|
|
1086
|
+
// of their values.
|
|
1087
|
+
const ENV_EXAMPLE_NAMES = [".env.example", ".env.sample", ".env.template"];
|
|
1088
|
+
const ENV_EXAMPLE_GLOBS = ENV_EXAMPLE_NAMES.map((name) => `:(glob)**/${name}`);
|
|
1089
|
+
|
|
1090
|
+
function isEnvExample(path: string): boolean {
|
|
1091
|
+
return ENV_EXAMPLE_NAMES.some((name) => path === name || path.endsWith(`/${name}`));
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async function untrackedEnvFiles(): Promise<string[]> {
|
|
1095
|
+
const listed = await run(["git", "ls-files", "--others", "--exclude-standard", "--", ...ENV_GLOBS], { allowFailure: true });
|
|
1096
|
+
return listed.stdout.split("\n").filter(Boolean).filter((path) => !isEnvExample(path));
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// The example files document the variable names and hold none of the values,
|
|
1100
|
+
// so they ride along after the blanket exclude took them out.
|
|
1101
|
+
async function addEnvExamples(): Promise<void> {
|
|
1102
|
+
const listed = await run(["git", "ls-files", "--others", "--modified", "--exclude-standard", "--", ...ENV_EXAMPLE_GLOBS], { allowFailure: true });
|
|
1103
|
+
const examples = [...new Set(listed.stdout.split("\n").filter(Boolean))];
|
|
1104
|
+
if (examples.length > 0) await run(["git", "add", "--", ...examples], { allowFailure: true });
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// The first commit belongs to the user, so create-shibumi never makes one;
|
|
1108
|
+
// approving the plan is where the user makes it.
|
|
1109
|
+
async function commitEverything(message: string): Promise<boolean> {
|
|
1110
|
+
const secrets = await untrackedEnvFiles();
|
|
1111
|
+
await run(["git", "add", "-A", "--", ".", ...ENV_EXCLUDES]);
|
|
1112
|
+
await addEnvExamples();
|
|
1113
|
+
if ((await run(["git", "diff", "--cached", "--quiet"], { allowFailure: true })).exitCode === 0) return false;
|
|
1114
|
+
const commit = await run(["git", "commit", "-m", message], { inherit: true, allowFailure: true });
|
|
1115
|
+
if (commit.exitCode !== 0) throw new Error("git commit failed.\n\nNext: fix the git error above (identity, hooks), then run bun ship:setup.");
|
|
1116
|
+
log.success(`Committed: ${message}`);
|
|
1117
|
+
if (secrets.length > 0) {
|
|
1118
|
+
log.warn(`Left ${secrets.join(", ")} out of the commit.\nNext: add ${secrets.length === 1 ? "it" : "them"} to .gitignore, and set production values with bun ship:env set KEY=VALUE.`);
|
|
1119
|
+
}
|
|
1120
|
+
return true;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// A repository with history gets a path-scoped commit instead, so unrelated
|
|
1124
|
+
// work in progress is never swept into a deployment commit.
|
|
1125
|
+
const DEPLOYMENT_FILES = ["Dockerfile", "compose.yaml", "compose.yml", ".dockerignore", "scripts/static-server.ts", "package.json", "bun.lock"];
|
|
1126
|
+
|
|
1127
|
+
async function commitDeploymentFiles(): Promise<boolean> {
|
|
1128
|
+
const present: string[] = [];
|
|
1129
|
+
for (const file of DEPLOYMENT_FILES) if (await Bun.file(join(root, file)).exists()) present.push(file);
|
|
1130
|
+
if (present.length === 0) return false;
|
|
1131
|
+
await run(["git", "add", "--", ...present]);
|
|
1132
|
+
if ((await run(["git", "diff", "--cached", "--quiet", "--", ...present], { allowFailure: true })).exitCode === 0) return false;
|
|
1133
|
+
const commit = await run(["git", "commit", "--only", "-m", "Add deployment configuration", "--", ...present], { inherit: true, allowFailure: true });
|
|
1134
|
+
if (commit.exitCode !== 0) throw new Error("git commit failed.\n\nNext: fix the git error above (identity, hooks), then run bun ship:setup.");
|
|
1135
|
+
log.success("Committed deployment configuration");
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// No GitHub origin is not an error: setup offers to create the repository.
|
|
1140
|
+
// Private by default; --public opts out. There is no visibility question.
|
|
1141
|
+
async function createGitHubRepository(facts: ProjectFacts): Promise<string> {
|
|
1142
|
+
if (!Bun.which("gh")) {
|
|
1143
|
+
throw new Error("Creating the GitHub repository needs the GitHub CLI.\n\nNext: install gh from https://cli.github.com, or add an origin remote yourself, then run bun ship:setup.");
|
|
1144
|
+
}
|
|
1145
|
+
await ensureGitHubAuth();
|
|
1146
|
+
const created = await run([
|
|
1147
|
+
"gh", "repo", "create", facts.name, options.publicRepo ? "--public" : "--private",
|
|
1148
|
+
"--source", ".", "--remote", "origin", "--push",
|
|
1149
|
+
], { allowFailure: true });
|
|
1150
|
+
if (created.exitCode !== 0) {
|
|
1151
|
+
throw new Error(`${created.stderr.trim() || "gh repo create failed"}\n\nNext: create the repository yourself, add it as origin, then run bun ship:setup.`);
|
|
1152
|
+
}
|
|
1153
|
+
const origin = repositoryFromRemote(await git("remote", "get-url", "origin"));
|
|
1154
|
+
log.success(`Created ${options.publicRepo ? "public" : "private"} repo ${origin} and pushed ${facts.branch}`);
|
|
1155
|
+
return origin;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async function githubOwner(): Promise<string | undefined> {
|
|
1159
|
+
if (!Bun.which("gh")) return undefined;
|
|
1160
|
+
const login = await run(["gh", "api", "user", "--jq", ".login"], { allowFailure: true });
|
|
1161
|
+
return login.exitCode === 0 && login.stdout.trim() ? login.stdout.trim() : undefined;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// One rendered block instead of six questions. Every line names something
|
|
1165
|
+
// the single "Run setup?" confirm authorises.
|
|
1166
|
+
export function setupPlanLines(input: {
|
|
1167
|
+
target: string;
|
|
1168
|
+
domain: string;
|
|
1169
|
+
branch: string;
|
|
1170
|
+
newRepository?: string;
|
|
1171
|
+
visibility: "private" | "public";
|
|
1172
|
+
generate?: string;
|
|
1173
|
+
commit: boolean;
|
|
1174
|
+
trigger: "ship" | "github-push";
|
|
1175
|
+
}): string[] {
|
|
1176
|
+
return [
|
|
1177
|
+
...(input.generate ? [input.generate] : []),
|
|
1178
|
+
...(input.newRepository ? [`Create ${input.visibility} repo ${input.newRepository}, push ${input.branch}`] : []),
|
|
1179
|
+
`Connect to ${input.target}, save target for this project`,
|
|
1180
|
+
"Install or upgrade shibumi-server (sudo password once)",
|
|
1181
|
+
`Register ${input.domain}`,
|
|
1182
|
+
...(input.commit ? ["Commit and push deployment files"] : []),
|
|
1183
|
+
`Deploys run on: ${input.trigger === "github-push" ? `git push origin ${input.branch}` : "bun ship"}`,
|
|
1184
|
+
];
|
|
936
1185
|
}
|
|
937
1186
|
|
|
938
1187
|
async function inferredProject() {
|
|
@@ -1021,10 +1270,14 @@ async function requestSshTarget(configHostname?: string): Promise<string | undef
|
|
|
1021
1270
|
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>");
|
|
1022
1271
|
return suggestion;
|
|
1023
1272
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1273
|
+
// The plan states where the target is saved and that it is not committed,
|
|
1274
|
+
// so plan mode goes straight to the question.
|
|
1275
|
+
if (!planSetup()) {
|
|
1276
|
+
explain(
|
|
1277
|
+
"Local configuration",
|
|
1278
|
+
`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.`,
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1028
1281
|
const answer = await text({
|
|
1029
1282
|
message: "SSH target (user@server or alias)",
|
|
1030
1283
|
placeholder: suggestion ?? "user@example-vps.com",
|
|
@@ -1033,7 +1286,7 @@ async function requestSshTarget(configHostname?: string): Promise<string | undef
|
|
|
1033
1286
|
if (isCancel(answer)) return undefined;
|
|
1034
1287
|
const target = answer || suggestion;
|
|
1035
1288
|
if (!target) return undefined;
|
|
1036
|
-
if (!await approve(`Save ${target} locally and connect?`)) return undefined;
|
|
1289
|
+
if (!planSetup() && !await approve(`Save ${target} locally and connect?`)) return undefined;
|
|
1037
1290
|
return target;
|
|
1038
1291
|
}
|
|
1039
1292
|
|
|
@@ -1098,7 +1351,7 @@ async function ensureServer(target: string): Promise<void> {
|
|
|
1098
1351
|
version.exitCode === 0 ? `shibumi-server ${version.stdout.trim()} needs an upgrade` : "shibumi-server is not installed",
|
|
1099
1352
|
"This runs the reviewed installer on the SSH server. SSH and sudo prompts stay attached directly to your terminal.",
|
|
1100
1353
|
);
|
|
1101
|
-
if (!await
|
|
1354
|
+
if (!await approvePlanned("Install or upgrade shibumi-server now?")) throw new Error("server setup cancelled");
|
|
1102
1355
|
const result = await ssh(target, ["curl -fsSL https://shibumistack.dev/install/server | bash"], { tty: true, allowFailure: true });
|
|
1103
1356
|
if (result.exitCode !== 0) throw new Error("remote shibumi-server installation failed");
|
|
1104
1357
|
const installed = await ssh(target, [SERVER_CLI, "--version"], { allowFailure: true });
|
|
@@ -1109,19 +1362,8 @@ async function ensureServer(target: string): Promise<void> {
|
|
|
1109
1362
|
|
|
1110
1363
|
// Reuse an existing registration silently. New apps retain interactive SSH so
|
|
1111
1364
|
// server and sudo prompts stay attached to the local terminal.
|
|
1112
|
-
async function remoteSetup(target: string,
|
|
1365
|
+
async function remoteSetup(target: string, domain: string): Promise<ClientConfig> {
|
|
1113
1366
|
const project = await inferredProject();
|
|
1114
|
-
let domain = setupDomain(options.domain, current?.domain, project.domain);
|
|
1115
|
-
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>");
|
|
1116
|
-
if (!domain) {
|
|
1117
|
-
const answer = await text({
|
|
1118
|
-
message: "App domain",
|
|
1119
|
-
placeholder: "example.com",
|
|
1120
|
-
validate: (value) => value && DOMAIN.test(value) ? undefined : "Use a lowercase public hostname",
|
|
1121
|
-
});
|
|
1122
|
-
if (isCancel(answer)) throw new Error("setup cancelled");
|
|
1123
|
-
domain = answer;
|
|
1124
|
-
}
|
|
1125
1367
|
const serverHostname = await resolvedSshHostname(target);
|
|
1126
1368
|
await rememberSshTarget(serverHostname, target);
|
|
1127
1369
|
await ensureServer(target);
|
|
@@ -1136,7 +1378,7 @@ async function remoteSetup(target: string, _force: boolean, current?: ClientConf
|
|
|
1136
1378
|
"Server setup required",
|
|
1137
1379
|
`SSH target ${target}\nDomain ${domain}\nRepository github:${project.repository}\n\nSSH and sudo prompts stay attached to this terminal.`,
|
|
1138
1380
|
);
|
|
1139
|
-
if (!await
|
|
1381
|
+
if (!await approvePlanned("Continue through SSH?")) throw new Error("server setup cancelled");
|
|
1140
1382
|
const setup = await ssh(target, [
|
|
1141
1383
|
"env", "SHIBUMI_SHIP_SETUP=1", SERVER_CLI, "add", domain,
|
|
1142
1384
|
"--repository", `github:${project.repository}`,
|
|
@@ -1178,19 +1420,19 @@ async function ensureGitHubAuth(): Promise<void> {
|
|
|
1178
1420
|
if (status.exitCode === 0) return;
|
|
1179
1421
|
explain("GitHub sign-in required", "GitHub CLI stores your credentials. Shibumi never reads them.");
|
|
1180
1422
|
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.");
|
|
1181
|
-
if (!await approve("Sign in to GitHub now?")) throw new Error("Next: run gh auth login -h github.com -p https -w, then rerun
|
|
1423
|
+
if (!await approve("Sign in to GitHub now?")) throw new Error("Next: run gh auth login -h github.com -p https -w, then rerun this command.");
|
|
1182
1424
|
const login = await run(["gh", "auth", "login", "-h", "github.com", "-p", "https", "-w"], { inherit: true, allowFailure: true });
|
|
1183
1425
|
if (login.exitCode !== 0 || (await run(["gh", "auth", "status", "-h", "github.com"], { allowFailure: true })).exitCode !== 0) {
|
|
1184
|
-
throw new Error("GitHub sign-in did not complete.\n\nNext: run gh auth login -h github.com -p https -w, then rerun
|
|
1426
|
+
throw new Error("GitHub sign-in did not complete.\n\nNext: run gh auth login -h github.com -p https -w, then rerun this command.");
|
|
1185
1427
|
}
|
|
1186
1428
|
}
|
|
1187
1429
|
|
|
1188
1430
|
async function authorizeWebhookAccess(): Promise<void> {
|
|
1189
1431
|
explain("GitHub webhook access required", "GitHub CLI needs admin:repo_hook to create or repair this repository webhook.");
|
|
1190
1432
|
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.");
|
|
1191
|
-
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.");
|
|
1433
|
+
if (!await approve("Authorize webhook access now?")) throw new Error("Next: run gh auth refresh -h github.com -s admin:repo_hook, then rerun bun ship:webhook.");
|
|
1192
1434
|
const refresh = await run(["gh", "auth", "refresh", "-h", "github.com", "-s", "admin:repo_hook"], { inherit: true, allowFailure: true });
|
|
1193
|
-
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.");
|
|
1435
|
+
if (refresh.exitCode !== 0) throw new Error("GitHub webhook authorization did not complete.\n\nNext: run gh auth refresh -h github.com -s admin:repo_hook, then rerun bun ship:webhook.");
|
|
1194
1436
|
}
|
|
1195
1437
|
|
|
1196
1438
|
async function findWebhook(config: ClientConfig): Promise<GitHubWebhook | undefined> {
|
|
@@ -1201,24 +1443,26 @@ async function findWebhook(config: ClientConfig): Promise<GitHubWebhook | undefi
|
|
|
1201
1443
|
await authorizeWebhookAccess();
|
|
1202
1444
|
hooks = await run(["gh", "api", `repos/${repository}/hooks?per_page=100`], { allowFailure: true });
|
|
1203
1445
|
}
|
|
1204
|
-
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.`);
|
|
1446
|
+
if (hooks.exitCode !== 0) throw new Error(`${hooks.stderr.trim() || "GitHub CLI could not read repository webhooks"}\n\nNext: confirm repository admin access, then rerun bun ship:webhook.`);
|
|
1205
1447
|
return matchingWebhook(JSON.parse(hooks.stdout), config.webhookUrl);
|
|
1206
1448
|
}
|
|
1207
1449
|
|
|
1208
1450
|
// Fetch the secret only when GitHub needs it. It moves through process memory
|
|
1209
1451
|
// from server output to `gh` input and is never printed or written locally.
|
|
1210
|
-
|
|
1452
|
+
// Returns true when this run created the hook, which is the only case where
|
|
1453
|
+
// a later failure may take it back down.
|
|
1454
|
+
async function ensureWebhook(config: ClientConfig, target: string, assumeApproved = false): Promise<boolean> {
|
|
1211
1455
|
const existing = await findWebhook(config);
|
|
1212
1456
|
if (existing && !existing.needsRepair) {
|
|
1213
1457
|
log.success("GitHub webhook is active");
|
|
1214
|
-
return;
|
|
1458
|
+
return false;
|
|
1215
1459
|
}
|
|
1216
1460
|
const repository = config.repository.slice("github:".length);
|
|
1217
1461
|
explain(
|
|
1218
1462
|
existing ? "GitHub webhook needs repair" : "GitHub webhook is missing",
|
|
1219
1463
|
`Repository ${repository}\nPayload URL ${config.webhookUrl}\nEvents push\n\nThe secret travels from server to GitHub CLI through memory only.`,
|
|
1220
1464
|
);
|
|
1221
|
-
if (!existing && !await approve("Create webhook with GitHub CLI?")) throw new Error(`Next: review ${config.webhookUrl} at https://github.com/${repository}/settings/hooks`);
|
|
1465
|
+
if (!existing && !assumeApproved && !await approve("Create webhook with GitHub CLI?")) throw new Error(`Next: review ${config.webhookUrl} at https://github.com/${repository}/settings/hooks`);
|
|
1222
1466
|
if (existing) log.info("Refreshing webhook secret from server configuration");
|
|
1223
1467
|
const secretResult = await ssh(target, ["env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "webhook-secret", config.appId]);
|
|
1224
1468
|
const secretValue: unknown = JSON.parse(secretResult.stdout);
|
|
@@ -1235,14 +1479,14 @@ async function ensureWebhook(config: ClientConfig, target: string): Promise<void
|
|
|
1235
1479
|
await authorizeWebhookAccess();
|
|
1236
1480
|
result = await run(args, { input, allowFailure: true });
|
|
1237
1481
|
}
|
|
1238
|
-
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.`);
|
|
1482
|
+
if (result.exitCode !== 0) throw new Error(`${result.stderr.trim() || "GitHub CLI could not configure webhook"}\n\nNext: confirm repository admin access, then rerun bun ship:webhook.`);
|
|
1239
1483
|
const hookId = existing?.id ?? (JSON.parse(result.stdout) as { id?: unknown }).id;
|
|
1240
1484
|
if (typeof hookId !== "number") throw new Error("GitHub returned an invalid webhook");
|
|
1241
1485
|
if (existing && !existing.active) {
|
|
1242
1486
|
result = await run(["gh", "api", "-X", "PATCH", `repos/${repository}/hooks/${hookId}`, "--input", "-"], {
|
|
1243
1487
|
input: JSON.stringify({ active: true, events: ["push"] }), allowFailure: true,
|
|
1244
1488
|
});
|
|
1245
|
-
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:
|
|
1489
|
+
if (result.exitCode !== 0) throw new Error(`${result.stderr.trim() || "GitHub CLI could not enable webhook"}\n\nNext: confirm repository admin access, then rerun bun ship:webhook.`);
|
|
1246
1490
|
}
|
|
1247
1491
|
const ping = await run(["gh", "api", "-X", "POST", `repos/${repository}/hooks/${hookId}/pings`], { allowFailure: true });
|
|
1248
1492
|
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.`);
|
|
@@ -1251,22 +1495,22 @@ async function ensureWebhook(config: ClientConfig, target: string): Promise<void
|
|
|
1251
1495
|
const checked = await run(["gh", "api", `repos/${repository}/hooks/${hookId}`], { allowFailure: true });
|
|
1252
1496
|
if (checked.exitCode === 0 && (JSON.parse(checked.stdout) as { last_response?: { code?: unknown } }).last_response?.code === 200) {
|
|
1253
1497
|
log.success(existing ? "GitHub webhook repaired and tested" : "GitHub webhook created and tested");
|
|
1254
|
-
return;
|
|
1498
|
+
return !existing;
|
|
1255
1499
|
}
|
|
1256
1500
|
}
|
|
1257
|
-
throw new Error(`GitHub webhook is configured but not reachable yet.\n\nNext: confirm ${config.domain} DNS and TLS, then run bun ship:
|
|
1501
|
+
throw new Error(`GitHub webhook is configured but not reachable yet.\n\nNext: confirm ${config.domain} DNS and TLS, then run bun ship:webhook. For proxied Cloudflare domains, use Full (strict) SSL/TLS mode. Prefer deploying with bun ship? Run bun ship:webhook --off.\n\nGitHub: https://github.com/${repository}/settings/hooks`);
|
|
1258
1502
|
}
|
|
1259
1503
|
|
|
1260
|
-
async function disableWebhook(config: ClientConfig): Promise<void> {
|
|
1504
|
+
async function disableWebhook(config: ClientConfig, assumeApproved = false): Promise<void> {
|
|
1261
1505
|
const repository = config.repository.slice("github:".length);
|
|
1262
1506
|
const settings = `https://github.com/${repository}/settings/hooks`;
|
|
1263
1507
|
if (!Bun.which("gh") || (await run(["gh", "auth", "status", "-h", "github.com"], { allowFailure: true })).exitCode !== 0) {
|
|
1264
|
-
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:
|
|
1508
|
+
log.warn(`Direct shipping enabled. GitHub webhook cleanup skipped because GitHub CLI is not authenticated.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:webhook --off after GitHub sign-in.`);
|
|
1265
1509
|
return;
|
|
1266
1510
|
}
|
|
1267
1511
|
const hooks = await run(["gh", "api", `repos/${repository}/hooks?per_page=100`], { allowFailure: true });
|
|
1268
1512
|
if (hooks.exitCode !== 0) {
|
|
1269
|
-
log.warn(`Direct shipping enabled. GitHub webhook cleanup could not reach GitHub.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:
|
|
1513
|
+
log.warn(`Direct shipping enabled. GitHub webhook cleanup could not reach GitHub.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:webhook --off later.`);
|
|
1270
1514
|
return;
|
|
1271
1515
|
}
|
|
1272
1516
|
const existing = matchingWebhook(JSON.parse(hooks.stdout), config.webhookUrl);
|
|
@@ -1274,13 +1518,15 @@ async function disableWebhook(config: ClientConfig): Promise<void> {
|
|
|
1274
1518
|
log.success("GitHub webhook is disabled");
|
|
1275
1519
|
return;
|
|
1276
1520
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1521
|
+
if (!assumeApproved) {
|
|
1522
|
+
explain("Disable deploy-on-push", `Repository ${repository}\nPayload URL ${config.webhookUrl}\n\nGit pushes will stop changing production. Run bun ship to deploy.`);
|
|
1523
|
+
if (!await approve("Disable GitHub webhook?")) throw new Error("webhook change cancelled");
|
|
1524
|
+
}
|
|
1279
1525
|
const result = await run(["gh", "api", "-X", "PATCH", `repos/${repository}/hooks/${existing.id}`, "--input", "-"], {
|
|
1280
1526
|
input: JSON.stringify({ active: false }), allowFailure: true,
|
|
1281
1527
|
});
|
|
1282
1528
|
if (result.exitCode !== 0) {
|
|
1283
|
-
log.warn(`Direct shipping enabled. GitHub webhook cleanup failed.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:
|
|
1529
|
+
log.warn(`Direct shipping enabled. GitHub webhook cleanup failed.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:webhook --off later.`);
|
|
1284
1530
|
return;
|
|
1285
1531
|
}
|
|
1286
1532
|
log.success("GitHub webhook disabled");
|
|
@@ -1299,42 +1545,162 @@ async function setDeploymentMode(config: ClientConfig, target: string, trigger:
|
|
|
1299
1545
|
return { ...validateConfig(JSON.parse(downloaded.stdout)), trigger };
|
|
1300
1546
|
}
|
|
1301
1547
|
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1548
|
+
// Opt-in push-to-deploy. Setup never creates a webhook: with the default
|
|
1549
|
+
// `bun ship` trigger it buys nothing, and it costs a GitHub sign-in plus an
|
|
1550
|
+
// admin:repo_hook grant. This command pays that cost only when asked, and
|
|
1551
|
+
// --off reverses both halves (webhook and trigger).
|
|
1552
|
+
async function runWebhook(): Promise<void> {
|
|
1553
|
+
intro(`渋み ship webhook${options.off ? " --off" : ""}`);
|
|
1554
|
+
try {
|
|
1555
|
+
const config = await readConfig();
|
|
1556
|
+
if (!config) throw new Error("Shibumi setup is missing.\n\nNext: run bun ship:setup.");
|
|
1557
|
+
const target = await projectTarget(config);
|
|
1558
|
+
if (options.off) {
|
|
1559
|
+
// Runs whatever the recorded trigger says: a hook can outlive the
|
|
1560
|
+
// trigger that installed it, and that hook is the thing to switch off.
|
|
1561
|
+
const updated = config.trigger === "github-push"
|
|
1562
|
+
? await setDeploymentMode({ ...config, trigger: "ship" }, target, "ship")
|
|
1563
|
+
: config;
|
|
1564
|
+
await writeFile(configPath, `${JSON.stringify(updated, null, 2)}\n`);
|
|
1565
|
+
await disableWebhook(updated, true);
|
|
1566
|
+
await offerSetupCommit(updated);
|
|
1567
|
+
outro("Pushes no longer deploy. Deploys run on: bun ship");
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
if (!Bun.which("gh")) throw new Error("Push-to-deploy needs the GitHub CLI.\n\nNext: install gh from https://cli.github.com, then run bun ship:webhook.");
|
|
1571
|
+
const already = config.trigger === "github-push";
|
|
1572
|
+
explain(
|
|
1573
|
+
already ? "Push-to-deploy: repair" : "Push-to-deploy",
|
|
1574
|
+
`Every push to ${config.branch} deploys ${config.domain} automatically.\nThe webhook secret travels from server to GitHub CLI through memory only.`,
|
|
1575
|
+
);
|
|
1576
|
+
await ensureGitHubAuth();
|
|
1577
|
+
if (!await approve(already ? "Repair the webhook and keep push-to-deploy?" : "Install webhook and switch to push-to-deploy?")) {
|
|
1578
|
+
throw new Error("Next: run bun ship:webhook when you want pushes to deploy.");
|
|
1579
|
+
}
|
|
1580
|
+
// Hook first, then the trigger: if the trigger switch fails, the hook is
|
|
1581
|
+
// taken back down, so an active hook always means trigger github-push.
|
|
1582
|
+
const created = await ensureWebhook(config, target, true);
|
|
1583
|
+
let updated: ClientConfig;
|
|
1584
|
+
try {
|
|
1585
|
+
updated = await setDeploymentMode({ ...config, trigger: "github-push" }, target, "github-push");
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
// Only undo what this run did: a hook that was already there (repair
|
|
1588
|
+
// path) stays, and its project keeps deploying on push.
|
|
1589
|
+
if (created) await disableWebhook(config, true);
|
|
1590
|
+
throw error;
|
|
1591
|
+
}
|
|
1592
|
+
await writeFile(configPath, `${JSON.stringify(updated, null, 2)}\n`);
|
|
1593
|
+
await offerSetupCommit(updated);
|
|
1594
|
+
outro(`git push origin ${updated.branch} now deploys. Undo: bun ship:webhook --off`);
|
|
1595
|
+
} finally {
|
|
1596
|
+
await closeSshControl();
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
interface SetupResult {
|
|
1601
|
+
config: ClientConfig;
|
|
1602
|
+
target: string;
|
|
1603
|
+
changed: boolean;
|
|
1604
|
+
setupCommit?: SetupCommit;
|
|
1316
1605
|
}
|
|
1317
1606
|
|
|
1318
|
-
async function setup(force: boolean): Promise<
|
|
1607
|
+
async function setup(force: boolean): Promise<SetupResult | undefined> {
|
|
1319
1608
|
let config = await readConfig();
|
|
1320
|
-
|
|
1321
|
-
|
|
1609
|
+
const first = force || !config;
|
|
1610
|
+
const previous = config;
|
|
1611
|
+
// Projects set up before ship:webhook existed keep their github-push
|
|
1612
|
+
// trigger; new ones deploy on bun ship until ship:webhook says otherwise.
|
|
1613
|
+
const trigger = previous?.trigger ?? "ship";
|
|
1614
|
+
let deployment: { decision: DeploymentDecision; pending: boolean } | undefined;
|
|
1615
|
+
if (first) {
|
|
1616
|
+
deployment = await prepareDeployment();
|
|
1617
|
+
if (!deployment) return undefined;
|
|
1618
|
+
}
|
|
1322
1619
|
let target = await configuredSshTarget(config?.server.hostname);
|
|
1323
1620
|
if (!target) target = await requestSshTarget(config?.server.hostname);
|
|
1324
1621
|
if (!target) throw new Error("SSH server is required");
|
|
1325
|
-
|
|
1326
|
-
|
|
1622
|
+
if (first && deployment) {
|
|
1623
|
+
// Question two of two. Everything after this is plan, confirm, run.
|
|
1624
|
+
const domain = await resolveDomain(config);
|
|
1625
|
+
const facts = await projectFacts();
|
|
1626
|
+
if (!facts.origin && agentRun) {
|
|
1627
|
+
throw new Error(`This project has no GitHub origin.\n\nAgent: ask user whether to create a repository for ${facts.name}, then run bun ship:setup -y (add --public for a public repo).`);
|
|
1628
|
+
}
|
|
1629
|
+
const owner = facts.origin ? undefined : await githubOwner();
|
|
1630
|
+
const willCommit = !facts.committed || !facts.composeTracked || facts.dirty || deployment.pending || !previous;
|
|
1631
|
+
// Rendered in every mode, prompted in none but a plan run: even under
|
|
1632
|
+
// --yes the transcript has to say what this run is about to do.
|
|
1633
|
+
explain("Plan", setupPlanLines({
|
|
1634
|
+
target,
|
|
1635
|
+
domain,
|
|
1636
|
+
branch: facts.branch,
|
|
1637
|
+
newRepository: facts.origin ? undefined : owner ? `${owner}/${facts.name}` : facts.name,
|
|
1638
|
+
visibility: options.publicRepo ? "public" : "private",
|
|
1639
|
+
generate: deploymentPlanLine(deployment.decision),
|
|
1640
|
+
commit: willCommit,
|
|
1641
|
+
trigger,
|
|
1642
|
+
}).join("\n"));
|
|
1643
|
+
if (planSetup()) {
|
|
1644
|
+
const accepted = await confirm({ message: "Run setup?", initialValue: true });
|
|
1645
|
+
if (isCancel(accepted) || !accepted) {
|
|
1646
|
+
cancel("Setup cancelled. Nothing was changed.");
|
|
1647
|
+
return undefined;
|
|
1648
|
+
}
|
|
1649
|
+
planApproved = true;
|
|
1650
|
+
}
|
|
1651
|
+
if (deployment.pending) await writeDeployment(deployment.decision);
|
|
1652
|
+
// A repository needs a commit before it can be pushed, and registration
|
|
1653
|
+
// reads the Compose file out of the committed tree. Each approvePlanned()
|
|
1654
|
+
// here is answered by the plan confirm; only --interactive asks again.
|
|
1655
|
+
if (!facts.committed) {
|
|
1656
|
+
if (!await approvePlanned("Commit this project now?")) throw new Error("Next: commit your project, then run bun ship:setup.");
|
|
1657
|
+
await commitEverything("Initial commit");
|
|
1658
|
+
} else if (!facts.composeTracked || deployment.pending) {
|
|
1659
|
+
if (!await approvePlanned("Commit the deployment files now?")) throw new Error("Next: commit the deployment files, then run bun ship:setup.");
|
|
1660
|
+
await commitDeploymentFiles();
|
|
1661
|
+
}
|
|
1662
|
+
if (!facts.origin) {
|
|
1663
|
+
if (!await approvePlanned(`Create ${options.publicRepo ? "public" : "private"} repo ${facts.name} and push ${facts.branch}?`)) {
|
|
1664
|
+
throw new Error("Next: create the repository, add it as origin, then run bun ship:setup.");
|
|
1665
|
+
}
|
|
1666
|
+
await createGitHubRepository(facts);
|
|
1667
|
+
}
|
|
1668
|
+
config = await remoteSetup(target, domain);
|
|
1669
|
+
}
|
|
1327
1670
|
if (!config) throw new Error("deployment setup did not return client configuration");
|
|
1328
1671
|
await rememberSshTarget(config.server.hostname, target);
|
|
1329
|
-
const trigger = await selectTrigger(previous?.trigger ?? config.trigger, force);
|
|
1330
1672
|
config = await setDeploymentMode({ ...config, trigger }, target, trigger);
|
|
1673
|
+
// Persisting is ship:setup's job. A bare `bun ship` that had to run setup
|
|
1674
|
+
// leaves the commit to runShip below, exactly as it did before v48.
|
|
1675
|
+
let setupCommit: SetupCommit | undefined;
|
|
1331
1676
|
if (force) {
|
|
1332
1677
|
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1678
|
+
log.success(trigger === "ship"
|
|
1679
|
+
? "Deployments run through bun ship"
|
|
1680
|
+
: `Deployments run on every push to ${config.branch}`);
|
|
1681
|
+
// The plan said commit and push, so this run does both, and runShip is
|
|
1682
|
+
// told the outcome so it never asks the same question twice.
|
|
1683
|
+
setupCommit = await offerSetupCommit(config);
|
|
1684
|
+
if (setupCommit !== "declined") await pushSetupCommit(config.branch);
|
|
1685
|
+
}
|
|
1686
|
+
return {
|
|
1687
|
+
config,
|
|
1688
|
+
target,
|
|
1689
|
+
setupCommit,
|
|
1690
|
+
changed: !previous || JSON.stringify(previous) !== JSON.stringify(config),
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
async function pushSetupCommit(branch: string): Promise<void> {
|
|
1695
|
+
if ((await run(["git", "remote", "get-url", "origin"], { allowFailure: true })).exitCode !== 0) return;
|
|
1696
|
+
const ahead = await run(["git", "rev-list", "--count", `origin/${branch}..HEAD`], { allowFailure: true });
|
|
1697
|
+
if (ahead.exitCode === 0 && ahead.stdout.trim() === "0") return;
|
|
1698
|
+
const push = await run(["git", "push", "origin", branch], { inherit: true, allowFailure: true });
|
|
1699
|
+
if (push.exitCode !== 0) {
|
|
1700
|
+
log.warn(`Could not push ${branch}.\nNext: git push origin ${branch}, then run bun ship.`);
|
|
1701
|
+
return;
|
|
1336
1702
|
}
|
|
1337
|
-
|
|
1703
|
+
log.success(`Pushed ${branch} to origin`);
|
|
1338
1704
|
}
|
|
1339
1705
|
|
|
1340
1706
|
// Refuse ambiguous deploys: wrong origin, wrong branch, dirty work, or remote
|
|
@@ -1612,7 +1978,7 @@ async function followStatus(config: ClientConfig, target: string, commit: string
|
|
|
1612
1978
|
}
|
|
1613
1979
|
if (!lastStage && Date.now() >= webhookDeadline) {
|
|
1614
1980
|
progress.stop("Webhook did not start deployment", 1);
|
|
1615
|
-
throw new Error(`GitHub webhook did not reach shibumi-server.\n\nNext:
|
|
1981
|
+
throw new Error(`GitHub webhook did not reach shibumi-server.\n\nNext: run bun ship:webhook to repair delivery (or bun ship:webhook --off to deploy with bun ship instead).\n\nGitHub: https://github.com/${config.repository.slice("github:".length)}/settings/hooks`);
|
|
1616
1982
|
}
|
|
1617
1983
|
await Bun.sleep(2_000);
|
|
1618
1984
|
}
|
|
@@ -1818,12 +2184,15 @@ export async function runShip(): Promise<void> {
|
|
|
1818
2184
|
const forceSetup = options.setup;
|
|
1819
2185
|
const result = await setup(forceSetup);
|
|
1820
2186
|
if (!result) return;
|
|
1821
|
-
|
|
2187
|
+
// ship:setup already committed and pushed; asking again would prompt
|
|
2188
|
+
// twice and, on a decline, commit without pushing.
|
|
2189
|
+
const setupCommit = result.setupCommit ?? await offerSetupCommit(result.config);
|
|
1822
2190
|
if (setupCommit === "declined") {
|
|
1823
2191
|
outro(`${accent("Next:")} review and commit Shibumi setup files, then run bun ship.`);
|
|
1824
2192
|
return;
|
|
1825
2193
|
}
|
|
1826
|
-
|
|
2194
|
+
const firstRun = forceSetup || result.changed;
|
|
2195
|
+
if (firstRun) {
|
|
1827
2196
|
// Setup succeeded with everything committed, so the first deploy is one
|
|
1828
2197
|
// Enter away. Offer it here instead of ending on "Next: bun ship".
|
|
1829
2198
|
// Only for direct-ship triggers in an interactive run: github-push
|
|
@@ -1832,7 +2201,12 @@ export async function runShip(): Promise<void> {
|
|
|
1832
2201
|
? await confirm({ message: "Ship now?", initialValue: true })
|
|
1833
2202
|
: false;
|
|
1834
2203
|
if (shipNow !== true || isCancel(shipNow)) {
|
|
1835
|
-
|
|
2204
|
+
// A commit made just above still has to reach origin; leaving here
|
|
2205
|
+
// must not leave "commit and push" half done.
|
|
2206
|
+
if (setupCommit === "committed") await pushSetupCommit(result.config.branch);
|
|
2207
|
+
outro(result.config.trigger === "github-push"
|
|
2208
|
+
? `${accent("Next:")} git push origin ${result.config.branch} to deploy`
|
|
2209
|
+
: `${accent("Next:")} bun ship\n Prefer push-to-deploy? bun ship:webhook`);
|
|
1836
2210
|
return;
|
|
1837
2211
|
}
|
|
1838
2212
|
}
|
|
@@ -1865,7 +2239,9 @@ export async function runShip(): Promise<void> {
|
|
|
1865
2239
|
const complete = spinner();
|
|
1866
2240
|
complete.start("Finishing ship");
|
|
1867
2241
|
complete.stop(`Shipped in ${formatDuration(Date.now() - startedAt)} (--rollback if needed)`);
|
|
1868
|
-
outro(
|
|
2242
|
+
outro(firstRun && result.config.trigger === "ship"
|
|
2243
|
+
? `Live at https://${result.config.domain}\n Deploys run on: bun ship. Prefer push-to-deploy? bun ship:webhook`
|
|
2244
|
+
: `https://${result.config.domain}`);
|
|
1869
2245
|
} finally {
|
|
1870
2246
|
await closeSshControl();
|
|
1871
2247
|
}
|
|
@@ -1876,7 +2252,7 @@ export function immutableShipSource(source: string): string | undefined {
|
|
|
1876
2252
|
}
|
|
1877
2253
|
|
|
1878
2254
|
export function shouldCheckForShipUpdate(value: ShipOptions): boolean {
|
|
1879
|
-
return !(value.setup || value.update || value.rollback || value.logs || value.status || value.dev);
|
|
2255
|
+
return !(value.setup || value.update || value.rollback || value.logs || value.status || value.dev || value.webhook);
|
|
1880
2256
|
}
|
|
1881
2257
|
|
|
1882
2258
|
async function runLatestShipClient(args: string[]): Promise<boolean> {
|
|
@@ -2063,6 +2439,7 @@ export function runShipCli(): void {
|
|
|
2063
2439
|
: options.logs ? showLogs()
|
|
2064
2440
|
: options.status ? showStatus()
|
|
2065
2441
|
: options.dev ? runDev()
|
|
2442
|
+
: options.webhook ? runWebhook()
|
|
2066
2443
|
: runShip();
|
|
2067
2444
|
action.catch((error) => {
|
|
2068
2445
|
cancel(error instanceof Error ? error.message : String(error));
|