create-shibumi 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -2
  3. package/package.json +57 -5
  4. package/scripts/shibumi.lock.json +22 -0
  5. package/scripts/ship.lock.json +4 -0
  6. package/src/args.ts +162 -0
  7. package/src/cli.ts +232 -0
  8. package/src/create.ts +272 -0
  9. package/src/templates/blog/README.md +10 -0
  10. package/src/templates/blog/agents.md +36 -0
  11. package/src/templates/blog/astro.config.mjs +13 -0
  12. package/src/templates/blog/bun.lock +787 -0
  13. package/src/templates/blog/gitignore +4 -0
  14. package/src/templates/blog/package.json +29 -0
  15. package/src/templates/blog/public/og-default.png +0 -0
  16. package/src/templates/blog/public/style.css +147 -0
  17. package/src/templates/blog/src/components/BaseHead.astro +37 -0
  18. package/src/templates/blog/src/content/blog/one-vps-is-plenty.md +13 -0
  19. package/src/templates/blog/src/content/blog/own-your-source.md +13 -0
  20. package/src/templates/blog/src/content/blog/writing-for-agents.md +13 -0
  21. package/src/templates/blog/src/content.config.ts +17 -0
  22. package/src/templates/blog/src/layouts/Base.astro +26 -0
  23. package/src/templates/blog/src/pages/404.astro +11 -0
  24. package/src/templates/blog/src/pages/index.astro +30 -0
  25. package/src/templates/blog/src/pages/llms.txt.ts +29 -0
  26. package/src/templates/blog/src/pages/posts/[id].astro +31 -0
  27. package/src/templates/blog/src/pages/posts/[id].md.ts +18 -0
  28. package/src/templates/blog/src/pages/robots.txt.ts +6 -0
  29. package/src/templates/blog/src/pages/rss.xml.ts +21 -0
  30. package/src/templates/blog/src/site.ts +7 -0
  31. package/src/templates/blog/tsconfig.json +5 -0
  32. package/src/templates/full-stack/.dockerignore +14 -0
  33. package/src/templates/full-stack/Dockerfile +23 -0
  34. package/src/templates/full-stack/README.md +10 -0
  35. package/src/templates/full-stack/agents.md +60 -0
  36. package/src/templates/full-stack/bun.lock +225 -0
  37. package/src/templates/full-stack/compose.yaml +24 -0
  38. package/src/templates/full-stack/drizzle.config.ts +11 -0
  39. package/src/templates/full-stack/gitignore +6 -0
  40. package/src/templates/full-stack/package.json +39 -0
  41. package/src/templates/full-stack/public/app.js +11 -0
  42. package/src/templates/full-stack/public/style.css +60 -0
  43. package/src/templates/full-stack/public/vendor/alpine-csp-3.16.2.min.js +23 -0
  44. package/src/templates/full-stack/scripts/db.ts +39 -0
  45. package/src/templates/full-stack/src/app.ts +106 -0
  46. package/src/templates/full-stack/src/db/index.ts +11 -0
  47. package/src/templates/full-stack/src/db/lifecycle.ts +214 -0
  48. package/src/templates/full-stack/src/db/migrations/0001_notes.sql +9 -0
  49. package/src/templates/full-stack/src/db/schema.ts +13 -0
  50. package/src/templates/full-stack/src/env.ts +24 -0
  51. package/src/templates/full-stack/src/server.ts +30 -0
  52. package/src/templates/full-stack/test/app.test.ts +144 -0
  53. package/src/templates/full-stack/test/db.test.ts +169 -0
  54. package/src/templates/full-stack/tsconfig.json +19 -0
  55. package/src/templates/shibumi.ts +809 -0
  56. package/src/templates/ship.ts +2056 -0
  57. package/src/templates/static/README.md +10 -0
  58. package/src/templates/static/agents.md +31 -0
  59. package/src/templates/static/gitignore +2 -0
  60. package/src/templates/static/package.json +17 -0
  61. package/src/templates/static/public/404.html +15 -0
  62. package/src/templates/static/public/index.html +16 -0
  63. package/src/templates/static/public/style.css +50 -0
  64. package/src/templates/static/scripts/preview.ts +42 -0
  65. package/src/templates/web/.dockerignore +12 -0
  66. package/src/templates/web/Dockerfile +24 -0
  67. package/src/templates/web/README.md +10 -0
  68. package/src/templates/web/agents.md +54 -0
  69. package/src/templates/web/bun.lock +45 -0
  70. package/src/templates/web/compose.yaml +17 -0
  71. package/src/templates/web/gitignore +5 -0
  72. package/src/templates/web/package.json +33 -0
  73. package/src/templates/web/public/app.js +11 -0
  74. package/src/templates/web/public/style.css +60 -0
  75. package/src/templates/web/public/vendor/alpine-csp-3.16.2.min.js +23 -0
  76. package/src/templates/web/src/app.ts +96 -0
  77. package/src/templates/web/src/env.ts +21 -0
  78. package/src/templates/web/src/server.ts +23 -0
  79. package/src/templates/web/test/app.test.ts +137 -0
  80. package/src/templates/web/tsconfig.json +13 -0
