create-shibumi 0.2.8 → 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 +17 -4
- package/package.json +2 -2
- package/scripts/ship.lock.json +2 -2
- package/src/adopt.ts +250 -0
- package/src/args.ts +32 -6
- package/src/cli.ts +230 -41
- package/src/templates/blog/agents.md +4 -1
- package/src/templates/blog/bun.lock +1 -0
- package/src/templates/blog/gitignore +2 -0
- package/src/templates/blog/package.json +4 -2
- package/src/templates/blog/public/favicon.svg +1 -0
- package/src/templates/blog/public/style.css +77 -4
- package/src/templates/blog/src/components/BaseHead.astro +4 -0
- package/src/templates/blog/src/content/blog/one-vps-is-plenty.md +7 -5
- package/src/templates/blog/src/content/blog/own-your-source.md +9 -5
- package/src/templates/blog/src/content/blog/writing-for-agents.md +7 -5
- package/src/templates/blog/src/content.config.ts +3 -0
- package/src/templates/blog/src/layouts/Base.astro +12 -1
- package/src/templates/blog/src/pages/index.astro +5 -2
- package/src/templates/blog/src/pages/llms.txt.ts +1 -1
- package/src/templates/blog/src/pages/posts/[id].astro +2 -1
- package/src/templates/blog/src/pages/posts/[id].md.ts +1 -1
- package/src/templates/blog/src/pages/rss.xml.ts +1 -1
- package/src/templates/full-stack/agents.md +1 -0
- package/src/templates/full-stack/package.json +1 -0
- package/src/templates/full-stack/public/favicon.svg +1 -0
- package/src/templates/full-stack/public/style.css +5 -6
- package/src/templates/full-stack/src/app.ts +5 -3
- package/src/templates/ship.ts +551 -153
- package/src/templates/static/agents.md +1 -0
- package/src/templates/{web → static}/bun.lock +1 -21
- package/src/templates/static/gitignore +2 -0
- package/src/templates/static/package.json +6 -2
- package/src/templates/static/public/favicon.svg +1 -0
- package/src/templates/static/public/index.html +11 -3
- package/src/templates/static/public/style.css +66 -20
- package/src/templates/web/.dockerignore +0 -12
- package/src/templates/web/Dockerfile +0 -24
- package/src/templates/web/README.md +0 -10
- package/src/templates/web/agents.md +0 -54
- package/src/templates/web/compose.yaml +0 -17
- package/src/templates/web/gitignore +0 -5
- package/src/templates/web/package.json +0 -34
- package/src/templates/web/public/app.js +0 -11
- package/src/templates/web/public/style.css +0 -150
- package/src/templates/web/public/vendor/alpine-csp-3.16.2.min.js +0 -23
- package/src/templates/web/public/vendor/shibumi.css +0 -422
- package/src/templates/web/src/app.ts +0 -106
- package/src/templates/web/src/env.ts +0 -21
- package/src/templates/web/src/server.ts +0 -23
- package/src/templates/web/test/app.test.ts +0 -145
- package/src/templates/web/tsconfig.json +0 -13
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
2
|
-
import { HTTPException } from "hono/http-exception";
|
|
3
|
-
import { serveStatic } from "hono/bun";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
|
|
6
|
-
// The exact response header set; test/app.test.ts asserts every entry on
|
|
7
|
-
// success, error, API, and static responses. Change both together.
|
|
8
|
-
export const SECURITY_HEADERS: Record<string, string> = {
|
|
9
|
-
"Content-Security-Policy":
|
|
10
|
-
"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'",
|
|
11
|
-
"X-Content-Type-Options": "nosniff",
|
|
12
|
-
"X-Frame-Options": "DENY",
|
|
13
|
-
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
14
|
-
"Permissions-Policy": "camera=(), geolocation=(), microphone=()",
|
|
15
|
-
"Cross-Origin-Opener-Policy": "same-origin",
|
|
16
|
-
"Cross-Origin-Resource-Policy": "same-origin",
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
function applyHeaders(res: Response): Response {
|
|
20
|
-
for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
|
|
21
|
-
res.headers.set(name, value);
|
|
22
|
-
}
|
|
23
|
-
return res;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Factory so tests can build an instance and add probe routes before the
|
|
27
|
-
// router freezes on first dispatch.
|
|
28
|
-
export function createApp(): Hono {
|
|
29
|
-
const app = new Hono();
|
|
30
|
-
|
|
31
|
-
app.use("*", async (c, next) => {
|
|
32
|
-
await next();
|
|
33
|
-
applyHeaders(c.res);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
// Thrown errors bypass the middleware above (the exception unwinds past
|
|
37
|
-
// its post-next line), so the error handler applies the same set.
|
|
38
|
-
// Deliberate HTTP errors (framework middleware like CSRF throws
|
|
39
|
-
// HTTPException) keep their status; everything else is a logged 500.
|
|
40
|
-
app.onError((err, c) => {
|
|
41
|
-
if (err instanceof HTTPException) return applyHeaders(err.getResponse());
|
|
42
|
-
console.error(err);
|
|
43
|
-
return applyHeaders(c.text("Internal error", 500));
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
app.get("/healthz", (c) => c.json({ ok: true }));
|
|
47
|
-
|
|
48
|
-
const helloQuery = z.object({
|
|
49
|
-
name: z.string().min(1).max(100).optional(),
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
app.get("/api/hello", (c) => {
|
|
53
|
-
const parsed = helloQuery.safeParse(c.req.query());
|
|
54
|
-
if (!parsed.success) {
|
|
55
|
-
return c.json({ error: "Invalid query" }, 400);
|
|
56
|
-
}
|
|
57
|
-
return c.json({ hello: parsed.data.name ?? "world" });
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
const page = `<!doctype html>
|
|
61
|
-
<html lang="en">
|
|
62
|
-
<head>
|
|
63
|
-
<meta charset="utf-8" />
|
|
64
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
65
|
-
<title>Web app · shibumi</title>
|
|
66
|
-
<link rel="stylesheet" href="/public/vendor/shibumi.css" />
|
|
67
|
-
<link rel="stylesheet" href="/public/style.css" />
|
|
68
|
-
<!-- app.js must load before Alpine so the alpine:init listener exists
|
|
69
|
-
when Alpine starts; both defer, so document order is execution order. -->
|
|
70
|
-
<script src="/public/app.js" defer></script>
|
|
71
|
-
<script src="/public/vendor/alpine-csp-3.16.2.min.js" defer></script>
|
|
72
|
-
</head>
|
|
73
|
-
<body>
|
|
74
|
-
<main class="scaffold">
|
|
75
|
-
<p class="masthead"><span class="masthead-mark">渋み</span> shibumi web</p>
|
|
76
|
-
<h1>Web app</h1>
|
|
77
|
-
<p class="lede">Hono serves the routes, Zod validates every input, and Alpine runs the client behavior. Deploy to your own server with one command.</p>
|
|
78
|
-
<section class="demo" x-data="counter" aria-label="Alpine counter demo">
|
|
79
|
-
<button x-on:click="inc" type="button">Count</button>
|
|
80
|
-
<output x-text="count">0</output>
|
|
81
|
-
<span class="demo-hint">client state without a build step</span>
|
|
82
|
-
</section>
|
|
83
|
-
<ul class="endpoints">
|
|
84
|
-
<li><a href="/api/hello?name=you"><span class="endpoint-path">GET /api/hello</span><span>query validated with Zod</span></a></li>
|
|
85
|
-
<li><a href="/healthz"><span class="endpoint-path">GET /healthz</span><span>the check every deploy waits for</span></a></li>
|
|
86
|
-
</ul>
|
|
87
|
-
<footer class="colophon">
|
|
88
|
-
<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 email</code>.</p>
|
|
89
|
-
<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>
|
|
90
|
-
</footer>
|
|
91
|
-
</main>
|
|
92
|
-
</body>
|
|
93
|
-
</html>
|
|
94
|
-
`;
|
|
95
|
-
|
|
96
|
-
app.get("/", (c) => c.html(page));
|
|
97
|
-
|
|
98
|
-
// GET-only: static files must not answer mutation verbs.
|
|
99
|
-
app.get("/public/*", serveStatic({ root: "./" }));
|
|
100
|
-
|
|
101
|
-
app.notFound((c) => c.text("Not found", 404));
|
|
102
|
-
|
|
103
|
-
return app;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export const app = createApp();
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
// Validate the environment at the boundary; the app never reads process.env
|
|
4
|
-
// directly. Add new variables here so misconfiguration fails at startup.
|
|
5
|
-
const schema = z.object({
|
|
6
|
-
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
export type Env = z.infer<typeof schema>;
|
|
10
|
-
|
|
11
|
-
export function loadEnv(source: Record<string, string | undefined> = process.env): Env {
|
|
12
|
-
const parsed = schema.safeParse(source);
|
|
13
|
-
if (!parsed.success) {
|
|
14
|
-
console.error("Invalid environment:");
|
|
15
|
-
for (const issue of parsed.error.issues) {
|
|
16
|
-
console.error(` ${issue.path.join(".")}: ${issue.message}`);
|
|
17
|
-
}
|
|
18
|
-
process.exit(1);
|
|
19
|
-
}
|
|
20
|
-
return parsed.data;
|
|
21
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { app } from "./app";
|
|
2
|
-
import { loadEnv } from "./env";
|
|
3
|
-
|
|
4
|
-
const env = loadEnv();
|
|
5
|
-
|
|
6
|
-
const server = Bun.serve({
|
|
7
|
-
port: env.PORT,
|
|
8
|
-
// Cap request bodies so an unauthenticated client cannot exhaust memory
|
|
9
|
-
// before a route (or its rate limiter) runs. Raise it for large uploads.
|
|
10
|
-
maxRequestBodySize: 1024 * 1024,
|
|
11
|
-
fetch: app.fetch,
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
console.log(`Listening on http://localhost:${server.port}`);
|
|
15
|
-
|
|
16
|
-
// Graceful shutdown: stop accepting connections, let in-flight requests
|
|
17
|
-
// finish, then exit. The container runtime sends SIGTERM on replacement.
|
|
18
|
-
async function shutdown(code: number): Promise<void> {
|
|
19
|
-
await server.stop();
|
|
20
|
-
process.exit(code);
|
|
21
|
-
}
|
|
22
|
-
process.on("SIGTERM", () => void shutdown(0));
|
|
23
|
-
process.on("SIGINT", () => void shutdown(0));
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { app, createApp } from "../src/app";
|
|
3
|
-
|
|
4
|
-
// Independent copy of the required header contract. If src/app.ts weakens a
|
|
5
|
-
// header, this literal makes the test fail instead of silently adapting.
|
|
6
|
-
const REQUIRED_HEADERS: Record<string, string> = {
|
|
7
|
-
"Content-Security-Policy":
|
|
8
|
-
"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'",
|
|
9
|
-
"X-Content-Type-Options": "nosniff",
|
|
10
|
-
"X-Frame-Options": "DENY",
|
|
11
|
-
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
12
|
-
"Permissions-Policy": "camera=(), geolocation=(), microphone=()",
|
|
13
|
-
"Cross-Origin-Opener-Policy": "same-origin",
|
|
14
|
-
"Cross-Origin-Resource-Policy": "same-origin",
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
async function req(path: string, init?: RequestInit): Promise<Response> {
|
|
18
|
-
return app.fetch(new Request(`http://localhost${path}`, init));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function expectSecurityHeaders(res: Response): void {
|
|
22
|
-
for (const [name, value] of Object.entries(REQUIRED_HEADERS)) {
|
|
23
|
-
expect(res.headers.get(name)).toBe(value);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe("routes", () => {
|
|
28
|
-
it("serves the home page", async () => {
|
|
29
|
-
const res = await req("/");
|
|
30
|
-
expect(res.status).toBe(200);
|
|
31
|
-
const html = await res.text();
|
|
32
|
-
expect(html).toContain("Web app");
|
|
33
|
-
// app.js must come before Alpine so the alpine:init listener registers
|
|
34
|
-
// before Alpine starts.
|
|
35
|
-
expect(html.indexOf("/public/app.js")).toBeLessThan(
|
|
36
|
-
html.indexOf("/public/vendor/alpine-csp-3.16.2.min.js")
|
|
37
|
-
);
|
|
38
|
-
expectSecurityHeaders(res);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("answers the health check", async () => {
|
|
42
|
-
const res = await req("/healthz");
|
|
43
|
-
expect(res.status).toBe(200);
|
|
44
|
-
expect(await res.json()).toEqual({ ok: true });
|
|
45
|
-
expectSecurityHeaders(res);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("validates API query input", async () => {
|
|
49
|
-
const ok = await req("/api/hello?name=you");
|
|
50
|
-
expect(ok.status).toBe(200);
|
|
51
|
-
expect(await ok.json()).toEqual({ hello: "you" });
|
|
52
|
-
|
|
53
|
-
const bad = await req(`/api/hello?name=${"x".repeat(101)}`);
|
|
54
|
-
expect(bad.status).toBe(400);
|
|
55
|
-
expectSecurityHeaders(bad);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("returns 404 with security headers", async () => {
|
|
59
|
-
const res = await req("/nope");
|
|
60
|
-
expect(res.status).toBe(404);
|
|
61
|
-
expectSecurityHeaders(res);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("serves static assets with security headers", async () => {
|
|
65
|
-
const res = await req("/public/style.css");
|
|
66
|
-
expect(res.status).toBe(200);
|
|
67
|
-
expectSecurityHeaders(res);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("keeps security headers on thrown-error responses", async () => {
|
|
71
|
-
// Fresh instance: the shared app's router freezes on first dispatch.
|
|
72
|
-
const probe = createApp();
|
|
73
|
-
probe.get("/__boom", () => {
|
|
74
|
-
throw new Error("test explosion");
|
|
75
|
-
});
|
|
76
|
-
// onError logs the exception before answering; silence it so the
|
|
77
|
-
// deliberate explosion does not read as a failure in test output.
|
|
78
|
-
const errorLog = console.error;
|
|
79
|
-
console.error = () => {};
|
|
80
|
-
try {
|
|
81
|
-
const res = await probe.fetch(new Request("http://localhost/__boom"));
|
|
82
|
-
expect(res.status).toBe(500);
|
|
83
|
-
expectSecurityHeaders(res);
|
|
84
|
-
} finally {
|
|
85
|
-
console.error = errorLog;
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("security", () => {
|
|
91
|
-
it("registers exactly the expected routes", () => {
|
|
92
|
-
// Exact allowlist: any new route (including app.all handlers that could
|
|
93
|
-
// hide a mutation endpoint) must be added here deliberately.
|
|
94
|
-
const routes = app.routes.map((r) => `${r.method} ${r.path}`);
|
|
95
|
-
expect(routes.sort()).toEqual(
|
|
96
|
-
[
|
|
97
|
-
"ALL /*",
|
|
98
|
-
"GET /public/*",
|
|
99
|
-
"GET /",
|
|
100
|
-
"GET /api/hello",
|
|
101
|
-
"GET /healthz",
|
|
102
|
-
].sort()
|
|
103
|
-
);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("rejects every mutation verb on every route", async () => {
|
|
107
|
-
for (const path of ["/", "/api/hello", "/healthz", "/public/style.css"]) {
|
|
108
|
-
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
|
109
|
-
const res = await req(path, { method });
|
|
110
|
-
expect([404, 405]).toContain(res.status);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("does not expose files outside public/ via traversal", async () => {
|
|
116
|
-
for (const path of [
|
|
117
|
-
"/public/../package.json",
|
|
118
|
-
"/public/%2e%2e/package.json",
|
|
119
|
-
"/public/..%2fpackage.json",
|
|
120
|
-
"/public/%2e%2e%2fsrc/app.ts",
|
|
121
|
-
"/public/..\\package.json",
|
|
122
|
-
"/public/../.env",
|
|
123
|
-
]) {
|
|
124
|
-
const res = await req(path);
|
|
125
|
-
if (res.status === 200) {
|
|
126
|
-
const body = await res.text();
|
|
127
|
-
expect(body).not.toContain('"scripts"');
|
|
128
|
-
expect(body).not.toContain("Bun.serve");
|
|
129
|
-
} else {
|
|
130
|
-
expect([400, 404]).toContain(res.status);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("pins a CSP without unsafe-inline or unsafe-eval", () => {
|
|
136
|
-
const csp = REQUIRED_HEADERS["Content-Security-Policy"]!;
|
|
137
|
-
expect(csp).not.toContain("unsafe-inline");
|
|
138
|
-
expect(csp).not.toContain("unsafe-eval");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("ships the pinned Alpine build the page references", async () => {
|
|
142
|
-
const file = Bun.file(new URL("../public/vendor/alpine-csp-3.16.2.min.js", import.meta.url));
|
|
143
|
-
expect(await file.exists()).toBe(true);
|
|
144
|
-
});
|
|
145
|
-
});
|
|
@@ -1,13 +0,0 @@
|
|
|
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": ["bun-types"]
|
|
11
|
-
},
|
|
12
|
-
"include": ["src", "test"]
|
|
13
|
-
}
|