create-shibumi 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +17 -4
  2. package/package.json +2 -2
  3. package/scripts/ship.lock.json +2 -2
  4. package/src/adopt.ts +250 -0
  5. package/src/args.ts +32 -6
  6. package/src/cli.ts +230 -41
  7. package/src/templates/blog/agents.md +4 -1
  8. package/src/templates/blog/bun.lock +1 -0
  9. package/src/templates/blog/gitignore +2 -0
  10. package/src/templates/blog/package.json +4 -2
  11. package/src/templates/blog/public/favicon.svg +1 -0
  12. package/src/templates/blog/public/style.css +77 -4
  13. package/src/templates/blog/src/components/BaseHead.astro +4 -0
  14. package/src/templates/blog/src/content/blog/one-vps-is-plenty.md +7 -5
  15. package/src/templates/blog/src/content/blog/own-your-source.md +9 -5
  16. package/src/templates/blog/src/content/blog/writing-for-agents.md +7 -5
  17. package/src/templates/blog/src/content.config.ts +3 -0
  18. package/src/templates/blog/src/layouts/Base.astro +12 -1
  19. package/src/templates/blog/src/pages/index.astro +5 -2
  20. package/src/templates/blog/src/pages/llms.txt.ts +1 -1
  21. package/src/templates/blog/src/pages/posts/[id].astro +2 -1
  22. package/src/templates/blog/src/pages/posts/[id].md.ts +1 -1
  23. package/src/templates/blog/src/pages/rss.xml.ts +1 -1
  24. package/src/templates/full-stack/agents.md +1 -0
  25. package/src/templates/full-stack/package.json +1 -0
  26. package/src/templates/full-stack/public/favicon.svg +1 -0
  27. package/src/templates/full-stack/public/style.css +5 -6
  28. package/src/templates/full-stack/src/app.ts +5 -3
  29. package/src/templates/ship.ts +551 -153
  30. package/src/templates/static/agents.md +1 -0
  31. package/src/templates/{web → static}/bun.lock +1 -21
  32. package/src/templates/static/gitignore +2 -0
  33. package/src/templates/static/package.json +6 -2
  34. package/src/templates/static/public/favicon.svg +1 -0
  35. package/src/templates/static/public/index.html +11 -3
  36. package/src/templates/static/public/style.css +66 -20
  37. package/src/templates/web/.dockerignore +0 -12
  38. package/src/templates/web/Dockerfile +0 -24
  39. package/src/templates/web/README.md +0 -10
  40. package/src/templates/web/agents.md +0 -54
  41. package/src/templates/web/compose.yaml +0 -17
  42. package/src/templates/web/gitignore +0 -5
  43. package/src/templates/web/package.json +0 -34
  44. package/src/templates/web/public/app.js +0 -11
  45. package/src/templates/web/public/style.css +0 -150
  46. package/src/templates/web/public/vendor/alpine-csp-3.16.2.min.js +0 -23
  47. package/src/templates/web/public/vendor/shibumi.css +0 -422
  48. package/src/templates/web/src/app.ts +0 -106
  49. package/src/templates/web/src/env.ts +0 -21
  50. package/src/templates/web/src/server.ts +0 -23
  51. package/src/templates/web/test/app.test.ts +0 -145
  52. package/src/templates/web/tsconfig.json +0 -13
@@ -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/v46.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,15 +88,19 @@ 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;
98
102
  spa: boolean;
103
+ noSpa?: boolean;
99
104
  }
100
105
 
101
106
  export interface StaticSiteConfig {
@@ -104,7 +109,7 @@ export interface StaticSiteConfig {
104
109
  spa: boolean;
105
110
  }
106
111
 
107
- 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 };
108
113
  let agentRun = false;
109
114
 
