create-shibumi 0.2.3 → 0.2.7

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.
@@ -1,10 +1,10 @@
1
1
  import { Hono } from "hono";
2
2
  import { HTTPException } from "hono/http-exception";
3
3
  import { serveStatic } from "hono/bun";
4
- import { desc } from "drizzle-orm";
4
+ import { desc, eq, sql } from "drizzle-orm";
5
5
  import { z } from "zod";
6
6
  import { db } from "./db";
7
- import { notes } from "./db/schema";
7
+ import { counters, notes } from "./db/schema";
8
8
 
9
9
  // The exact response header set; test/app.test.ts asserts every entry on
10
10
  // success, error, API, and static responses. Change both together.
@@ -67,12 +67,47 @@ export function createApp(): Hono {
67
67
  return c.json({ notes: rows });
68
68
  });
69
69
 
70
+ // The demo counter is the template's single unauthenticated mutation. It is
71
+ // safe by construction: one shared row, no request body read, value clamped
72
+ // by the schema CHECK, and an in-memory per-IP rate limit. Treat any other
73
+ // mutation route as needing authentication first (bun shi add auth).
74
+ const COUNTER_RATE = 30;
75
+ const COUNTER_WINDOW_MS = 60_000;
76
+ const counterHits = new Map<string, { count: number; resetAt: number }>();
77
+ function counterAllowed(key: string, now = Date.now()): boolean {
78
+ const entry = counterHits.get(key);
79
+ if (!entry || entry.resetAt <= now) {
80
+ if (counterHits.size > 10_000) counterHits.clear();
81
+ counterHits.set(key, { count: 1, resetAt: now + COUNTER_WINDOW_MS });
82
+ return true;
83
+ }
84
+ entry.count += 1;
85
+ return entry.count <= COUNTER_RATE;
86
+ }
87
+
88
+ app.get("/api/counter", async (c) => {
89
+ const row = await db.select().from(counters).where(eq(counters.id, 1)).get();
90
+ return c.json({ count: row?.value ?? 0 });
91
+ });
92
+
93
+ app.post("/api/counter", async (c) => {
94
+ const ip = (c.req.header("x-forwarded-for") ?? "local").split(",")[0]!.trim();
95
+ if (!counterAllowed(ip)) return c.json({ error: "Too many increments; try again in a minute." }, 429);
96
+ const [row] = await db
97
+ .update(counters)
98
+ .set({ value: sql`MIN(${counters.value} + 1, 1000000000)` })
99
+ .where(eq(counters.id, 1))
100
+ .returning();
101
+ return c.json({ count: row?.value ?? 0 });
102
+ });
103
+
70
104
  const page = `<!doctype html>
71
105
  <html lang="en">
72
106
  <head>
73
107
  <meta charset="utf-8" />
74
108
  <meta name="viewport" content="width=device-width, initial-scale=1" />
75
- <title>Shibumi web app</title>
109
+ <title>Full Stack app · shibumi</title>
110
+ <link rel="stylesheet" href="/public/vendor/shibumi.css" />
76
111
  <link rel="stylesheet" href="/public/style.css" />
77
112
  <!-- app.js must load before Alpine so the alpine:init listener exists
78
113
  when Alpine starts; both defer, so document order is execution order. -->
@@ -80,14 +115,24 @@ const page = `<!doctype html>
80
115
  <script src="/public/vendor/alpine-csp-3.16.2.min.js" defer></script>
81
116
  </head>
82
117
  <body>
83
- <main>
84
- <h1>It persists.</h1>
85
- <p>Hono routes, Zod validation, Alpine behavior, and SQLite with Drizzle under a persistent volume. To get started, open <code>src/app.ts</code>; this page lives there.</p>
86
- <section x-data="counter">
118
+ <main class="scaffold">
119
+ <p class="masthead"><span class="masthead-mark">渋み</span> shibumi full stack</p>
120
+ <h1>Full Stack app</h1>
121
+ <p class="lede">Hono serves the routes, Zod validates every input, Alpine runs the client behavior, and SQLite with Drizzle keeps your data on a volume that survives every deploy.</p>
122
+ <section class="demo" x-data="counter" aria-label="Alpine counter demo">
87
123
  <button x-on:click="inc" type="button">Count</button>
88
- <output x-text="count"></output>
124
+ <output x-text="count">0</output>
125
+ <span class="demo-hint">stored in SQLite; reloads and deploys keep it</span>
89
126
  </section>