package/src/create.ts ADDED
@@ -0,0 +1,272 @@
1
+ import {
2
+ cpSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ renameSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "fs";
11
+ import { dirname, join } from "path";
12
+ import { validateName, type TemplateId } from "./args";
13
+
14
+ export interface CreateOptions {
15
+ name: string;
16
+ parentDir: string;
17
+ template: TemplateId;
18
+ git: boolean;
19
+ install: boolean;
20
+ templatesDir?: string;
21
+ }
22
+
23
+ export interface RunResult {
24
+ ok: boolean;
25
+ code: number;
26
+ }
27
+
28
+ export type Runner = (cmd: string[], cwd: string) => Promise<RunResult>;
29
+
30
+ export class CreateError extends Error {
31
+ exitCode: number;
32
+ constructor(message: string, exitCode = 1) {
33
+ super(message);
34
+ this.exitCode = exitCode;
35
+ }
36
+ }
37
+
38
+ export type StepReporter = (step: string) => void;
39
+
40
+ // Children spawned by the default runner, killed before signal cleanup so a
41
+ // running `git`/`bun install` cannot recreate temp contents after removal.
42
+ const activeChildren = new Set<ReturnType<typeof Bun.spawn>>();
43
+
44
+ function killActiveChildren(): void {
45
+ for (const child of activeChildren) {
46
+ try {
47
+ // SIGKILL: the signal handler cannot await child exit, so the child
48
+ // must die immediately or it could recreate temp contents mid-removal.
49
+ child.kill("SIGKILL");
50
+ } catch {
51
+ // Child already gone.
52
+ }
53
+ }
54
+ }
55
+
56
+ async function defaultRun(cmd: string[], cwd: string): Promise<RunResult> {
57
+ const proc = Bun.spawn(cmd, { cwd, stdin: "ignore", stdout: "ignore", stderr: "ignore" });
58
+ activeChildren.add(proc);
59
+ try {
60
+ const code = await proc.exited;
61
+ return { ok: code === 0, code };
62
+ } finally {
63
+ activeChildren.delete(proc);
64
+ }
65
+ }
66
+
67
+ const DEFAULT_TEMPLATES_DIR = join(import.meta.dir, "templates");
68
+
69
+ function removeVerified(path: string): boolean {
70
+ for (let attempt = 0; attempt < 2; attempt++) {
71
+ try {
72
+ rmSync(path, { recursive: true, force: true });
73
+ } catch {
74
+ // Retry once, then report below.
75
+ }
76
+ if (!existsSync(path)) return true;
77
+ }
78
+ process.stderr.write(`Could not remove temporary directory, delete it manually: ${path}\n`);
79
+ return false;
80
+ }
81
+
82
+ /**
83
+ * Atomic scaffold: build the project in a temp sibling directory created
84
+ * exclusively via mkdtemp, verify it, reserve the destination with mkdir,
85
+ * then rename into place (rename only ever replaces the empty directory this
86
+ * process created). On any failure, cancellation, or signal the destination
87
+ * is never left populated and the temp directory is removed. Never overwrites
88
+ * existing content. Never stages or commits in git.
89
+ */
90
+ export async function createProject(
91
+ opts: CreateOptions,
92
+ run: Runner = defaultRun,
93
+ onStep: StepReporter = () => {}
94
+ ): Promise<{ dest: string }> {
95
+ const nameError = validateName(opts.name);
96
+ if (nameError) {
97
+ throw new CreateError(nameError, 2);
98
+ }
99
+
100
+ const dest = join(opts.parentDir, opts.name);
101
+ if (existsSync(dest)) {
102
+ throw new CreateError(
103
+ `Destination already exists: ${dest}\nChoose another name or remove it first.`
104
+ );
105
+ }
106
+
107
+ const templatesDir = opts.templatesDir ?? DEFAULT_TEMPLATES_DIR;
108
+ const templateSrc = join(templatesDir, opts.template);
109
+ if (!existsSync(templateSrc)) {
110
+ throw new CreateError(`Template "${opts.template}" is not available in this build.`);
111
+ }
112
+
113
+ // mkdtemp creates the directory exclusively, so concurrent creators can
114
+ // never share (or delete) each other's temp path.
115
+ const tmp = mkdtempSync(join(dirname(dest), `.${opts.name}.shibumi-tmp-`));
116
+ let reserved: string | null = null;
117
+
118
+ let cleaned = false;
119
+ const cleanup = () => {
120
+ if (cleaned) return;
121
+ cleaned = true;
122
+ killActiveChildren();
123
+ removeVerified(tmp);
124
+ if (reserved) removeVerified(reserved);
125
+ };
126
+ const onSigint = () => {
127
+ cleanup();
128
+ process.exit(130);
129
+ };
130
+ const onSigterm = () => {
131
+ cleanup();
132
+ process.exit(143);
133
+ };
134
+ process.on("SIGINT", onSigint);
135
+ process.on("SIGTERM", onSigterm);
136
+
137
+ try {
138
+ onStep("copy");
139
+ // tmp was created exclusively by mkdtemp above and is empty.
140
+ cpSync(templateSrc, tmp, { recursive: true });
141
+
142
+ // npm pack always strips .gitignore files, so templates store the file
143
+ // as "gitignore" and it is renamed into place here.
144
+ const packSafeIgnore = join(tmp, "gitignore");
145
+ if (existsSync(packSafeIgnore)) {
146
+ renameSync(packSafeIgnore, join(tmp, ".gitignore"));
147
+ }
148
+
149
+ const pkgPath = join(tmp, "package.json");
150
+ if (existsSync(pkgPath)) {
151
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
152
+ name?: string;
153
+ scripts?: Record<string, string>;
154
+ };
155
+ const templateName = pkg.name;
156
+ pkg.name = opts.name;
157
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
158
+
159
+ // Keep the shipped lockfile's workspace name in sync with the rename.
160
+ const lockPath = join(tmp, "bun.lock");
161
+ if (templateName && existsSync(lockPath)) {
162
+ const lock = readFileSync(lockPath, "utf8");
163
+ writeFileSync(
164
+ lockPath,
165
+ lock.replace(`"name": "${templateName}"`, `"name": "${opts.name}"`)
166
+ );
167
+ }
168
+
169
+ // Templates whose scripts use the Ship client get the vendored,
170
+ // checksum-locked copy; it is package-level, not per-template. The
171
+ // lock ships in the tarball and is verified here at scaffold time, so
172
+ // a substituted ship.ts in a tampered artifact fails loudly.
173
+ if (pkg.scripts?.ship) {
174
+ const shipSrc = join(templatesDir, "ship.ts");
175
+ const lockPath = join(templatesDir, "..", "..", "scripts", "ship.lock.json");
176
+ if (!existsSync(shipSrc)) {
177
+ throw new CreateError(`Vendored Ship client missing at ${shipSrc}; aborting.`);
178
+ }
179
+ if (opts.templatesDir === undefined) {
180
+ const lock = JSON.parse(readFileSync(lockPath, "utf8")) as { sha256: string };
181
+ const digest = new Bun.CryptoHasher("sha256")
182
+ .update(await Bun.file(shipSrc).arrayBuffer())
183
+ .digest("hex");
184
+ if (digest !== lock.sha256) {
185
+ throw new CreateError(
186
+ `Vendored Ship client does not match its checksum lock; the package may be corrupt. Reinstall create-shibumi.`
187
+ );
188
+ }
189
+ }
190
+ mkdirSync(join(tmp, "scripts"), { recursive: true });
191
+ cpSync(shipSrc, join(tmp, "scripts", "ship.ts"));
192
+ }
193
+
194
+ // Same pattern for the extension installer: vendored, checksum-locked,
195
+ // verified at scaffold time. Extension bundles are embedded in the
196
+ // file, so `bun run shibumi add` never touches the network.
197
+ if (pkg.scripts?.shibumi) {
198
+ const shibumiSrc = join(templatesDir, "shibumi.ts");
199
+ const shibumiLockPath = join(templatesDir, "..", "..", "scripts", "shibumi.lock.json");
200
+ if (!existsSync(shibumiSrc)) {
201
+ throw new CreateError(`Vendored extension installer missing at ${shibumiSrc}; aborting.`);
202
+ }
203
+ if (opts.templatesDir === undefined) {
204
+ const lock = JSON.parse(readFileSync(shibumiLockPath, "utf8")) as { sha256: string };
205
+ const digest = new Bun.CryptoHasher("sha256")
206
+ .update(await Bun.file(shibumiSrc).arrayBuffer())
207
+ .digest("hex");
208
+ if (digest !== lock.sha256) {
209
+ throw new CreateError(
210
+ `Vendored extension installer does not match its checksum lock; the package may be corrupt. Reinstall create-shibumi.`
211
+ );
212
+ }
213
+ }
214
+ mkdirSync(join(tmp, "scripts"), { recursive: true });
215
+ cpSync(shibumiSrc, join(tmp, "scripts", "shibumi.ts"));
216
+ }
217
+ }
218
+
219
+ if (opts.git) {
220
+ onStep("git");
221
+ const gitAvailable = await run(["git", "--version"], dirname(tmp));
222
+ if (!gitAvailable.ok) {
223
+ throw new CreateError(`git not found. Install git or re-run with --no-git.`);
224
+ }
225
+ const init = await run(["git", "init"], tmp);
226
+ if (!init.ok) {
227
+ throw new CreateError(`git init failed. Re-run with --no-git and initialize manually.`);
228
+ }
229
+ // Nothing is staged or committed; the first commit belongs to the user.
230
+ }
231
+
232
+ if (opts.install) {
233
+ onStep("install");
234
+ const install = await run(["bun", "install"], tmp);
235
+ if (!install.ok) {
236
+ throw new CreateError(
237
+ `Dependency install failed. Re-run with --no-install, then run "bun install" inside the project.`
238
+ );
239
+ }
240
+ }
241
+
242
+ onStep("verify");
243
+ if (existsSync(join(templateSrc, "package.json")) && !existsSync(pkgPath)) {
244
+ throw new CreateError(`Generated project is missing package.json; aborting.`);
245
+ }
246
+
247
+ // Reserve the destination: mkdir fails if anything appeared meanwhile,
248
+ // and rename below only ever replaces this empty reservation.
249
+ try {
250
+ mkdirSync(dest);
251
+ reserved = dest;
252
+ } catch {
253
+ throw new CreateError(
254
+ `Destination was created while scaffolding: ${dest}\nNothing was written there. Re-run with a different name.`
255
+ );
256
+ }
257
+ try {
258
+ renameSync(tmp, dest);
259
+ reserved = null;
260
+ } catch {
261
+ throw new CreateError(
262
+ `Could not move the project into place: ${dest}\nNothing was written there.`
263
+ );
264
+ }
265
+
266
+ return { dest };
267
+ } finally {
268
+ process.off("SIGINT", onSigint);
269
+ process.off("SIGTERM", onSigterm);
270
+ cleanup();
271
+ }
272
+ }
@@ -0,0 +1,10 @@
1
+ # Shibumi blog
2
+
3
+ An Astro blog with the boring parts done: RSS, sitemap, OG and SEO meta from a schema your posts can't skip, an `llms.txt`, and a markdown alternate of every post for readers who curl.
4
+
5
+ ```sh
6
+ bun install
7
+ bun dev
8
+ ```
9
+
10
+ Write posts in `src/content/blog/`. Set `site` in `astro.config.mjs`, then `bun ship:setup` and `bun ship` to deploy. `agents.md` has the house rules.
@@ -0,0 +1,36 @@
1
+ # Agent Notes
2
+
3
+ Astro blog generated by create-shibumi. Static output, shibumi design tokens, served as HTML for people and markdown for agents.
4
+
5
+ ## Commands
6
+
7
+ ```sh
8
+ bun dev # astro dev server
9
+ bun run build # astro build to dist/
10
+ bun run check # astro check (types + templates)
11
+ bun ship:setup # configure the deploy target (preconfigured: static, dist/, build script)
12
+ bun ship # verify dist/, build the static image, upload, deploy
13
+ ```
14
+
15
+ ## Routes and templates
16
+
17
+ - Posts are markdown files in `src/content/blog/`; the schema in `src/content.config.ts` requires title (max 60), description (50 to 160 characters, drives meta/RSS/llms.txt), date, optional ogImage.
18
+ - `src/pages/index.astro` lists posts; `src/pages/posts/[id].astro` renders one; `src/layouts/Base.astro` and `src/components/BaseHead.astro` own the frame and every meta tag.
19
+ - Generated endpoints: `/rss.xml`, `/llms.txt`, `/robots.txt`, and a markdown alternate for every post at `/posts/<id>.md`. All derive from the content collection; never hand-edit their output.
20
+ - Site identity lives in `src/site.ts`. Design lives in `public/style.css` (one file, shibumi tokens, semantic selectors).
21
+
22
+ ## Trust boundaries and validation
23
+
24
+ Static site: no server code in production. The build runs on your machine from the committed tree; Ship verifies `dist/` (inside root, index.html, no symlinks) and packages only that output with a pinned BusyBox httpd running unprivileged.
25
+
26
+ ## Database
27
+
28
+ None.
29
+
30
+ ## Files Ship generates
31
+
32
+ `bun ship:setup` writes `Dockerfile`, `compose.yaml`, `.dockerignore`, and `shibumi-server.json` (all committed). SSH targets stay in `~/.config/shibumi/config.json`, never committed.
33
+
34
+ ## Checks required before commit
35
+
36
+ Set `site` in `astro.config.mjs` to the real domain before the first `bun ship`; sitemap, RSS, canonicals, and og:url derive from it. `bun run check` and `bun run build` must pass. Keep `build.format: "directory"` so the static server routes clean URLs.
@@ -0,0 +1,13 @@
1
+ // @ts-check
2
+ import sitemap from "@astrojs/sitemap";
3
+ import { defineConfig } from "astro/config";
4
+
5
+ export default defineConfig({
6
+ // Set this to your real domain before the first `bun ship`.
7
+ // Sitemap, RSS, canonical URLs, and og:url all derive from it.
8
+ site: "https://example.com",
9
+ integrations: [sitemap()],
10
+ // "directory" keeps clean URLs as posts/foo/index.html, which the pinned
11
+ // static server routes without any adapter or rewrites.
12
+ build: { format: "directory" },
13
+ });