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.
@@ -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 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.
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/v47.ts";
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 === "--trigger" || argument === "--output-dir" || argument === "--build-script") {
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 if (argument === "--build-script") parsed.buildScript = value;
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.trigger && !parsed.setup) throw new Error("--trigger requires --setup");
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
- async function offerSetupCommit(config: ClientConfig): Promise<"none" | "committed" | "declined"> {
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 approve(updateOnly ? "Commit ship client update now?" : "Commit deployment setup now?")) return "declined";
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
- async function prepareCompose(): Promise<boolean> {
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 false;
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 existingCompose = (await Promise.all(names.map(async (name) => await Bun.file(join(root, name)).exists() ? name : undefined))).find(Boolean);
787
- if (existingCompose) {
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
- async function generateStaticDeployment(): Promise<void> {
861
- // Script-less generators (Jekyll) have no package.json; create a minimal one
862
- // so bun ship commands and an optional build script have a home.
863
- const packagePath = join(root, "package.json");
864
- let packageJson: { name?: unknown; scripts?: Record<string, unknown> };
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 packageJson.scripts?.[buildScript] !== "string") {
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 packageJson.scripts?.build === "string") buildScript = "build";
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 staticConfig: StaticSiteConfig = { outputDir: outputDir!, buildScript, spa };
918
- const templates = staticDeploymentFileTemplates(staticConfig);
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
- for (const [name, contents] of Object.entries(templates)) {
927
- await writeFile(join(root, name), contents, { mode: 0o644 });
928
- written.push(name);
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 (spa) {
931
- await mkdir(join(root, "scripts"), { recursive: true });
932
- await writeFile(join(root, "scripts", "static-server.ts"), staticServerSource(staticConfig.outputDir), { mode: 0o644 });
933
- written.push("scripts/static-server.ts");
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
- log.success(`Generated ${written.join(", ")}`);
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
- explain(
1025
- "Local configuration",
1026
- `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.`,
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 approve("Install or upgrade shibumi-server now?")) throw new Error("server setup cancelled");
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, _force: boolean, current?: ClientConfig): Promise<ClientConfig> {
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 approve("Continue through SSH?")) throw new Error("server setup cancelled");
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 bun ship.");
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 bun ship.");
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
- async function ensureWebhook(config: ClientConfig, target: string): Promise<void> {
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:setup.`);
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:setup. For proxied Cloudflare domains, use Full (strict) SSL/TLS mode.\n\nGitHub: https://github.com/${repository}/settings/hooks`);
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:setup after GitHub sign-in.`);
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:setup later.`);
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
- explain("Disable deploy-on-push", `Repository ${repository}\nPayload URL ${config.webhookUrl}\n\nGit pushes will stop changing production. Run bun ship to deploy.`);
1278
- if (!await approve("Disable GitHub webhook?")) throw new Error("webhook change cancelled");
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:setup later.`);
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
- async function selectTrigger(current: ClientConfig["trigger"], force: boolean): Promise<ClientConfig["trigger"]> {
1303
- if (options.trigger) return options.trigger;
1304
- if (!force || options.yes || agentRun) return current;
1305
- log.info(`Current deployment: ${current === "ship" ? "Run bun ship" : "Every GitHub push"}`);
1306
- const answer = await select({
1307
- message: "How do you want to deploy?",
1308
- initialValue: current,
1309
- options: [
1310
- { value: "ship", label: "Run bun ship", hint: "recommended" },
1311
- { value: "github-push", label: "Deploy every GitHub push" },
1312
- ],
1313
- });
1314
- if (isCancel(answer)) throw new Error("setup cancelled");
1315
- return answer as ClientConfig["trigger"];
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<{ config: ClientConfig; target: string; changed: boolean } | undefined> {
1607
+ async function setup(force: boolean): Promise<SetupResult | undefined> {
1319
1608
  let config = await readConfig();
1320
- if ((force || !config) && await prepareCompose()) return undefined;
1321
- if (force || !config) await inferredProject();
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
- const previous = config;
1326
- if (force || !config) config = await remoteSetup(target, force, config);
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
- if (trigger === "github-push") await ensureWebhook(config, target);
1334
- else if (previous) await disableWebhook(config);
1335
- else log.success("Deployments run through bun ship");
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
- return { config, target, changed: !previous || JSON.stringify(previous) !== JSON.stringify(config) };
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: check https://github.com/${config.repository.slice("github:".length)}/settings/hooks, then rerun bun ship after repairing delivery.`);
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
- const setupCommit = await offerSetupCommit(result.config);
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
- if (forceSetup || result.changed) {
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
- outro(`${accent("Next:")} ${result.config.trigger === "github-push" ? `git push origin ${result.config.branch} to deploy` : "bun ship"}`);
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(`https://${result.config.domain}`);
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));