create-shibumi 0.2.9 → 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 CHANGED
@@ -8,27 +8,40 @@ cd my-app
8
8
  bun dev
9
9
  ```
10
10
 
11
+ Already have a project? Run it inside that project instead, and it gets deploy tooling rather than a scaffold:
12
+
13
+ ```sh
14
+ bun create shibumi@latest .
15
+ ```
16
+
11
17
  ## Three starting points
12
18
 
13
- 1. **Static site**: publish a verified build directory such as `./dist`, `public`, `build`, or `out` from any framework.
14
- 2. **Bun web**: Hono, HTML, CSS, Alpine, Zod, tests, and a health endpoint.
15
- 3. **SQLite full stack**: the Bun web project plus Drizzle, migrations, persistent data, backup, and restore.
19
+ 1. **Bun full-stack app**: Hono, HTML, CSS, Alpine, Zod, tests, a health endpoint, and SQLite through Drizzle with migrations, persistent data, backup, and restore.
20
+ 2. **Blog**: Astro with posts, RSS, sitemap, SEO meta, and llms.txt.
21
+ 3. **Static site**: publish a verified build directory such as `./dist`, `public`, `build`, or `out` from any framework.
16
22
 
17
23
  All three deploy to a Linux VPS or homelab through [shibumi-server](https://server.shibumistack.dev). Other providers can wait until their generated projects pass the same artifact and deployment tests.
18
24
 
19
25
  ## Flags
20
26
 
21
27
  ```text
22
- --template <id> static, web, or full-stack
28
+ --template <id> full-stack, blog, or static
23
29
  --yes, -y non-interactive; requires name and --template
24
30
  --no-git skip git init
25
31
  --no-install skip dependency install
32
+ --spa adopting only: unknown paths serve index.html
26
33
  --help, -h show help
27
34
  --version show version