90
- <p><a href="/api/hello?name=you">/api/hello</a> · <a href="/api/notes">/api/notes</a> · <a href="/healthz">/healthz</a></p>
127
+ <ul class="endpoints">
128
+ <li><a href="/api/hello?name=you"><span class="endpoint-path">GET /api/hello</span><span>query validated with Zod</span></a></li>
129
+ <li><a href="/api/notes"><span class="endpoint-path">GET /api/notes</span><span>Drizzle read from SQLite on the data volume</span></a></li>
130
+ <li><a href="/healthz"><span class="endpoint-path">GET /healthz</span><span>the check every deploy waits for</span></a></li>
131
+ </ul>
132
+ <footer class="colophon">
133
+ <p>This page lives in <code>src/app.ts</code>. House rules for coding agents are in <code>agents.md</code>. Add features with <code>bun shi add auth</code>.</p>
134
+ <p class="colophon-links"><a href="https://shibumistack.dev/docs" rel="noreferrer">shibumi docs</a> · <a href="https://server.shibumistack.dev/docs" rel="noreferrer">server docs</a> · <a href="https://shibumistack.dev/docs/cli/extensions" rel="noreferrer">extensions</a></p>
135
+ </footer>
91
136
  </main>
92
137
  </body>
93
138
  </html>
@@ -0,0 +1,6 @@
1
+ CREATE TABLE counters (
2
+ id INTEGER PRIMARY KEY CHECK (id = 1),
3
+ value INTEGER NOT NULL DEFAULT 0 CHECK (value BETWEEN 0 AND 1000000000)
4
+ );
5
+
6
+ INSERT INTO counters (id, value) VALUES (1, 0);
@@ -11,3 +11,11 @@ export const notes = sqliteTable("notes", {
11
11
  .notNull()
12
12
  .default(sql`(datetime('now'))`),
13
13
  });
14
+
15
+ // One demo counter row (id fixed to 1). The page increments it through
16
+ // POST /api/counter, the only unauthenticated mutation in the template:
17
+ // rate-limited, clamped by a CHECK constraint, and holding no user data.
18
+ export const counters = sqliteTable("counters", {
19
+ id: integer("id").primaryKey(),
20
+ value: integer("value").notNull().default(0),
21
+ });
@@ -39,7 +39,7 @@ describe("routes", () => {
39
39
  const res = await req("/");
40
40
  expect(res.status).toBe(200);
41
41
  const html = await res.text();
42
- expect(html).toContain("It persists.");
42
+ expect(html).toContain("Full Stack app");
43
43
  expect(html.indexOf("/public/app.js")).toBeLessThan(
44
44
  html.indexOf("/public/vendor/alpine-csp-3.16.2.min.js")
45
45
  );
@@ -84,9 +84,17 @@ describe("routes", () => {
84
84
  probe.get("/__boom", () => {
85
85
  throw new Error("test explosion");
86
86
  });
87
- const res = await probe.fetch(new Request("http://localhost/__boom"));
88
- expect(res.status).toBe(500);
89
- expectSecurityHeaders(res);
87
+ // onError logs the exception before answering; silence it so the
88
+ // deliberate explosion does not read as a failure in test output.
89
+ const errorLog = console.error;
90
+ console.error = () => {};
91
+ try {
92
+ const res = await probe.fetch(new Request("http://localhost/__boom"));
93
+ expect(res.status).toBe(500);
94
+ expectSecurityHeaders(res);
95
+ } finally {
96
+ console.error = errorLog;
97
+ }
90
98
  });
91
99
  });
92
100
 
@@ -100,18 +108,41 @@ describe("security", () => {
100
108
  "GET /",
101
109
  "GET /api/hello",
102
110
  "GET /api/notes",
111
+ "GET /api/counter",
112
+ "POST /api/counter",
103
113
  "GET /healthz",
104
114
  ].sort()
105
115
  );
106
116
  });
107
117
 
