create-shibumi 0.2.9 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -1,9 +1,23 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { cancel, confirm, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
4
- import { existsSync, readFileSync } from "fs";
4
+ import { existsSync, readFileSync, readdirSync } from "fs";
5
5
  import { join } from "path";
6
- import { HELP_TEXT, bunVersionProblem, parseArgs, validateName, type TemplateId } from "./args";
6
+ import {
7
+ AdoptError,
8
+ adoptProject,
9
+ dependencyInstall,
10
+ detectBuildOutput,
11
+ loadShipStatic,
12
+ } from "./adopt";
13
+ import {
14
+ HELP_TEXT,
15
+ bunVersionProblem,
16
+ parseArgs,
17
+ validateName,
18
+ type ParsedArgs,
19
+ type TemplateId,
20
+ } from "./args";
7
21
  import { CreateError, createProject } from "./create";
8
22
 
9
23
  const VERSION = (
@@ -30,13 +44,216 @@ async function printInstaller(): Promise<never> {
30
44
  const bytes = await Bun.file(installer).arrayBuffer();
31
45
  const digest = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
32
46
  if (digest !== lock.sha256) {
33
- process.stderr.write("Vendored installer does not match its checksum lock; reinstall create-shibumi.\n");
47
+ process.stderr.write("Packaged installer does not match its checksum lock; reinstall create-shibumi.\n");
34
48
  process.exit(1);
35
49
  }
36
50
  process.stdout.write(readFileSync(installer, "utf8"));
37
51
  process.exit(0);
38
52
  }
39
53
 
54
+ const dim = (value: string) => `\x1b[2m${value}\x1b[22m`;
55
+ const accent = (value: string) => `\x1b[38;5;208m${value}\x1b[0m`;
56
+
57
+ // Offer the first deployment in the same run. Both entry paths end here, so
58
+ // "Deploy to a VPS now?" means the same thing whether the project was just
59
+ // scaffolded or just adopted.
60
+ async function runShipSetup(dest: string, label: string): Promise<void> {
61
+ const proc = Bun.spawn(["bun", "run", "ship:setup"], {
62
+ cwd: dest,
63
+ stdin: "inherit",
64
+ stdout: "inherit",
65
+ stderr: "inherit",
66
+ });
67
+ const code = await proc.exited;
68
+ if (code !== 0) {
69
+ process.stderr.write(
70
+ `ship:setup did not finish. Your project is intact; run "bun ship:setup" ${label} to retry.\n`
71
+ );
72
+ process.exit(1);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * `bun create shibumi .`: vendor the Ship client into the project that is
78
+ * already here instead of scaffolding a new one. Nothing that exists is
79
+ * overwritten or reinterpreted, and git is never touched.
80
+ */
81
+ async function adoptExisting(args: ParsedArgs): Promise<void> {
82
+ const root = process.cwd();
83
+ const entries = readdirSync(root, { withFileTypes: true }).filter((entry) => entry.name !== ".git");
84
+ if (entries.length === 0) {
85
+ process.stderr.write(
86
+ `Nothing to adopt: this directory is empty.\nRun bun create shibumi <name> to scaffold a new project.\n`
87
+ );
88
+ process.exit(2);
89
+ }
90
+
91
+ const packagePath = join(root, "package.json");
92
+ const pkg = (existsSync(packagePath)
93
+ ? JSON.parse(readFileSync(packagePath, "utf8"))
94
+ : {}) as {
95
+ scripts?: Record<string, string>;
96
+ dependencies?: Record<string, string>;
97
+ devDependencies?: Record<string, string>;
98
+ };
99
+ // Adopt generates the static image and only that. A start script means
100
+ // something runs in the container, which is ship:setup's job: it asks
101
+ // server-or-static and writes the matching files.
102
+ if (pkg.scripts?.start) {
103
+ process.stderr.write(
104
+ [
105
+ `This looks like a server app (package.json has a start script).`,
106
+ `Next: run "bun ship:setup" here; it generates server deployment files.`,
107
+ `Shipping a static build from a project with a start script? Run "bun ship:setup --static --output-dir <dir>".`,
108
+ "",
109
+ ].join("\n")
110
+ );
111
+ process.exit(2);
112
+ }
113
+ const detected = detectBuildOutput({
114
+ dependencies: { ...pkg.dependencies, ...pkg.devDependencies },
115
+ files: entries.filter((entry) => entry.isFile()).map((entry) => entry.name),
116
+ directories: entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
117
+ });
118
+ // A flat site at the project root has no directory to serve, and the image
119
+ // never packages the whole checkout.
120
+ if (!detected && entries.some((entry) => entry.isFile() && entry.name === "index.html")) {
121
+ process.stderr.write(
122
+ [
123
+ `A static image serves one directory, and index.html is at the project root.`,
124
+ `Next: move the site into public/ (mkdir public && git mv index.html public/), then run bun create shibumi . again.`,
125
+ "",
126
+ ].join("\n")
127
+ );
128
+ process.exit(2);
129
+ }
130
+ log.info(
131
+ detected
132
+ ? `Existing project found (${detected.framework} detected)`
133
+ : "Existing project found"
134
+ );
135
+
136
+ const interactive = !args.yes;
137
+ if (interactive) {
138
+ const proceed = await confirm({
139
+ message: "Add deploy tooling to this project?",
140
+ active: "Yes",
141
+ inactive: "No",
142
+ initialValue: true,
143
+ });
144
+ if (isCancel(proceed) || !proceed) {
145
+ cancel("Cancelled. Nothing was changed.");
146
+ process.exit(130);
147
+ }
148
+ }
149
+
150
+ const ship = await loadShipStatic();
151
+ let outputDir = detected?.outputDir;
152
+ if (interactive) {
153
+ const elsewhere = "";
154
+ if (detected) {
155
+ const choice = await select({
156
+ message: "Built site directory?",
157
+ options: [
158
+ { value: detected.outputDir, label: `${detected.outputDir}/ ${dim("(detected)")}` },
159
+ { value: elsewhere, label: "Somewhere else" },
160
+ ],
161
+ });
162
+ if (isCancel(choice)) exitCancelled();
163
+ outputDir = choice === elsewhere ? undefined : choice;
164
+ }
165
+ if (!outputDir) {
166
+ const answer = await text({
167
+ message: "Built site directory?",
168
+ placeholder: detected?.outputDir ?? "dist",
169
+ validate: (value) =>
170
+ value ? ship.staticOutputDirProblem(value) : "Enter the directory your build writes",
171
+ });
172
+ if (isCancel(answer)) exitCancelled();
173
+ outputDir = answer;
174
+ }
175
+ }
176
+ if (!outputDir) {
177
+ process.stderr.write(
178
+ `Could not detect the built site directory.\nRun bun create shibumi . in a terminal to choose one.\n`
179
+ );
180
+ process.exit(1);
181
+ }
182
+
183
+ let result;
184
+ try {
185
+ result = await adoptProject({
186
+ root,
187
+ outputDir,
188
+ buildScript: typeof pkg.scripts?.build === "string" ? "build" : undefined,
189
+ spa: args.spa,
190
+ ship,
191
+ });
192
+ } catch (err) {
193
+ if (err instanceof AdoptError) {
194
+ process.stderr.write(`${err.message}\n`);
195
+ process.exit(err.exitCode);
196
+ }
197
+ throw err;
198
+ }
199
+
200
+ log.success(`Wrote ${result.written.join(", ")}`);
201
+ if (result.scripts.length > 0) log.success(`Added scripts: ${result.scripts.join(", ")}`);
202
+ if (result.kept.length > 0) log.info(`Left untouched: ${result.kept.join(", ")}`);
203
+
204
+ // The vendored client imports @clack/prompts. A project that already has a
205
+ // node_modules gets no auto-install, so declaring the dependency is not
206
+ // enough: every interactive ship command would die on the missing import.
207
+ if (result.dependency) {
208
+ const install = dependencyInstall(
209
+ entries.filter((entry) => entry.isFile()).map((entry) => entry.name)
210
+ );
211
+ if (install.manual) {
212
+ log.warn(
213
+ `This project has its own lockfile, so nothing was installed here.\nNext: ${install.manual}, then bun ship:setup.`
214
+ );
215
+ } else if (!args.install) {
216
+ log.warn("Run bun install before bun ship:setup; the ship client imports @clack/prompts.");
217
+ } else {
218
+ const s = spinner();
219
+ s.start("Installing @clack/prompts");
220
+ const proc = Bun.spawn(install.command, {
221
+ cwd: root,
222
+ stdin: "ignore",
223
+ stdout: "ignore",
224
+ stderr: "pipe",
225
+ });
226
+ const ok = (await proc.exited) === 0;
227
+ s.stop(ok ? "Installed @clack/prompts" : "Could not install @clack/prompts");
228
+ if (!ok) log.warn("Run bun install before bun ship:setup; the ship client imports @clack/prompts.");
229
+ }
230
+ }
231
+
232
+ let deployNow = false;
233
+ if (interactive) {
234
+ const answer = await confirm({
235
+ message: "Deploy to a VPS now?",
236
+ active: "Yes",
237
+ inactive: "Later",
238
+ initialValue: false,
239
+ });
240
+ if (isCancel(answer)) exitCancelled();
241
+ deployNow = answer;
242
+ }
243
+ if (deployNow) await runShipSetup(root, "here");
244
+
245
+ log.message(
246
+ [
247
+ deployNow
248
+ ? `${accent("next")} bun ship ${dim("deploy your first commit")}`
249
+ : `${accent("next")} bun ship:setup ${dim("connect your VPS when you're ready")}`,
250
+ "",
251
+ dim(`Deployments serve ${outputDir}/. Review the generated Dockerfile and compose.yaml.`),
252
+ ].join("\n")
253
+ );
254
+ outro(`Docs: ${accent("https://shibumistack.dev/docs")}`);
255
+ }
256
+
40
257
  async function main(): Promise<void> {
41
258
  if (process.argv.slice(2).includes("--print-installer")) {
42
259
  await printInstaller();
@@ -65,13 +282,20 @@ async function main(): Promise<void> {
65
282
  const interactive = !args.yes;
66
283
  if (interactive && !process.stdin.isTTY) {
67
284
  process.stderr.write(
68
- `No interactive terminal. Use --yes with a project name and --template.\n`
285
+ args.adopt
286
+ ? `No interactive terminal. Run "bun create shibumi . --yes" to adopt this project with its detected build directory.\n`
287
+ : `No interactive terminal. Use --yes with a project name and --template.\n`
69
288
  );
70
289
  process.exit(2);
71
290
  }
72
291
 
73
292
  intro("渋み shibumi");
74
293
 
294
+ if (args.adopt) {
295
+ await adoptExisting(args);
296
+ return;
297
+ }
298
+
75
299
  let name: string;
76
300
  if (args.name) {
77
301
  name = args.name;
@@ -89,7 +313,6 @@ async function main(): Promise<void> {
89
313
  if (args.template) {
90
314
  template = args.template;
91
315
  } else {
92
- const dim = (value: string) => `\x1b[2m${value}\x1b[22m`;
93
316
  const detail = (value: string) => `\n ${dim(value)}`;
94
317
  const answer = await select({
95
318
  message: "What are you shipping?",
@@ -99,8 +322,8 @@ async function main(): Promise<void> {
99
322
  label: `Bun full-stack app ${dim("(recommended)")}${detail("Hono, Alpine, and SQLite with migrations and backups")}`,
100
323
  },
101
324
  {
102
- value: "web" as TemplateId,
103
- label: `Bun web app${detail("Hono, Alpine, and Zod; no database")}`,
325
+ value: "blog" as TemplateId,
326
+ label: `Blog${detail("with RSS, sitemap, SEO")}`,
104
327
  },
105
328
  {
106
329
  value: "static" as TemplateId,
@@ -110,26 +333,6 @@ async function main(): Promise<void> {
110
333
  });
111
334
  if (isCancel(answer)) exitCancelled();
112
335
  template = answer as TemplateId;
113
- if (template === "static") {
114
- const start = await select({
115
- message: "Start from?",
116
- options: [
117
- {
118
- value: "static" as TemplateId,
119
- label: `Plain files${detail("index.html and friends in public/")}`,
120
- },
121
- {
122
- value: "blog" as TemplateId,
123
- label: `Astro blog${detail("posts, RSS, sitemap, SEO meta, llms.txt, markdown alternates")}`,
124
- },
125
- ],
126
- });
127
- if (isCancel(start)) exitCancelled();
128
- template = start as TemplateId;
129
- if (template === "static") {
130
- log.info("Using a generator? Point bun ship:setup at its output directory later.");
131
- }
132
- }
133
336
  }
134
337
 
135
338
  let deployNow = false;
@@ -171,7 +374,7 @@ async function main(): Promise<void> {
171
374
  if (args.install) log.success("Dependencies installed");
172
375
  else log.info("Install skipped; run bun install inside the project");
173
376
  if (existsSync(join(result.dest, "scripts", "ship.ts"))) {
174
- log.success("Ship client vendored (scripts/ship.ts)");
377
+ log.success("Deploy script added (scripts/ship.ts)");
175
378
  }
176
379
  } catch (err) {
177
380
  if (err instanceof CreateError) {
@@ -189,19 +392,7 @@ async function main(): Promise<void> {
189
392
  scripts?: Record<string, string>;
190
393
  };
191
394
  if (pkg.scripts?.["ship:setup"] && existsSync(join(dest, "scripts", "ship.ts"))) {
192
- const proc = Bun.spawn(["bun", "run", "ship:setup"], {
193
- cwd: dest,
194
- stdin: "inherit",
195
- stdout: "inherit",
196
- stderr: "inherit",
197
- });
198
- const code = await proc.exited;
199
- if (code !== 0) {
200
- process.stderr.write(
201
- `ship:setup did not finish. Your project is intact; run "bun ship:setup" inside ${name} to retry.\n`
202
- );
203
- process.exit(1);
204
- }
395
+ await runShipSetup(dest, `inside ${name}`);
205
396
  } else {
206
397
  process.stdout.write(
207
398
  `This template has no ship:setup yet. Run "bun ship:setup" inside ${name} once it does.\n`
@@ -209,8 +400,6 @@ async function main(): Promise<void> {
209
400
  }
210
401
  }
211
402
 
212
- const accent = (value: string) => `\x1b[38;5;208m${value}\x1b[0m`;
213
- const dim = (value: string) => `\x1b[2m${value}\x1b[22m`;
214
403
  log.message(
215
404
  [
216
405
  `${accent("next")} cd ${name}`,
@@ -10,6 +10,7 @@ bun run build # astro build to dist/
10
10
  bun run check # astro check (types + templates)
11
11
  bun ship:setup # configure the deploy target (preconfigured: static, dist/, build script)
12
12
  bun ship # verify dist/, build the static image, upload, deploy
13
+ bun ship:webhook # opt in to push-to-deploy; --off reverses it
13
14
  ```
14
15
 
15
16
  ## Routes and templates
@@ -1,4 +1,6 @@
1
1
  node_modules/
2
2
  dist/
3
3
  .astro/
4
+ .env
5
+ .env.*
4
6
  .DS_Store
@@ -12,7 +12,8 @@
12
12
  "ship:setup": "bun scripts/ship.ts --setup --static --output-dir dist --build-script build --no-spa",
13
13
  "ship:update": "bun scripts/ship.ts --update",
14
14
  "ship:status": "bun scripts/ship.ts --status",
15
- "ship:logs": "bun scripts/ship.ts --logs"
15
+ "ship:logs": "bun scripts/ship.ts --logs",
16
+ "ship:webhook": "bun scripts/ship.ts --webhook"
16
17
  },
17
18
  "dependencies": {
18
19
  "@astrojs/rss": "4.0.19",
@@ -15,6 +15,7 @@ bun db:migrate # backup (when needed), then apply pending migrations
15
15
  bun db:backup # manual VACUUM INTO backup with sha256 sidecar
16
16
  bun db:restore <backup> # offline restore; stop the app first
17
17
  bun ship # build image, upload, deploy via shibumi-server
18
+ bun ship:webhook # opt in to push-to-deploy; --off reverses it
18
19
  bun run shibumi add <name> # install an extension (auth, email); --dry-run previews
19
20
  ```
20
21
 
@@ -15,6 +15,7 @@
15
15
  "ship:update": "bun scripts/ship.ts --update",
16
16
  "ship:status": "bun scripts/ship.ts --status",
17
17
  "ship:logs": "bun scripts/ship.ts --logs",
18
+ "ship:webhook": "bun scripts/ship.ts --webhook",
18
19
  "ship:env": "bun scripts/ship.ts --env",
19
20
  "shibumi": "bun scripts/shibumi.ts",
20
21
  "shi": "bun scripts/shibumi.ts",