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
@@ -0,0 +1,30 @@
1
+ import { app } from "./app";
2
+ import { sqlite } from "./db";
3
+ import { bootDatabase } from "./db/lifecycle";
4
+ import { loadEnv } from "./env";
5
+
6
+ const env = loadEnv();
7
+
8
+ // Boot ordering: backup (when migrations are pending on an existing
9
+ // database), migrate, then serve. Failure exits before the health check
10
+ // can pass, so the previous deployment stays live.
11
+ await bootDatabase(sqlite, env.DB_PATH);
12
+
13
+ const server = Bun.serve({
14
+ port: env.PORT,
15
+ // Cap request bodies so an unauthenticated client cannot exhaust memory
16
+ // before a route (or its rate limiter) runs. Raise it for large uploads.
17
+ maxRequestBodySize: 1024 * 1024,
18
+ fetch: app.fetch,
19
+ });
20
+
21
+ console.log(`Listening on http://localhost:${server.port}`);
22
+
23
+ // Graceful shutdown: stop accepting connections, let in-flight requests
24
+ // finish, then exit. The container runtime sends SIGTERM on replacement.
25
+ async function shutdown(code: number): Promise<void> {
26
+ await server.stop();
27
+ process.exit(code);
28
+ }
29
+ process.on("SIGTERM", () => void shutdown(0));
30
+ process.on("SIGINT", () => void shutdown(0));
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // The db module opens DB_PATH at import time, so point it at a scratch
7
+ // database before the app loads.
8
+ process.env.DB_PATH = join(mkdtempSync(join(tmpdir(), "app-test-")), "app.db");
9
+ const { app, createApp } = await import("../src/app");
10
+ const { sqlite } = await import("../src/db");
11
+ const { applyMigrations } = await import("../src/db/lifecycle");
12
+ await applyMigrations(sqlite);
13
+
14
+ // Independent copy of the required header contract. If src/app.ts weakens a
15
+ // header, this literal makes the test fail instead of silently adapting.
16
+ const REQUIRED_HEADERS: Record<string, string> = {
17
+ "Content-Security-Policy":
18
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
19
+ "X-Content-Type-Options": "nosniff",
20
+ "X-Frame-Options": "DENY",
21
+ "Referrer-Policy": "strict-origin-when-cross-origin",
22
+ "Permissions-Policy": "camera=(), geolocation=(), microphone=()",
23
+ "Cross-Origin-Opener-Policy": "same-origin",
24
+ "Cross-Origin-Resource-Policy": "same-origin",
25
+ };
26
+
27
+ async function req(path: string, init?: RequestInit): Promise<Response> {
28
+ return app.fetch(new Request(`http://localhost${path}`, init));
29
+ }
30
+
31
+ function expectSecurityHeaders(res: Response): void {
32
+ for (const [name, value] of Object.entries(REQUIRED_HEADERS)) {
33
+ expect(res.headers.get(name)).toBe(value);
34
+ }
35
+ }
36
+
37
+ describe("routes", () => {
38
+ it("serves the home page", async () => {
39
+ const res = await req("/");
40
+ expect(res.status).toBe(200);
41
+ const html = await res.text();
42
+ expect(html).toContain("It persists.");
43
+ expect(html.indexOf("/public/app.js")).toBeLessThan(
44
+ html.indexOf("/public/vendor/alpine-csp-3.16.2.min.js")
45
+ );
46
+ expectSecurityHeaders(res);
47
+ });
48
+
49
+ it("answers the health check", async () => {
50
+ const res = await req("/healthz");
51
+ expect(res.status).toBe(200);
52
+ expect(await res.json()).toEqual({ ok: true });
53
+ expectSecurityHeaders(res);
54
+ });
55
+
56
+ it("serves notes from the migrated database", async () => {
57
+ const res = await req("/api/notes");
58
+ expect(res.status).toBe(200);
59
+ const body = (await res.json()) as { notes: Array<{ title: string }> };
60
+ expect(body.notes.map((note) => note.title)).toContain("It persists.");
61
+ expectSecurityHeaders(res);
62
+ });
63
+
64
+ it("validates API query input", async () => {
65
+ const bad = await req(`/api/hello?name=${"x".repeat(101)}`);
66
+ expect(bad.status).toBe(400);
67
+ expectSecurityHeaders(bad);
68
+ });
69
+
70
+ it("returns 404 with security headers", async () => {
71
+ const res = await req("/nope");
72
+ expect(res.status).toBe(404);
73
+ expectSecurityHeaders(res);
74
+ });
75
+
76
+ it("serves static assets with security headers", async () => {
77
+ const res = await req("/public/style.css");
78
+ expect(res.status).toBe(200);
79
+ expectSecurityHeaders(res);
80
+ });
81
+
82
+ it("keeps security headers on thrown-error responses", async () => {
83
+ const probe = createApp();
84
+ probe.get("/__boom", () => {
85
+ throw new Error("test explosion");
86
+ });
87
+ const res = await probe.fetch(new Request("http://localhost/__boom"));
88
+ expect(res.status).toBe(500);
89
+ expectSecurityHeaders(res);
90
+ });
91
+ });
92
+
93
+ describe("security", () => {
94
+ it("registers exactly the expected routes", () => {
95
+ const routes = app.routes.map((r) => `${r.method} ${r.path}`);
96
+ expect(routes.sort()).toEqual(
97
+ [
98
+ "ALL /*",
99
+ "GET /public/*",
100
+ "GET /",
101
+ "GET /api/hello",
102
+ "GET /api/notes",
103
+ "GET /healthz",
104
+ ].sort()
105
+ );
106
+ });
107
+
108
+ it("rejects every mutation verb on every route", async () => {
109
+ for (const path of ["/", "/api/hello", "/api/notes", "/healthz", "/public/style.css"]) {
110
+ for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
111
+ const res = await req(path, { method });
112
+ expect([404, 405]).toContain(res.status);
113
+ }
114
+ }
115
+ });
116
+
117
+ it("pins a CSP without unsafe-inline or unsafe-eval", () => {
118
+ const csp = REQUIRED_HEADERS["Content-Security-Policy"]!;
119
+ expect(csp).not.toContain("unsafe-inline");
120
+ expect(csp).not.toContain("unsafe-eval");
121
+ });
122
+
123
+ it("does not expose files outside public/ via traversal", async () => {
124
+ for (const path of [
125
+ "/public/../package.json",
126
+ "/public/%2e%2e/package.json",
127
+ "/public/..%2fpackage.json",
128
+ "/public/../.env",
129
+ ]) {
130
+ const res = await req(path);
131
+ if (res.status === 200) {
132
+ const body = await res.text();
133
+ expect(body).not.toContain('"scripts"');
134
+ } else {
135
+ expect([400, 404]).toContain(res.status);
136
+ }
137
+ }
138
+ });
139
+
140
+ it("ships the pinned Alpine build the page references", async () => {
141
+ const file = Bun.file(new URL("../public/vendor/alpine-csp-3.16.2.min.js", import.meta.url));
142
+ expect(await file.exists()).toBe(true);
143
+ });
144
+ });
@@ -0,0 +1,169 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ applyMigrations,
7
+ appliedMigrations,
8
+ backupDatabase,
9
+ bootDatabase,
10
+ listMigrations,
11
+ pendingMigrations,
12
+ restoreDatabase,
13
+ openDatabase,
14
+ } from "../src/db/lifecycle";
15
+
16
+ let dir: string;
17
+ let dbPath: string;
18
+
19
+ beforeEach(() => {
20
+ dir = mkdtempSync(join(tmpdir(), "db-test-"));
21
+ dbPath = join(dir, "app.db");
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(dir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("migrations", () => {
29
+ it("applies every migration to a fresh database and answers representative queries", async () => {
30
+ const db = openDatabase(dbPath);
31
+ expect(pendingMigrations(db).length).toBe(listMigrations().length);
32
+ const applied = await applyMigrations(db);
33
+ expect(applied).toEqual(listMigrations().map((m) => m.name));
34
+ expect(pendingMigrations(db)).toEqual([]);
35
+
36
+ const titles = db.query<{ title: string }, []>("SELECT title FROM notes ORDER BY id").all();
37
+ expect(titles.map((row) => row.title)).toContain("It persists.");
38
+ db.query("INSERT INTO notes (title) VALUES (?)").run("second");
39
+ expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM notes").get()?.n).toBe(2);
40
+ });
41
+
42
+ it("is idempotent and journaled", async () => {
43
+ const db = openDatabase(dbPath);
44
+ await applyMigrations(db);
45
+ expect(await applyMigrations(db)).toEqual([]);
46
+ expect(appliedMigrations(db)).toEqual(listMigrations().map((m) => m.name));
47
+ });
48
+
49
+ it("applies durability pragmas on open", () => {
50
+ const db = openDatabase(dbPath);
51
+ expect(db.query<{ journal_mode: string }, []>("PRAGMA journal_mode").get()?.journal_mode).toBe("wal");
52
+ expect(db.query<{ foreign_keys: number }, []>("PRAGMA foreign_keys").get()?.foreign_keys).toBe(1);
53
+ expect(db.query<{ timeout: number }, []>("PRAGMA busy_timeout").get()?.timeout).toBe(5000);
54
+ });
55
+ });
56
+
57
+ describe("backup and restore", () => {
58
+ it("backs up with a checksum sidecar and restores after data loss", async () => {
59
+ const db = openDatabase(dbPath);
60
+ await applyMigrations(db);
61
+ db.query("INSERT INTO notes (title) VALUES (?)").run("keep me");
62
+
63
+ const backup = await backupDatabase(db, dbPath);
64
+ expect(existsSync(backup)).toBe(true);
65
+ expect(existsSync(`${backup}.sha256`)).toBe(true);
66
+
67
+ db.query("DELETE FROM notes").run();
68
+ db.close();
69
+
70
+ await restoreDatabase(backup, dbPath);
71
+ const restored = openDatabase(dbPath);
72
+ const titles = restored.query<{ title: string }, []>("SELECT title FROM notes").all();
73
+ expect(titles.map((row) => row.title)).toContain("keep me");
74
+ });
75
+
76
+ it("rejects a corrupted backup by checksum", async () => {
77
+ const db = openDatabase(dbPath);
78
+ await applyMigrations(db);
79
+ const backup = await backupDatabase(db, dbPath);
80
+ db.close();
81
+ const raw = new Uint8Array(await Bun.file(backup).arrayBuffer());
82
+ raw[100] = raw[100]! ^ 0xff;
83
+ await Bun.write(backup, raw);
84
+ await expect(restoreDatabase(backup, dbPath)).rejects.toThrow("checksum mismatch");
85
+ });
86
+
87
+ it("keeps only the newest five backups", async () => {
88
+ const db = openDatabase(dbPath);
89
+ await applyMigrations(db);
90
+ for (let index = 0; index < 7; index += 1) {
91
+ await backupDatabase(db, dbPath);
92
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, 5));
93
+ }
94
+ const backups = readdirSync(join(dir, "backups")).filter((name) => name.endsWith(".sqlite"));
95
+ expect(backups.length).toBe(5);
96
+ });
97
+ });
98
+
99
+ describe("boot ordering", () => {
100
+ it("backs up before applying pending migrations to an existing database", async () => {
101
+ const db = openDatabase(dbPath);
102
+ // Simulate a database predating this tooling: user data exists but no
103
+ // journal. Boot must back it up before migrating.
104
+ db.exec("CREATE TABLE legacy (id INTEGER PRIMARY KEY);");
105
+ db.exec("INSERT INTO legacy (id) VALUES (1);");
106
+ await bootDatabase(db, dbPath);
107
+ expect(readdirSync(join(dir, "backups")).some((name) => name.endsWith(".sqlite"))).toBe(true);
108
+ expect(pendingMigrations(db)).toEqual([]);
109
+ });
110
+
111
+ it("does not back up a brand-new database", async () => {
112
+ const db = openDatabase(dbPath);
113
+ await bootDatabase(db, dbPath);
114
+ expect(existsSync(join(dir, "backups"))).toBe(false);
115
+ expect(pendingMigrations(db)).toEqual([]);
116
+ });
117
+
118
+ it("rolls back a failing migration and keeps the journal clean", async () => {
119
+ const db = openDatabase(dbPath);
120
+ await applyMigrations(db);
121
+ const before = appliedMigrations(db);
122
+ const { writeFileSync, mkdirSync } = await import("node:fs");
123
+ const extraDir = join(dir, "migrations-extra");
124
+ mkdirSync(extraDir, { recursive: true });
125
+ writeFileSync(join(extraDir, "9998_good.sql"), "CREATE TABLE extra_ok (id INTEGER PRIMARY KEY);");
126
+ writeFileSync(join(extraDir, "9999_broken.sql"), "CREATE TABLE broken (;");
127
+ await expect(applyMigrations(db, extraDir)).rejects.toThrow("9999_broken.sql failed and was rolled back");
128
+ // The good migration before the broken one committed; the broken one
129
+ // left no journal row and no partial objects.
130
+ expect(appliedMigrations(db)).toEqual([...before, "9998_good.sql"].sort());
131
+ expect(db.query("SELECT name FROM sqlite_master WHERE name = 'broken'").get()).toBeNull();
132
+ });
133
+ });
134
+
135
+ describe("migration guards", () => {
136
+ it("rejects migrations containing transaction control", async () => {
137
+ const db = openDatabase(dbPath);
138
+ const { writeFileSync, mkdirSync } = await import("node:fs");
139
+ const extraDir = join(dir, "migrations-tx");
140
+ mkdirSync(extraDir, { recursive: true });
141
+ writeFileSync(join(extraDir, "0001_tx.sql"), "BEGIN;\nCREATE TABLE x (id INTEGER);\nCOMMIT;");
142
+ await expect(applyMigrations(db, extraDir)).rejects.toThrow("transaction control");
143
+ });
144
+
145
+ it("rejects backfilled migrations sorting before applied ones", async () => {
146
+ const db = openDatabase(dbPath);
147
+ await applyMigrations(db);
148
+ const { writeFileSync, mkdirSync } = await import("node:fs");
149
+ const extraDir = join(dir, "migrations-backfill");
150
+ mkdirSync(extraDir, { recursive: true });
151
+ writeFileSync(join(extraDir, "0000_early.sql"), "CREATE TABLE early (id INTEGER);");
152
+ expect(() => pendingMigrations(db, extraDir)).toThrow("Rename them to sort last");
153
+ });
154
+
155
+ it("retention never touches unrelated files in the backups directory", async () => {
156
+ const db = openDatabase(dbPath);
157
+ await applyMigrations(db);
158
+ const { writeFileSync, mkdirSync } = await import("node:fs");
159
+ mkdirSync(join(dir, "backups"), { recursive: true });
160
+ writeFileSync(join(dir, "backups", "unrelated.sqlite"), "not ours");
161
+ for (let index = 0; index < 7; index += 1) {
162
+ await backupDatabase(db, dbPath);
163
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, 5));
164
+ }
165
+ const files = readdirSync(join(dir, "backups"));
166
+ expect(files).toContain("unrelated.sqlite");
167
+ expect(files.filter((name) => name.startsWith("app.db.") && name.endsWith(".sqlite")).length).toBe(5);
168
+ });
169
+ });
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "resolveJsonModule": true,
7
+ "noEmit": true,
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "types": [
11
+ "bun-types"
12
+ ],
13
+ "skipLibCheck": true
14
+ },
15
+ "include": [
16
+ "src",
17
+ "test"
18
+ ]
19
+ }