110
115
  export function isAgentExecution(env: NodeJS.ProcessEnv = process.env, stdinTTY = Boolean(process.stdin.isTTY), stdoutTTY = Boolean(process.stdout.isTTY)): boolean {
@@ -146,7 +151,7 @@ function spinner(): ShipSpinner {
146
151
  }
147
152
 
148
153
  export function parseShipArgs(args: string[]): ShipOptions {
149
- const parsed: ShipOptions = { setup: false, update: false, rollback: false, logs: false, status: false, dev: false, rebuild: false, yes: false, staticSite: false, spa: false };
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 };
150
155
  for (let index = 0; index < args.length; index += 1) {
151
156
  const argument = args[index];
152
157
  if (argument === "--") continue;
@@ -156,27 +161,34 @@ export function parseShipArgs(args: string[]): ShipOptions {
156
161
  else if (argument === "--logs") parsed.logs = true;
157
162
  else if (argument === "--status") parsed.status = true;
158
163
  else if (argument === "--dev") parsed.dev = true;
164
+ else if (argument === "--webhook") parsed.webhook = true;
165
+ else if (argument === "--off") parsed.off = true;
159
166
  else if (argument === "--rebuild") parsed.rebuild = true;
160
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;
161
170
  else if (argument === "--static") parsed.staticSite = true;
162
171
  else if (argument === "--spa") parsed.spa = true;
163
- else if (argument === "--server" || argument === "--domain" || argument === "--trigger" || argument === "--output-dir" || argument === "--build-script") {
172
+ else if (argument === "--no-spa") parsed.noSpa = true;
173
+ else if (argument === "--server" || argument === "--domain" || argument === "--output-dir" || argument === "--build-script") {
164
174
  const value = args[index + 1];
165
175
  if (!value || value.startsWith("-")) throw new Error(`${argument} requires a value`);
166
176
  if (argument === "--server") parsed.server = value;
167
177
  else if (argument === "--domain") parsed.domain = value;
168
178
  else if (argument === "--output-dir") parsed.outputDir = value;
169
- else if (argument === "--build-script") parsed.buildScript = value;
170
- else if (value === "ship" || value === "github-push") parsed.trigger = value;
171
- else throw new Error("--trigger must be ship or github-push");
179
+ else parsed.buildScript = value;
172
180
  index += 1;
173
181
  } else throw new Error(`unknown ship option: ${argument}`);
174
182
  }
175
- if ([parsed.setup, parsed.update, parsed.rollback, parsed.logs, parsed.status, parsed.dev].filter(Boolean).length > 1) throw new Error("choose only one ship action");
176
- if (parsed.rebuild && (parsed.setup || parsed.update || parsed.rollback || parsed.logs || parsed.status || parsed.dev)) throw new Error("--rebuild applies only to shipping");
177
- if (parsed.trigger && !parsed.setup) throw new Error("--trigger requires --setup");
178
- if ((parsed.staticSite || parsed.outputDir || parsed.buildScript || parsed.spa) && !parsed.setup) throw new Error("--static, --output-dir, --build-script, and --spa require --setup");
179
- if ((parsed.outputDir || parsed.buildScript || parsed.spa) && !parsed.staticSite) throw new Error("--output-dir, --build-script, and --spa require --static");
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");
189
+ if (parsed.spa && parsed.noSpa) throw new Error("--spa and --no-spa are mutually exclusive");
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");
191
+ if ((parsed.outputDir || parsed.buildScript || parsed.spa || parsed.noSpa) && !parsed.staticSite) throw new Error("--output-dir, --build-script, and --spa require --static");
180
192
  if (parsed.outputDir) {
181
193
  const problem = staticOutputDirProblem(parsed.outputDir);
182
194
  if (problem) throw new Error(problem);
@@ -293,12 +305,28 @@ async function rememberSshTarget(hostname: string, sshTarget: string): Promise<v
293
305
  log.success(`Saved server ${sshTarget} in ${path}`);
294
306
  }
295
307
 
308
+ function planSetup(): boolean {
309
+ return !options.interactive && !options.yes && !agentRun
310
+ && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
311
+ }
312
+
296
313
  async function approve(message: string): Promise<boolean> {
297
314
  if (options.yes || agentRun) return true;
298
315
  const accepted = await confirm({ message, initialValue: true });
299
316
  return !isCancel(accepted) && accepted;
300
317
  }
301
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
+
302
330
  function explain(title: string, message: string): void {
303
331
  log.info(`${title}\n${message}`);
304
332
  }
@@ -339,7 +367,9 @@ async function git(...args: string[]): Promise<string> {
339
367
 
340
368
  const setupFiles = ["package.json", "bun.lock", "scripts/ship.ts", "shibumi-server.json"];
341
369
 
342
- async function offerSetupCommit(config: ClientConfig): Promise<"none" | "committed" | "declined"> {
370
+ type SetupCommit = "none" | "committed" | "declined";
371
+
372
+ async function offerSetupCommit(config: ClientConfig): Promise<SetupCommit> {
343
373
  const changed: string[] = [];
344
374
  for (const file of setupFiles) {
345
375
  const status = await run(["git", "status", "--porcelain", "--", file]);
@@ -352,7 +382,7 @@ async function offerSetupCommit(config: ClientConfig): Promise<"none" | "committ
352
382
  return "declined";
353
383
  }
354
384
  const trackedConfig = (await run(["git", "ls-files", "--error-unmatch", "shibumi-server.json"], { allowFailure: true })).exitCode === 0;
355
- if (!await approve(updateOnly ? "Commit ship client update now?" : "Commit deployment setup now?")) return "declined";
385
+ if (!await approvePlanned(updateOnly ? "Commit ship client update now?" : "Commit deployment setup now?")) return "declined";
356
386
  await run(["git", "add", "--", ...changed]);
357
387
  await run(["git", "commit", "--only", "-m", trackedConfig ? "Update Shibumi deployment" : "Add Shibumi deployment", "--", ...changed], { inherit: true });
358
388
  log.success(updateOnly ? "Committed ship client update" : "Committed Shibumi deployment setup");
@@ -483,7 +513,6 @@ CMD ["bun", "run", "start"]
483
513
  environment:
484
514
  HOST: 0.0.0.0
485
515
  PORT: "3000"
486
- init: true
487
516
  restart: unless-stopped
488
517
  healthcheck:
489
518
  test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
@@ -657,7 +686,6 @@ ENTRYPOINT ["/busybox", "httpd", "-f", "-p", "3000", "-h", "/www", "-c", "/www/h
657
686
  - "127.0.0.1:\${SHIBUMI_PORT:-9001}:3000"
658
687
  labels:
659
688
  ${staticComposeLabels(config)}
660
- init: true
661
689
  restart: unless-stopped
662
690
  healthcheck:
663
691
  test: ${healthcheck}
@@ -773,20 +801,35 @@ async function otherWorktreeCompose(): Promise<WorktreeCompose[]> {
773
801
  return alternatives;
774
802
  }
775
803
 
776
- 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> {
777
823
  const branch = await git("branch", "--show-current");
778
824
  if (!branch) throw new Error("ship requires a named Git branch");
779
825
  const tracked = (await git("ls-files")).split("\n").filter(Boolean);
780
- if (composeCandidates(tracked).length > 0) return false;
826
+ if (composeCandidates(tracked).length > 0) return { kind: "tracked" };
781
827
  const alternatives = await otherWorktreeCompose();
782
828
  if (alternatives.length > 0) throw new Error(missingComposeMessage(branch, alternatives));
783
829
 
784
830
  const names = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"];
785
- const existingCompose = (await Promise.all(names.map(async (name) => await Bun.file(join(root, name)).exists() ? name : undefined))).find(Boolean);
786
- if (existingCompose) {
787
- outro(`Found uncommitted ${existingCompose}.\n\nNext: review it, commit and push it, then run bun ship:setup.`);
788
- return true;
789
- }
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 };
790
833
 
791
834
  if (agentRun && !options.yes) {
792
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).");
@@ -803,58 +846,19 @@ async function prepareCompose(): Promise<boolean> {
803
846
  if (isCancel(kind)) throw new Error(missingComposeMessage(branch, []));
804
847
  wantStatic = kind === "static";
805
848
  }
806
-
807
- if (wantStatic) {
808
- await generateStaticDeployment();
809
- outro("Review generated deployment files.\n\nNext: commit and push these changes, then run bun ship:setup.");
810
- return true;
811
- }
812
-
813
- const packageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { scripts?: Record<string, unknown> };
814
- const dockerfileExists = await Bun.file(join(root, "Dockerfile")).exists();
815
- if (!dockerfileExists && typeof packageJson.scripts?.start !== "string") {
816
- throw new Error("Dockerfile generation requires a package.json start script.\n\nNext: add a start script that binds to 0.0.0.0 and reads PORT, then run bun ship:setup.");
817
- }
818
- const templates = deploymentFileTemplates(typeof packageJson.scripts?.build === "string");
819
- const written: string[] = [];
820
- for (const [name, contents] of Object.entries(templates)) {
821
- if (name === "Dockerfile" && dockerfileExists) continue;
822
- if (await Bun.file(join(root, name)).exists()) continue;
823
- await writeFile(join(root, name), contents, { mode: 0o644 });
824
- written.push(name);
825
- }
826
- log.success(`Generated ${written.join(", ")}`);
827
- outro("Review generated deployment files and verify app binds to 0.0.0.0 and reads PORT.\n\nNext: commit and push these changes, then run bun ship:setup.");
828
- return true;
849
+ return wantStatic ? { kind: "static", config: await staticDeploymentInputs() } : await serverDeploymentInputs();
829
850
  }
830
851
 
831
- const SHIP_SCRIPTS = {
832
- ship: "bun scripts/ship.ts",
833
- "ship:setup": "bun scripts/ship.ts --setup",
834
- "ship:update": "bun scripts/ship.ts --update",
835
- "ship:status": "bun scripts/ship.ts --status",
836
- "ship:logs": "bun scripts/ship.ts --logs",
837
- };
838
-
839
- async function generateStaticDeployment(): Promise<void> {
840
- // Script-less generators (Jekyll) have no package.json; create a minimal one
841
- // so bun ship commands and an optional build script have a home.
842
- const packagePath = join(root, "package.json");
843
- let packageJson: { name?: unknown; scripts?: Record<string, unknown> };
844
- if (await Bun.file(packagePath).exists()) {
845
- packageJson = JSON.parse(await readFile(packagePath, "utf8")) as typeof packageJson;
846
- } else {
847
- const name = root.split("/").pop()?.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "") || "static-site";
848
- packageJson = { name, private: true, scripts: { ...SHIP_SCRIPTS } } as typeof packageJson;
849
- await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, { mode: 0o644 });
850
- log.success("Created minimal package.json");
851
- }
852
-
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;
853
857
  let buildScript = options.buildScript;
854
- if (buildScript && typeof packageJson.scripts?.[buildScript] !== "string") {
858
+ if (buildScript && typeof scripts?.[buildScript] !== "string") {
855
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.`);
856
860
  }
857
- if (!buildScript && typeof packageJson.scripts?.build === "string") buildScript = "build";
861
+ if (!buildScript && typeof scripts?.build === "string") buildScript = "build";
858
862
 
859
863
  let outputDir = options.outputDir;
860
864
  if (!outputDir) {
@@ -880,7 +884,7 @@ async function generateStaticDeployment(): Promise<void> {
880
884
  if (problem) throw new Error(problem);
881
885
 
882
886
  let spa = options.spa;
883
- if (!spa && !agentRun && !options.yes) {
887
+ if (!spa && !options.noSpa && !agentRun && !options.yes) {
884
888
  const answer = await confirm({ message: "Single-page app? (unknown paths serve index.html)", initialValue: false });
885
889
  if (isCancel(answer)) throw new Error("setup cancelled");
886
890
  spa = answer;
@@ -893,25 +897,291 @@ async function generateStaticDeployment(): Promise<void> {
893
897
  }
894
898
  }
895
899
 
896
- const staticConfig: StaticSiteConfig = { outputDir: outputDir!, buildScript, spa };
897
- const templates = staticDeploymentFileTemplates(staticConfig);
898
- 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"] : [])];
899
902
  const conflicts: string[] = [];
900
903
  for (const name of targets) if (await Bun.file(join(root, name)).exists()) conflicts.push(name);
901
904
  if (conflicts.length > 0) {
902
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.`);
903
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[]> {
904
920
  const written: string[] = [];
905
- for (const [name, contents] of Object.entries(templates)) {
906
- await writeFile(join(root, name), contents, { mode: 0o644 });
907
- written.push(name);
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
+ }
908
947
  }
909
- if (spa) {
910
- await mkdir(join(root, "scripts"), { recursive: true });
911
- await writeFile(join(root, "scripts", "static-server.ts"), staticServerSource(staticConfig.outputDir), { mode: 0o644 });
912
- written.push("scripts/static-server.ts");
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;
913
962
  }
914
- log.success(`Generated ${written.join(", ")}`);
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;
996
+ }
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
+ ];
915
1185
  }
916
1186
 
917
1187
  async function inferredProject() {
@@ -1000,10 +1270,14 @@ async function requestSshTarget(configHostname?: string): Promise<string | undef
1000
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>");
1001
1271
  return suggestion;
1002
1272
  }
1003
- explain(
1004
- "Local configuration",
1005
- `Use the same user@server target or SSH alias you use in your terminal.\nIt will be saved in ${clientSettingsPath()} on this computer and will not be committed.\nResolved server hostname, app domain, and deploy settings go in committed shibumi-server.json.`,
1006
- );
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
+ }
1007
1281
  const answer = await text({
1008
1282
  message: "SSH target (user@server or alias)",
1009
1283
  placeholder: suggestion ?? "user@example-vps.com",
@@ -1012,7 +1286,7 @@ async function requestSshTarget(configHostname?: string): Promise<string | undef
1012
1286
  if (isCancel(answer)) return undefined;
1013
1287
  const target = answer || suggestion;
1014
1288
  if (!target) return undefined;
1015
- if (!await approve(`Save ${target} locally and connect?`)) return undefined;
1289
+ if (!planSetup() && !await approve(`Save ${target} locally and connect?`)) return undefined;
1016
1290
  return target;
1017
1291
  }
1018
1292
 
@@ -1077,7 +1351,7 @@ async function ensureServer(target: string): Promise<void> {
1077
1351
  version.exitCode === 0 ? `shibumi-server ${version.stdout.trim()} needs an upgrade` : "shibumi-server is not installed",
1078
1352
  "This runs the reviewed installer on the SSH server. SSH and sudo prompts stay attached directly to your terminal.",
1079
1353
  );
1080
- 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");
1081
1355
  const result = await ssh(target, ["curl -fsSL https://shibumistack.dev/install/server | bash"], { tty: true, allowFailure: true });
1082
1356
  if (result.exitCode !== 0) throw new Error("remote shibumi-server installation failed");
1083
1357
  const installed = await ssh(target, [SERVER_CLI, "--version"], { allowFailure: true });
@@ -1088,19 +1362,8 @@ async function ensureServer(target: string): Promise<void> {
1088
1362
 
1089
1363
  // Reuse an existing registration silently. New apps retain interactive SSH so
1090
1364
  // server and sudo prompts stay attached to the local terminal.
1091
- async function remoteSetup(target: string, _force: boolean, current?: ClientConfig): Promise<ClientConfig> {
1365
+ async function remoteSetup(target: string, domain: string): Promise<ClientConfig> {
1092
1366
  const project = await inferredProject();
1093
- let domain = setupDomain(options.domain, current?.domain, project.domain);
1094
- if (!domain && (agentRun || options.yes)) throw new Error("App domain could not be inferred.\n\nAgent: ask user for the app domain, then run bun ship:setup --domain <domain>");
1095
- if (!domain) {
1096
- const answer = await text({
1097
- message: "App domain",
1098
- placeholder: "example.com",
1099
- validate: (value) => value && DOMAIN.test(value) ? undefined : "Use a lowercase public hostname",
1100
- });
1101
- if (isCancel(answer)) throw new Error("setup cancelled");
1102
- domain = answer;
1103
- }
1104
1367
  const serverHostname = await resolvedSshHostname(target);
1105
1368
  await rememberSshTarget(serverHostname, target);
1106
1369
  await ensureServer(target);
@@ -1115,7 +1378,7 @@ async function remoteSetup(target: string, _force: boolean, current?: ClientConf
1115
1378
  "Server setup required",
1116
1379
  `SSH target ${target}\nDomain ${domain}\nRepository github:${project.repository}\n\nSSH and sudo prompts stay attached to this terminal.`,
1117
1380
  );
1118
- 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");
1119
1382
  const setup = await ssh(target, [
1120
1383
  "env", "SHIBUMI_SHIP_SETUP=1", SERVER_CLI, "add", domain,
1121
1384
  "--repository", `github:${project.repository}`,
@@ -1157,19 +1420,19 @@ async function ensureGitHubAuth(): Promise<void> {
1157
1420
  if (status.exitCode === 0) return;
1158
1421
  explain("GitHub sign-in required", "GitHub CLI stores your credentials. Shibumi never reads them.");
1159
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.");
1160
- if (!await approve("Sign in to GitHub now?")) throw new Error("Next: run gh auth login -h github.com -p https -w, then rerun bun ship.");
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.");
1161
1424
  const login = await run(["gh", "auth", "login", "-h", "github.com", "-p", "https", "-w"], { inherit: true, allowFailure: true });
1162
1425
  if (login.exitCode !== 0 || (await run(["gh", "auth", "status", "-h", "github.com"], { allowFailure: true })).exitCode !== 0) {
1163
- throw new Error("GitHub sign-in did not complete.\n\nNext: run gh auth login -h github.com -p https -w, then rerun bun ship.");
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.");
1164
1427
  }
1165
1428
  }
1166
1429
 
1167
1430
  async function authorizeWebhookAccess(): Promise<void> {
1168
1431
  explain("GitHub webhook access required", "GitHub CLI needs admin:repo_hook to create or repair this repository webhook.");
1169
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.");
1170
- if (!await approve("Authorize webhook access now?")) throw new Error("Next: run gh auth refresh -h github.com -s admin:repo_hook, then rerun bun ship.");
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.");
1171
1434
  const refresh = await run(["gh", "auth", "refresh", "-h", "github.com", "-s", "admin:repo_hook"], { inherit: true, allowFailure: true });
1172
- if (refresh.exitCode !== 0) throw new Error("GitHub webhook authorization did not complete.\n\nNext: run gh auth refresh -h github.com -s admin:repo_hook, then rerun bun ship.");
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.");
1173
1436
  }
1174
1437
 
1175
1438
  async function findWebhook(config: ClientConfig): Promise<GitHubWebhook | undefined> {
@@ -1180,24 +1443,26 @@ async function findWebhook(config: ClientConfig): Promise<GitHubWebhook | undefi
1180
1443
  await authorizeWebhookAccess();
1181
1444
  hooks = await run(["gh", "api", `repos/${repository}/hooks?per_page=100`], { allowFailure: true });
1182
1445
  }
1183
- if (hooks.exitCode !== 0) throw new Error(`${hooks.stderr.trim() || "GitHub CLI could not read repository webhooks"}\n\nNext: confirm repository admin access, then rerun bun ship.`);
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.`);
1184
1447
  return matchingWebhook(JSON.parse(hooks.stdout), config.webhookUrl);
1185
1448
  }
1186
1449
 
1187
1450
  // Fetch the secret only when GitHub needs it. It moves through process memory
1188
1451
  // from server output to `gh` input and is never printed or written locally.
1189
- 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> {
1190
1455
  const existing = await findWebhook(config);
1191
1456
  if (existing && !existing.needsRepair) {
1192
1457
  log.success("GitHub webhook is active");
1193
- return;
1458
+ return false;
1194
1459
  }
1195
1460
  const repository = config.repository.slice("github:".length);
1196
1461
  explain(
1197
1462
  existing ? "GitHub webhook needs repair" : "GitHub webhook is missing",
1198
1463
  `Repository ${repository}\nPayload URL ${config.webhookUrl}\nEvents push\n\nThe secret travels from server to GitHub CLI through memory only.`,
1199
1464
  );
1200
- 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`);
1201
1466
  if (existing) log.info("Refreshing webhook secret from server configuration");
1202
1467
  const secretResult = await ssh(target, ["env", "SHIBUMI_SKIP_UPDATE_CHECK=1", SERVER_CLI, "webhook-secret", config.appId]);
1203
1468
  const secretValue: unknown = JSON.parse(secretResult.stdout);
@@ -1214,14 +1479,14 @@ async function ensureWebhook(config: ClientConfig, target: string): Promise<void
1214
1479
  await authorizeWebhookAccess();
1215
1480
  result = await run(args, { input, allowFailure: true });
1216
1481
  }
1217
- if (result.exitCode !== 0) throw new Error(`${result.stderr.trim() || "GitHub CLI could not configure webhook"}\n\nNext: confirm repository admin access, then rerun bun ship.`);
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.`);
1218
1483
  const hookId = existing?.id ?? (JSON.parse(result.stdout) as { id?: unknown }).id;
1219
1484
  if (typeof hookId !== "number") throw new Error("GitHub returned an invalid webhook");
1220
1485
  if (existing && !existing.active) {
1221
1486
  result = await run(["gh", "api", "-X", "PATCH", `repos/${repository}/hooks/${hookId}`, "--input", "-"], {
1222
1487
  input: JSON.stringify({ active: true, events: ["push"] }), allowFailure: true,
1223
1488
  });
1224
- if (result.exitCode !== 0) throw new Error(`${result.stderr.trim() || "GitHub CLI could not enable webhook"}\n\nNext: confirm repository admin access, then rerun bun ship:setup.`);
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.`);
1225
1490
  }
1226
1491
  const ping = await run(["gh", "api", "-X", "POST", `repos/${repository}/hooks/${hookId}/pings`], { allowFailure: true });
1227
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.`);
@@ -1230,22 +1495,22 @@ async function ensureWebhook(config: ClientConfig, target: string): Promise<void
1230
1495
  const checked = await run(["gh", "api", `repos/${repository}/hooks/${hookId}`], { allowFailure: true });
1231
1496
  if (checked.exitCode === 0 && (JSON.parse(checked.stdout) as { last_response?: { code?: unknown } }).last_response?.code === 200) {
1232
1497
  log.success(existing ? "GitHub webhook repaired and tested" : "GitHub webhook created and tested");
1233
- return;
1498
+ return !existing;
1234
1499
  }
1235
1500
  }
1236
- throw new Error(`GitHub webhook is configured but not reachable yet.\n\nNext: confirm ${config.domain} DNS and TLS, then run bun ship:setup. For proxied Cloudflare domains, use Full (strict) SSL/TLS mode.\n\nGitHub: https://github.com/${repository}/settings/hooks`);
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`);
1237
1502
  }
1238
1503
 
1239
- async function disableWebhook(config: ClientConfig): Promise<void> {
1504
+ async function disableWebhook(config: ClientConfig, assumeApproved = false): Promise<void> {
1240
1505
  const repository = config.repository.slice("github:".length);
1241
1506
  const settings = `https://github.com/${repository}/settings/hooks`;
1242
1507
  if (!Bun.which("gh") || (await run(["gh", "auth", "status", "-h", "github.com"], { allowFailure: true })).exitCode !== 0) {
1243
- log.warn(`Direct shipping enabled. GitHub webhook cleanup skipped because GitHub CLI is not authenticated.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:setup after GitHub sign-in.`);
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.`);
1244
1509
  return;
1245
1510
  }
1246
1511
  const hooks = await run(["gh", "api", `repos/${repository}/hooks?per_page=100`], { allowFailure: true });
1247
1512
  if (hooks.exitCode !== 0) {
1248
- log.warn(`Direct shipping enabled. GitHub webhook cleanup could not reach GitHub.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:setup later.`);
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.`);
1249
1514
  return;
1250
1515
  }
1251
1516
  const existing = matchingWebhook(JSON.parse(hooks.stdout), config.webhookUrl);
@@ -1253,13 +1518,15 @@ async function disableWebhook(config: ClientConfig): Promise<void> {
1253
1518
  log.success("GitHub webhook is disabled");
1254
1519
  return;
1255
1520
  }
1256
- explain("Disable deploy-on-push", `Repository ${repository}\nPayload URL ${config.webhookUrl}\n\nGit pushes will stop changing production. Run bun ship to deploy.`);
1257
- if (!await approve("Disable GitHub webhook?")) throw new Error("webhook change cancelled");
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
+ }
1258
1525
  const result = await run(["gh", "api", "-X", "PATCH", `repos/${repository}/hooks/${existing.id}`, "--input", "-"], {
1259
1526
  input: JSON.stringify({ active: false }), allowFailure: true,
1260
1527
  });
1261
1528
  if (result.exitCode !== 0) {
1262
- log.warn(`Direct shipping enabled. GitHub webhook cleanup failed.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:setup later.`);
1529
+ log.warn(`Direct shipping enabled. GitHub webhook cleanup failed.\nNext: disable ${config.webhookUrl} at ${settings}, or rerun bun ship:webhook --off later.`);
1263
1530
  return;
1264
1531
  }
1265
1532
  log.success("GitHub webhook disabled");
@@ -1278,42 +1545,162 @@ async function setDeploymentMode(config: ClientConfig, target: string, trigger:
1278
1545
  return { ...validateConfig(JSON.parse(downloaded.stdout)), trigger };
1279
1546
  }
1280
1547
 
1281
- async function selectTrigger(current: ClientConfig["trigger"], force: boolean): Promise<ClientConfig["trigger"]> {
1282
- if (options.trigger) return options.trigger;
1283
- if (!force || options.yes || agentRun) return current;
1284
- log.info(`Current deployment: ${current === "ship" ? "Run bun ship" : "Every GitHub push"}`);
1285
- const answer = await select({
1286
- message: "How do you want to deploy?",
1287
- initialValue: current,
1288
- options: [
1289
- { value: "ship", label: "Run bun ship", hint: "recommended" },
1290
- { value: "github-push", label: "Deploy every GitHub push" },
1291
- ],
1292
- });
1293
- if (isCancel(answer)) throw new Error("setup cancelled");
1294
- return answer as ClientConfig["trigger"];
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;
1295
1605
  }
1296
1606
 
1297
- async function setup(force: boolean): Promise<{ config: ClientConfig; target: string; changed: boolean } | undefined> {
1607
+ async function setup(force: boolean): Promise<SetupResult | undefined> {
1298
1608
  let config = await readConfig();
1299
- if ((force || !config) && await prepareCompose()) return undefined;
1300
- 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
+ }
1301
1619
  let target = await configuredSshTarget(config?.server.hostname);
1302
1620
  if (!target) target = await requestSshTarget(config?.server.hostname);
1303
1621
  if (!target) throw new Error("SSH server is required");
1304
- const previous = config;
1305
- 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
+ }
1306
1670
  if (!config) throw new Error("deployment setup did not return client configuration");
1307
1671
  await rememberSshTarget(config.server.hostname, target);
1308
- const trigger = await selectTrigger(previous?.trigger ?? config.trigger, force);
1309
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;
1310
1676
  if (force) {
1311
1677
  await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
1312
- if (trigger === "github-push") await ensureWebhook(config, target);
1313
- else if (previous) await disableWebhook(config);
1314
- else log.success("Deployments run through bun ship");
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;
1315
1702
  }
1316
- return { config, target, changed: !previous || JSON.stringify(previous) !== JSON.stringify(config) };
1703
+ log.success(`Pushed ${branch} to origin`);
1317
1704
  }
1318
1705
 
1319
1706
  // Refuse ambiguous deploys: wrong origin, wrong branch, dirty work, or remote
@@ -1591,7 +1978,7 @@ async function followStatus(config: ClientConfig, target: string, commit: string
1591
1978
  }
1592
1979
  if (!lastStage && Date.now() >= webhookDeadline) {
1593
1980
  progress.stop("Webhook did not start deployment", 1);
1594
- throw new Error(`GitHub webhook did not reach shibumi-server.\n\nNext: check https://github.com/${config.repository.slice("github:".length)}/settings/hooks, then rerun bun ship after repairing delivery.`);
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`);
1595
1982
  }
1596
1983
  await Bun.sleep(2_000);
1597
1984
  }
@@ -1797,12 +2184,15 @@ export async function runShip(): Promise<void> {
1797
2184
  const forceSetup = options.setup;
1798
2185
  const result = await setup(forceSetup);
1799
2186
  if (!result) return;
1800
- 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);
1801
2190
  if (setupCommit === "declined") {
1802
2191
  outro(`${accent("Next:")} review and commit Shibumi setup files, then run bun ship.`);
1803
2192
  return;
1804
2193
  }
1805
- if (forceSetup || result.changed) {
2194
+ const firstRun = forceSetup || result.changed;
2195
+ if (firstRun) {
1806
2196
  // Setup succeeded with everything committed, so the first deploy is one
1807
2197
  // Enter away. Offer it here instead of ending on "Next: bun ship".
1808
2198
  // Only for direct-ship triggers in an interactive run: github-push
@@ -1811,7 +2201,12 @@ export async function runShip(): Promise<void> {
1811
2201
  ? await confirm({ message: "Ship now?", initialValue: true })
1812
2202
  : false;
1813
2203
  if (shipNow !== true || isCancel(shipNow)) {
1814
- 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`);
1815
2210
  return;
1816
2211
  }
1817
2212
  }
@@ -1844,7 +2239,9 @@ export async function runShip(): Promise<void> {
1844
2239
  const complete = spinner();
1845
2240
  complete.start("Finishing ship");
1846
2241
  complete.stop(`Shipped in ${formatDuration(Date.now() - startedAt)} (--rollback if needed)`);
1847
- 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}`);
1848
2245
  } finally {
1849
2246
  await closeSshControl();
1850
2247
  }
@@ -1855,7 +2252,7 @@ export function immutableShipSource(source: string): string | undefined {
1855
2252
  }
1856
2253
 
1857
2254
  export function shouldCheckForShipUpdate(value: ShipOptions): boolean {
1858
- 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);
1859
2256
  }
1860
2257
 
1861
2258
  async function runLatestShipClient(args: string[]): Promise<boolean> {
@@ -2042,6 +2439,7 @@ export function runShipCli(): void {
2042
2439
  : options.logs ? showLogs()
2043
2440
  : options.status ? showStatus()
2044
2441
  : options.dev ? runDev()
2442
+ : options.webhook ? runWebhook()
2045
2443
  : runShip();
2046
2444
  action.catch((error) => {
2047
2445
  cancel(error instanceof Error ? error.message : String(error));