28
35
  ```
29
36
 
30
37
  Creation is atomic: the project is built in a temporary sibling directory and renamed into place. Failure or cancellation leaves nothing behind, and an existing destination is never touched. Git init stages and commits nothing; the first commit belongs to you.
31
38
 
39
+ ## Adopting an existing project
40
+
41
+ `bun create shibumi .` detects where your build lands (Astro and Vite write `dist/`, Eleventy `_site/`, an exported Next.js `out/`, plain files `public/`), vendors `scripts/ship.ts`, adds the `ship` scripts, installs the one dependency that client needs, and generates a `Dockerfile`, `compose.yaml`, and `.dockerignore` for the static image. Git is never touched, and `--no-install` skips the install.
42
+
43
+ Two cases stop the run instead of guessing. Deployment files that already exist are never reinterpreted: a `compose.yaml` carrying Shibumi's static labels beside somebody else's `Dockerfile` would deploy the wrong artifact. A `package.json` with a `start` script belongs to `bun ship:setup`, which generates server deployment files. Finish with `bun ship:setup`.
44
+
32
45
  ## Guidance for coding agents
33
46
 
34
47
  Generated projects include a root `agents.md`. It records route locations, data rules, available checks, and files that need extra care.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-shibumi",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a Shibumi Stack project: Bun, Hono, Zod, Drizzle, SQLite, Alpine",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "src/cli.ts",
11
11
  "src/args.ts",
12
+ "src/adopt.ts",
12
13
  "src/create.ts",
13
14
  "src/templates/ship.ts",
14
15
  "src/templates/shibumi.ts",
@@ -17,7 +18,6 @@
17
18
  "src/templates/static/",
18
19
  "src/templates/blog/",
19
20
  "src/templates/full-stack/",
20
- "src/templates/web/",
21
21
  "README.md",
22
22
  "LICENSE"
23
23
  ],
@@ -1,4 +1,4 @@
1
1
  {
2
- "url": "https://shibumistack.dev/ship/v47.ts",
3
- "sha256": "7c4bee760498bea9f857934088e9fc1e3c1b835dfd49deb382d103248adedc63"
2
+ "url": "https://shibumistack.dev/ship/v48.ts",
3
+ "sha256": "425a8ca3d0b9b8b27660906ea38ec9acb3441a0e0fcf0bd3be191a8e3bd5bea6"
4
4
  }
package/src/adopt.ts ADDED
@@ -0,0 +1,250 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ const DEFAULT_TEMPLATES_DIR = join(import.meta.dir, "templates");
5
+ export const CLACK_VERSION = (
6
+ JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")) as {
7
+ dependencies: Record<string, string>;
8
+ }
9
+ ).dependencies["@clack/prompts"]!;
10
+
11
+ /**
12
+ * How to install the client's one dependency here. A project with an npm or
13
+ * pnpm lockfile must not get a bun.lock dropped beside it, so those get the
14
+ * matching command to run instead of a silent second lockfile.
15
+ */
16
+ export function dependencyInstall(lockfiles: string[]): { command: string[]; manual?: string } {
17
+ const pinned = `@clack/prompts@${CLACK_VERSION}`;
18
+ if (lockfiles.includes("package-lock.json")) return { command: [], manual: `npm install --save-dev ${pinned}` };
19
+ if (lockfiles.includes("pnpm-lock.yaml")) return { command: [], manual: `pnpm add -D ${pinned}` };
20
+ if (lockfiles.includes("yarn.lock")) return { command: [], manual: `yarn add --dev --exact ${pinned}` };
21
+ // --exact keeps the version this package pins, instead of widening it to ^.
22
+ return { command: ["bun", "add", "--dev", "--exact", pinned] };
23
+ }
24
+
25
+ export interface DetectedOutput {
26
+ framework: string;
27
+ outputDir: string;
28
+ // "framework" means a build tool named the directory; "directory" means the
29
+ // directory was simply there, which a Bun server app's dist/ also satisfies.
30
+ source: "framework" | "directory";
31
+ }
32
+
33
+ /**
34
+ * Where this project's build lands. Framework signals come first (Astro and
35
+ * Eleventy both pull Vite in, so a bare Vite match must lose), then a build
36
+ * directory that is already on disk, then plain files under public/.
37
+ */
38
+ export function detectBuildOutput(input: {
39
+ dependencies?: Record<string, unknown>;
40
+ files: string[];
41
+ directories?: string[];
42
+ }): DetectedOutput | undefined {
43
+ const dependencies = input.dependencies ?? {};
44
+ const dep = (name: string) => name in dependencies;
45
+ const file = (name: string) => input.files.includes(name);
46
+ // A plain file named dist must never satisfy a directory fallback.
47
+ const directory = (name: string) => (input.directories ?? []).includes(name);
48
+ const config = (base: string) =>
49
+ ["js", "mjs", "cjs", "ts"].some((extension) => file(`${base}.${extension}`));
50
+
51
+ const framework = (name: string, outputDir: string): DetectedOutput => ({ framework: name, outputDir, source: "framework" });
52
+ if (dep("astro") || config("astro.config")) return framework("Astro", "dist");
53
+ if (dep("@11ty/eleventy") || config(".eleventy") || config("eleventy.config")) {
54
+ return framework("Eleventy", "_site");
55
+ }
56
+ if (dep("next") || config("next.config")) return framework("Next.js", "out");
57
+ if (dep("vite") || config("vite.config")) return framework("Vite", "dist");
58
+ for (const candidate of ["dist", "_site", "out", "build"]) {
59
+ if (directory(candidate)) return { framework: "existing build output", outputDir: candidate, source: "directory" };
60
+ }
61
+ if (directory("public")) return { framework: "plain files", outputDir: "public", source: "directory" };
62
+ return undefined;
63
+ }
64
+
65
+ export function shipScripts(config: {
66
+ outputDir: string;
67
+ buildScript?: string;
68
+ spa: boolean;
69
+ }): Record<string, string> {
70
+ const setup = [
71
+ "bun scripts/ship.ts --setup --static",
72
+ `--output-dir ${config.outputDir}`,
73
+ ...(config.buildScript ? [`--build-script ${config.buildScript}`] : []),
74
+ config.spa ? "--spa" : "--no-spa",
75
+ ].join(" ");
76
+ return {
77
+ ship: "bun scripts/ship.ts",
78
+ "ship:setup": setup,
79
+ "ship:update": "bun scripts/ship.ts --update",
80
+ "ship:status": "bun scripts/ship.ts --status",
81
+ "ship:logs": "bun scripts/ship.ts --logs",
82
+ "ship:webhook": "bun scripts/ship.ts --webhook",
83
+ };
84
+ }
85
+
86
+ // The generators for the static image live in the Ship client, so adopted
87
+ // projects get byte-identical deployment files to the ones ship:setup writes.
88
+ // The specifier stays computed on purpose: the client is a vendored artifact
89
+ // with its own compiler settings and must not join this package's tsc program.
90
+ export interface ShipStatic {
91
+ staticDeploymentFileTemplates(config: {
92
+ outputDir: string;
93
+ buildScript?: string;
94
+ spa: boolean;
95
+ }): Record<string, string>;
96
+ staticServerSource(outputDir: string): string;
97
+ staticOutputDirProblem(value: string): string | undefined;
98
+ }
99
+
100
+ export class AdoptError extends Error {
101
+ exitCode: number;
102
+ constructor(message: string, exitCode = 1) {
103
+ super(message);
104
+ this.exitCode = exitCode;
105
+ }
106
+ }
107
+
108
+ function verifiedShipPath(templatesDir: string): string {
109
+ const shipPath = join(templatesDir, "ship.ts");
110
+ if (!existsSync(shipPath)) {
111
+ throw new AdoptError(`Vendored Ship client missing at ${shipPath}; aborting.`);
112
+ }
113
+ if (templatesDir === DEFAULT_TEMPLATES_DIR) {
114
+ const lock = JSON.parse(
115
+ readFileSync(join(import.meta.dir, "..", "scripts", "ship.lock.json"), "utf8")
116
+ ) as { sha256: string };
117
+ const digest = new Bun.CryptoHasher("sha256").update(readFileSync(shipPath)).digest("hex");
118
+ if (digest !== lock.sha256) {
119
+ throw new AdoptError(
120
+ `Vendored Ship client does not match its checksum lock; the package may be corrupt. Reinstall create-shibumi.`
121
+ );
122
+ }
123
+ }
124
+ return shipPath;
125
+ }
126
+
127
+ export async function loadShipStatic(templatesDir = DEFAULT_TEMPLATES_DIR): Promise<ShipStatic> {
128
+ return (await import(verifiedShipPath(templatesDir))) as ShipStatic;
129
+ }
130
+
131
+ export interface AdoptOptions {
132
+ root: string;
133
+ outputDir: string;
134
+ buildScript?: string;
135
+ spa: boolean;
136
+ ship: ShipStatic;
137
+ templatesDir?: string;
138
+ }
139
+
140
+ export interface AdoptResult {
141
+ written: string[];
142
+ kept: string[];
143
+ scripts: string[];
144
+ dependency: boolean;
145
+ }
146
+
147
+ function trackedState(root: string, path: string): "tracked" | "untracked" | "no-repository" {
148
+ const listed = Bun.spawnSync(["git", "ls-files", "--", path], { cwd: root, stdout: "pipe", stderr: "pipe" });
149
+ if (listed.exitCode !== 0) return "no-repository";
150
+ return listed.stdout.toString().trim() ? "tracked" : "untracked";
151
+ }
152
+
153
+ /**
154
+ * Vendor the Ship client into an existing project instead of scaffolding a new
155
+ * one. Refuses rather than reinterpreting a project's own packaging: deployment
156
+ * files that already exist may build or run something else entirely.
157
+ */
158
+ export async function adoptProject(opts: AdoptOptions): Promise<AdoptResult> {
159
+ const problem = opts.ship.staticOutputDirProblem(opts.outputDir);
160
+ if (problem) throw new AdoptError(problem, 2);
161
+
162
+ const templates = opts.ship.staticDeploymentFileTemplates({
163
+ outputDir: opts.outputDir,
164
+ buildScript: opts.buildScript,
165
+ spa: opts.spa,
166
+ });
167
+ // Same refusal ship:setup makes: a compose file carrying shibumi static
168
+ // labels next to somebody else's Dockerfile deploys the wrong artifact.
169
+ const targets = [...Object.keys(templates), ...(opts.spa ? ["scripts/static-server.ts"] : [])];
170
+ const conflicts = targets.filter((name) => existsSync(join(opts.root, name)));
171
+ if (conflicts.length > 0) {
172
+ throw new AdoptError(
173
+ `Adopting would generate ${conflicts.join(", ")}, which already exist and may package or run something else.\n\nNext: remove or rename them, then run bun create shibumi . again.`
174
+ );
175
+ }
176
+ // Without a build script the image can only contain what the commit
177
+ // contains, so the output has to be in git already.
178
+ const state = opts.buildScript ? "tracked" : trackedState(opts.root, opts.outputDir);
179
+ if (state !== "tracked") {
180
+ const next = state === "no-repository"
181
+ ? `git init && git add ${opts.outputDir} && git commit -m "Add site"`
182
+ : `commit ${opts.outputDir}/`;
183
+ throw new AdoptError(
184
+ `Without a build script, ${opts.outputDir}/ must be committed so shipped images match the exact commit.\n\nNext: ${next}, or add a build script to package.json, then run bun create shibumi . again.`
185
+ );
186
+ }
187
+
188
+ const templatesDir = opts.templatesDir ?? DEFAULT_TEMPLATES_DIR;
189
+ const written: string[] = [];
190
+ const kept: string[] = [];
191
+
192
+ if (existsSync(join(opts.root, "scripts", "ship.ts"))) {
193
+ kept.push("scripts/ship.ts");
194
+ } else {
195
+ mkdirSync(join(opts.root, "scripts"), { recursive: true });
196
+ copyFileSync(verifiedShipPath(templatesDir), join(opts.root, "scripts", "ship.ts"));
197
+ written.push("scripts/ship.ts");
198
+ }
199
+
200
+ // Script-less generators (Jekyll) have no package.json; the ship commands
201
+ // and the client's own dependency need one.
202
+ const packagePath = join(opts.root, "package.json");
203
+ const before = existsSync(packagePath) ? readFileSync(packagePath, "utf8") : undefined;
204
+ const pkg = (before === undefined
205
+ ? {
206
+ name: opts.root.split("/").pop()?.toLowerCase().replace(/[^a-z0-9._-]+/g, "-") || "app",
207
+ private: true,
208
+ type: "module",
209
+ }
210
+ : JSON.parse(before)) as {
211
+ scripts?: Record<string, string>;
212
+ devDependencies?: Record<string, string>;
213
+ dependencies?: Record<string, string>;
214
+ };
215
+
216
+ const scripts = pkg.scripts ?? {};
217
+ const added: string[] = [];
218
+ for (const [name, command] of Object.entries(
219
+ shipScripts({ outputDir: opts.outputDir, buildScript: opts.buildScript, spa: opts.spa })
220
+ )) {
221
+ if (scripts[name]) continue;
222
+ scripts[name] = command;
223
+ added.push(name);
224
+ }
225
+ pkg.scripts = scripts;
226
+ // The vendored client imports @clack/prompts; an adopted project with an
227
+ // existing node_modules gets no auto-install, so the dependency is declared
228
+ // and installed.
229
+ const dependency = !pkg.dependencies?.["@clack/prompts"] && !pkg.devDependencies?.["@clack/prompts"];
230
+ if (dependency) pkg.devDependencies = { ...pkg.devDependencies, "@clack/prompts": CLACK_VERSION };
231
+ const after = `${JSON.stringify(pkg, null, 2)}\n`;
232
+ if (after !== before) {
233
+ writeFileSync(packagePath, after, { mode: 0o644 });
234
+ written.push("package.json");
235
+ } else {
236
+ kept.push("package.json");
237
+ }
238
+
239
+ for (const [name, contents] of Object.entries(templates)) {
240
+ writeFileSync(join(opts.root, name), contents, { mode: 0o644 });
241
+ written.push(name);
242
+ }
243
+ if (opts.spa) {
244
+ mkdirSync(join(opts.root, "scripts"), { recursive: true });
245
+ writeFileSync(join(opts.root, "scripts", "static-server.ts"), opts.ship.staticServerSource(opts.outputDir), { mode: 0o644 });
246
+ written.push("scripts/static-server.ts");
247
+ }
248
+
249
+ return { written, kept, scripts: added, dependency };
250
+ }
package/src/args.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const TEMPLATES = ["static", "web", "full-stack", "blog"] as const;
1
+ export const TEMPLATES = ["static", "full-stack", "blog"] as const;
2
2
  export type TemplateId = (typeof TEMPLATES)[number];
3
3
 
4
4
  export interface ParsedArgs {
@@ -7,6 +7,9 @@ export interface ParsedArgs {
7
7
  yes: boolean;
8
8
  git: boolean;
9
9
  install: boolean;
10
+ // `bun create shibumi .` adopts the current project instead of scaffolding.
11
+ adopt: boolean;
12
+ spa: boolean;
10
13
  name?: string;
11
14
  template?: TemplateId;
12
15
  }
@@ -41,6 +44,7 @@ const BOOLEAN_FLAGS = new Set([
41
44
  "-y",
42
45
  "--no-git",
43
46
  "--no-install",
47
+ "--spa",
44
48
  ]);
45
49
 
46
50
  export function validateName(name: string): string | null {
@@ -60,6 +64,8 @@ export function parseArgs(argv: string[]): ParseResult {
60
64
  yes: false,
61
65
  git: true,
62
66
  install: true,
67
+ adopt: false,
68
+ spa: false,
63
69
  };
64
70
  const positionals: string[] = [];
65
71
 
@@ -110,6 +116,7 @@ export function parseArgs(argv: string[]): ParseResult {
110
116
  else if (flag === "--yes" || flag === "-y") args.yes = true;
111
117
  else if (flag === "--no-git") args.git = false;
112
118
  else if (flag === "--no-install") args.install = false;
119
+ else if (flag === "--spa") args.spa = true;
113
120
  continue;
114
121
  }
115
122
 
@@ -127,12 +134,29 @@ export function parseArgs(argv: string[]): ParseResult {
127
134
  };
128
135
  }
129
136
  if (positionals.length === 1) {
130
- const err = validateName(positionals[0]!);
131
- if (err) return { ok: false, error: err };
132
- args.name = positionals[0]!;
137
+ if (positionals[0] === ".") {
138
+ args.adopt = true;
139
+ } else {
140
+ const err = validateName(positionals[0]!);
141
+ if (err) return { ok: false, error: err };
142
+ args.name = positionals[0]!;
143
+ }
144
+ }
145
+
146
+ if (args.spa && !args.adopt) {
147
+ return {
148
+ ok: false,
149
+ error: `--spa applies to adopting an existing project: bun create shibumi .`,
150
+ };
151
+ }
152
+ if (args.adopt && args.template) {
153
+ return {
154
+ ok: false,
155
+ error: `--template does not apply to an existing project; bun create shibumi . adds deploy tooling to what is already here.`,
156
+ };
133
157
  }
134
158
 
135
- if (args.yes) {
159
+ if (args.yes && !args.adopt) {
136
160
  if (!args.name) {
137
161
  return { ok: false, error: `--yes requires a project name.` };
138
162
  }
@@ -148,13 +172,15 @@ export const HELP_TEXT = `create-shibumi: scaffold a Shibumi Stack project
148
172
 
149
173
  Usage
150
174
  bun create shibumi@latest [name] [flags]
175
+ bun create shibumi@latest . add deploy tooling to this project
151
176
  bunx create-shibumi [name] [flags]
152
177
 
153
178
  Flags
154
- --template <id> static, web, full-stack, or blog
179
+ --template <id> full-stack, blog, or static
155
180
  --yes, -y non-interactive; requires name and --template
156
181
  --no-git skip git init
157
182
  --no-install skip dependency install
183
+ --spa adopting only: unknown paths serve index.html
158
184
  --help, -h show this help
159
185
  --version show version
160
186