108
- it("rejects every mutation verb on every route", async () => {
118
+ it("rejects every mutation verb on every route except the demo counter", async () => {
109
119
  for (const path of ["/", "/api/hello", "/api/notes", "/healthz", "/public/style.css"]) {
110
120
  for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
111
121
  const res = await req(path, { method });
112
122
  expect([404, 405]).toContain(res.status);
113
123
  }
114
124
  }
125
+ for (const method of ["PUT", "PATCH", "DELETE"]) {
126
+ expect([404, 405]).toContain((await req("/api/counter", { method })).status);
127
+ }
128
+ });
129
+
130
+ it("persists counter increments in the database", async () => {
131
+ const before = (await (await req("/api/counter")).json()).count;
132
+ const res = await req("/api/counter", { method: "POST", headers: { "x-forwarded-for": "10.9.0.1" } });
133
+ expect(res.status).toBe(200);
134
+ expect((await res.json()).count).toBe(before + 1);
135
+ // A fresh read (what a reload does) sees the stored value.
136
+ expect((await (await req("/api/counter")).json()).count).toBe(before + 1);
137
+ });
138
+
139
+ it("rate-limits counter increments per IP", async () => {
140
+ let limited = false;
141
+ for (let i = 0; i < 40; i++) {
142
+ const res = await req("/api/counter", { method: "POST", headers: { "x-forwarded-for": "10.9.0.2" } });
143
+ if (res.status === 429) { limited = true; break; }
144
+ }
145
+ expect(limited).toBe(true);
115
146
  });
116
147
 
117
148
  it("pins a CSP without unsafe-inline or unsafe-eval", () => {
@@ -25,7 +25,7 @@ const SERVER_HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/;
25
25
  const COMMIT = /^[a-f0-9]{40}$/;
26
26
  const SERVER_CLI = "~/.local/bin/shibumi-server";
27
27
  const LATEST_SOURCE = "https://shibumistack.dev/ship/latest.ts";
28
- const CURRENT_SOURCE = "https://shibumistack.dev/ship/v45.ts";
28
+ const CURRENT_SOURCE = "https://shibumistack.dev/ship/v46.ts";
29
29
  let sshControlDirectory: string | undefined;
30
30
  let sshControlTarget: string | undefined;
31
31
 
@@ -1703,13 +1703,13 @@ function portIsBusy(port: number): Promise<boolean> {
1703
1703
  });
1704
1704
  }
1705
1705
 
1706
- export function formatDevStartup(port: number, domain: string, time: string, color = false): string {
1706
+ export function formatDevStartup(port: number, domain: string | undefined, time: string, color = false): string {
1707
1707
  const paint = (code: string, value: string) => color ? `\x1b[${code}m${value}\x1b[0m` : value;
1708
1708
  const row = (label: string, url: string) => `${paint("2", "┃")} ${label.padEnd(8)} ${paint("34", url)}`;
1709
1709
  return [
1710
1710
  `${paint("38;5;208", "渋み")} ship dev`,
1711
1711
  row("Local", `http://localhost:${port}/`),
1712
- row("Remote", `https://${domain}`),
1712
+ ...(domain ? [row("Remote", `https://${domain}`)] : []),
1713
1713
  `${paint("2", time)} starting app dev server...`,
1714
1714
  ].join("\n");
1715
1715
  }
@@ -1719,30 +1719,33 @@ function localTime(date = new Date()): string {
1719
1719
  }
1720
1720
 
1721
1721
  async function runDev(): Promise<void> {
1722
+ // Dev must work on a fresh scaffold, before any server exists: without
1723
+ // setup, fall back to the Shibumi port convention (registered apps get the
1724
+ // first free port above 9000) and skip the Remote row.
1722
1725
  const config = await readConfig();
1723
- if (!config) throw new Error("Shibumi setup is missing.\n\nNext: run bun ship:setup.");
1724
- if (await portIsBusy(config.port)) {
1726
+ const port = config?.port ?? 9000;
1727
+ if (await portIsBusy(port)) {
1725
1728
  const lsof = Bun.which("lsof");
1726
1729
  const fuser = Bun.which("fuser");
1727
- if (!lsof && !fuser) throw new Error(`Port ${config.port} is already in use.\n\nNext: stop that process, then run bun dev again.`);
1730
+ if (!lsof && !fuser) throw new Error(`Port ${port} is already in use.\n\nNext: stop that process, then run bun dev again.`);
1728
1731
  const found = lsof
1729
- ? await run([lsof, "-nP", `-iTCP:${config.port}`, "-sTCP:LISTEN", "-t"], { allowFailure: true })
1730
- : await run([fuser!, "-n", "tcp", String(config.port)], { allowFailure: true });
1732
+ ? await run([lsof, "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { allowFailure: true })
1733
+ : await run([fuser!, "-n", "tcp", String(port)], { allowFailure: true });
1731
1734
  const pids = [...new Set(`${found.stdout} ${found.stderr}`.split(/\s+/).filter((value) => /^\d+$/.test(value)).map(Number))];
1732
- if (pids.length === 0) throw new Error(`Port ${config.port} is already in use.\n\nNext: stop that process, then run bun dev again.`);
1735
+ if (pids.length === 0) throw new Error(`Port ${port} is already in use.\n\nNext: stop that process, then run bun dev again.`);
1733
1736
  const details = await run(["ps", "-o", "pid=,comm=", "-p", pids.join(",")], { allowFailure: true });
1734
- log.warn(`Port ${config.port} is in use${details.stdout.trim() ? `:\n${details.stdout.trim()}` : ""}`);
1737
+ log.warn(`Port ${port} is in use${details.stdout.trim() ? `:\n${details.stdout.trim()}` : ""}`);
1735
1738
  const accepted = await confirm({ message: "Stop it and start this project?", initialValue: false });
1736
1739
  if (isCancel(accepted) || !accepted) return;
1737
1740
  for (const pid of pids) process.kill(pid, "SIGTERM");
1738
1741
  const deadline = Date.now() + 5_000;
1739
- while (await portIsBusy(config.port) && Date.now() < deadline) await Bun.sleep(100);
1740
- if (await portIsBusy(config.port)) throw new Error(`Port ${config.port} did not stop.\n\nNext: stop PID ${pids.join(", ")} manually, then run bun dev again.`);
1742
+ while (await portIsBusy(port) && Date.now() < deadline) await Bun.sleep(100);
1743
+ if (await portIsBusy(port)) throw new Error(`Port ${port} did not stop.\n\nNext: stop PID ${pids.join(", ")} manually, then run bun dev again.`);
1741
1744
  }
1742
- process.stdout.write(`${formatDevStartup(config.port, config.domain, localTime(), supportsTerminalColor())}\n`);
1745
+ process.stdout.write(`${formatDevStartup(port, config?.domain, localTime(), supportsTerminalColor())}\n`);
1743
1746
  const child = Bun.spawn([process.execPath, "run", "dev:app"], {
1744
1747
  cwd: root,
1745
- env: { ...process.env, PORT: String(config.port), SHIBUMI_PORT: String(config.port) },
1748
+ env: { ...process.env, PORT: String(port), SHIBUMI_PORT: String(port) },
1746
1749
  stdin: "inherit",
1747
1750
  stdout: "inherit",
1748
1751
  stderr: "inherit",
@@ -1808,7 +1811,7 @@ export async function runShip(): Promise<void> {
1808
1811
  ? await confirm({ message: "Ship now?", initialValue: true })
1809
1812
  : false;
1810
1813
  if (shipNow !== true || isCancel(shipNow)) {
1811
- outro(`${accent("Next:")} ${result.config.trigger === "github-push" ? "git push deploys automatically" : "bun ship"}`);
1814
+ outro(`${accent("Next:")} ${result.config.trigger === "github-push" ? `git push origin ${result.config.branch} to deploy` : "bun ship"}`);
1812
1815
  return;
1813
1816
  }
1814
1817
  }
@@ -1,60 +1,144 @@
1
- :root {
2
- color-scheme: light dark;
3
- --paper: #f5f0e4;
4
- --ink: #1b130f;
5
- --accent: #e95f19;
6
- }
1
+ /* App styles on top of the vendored shibumi.css tokens (public/vendor/).
2
+ Edit freely; this file is yours. Tokens: --paper --ink --muted --line
3
+ --accent, type scale --text-xs..--text-2xl, --font-sans/serif/mono. */
7
4
 
8
- @media (prefers-color-scheme: dark) {
9
- :root {
10
- --paper: #1b130f;
11
- --ink: #f5f0e4;
12
- --accent: #ff8648;
13
- }
5
+ .scaffold {
6
+ max-width: 40.625rem;
7
+ margin: 0 auto;
8
+ padding: clamp(3rem, 9vh, 6.5rem) 1.5rem 4rem;
14
9
  }
15
10
 
16
- body {
17
- margin: 0;
18
- background: var(--paper);
19
- color: var(--ink);
20
- font-family: system-ui, sans-serif;
21
- font-size: 1.125rem;
22
- line-height: 1.6;
11
+ .masthead {
12
+ display: flex;
13
+ align-items: baseline;
14
+ gap: 0.6rem;
15
+ margin: 0 0 3.5rem;
16
+ padding-bottom: 1rem;
17
+ border-bottom: 1px solid var(--line);
18
+ color: var(--muted);
19
+ font-size: var(--text-sm);
20
+ letter-spacing: 0.04em;
23
21
  }
24
22
 
25
- main {
26
- max-width: 40rem;
27
- margin: 0 auto;
28
- padding: 4rem 1.5rem;
23
+ .masthead-mark {
24
+ color: var(--accent);
25
+ font-size: var(--text-md);
29
26
  }
30
27
 
31
- h1 {
32
- font-family: Georgia, "Times New Roman", serif;
33
- font-weight: 500;
28
+ .scaffold h1 {
29
+ margin: 0 0 0.75rem;
30
+ font-size: clamp(2.6rem, 7vw, 3.8rem);
31
+ font-weight: 450;
32
+ letter-spacing: -0.03em;
33
+ line-height: 1.05;
34
34
  }
35
35
 
36
- a {
37
- color: inherit;
38
- text-decoration: underline;
39
- text-decoration-color: color-mix(in srgb, var(--accent) 30%, transparent);
40
- text-underline-offset: 0.18em;
36
+ .lede {
37
+ margin: 0 0 3rem;
38
+ max-width: 34rem;
39
+ color: var(--muted);
40
+ font-size: var(--text-lg);
41
+ line-height: 1.55;
41
42
  }
42
43
 
43
- a:hover {
44
- text-decoration-color: var(--accent);
44
+ .demo {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 1rem;
48
+ margin-bottom: 3.25rem;
45
49
  }
46
50
 
47
- button {
48
- font: inherit;
49
- padding: 0.4rem 1rem;
51
+ .demo button {
52
+ font: 500 var(--text-md) var(--font-sans);
53
+ padding: 0.5rem 1.3rem;
50
54
  border: 1px solid var(--ink);
55
+ border-radius: 0.35rem;
51
56
  background: transparent;
52
57
  color: inherit;
53
- border-radius: 4px;
54
58
  cursor: pointer;
59
+ transition: border-color 160ms ease, color 160ms ease;
60
+ }
61
+
62
+ .demo button:hover,
63
+ .demo button:focus-visible {
64
+ border-color: var(--accent);
65
+ color: var(--accent);
55
66
  }
56
67
 
57
- output {
58
- margin-left: 0.75rem;
68
+ .demo output {
69
+ min-width: 2ch;
70
+ font-size: var(--text-lg);
59
71
  font-variant-numeric: tabular-nums;
60
72
  }
73
+
74
+ .demo-hint {
75
+ color: var(--muted);
76
+ font-size: var(--text-sm);
77
+ }
78
+
79
+ .endpoints {
80
+ margin: 0;
81
+ padding: 0;
82
+ list-style: none;
83
+ border-top: 1px solid var(--line);
84
+ }
85
+
86
+ main .endpoints a {
87
+ display: flex;
88
+ align-items: baseline;
89
+ justify-content: space-between;
90
+ gap: 1.5rem;
91
+ padding: 0.9rem 0.25rem;
92
+ border-bottom: 1px solid var(--line);
93
+ text-decoration: none;
94
+ text-decoration-color: transparent;
95
+ transition: background 160ms ease;
96
+ }
97
+
98
+ main .endpoints a:hover,
99
+ main .endpoints a:focus-visible {
100
+ background: var(--faint);
101
+ }
102
+
103
+ .endpoint-path {
104
+ font-family: var(--font-mono);
105
+ font-size: var(--text-sm);
106
+ }
107
+ /* Inline code as rounded chips on the faint layer. */
108
+ .scaffold code {
109
+ padding: 0.12em 0.42em;
110
+ border-radius: 0.35rem;
111
+ background: var(--faint);
112
+ font-family: var(--font-mono);
113
+ font-size: 0.85em;
114
+ }
115
+
116
+
117
+ main .endpoints a:hover .endpoint-path {
118
+ color: var(--accent);
119
+ }
120
+
121
+ .endpoints span {
122
+ color: var(--muted);
123
+ font-size: var(--text-sm);
124
+ text-align: right;
125
+ }
126
+
127
+ .colophon {
128
+ margin-top: 3.5rem;
129
+ }
130
+
131
+ .colophon p {
132
+ margin: 0 0 0.5rem;
133
+ color: var(--muted);
134
+ font-size: var(--text-sm);
135
+ line-height: 1.7;
136
+ }
137
+
138
+ .colophon code {
139
+ color: var(--ink);
140
+ }
141
+
142
+ .colophon-links {
143
+ font-size: var(--text-sm);
144
+ }