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.
- package/README.md +17 -4
- package/package.json +2 -2
- package/scripts/ship.lock.json +2 -2
- package/src/adopt.ts +250 -0
- package/src/args.ts +32 -6
- package/src/cli.ts +230 -41
- package/src/templates/blog/agents.md +4 -1
- package/src/templates/blog/bun.lock +1 -0
- package/src/templates/blog/gitignore +2 -0
- package/src/templates/blog/package.json +4 -2
- package/src/templates/blog/public/favicon.svg +1 -0
- package/src/templates/blog/public/style.css +77 -4
- package/src/templates/blog/src/components/BaseHead.astro +4 -0
- package/src/templates/blog/src/content/blog/one-vps-is-plenty.md +7 -5
- package/src/templates/blog/src/content/blog/own-your-source.md +9 -5
- package/src/templates/blog/src/content/blog/writing-for-agents.md +7 -5
- package/src/templates/blog/src/content.config.ts +3 -0
- package/src/templates/blog/src/layouts/Base.astro +12 -1
- package/src/templates/blog/src/pages/index.astro +5 -2
- package/src/templates/blog/src/pages/llms.txt.ts +1 -1
- package/src/templates/blog/src/pages/posts/[id].astro +2 -1
- package/src/templates/blog/src/pages/posts/[id].md.ts +1 -1
- package/src/templates/blog/src/pages/rss.xml.ts +1 -1
- package/src/templates/full-stack/agents.md +1 -0
- package/src/templates/full-stack/package.json +1 -0
- package/src/templates/full-stack/public/favicon.svg +1 -0
- package/src/templates/full-stack/public/style.css +5 -6
- package/src/templates/full-stack/src/app.ts +5 -3
- package/src/templates/ship.ts +551 -153
- package/src/templates/static/agents.md +1 -0
- package/src/templates/{web → static}/bun.lock +1 -21
- package/src/templates/static/gitignore +2 -0
- package/src/templates/static/package.json +6 -2
- package/src/templates/static/public/favicon.svg +1 -0
- package/src/templates/static/public/index.html +11 -3
- package/src/templates/static/public/style.css +66 -20
- package/src/templates/web/.dockerignore +0 -12
- package/src/templates/web/Dockerfile +0 -24
- package/src/templates/web/README.md +0 -10
- package/src/templates/web/agents.md +0 -54
- package/src/templates/web/compose.yaml +0 -17
- package/src/templates/web/gitignore +0 -5
- package/src/templates/web/package.json +0 -34
- package/src/templates/web/public/app.js +0 -11
- package/src/templates/web/public/style.css +0 -150
- package/src/templates/web/public/vendor/alpine-csp-3.16.2.min.js +0 -23
- package/src/templates/web/public/vendor/shibumi.css +0 -422
- package/src/templates/web/src/app.ts +0 -106
- package/src/templates/web/src/env.ts +0 -21
- package/src/templates/web/src/server.ts +0 -23
- package/src/templates/web/test/app.test.ts +0 -145
- package/src/templates/web/tsconfig.json +0 -13
package/src/templates/ship.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Project-owned client for shibumi-server.
|
|
5
5
|
*
|
|
6
|
-
* `bun ship:setup` connects this repository to one server and
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* `bun ship:setup` connects this repository to one server and registers the
|
|
7
|
+
* app. Later, `bun ship` checks local work, pushes one commit, triggers it
|
|
8
|
+
* over SSH, and follows status until the app is healthy. `bun ship:webhook`
|
|
9
|
+
* is the opt-in for push-to-deploy; `--off` reverses it.
|
|
9
10
|
*
|
|
10
11
|
* Commit this file and shibumi-server.json. SSH targets stay in machine-local
|
|
11
12
|
* Shibumi config. Webhook secrets stay on the server and pass directly to GitHub CLI.
|
|
@@ -25,7 +26,7 @@ const SERVER_HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/;
|
|
|
25
26
|
const COMMIT = /^[a-f0-9]{40}$/;
|
|
26
27
|
const SERVER_CLI = "~/.local/bin/shibumi-server";
|
|
27
28
|
const LATEST_SOURCE = "https://shibumistack.dev/ship/latest.ts";
|
|
28
|
-
const CURRENT_SOURCE = "https://shibumistack.dev/ship/
|
|
29
|
+
const CURRENT_SOURCE = "https://shibumistack.dev/ship/v48.ts";
|
|
29
30
|
let sshControlDirectory: string | undefined;
|
|
30
31
|
let sshControlTarget: string | undefined;
|
|
31
32
|
|
|
@@ -87,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 === "--
|
|
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
|
|
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.
|
|
178
|
-
if (
|
|
179
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
786
|
-
if (
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
"
|
|
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
|
|
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
|
|
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
|
|
897
|
-
const
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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 (
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1257
|
-
|
|
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:
|
|
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
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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<
|
|
1607
|
+
async function setup(force: boolean): Promise<SetupResult | undefined> {
|
|
1298
1608
|
let config = await readConfig();
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
-
|
|
1305
|
-
|
|
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
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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));
|