create-runlot 0.1.0-rc.100

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 (2) hide show
  1. package/dist/index.js +931 -0
  2. package/package.json +37 -0
package/dist/index.js ADDED
@@ -0,0 +1,931 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, resolve, sep } from "node:path";
6
+ import { createInterface } from "node:readline/promises";
7
+ var POSTS_MIGRATION = [
8
+ "-- Applied by `runlot deploy` (and by `runlot pg migrate`).",
9
+ "create table if not exists posts (",
10
+ " id serial primary key,",
11
+ " owner text not null,",
12
+ " title text not null",
13
+ ");",
14
+ "",
15
+ "-- One row so the example page has something to show before you write any.",
16
+ "insert into posts (owner, title)",
17
+ "select 'welcome', 'Your first post'",
18
+ "where not exists (select 1 from posts);",
19
+ ""
20
+ ].join("\n");
21
+ var TEMPLATE_NAMES = ["next", "hono", "static", "studio"];
22
+ function isTemplateName(v) {
23
+ return TEMPLATE_NAMES.includes(v);
24
+ }
25
+ var TEMPLATE_LIST = [
26
+ { name: "next", desc: "Next.js App Router \u2014 uses env.db and env.auth from server components" },
27
+ { name: "hono", desc: "Hono worker \u2014 REST API example (/posts, /upload)" },
28
+ { name: "static", desc: "A single static HTML page \u2014 assets only, no worker" },
29
+ // Last, so the numbers the other three answer to do not move.
30
+ { name: "studio", desc: "Vite + React + Tailwind and a Hono API \u2014 what runlot Studio builds" }
31
+ ];
32
+ var USAGE = [
33
+ "create-runlot <dir> [--org <slug>] [--template next|hono|static|studio]",
34
+ "",
35
+ " Creates a project from a Next.js, Hono, static-site, or studio template.",
36
+ " Without --template, in a terminal, it asks which template to use.",
37
+ " Then run: cd <dir> && runlot deploy"
38
+ ].join("\n");
39
+ var NAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
40
+ function pkgJson(obj) {
41
+ return JSON.stringify(obj, null, 2) + "\n";
42
+ }
43
+ var NEXT_TSCONFIG = pkgJson({
44
+ compilerOptions: {
45
+ lib: ["dom", "dom.iterable", "esnext"],
46
+ allowJs: true,
47
+ skipLibCheck: true,
48
+ strict: true,
49
+ noEmit: true,
50
+ esModuleInterop: true,
51
+ module: "esnext",
52
+ moduleResolution: "bundler",
53
+ resolveJsonModule: true,
54
+ isolatedModules: true,
55
+ jsx: "preserve",
56
+ incremental: true,
57
+ plugins: [{ name: "next" }]
58
+ },
59
+ include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
60
+ exclude: ["node_modules"]
61
+ });
62
+ var NEXT_CONFIG_TS = [
63
+ 'import type { NextConfig } from "next";',
64
+ "",
65
+ "const config: NextConfig = {};",
66
+ "",
67
+ "export default config;",
68
+ ""
69
+ ].join("\n");
70
+ function nextLayoutTsx(name) {
71
+ return [
72
+ 'import type { Metadata } from "next";',
73
+ 'import type { ReactNode } from "react";',
74
+ "",
75
+ "export const metadata: Metadata = {",
76
+ ` title: "${name}",`,
77
+ "};",
78
+ "",
79
+ "export default function RootLayout({ children }: { children: ReactNode }) {",
80
+ " return (",
81
+ '<html lang="en">',
82
+ " <body>{children}</body>",
83
+ " </html>",
84
+ " );",
85
+ "}",
86
+ ""
87
+ ].join("\n");
88
+ }
89
+ var NEXT_PAGE_TSX = [
90
+ 'import { Pool } from "@runlot/pg";',
91
+ 'import { env } from "@runlot/next";',
92
+ "",
93
+ "// `env` is request-scoped. Without this, Next prerenders this page during",
94
+ "// `next build`, where there is no request, and the build fails on the",
95
+ "// adapter's own error (field-notes #68).",
96
+ 'export const dynamic = "force-dynamic";',
97
+ "",
98
+ "export default async function PostsPage() {",
99
+ " const user = await env.auth.user();",
100
+ " if (!user) {",
101
+ " return (",
102
+ " <p>",
103
+ ' Please sign in. <a href="/login">Sign in</a>',
104
+ " </p>",
105
+ " );",
106
+ " }",
107
+ "",
108
+ " const db = new Pool({ db: env.db });",
109
+ "",
110
+ // `query` defaults its row type to Record<string, unknown>. Without the type
111
+ // argument `p.id` and `p.title` are `unknown`, and `next build` — which type
112
+ // checks — fails before the bundler is ever reached.
113
+ " const { rows } = await db.query<{ id: number; title: string }>(",
114
+ ' "select id, title from posts where owner = $1 limit 20",',
115
+ " [user.id],",
116
+ " );",
117
+ "",
118
+ " return (",
119
+ " <ul>",
120
+ " {rows.map((p) => <li key={p.id}>{p.title}</li>)}",
121
+ " </ul>",
122
+ " );",
123
+ "}",
124
+ ""
125
+ ].join("\n");
126
+ function nextReadme(name) {
127
+ return [
128
+ `# ${name}`,
129
+ "",
130
+ "A Next.js App Router template. `app/page.tsx` is a server component that lists",
131
+ "20 posts using `env.db` and `env.auth.user()`.",
132
+ "",
133
+ "```",
134
+ "runlot deploy",
135
+ "```",
136
+ "",
137
+ "Postgres, login and object storage come with the project \u2014 there is nothing to set up.",
138
+ "The App Router itself runs on workerd (OpenNext).",
139
+ "",
140
+ "`npm run dev` is `next dev` \u2014 the fast UI loop, without runlot's bindings.",
141
+ "`npm run preview` is `runlot dev`: it builds through OpenNext and serves the result",
142
+ "under workerd with the bindings wired up, which is where you check what will ship.",
143
+ ""
144
+ ].join("\n");
145
+ }
146
+ function nextTemplate(ctx) {
147
+ const { name, org } = ctx;
148
+ const runlotConfig = { name };
149
+ if (org) runlotConfig["org"] = org;
150
+ runlotConfig["framework"] = "next";
151
+ runlotConfig["assets"] = "public";
152
+ const packageJson = {
153
+ name,
154
+ version: "0.0.0",
155
+ private: true,
156
+ scripts: {
157
+ // `dev` stays `next dev`: for a Next project the UI loop is the thing you
158
+ // run all day, and `runlot dev` has to rebuild through OpenNext on every
159
+ // change. `preview` is the one that runs what will actually ship.
160
+ dev: "next dev",
161
+ preview: "runlot dev",
162
+ build: "next build",
163
+ start: "next start"
164
+ },
165
+ dependencies: {
166
+ next: "^16.3.4",
167
+ react: "^19.2.8",
168
+ "react-dom": "^19.2.8",
169
+ // The npm job in release.yml rewrites the version range with the same
170
+ // number as the tag — the value written here is the last published version.
171
+ "@runlot/next": "^0.1.0-rc.100",
172
+ "@runlot/pg": "^0.1.0-rc.100"
173
+ },
174
+ devDependencies: {
175
+ typescript: "^5.7.2",
176
+ "@types/react": "^19.2.18",
177
+ "@types/react-dom": "^19.2.5",
178
+ "@types/node": "^22.10.2"
179
+ }
180
+ };
181
+ return {
182
+ "package.json": pkgJson(packageJson),
183
+ "runlot.json": pkgJson(runlotConfig),
184
+ "tsconfig.json": NEXT_TSCONFIG,
185
+ "next.config.ts": NEXT_CONFIG_TS,
186
+ "app/layout.tsx": nextLayoutTsx(name),
187
+ "app/page.tsx": NEXT_PAGE_TSX,
188
+ "public/robots.txt": "User-agent: *\nAllow: /\n",
189
+ "migrations/0001_posts.sql": POSTS_MIGRATION,
190
+ // runlot deploy writes the generated OpenNext configuration into
191
+ // `.runlot-next` (docs/nextjs.md §2) — it is not source, so it is not
192
+ // something to commit.
193
+ ".gitignore": "node_modules\n.next\n.open-next\n.runlot-next\n.runlot\n.env.runlot\n",
194
+ "README.md": nextReadme(name)
195
+ };
196
+ }
197
+ var HONO_INDEX_TS = [
198
+ 'import { Hono } from "hono";',
199
+ 'import { Pool } from "@runlot/pg";',
200
+ 'import type { Db, Storage } from "@runlot/pg";',
201
+ 'import type { AuthBinding } from "@runlot/auth";',
202
+ "",
203
+ "// db, auth, and storage come with every runlot project.",
204
+ "interface Env {",
205
+ " db: Db;",
206
+ " auth: AuthBinding;",
207
+ " storage: Storage;",
208
+ " // public/ ships in the same bundle and is served through this.",
209
+ " assets: { fetch(request: Request): Promise<Response> };",
210
+ "}",
211
+ "",
212
+ "const app = new Hono<{ Bindings: Env }>();",
213
+ "",
214
+ 'app.get("/posts", async (c) => {',
215
+ " const user = await c.env.auth.user(c.req.raw);",
216
+ " // `user` is null until someone signs in, which is every first visit. The",
217
+ " // landing sample leaves this out because it is for display; a project that",
218
+ " // runs cannot (field-notes #69).",
219
+ " if (!user) {",
220
+ ' return c.json({ error: "sign in at /__runlot/auth/sign-in" }, 401);',
221
+ " }",
222
+ "",
223
+ " const db = new Pool({ db: c.env.db });",
224
+ "",
225
+ " const { rows } = await db.query(",
226
+ ' "select id, title from posts where owner = $1 limit 20",',
227
+ " [user.id],",
228
+ " );",
229
+ " return c.json(rows);",
230
+ "});",
231
+ "",
232
+ 'app.post("/upload", async (c) => {',
233
+ " const form = await c.req.formData();",
234
+ ' const file = form.get("file") as File;',
235
+ ' const url = await c.env.storage.put("covers/" + file.name, file);',
236
+ " return c.json({ url });",
237
+ "});",
238
+ "",
239
+ "// With `main` set, the worker is the entry point and static files are not",
240
+ "// served ahead of it \u2014 so whatever the routes above did not answer is handed",
241
+ "// to the assets binding. Without this line public/hello.txt is a 404, which",
242
+ "// is what the template shipped until field-notes #71.",
243
+ 'app.all("*", (c) => c.env.assets.fetch(c.req.raw));',
244
+ "",
245
+ "export default app;",
246
+ ""
247
+ ].join("\n");
248
+ function honoReadme(name) {
249
+ return [
250
+ `# ${name}`,
251
+ "",
252
+ "A Hono worker template. `src/index.ts` has two routes: `/posts` (a list) and",
253
+ "`/upload` (an object storage upload).",
254
+ "",
255
+ "```",
256
+ "runlot dev",
257
+ "runlot deploy",
258
+ "```",
259
+ "",
260
+ "`runlot dev` runs this worker on your own machine under workerd, with `env.storage`",
261
+ "and the static files real and `env.db` and `env.auth` as local stand-ins. It prints",
262
+ "which is which. Point `env.db` at a real database with `RUNLOT_DEV_DATABASE_URL`.",
263
+ "",
264
+ "`db`, `auth` and `storage` on `Env` come with the project \u2014 there is nothing to set up.",
265
+ ""
266
+ ].join("\n");
267
+ }
268
+ function honoTemplate(ctx) {
269
+ const { name, org } = ctx;
270
+ const runlotConfig = { name };
271
+ if (org) runlotConfig["org"] = org;
272
+ runlotConfig["main"] = "src/index.ts";
273
+ runlotConfig["assets"] = "public";
274
+ const packageJson = {
275
+ name,
276
+ version: "0.0.0",
277
+ private: true,
278
+ type: "module",
279
+ // `npm run dev` is the first thing a person types in a new directory, and
280
+ // for this template it should be the local run — not a deploy.
281
+ scripts: {
282
+ dev: "runlot dev",
283
+ deploy: "runlot deploy"
284
+ },
285
+ dependencies: {
286
+ hono: "^4.10.7",
287
+ "@runlot/pg": "^0.1.0-rc.100",
288
+ "@runlot/auth": "^0.1.0-rc.100"
289
+ },
290
+ devDependencies: {
291
+ typescript: "^5.7.2",
292
+ "@types/node": "^22.10.2"
293
+ }
294
+ };
295
+ const tsconfig = pkgJson({
296
+ compilerOptions: {
297
+ target: "ES2022",
298
+ lib: ["ES2022", "DOM"],
299
+ module: "ESNext",
300
+ moduleResolution: "bundler",
301
+ strict: true,
302
+ skipLibCheck: true,
303
+ noEmit: true
304
+ },
305
+ include: ["src"]
306
+ });
307
+ return {
308
+ "package.json": pkgJson(packageJson),
309
+ "runlot.json": pkgJson(runlotConfig),
310
+ "tsconfig.json": tsconfig,
311
+ "src/index.ts": HONO_INDEX_TS,
312
+ "public/hello.txt": "Static assets ship in the same bundle.\n",
313
+ "migrations/0001_posts.sql": POSTS_MIGRATION,
314
+ // `.runlot` is where `runlot dev` keeps the unpacked bundle, the local
315
+ // object store and the mail it did not send.
316
+ ".gitignore": "node_modules\n.runlot\n.env.runlot\n",
317
+ "README.md": honoReadme(name)
318
+ };
319
+ }
320
+ function staticIndexHtml(name) {
321
+ return [
322
+ "<!doctype html>",
323
+ '<html lang="en">',
324
+ " <head>",
325
+ ' <meta charset="utf-8" />',
326
+ ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
327
+ ` <title>${name}</title>`,
328
+ " </head>",
329
+ " <body>",
330
+ " <main>",
331
+ ` <h1>${name}</h1>`,
332
+ " <p>",
333
+ " This page is deployed as a static asset. Edit the files under <code>public/</code>",
334
+ " and run <code>runlot deploy</code> to publish the change.",
335
+ " </p>",
336
+ " </main>",
337
+ " </body>",
338
+ "</html>",
339
+ ""
340
+ ].join("\n");
341
+ }
342
+ function staticReadme(name) {
343
+ return [
344
+ `# ${name}`,
345
+ "",
346
+ "A static site template. It is a single `public/index.html` with no worker.",
347
+ "",
348
+ "```",
349
+ "runlot dev",
350
+ "runlot deploy",
351
+ "```",
352
+ "",
353
+ "`runlot dev` serves `public/` the way the deployment will, including the `notFound`",
354
+ "setting, and reloads when you save.",
355
+ "",
356
+ 'The project already has a database, login and storage. Add a worker (`"main"` in',
357
+ "`runlot.json`) when you want to use them \u2014 `env.db`, `env.auth` and `env.storage` are there.",
358
+ ""
359
+ ].join("\n");
360
+ }
361
+ function staticTemplate(ctx) {
362
+ const { name, org } = ctx;
363
+ const runlotConfig = { name };
364
+ if (org) runlotConfig["org"] = org;
365
+ runlotConfig["assets"] = "public";
366
+ return {
367
+ // If you use client-side routing (React Router and so on), add
368
+ // `"notFound": "spa"` — extensionless paths then answer with index.html.
369
+ "runlot.json": pkgJson(runlotConfig),
370
+ "public/index.html": staticIndexHtml(name),
371
+ ".gitignore": "node_modules\n.runlot\n",
372
+ "README.md": staticReadme(name)
373
+ };
374
+ }
375
+ var STUDIO_MIGRATION = [
376
+ "-- Applied by `runlot deploy` (and by `runlot pg migrate`).",
377
+ "--",
378
+ "-- Forward only. An applied migration is compared by content hash on the next run",
379
+ "-- (`Migration changed after it was applied`), so change the schema by adding",
380
+ "-- 0002_*.sql rather than by editing this file.",
381
+ "create table if not exists notes (",
382
+ " id serial primary key,",
383
+ " owner text not null,",
384
+ " title text not null,",
385
+ " created_at timestamptz not null default now()",
386
+ ");",
387
+ "",
388
+ "-- The list is per signed-in user, newest first.",
389
+ "create index if not exists notes_owner_id on notes (owner, id desc);",
390
+ ""
391
+ ].join("\n");
392
+ var STUDIO_SEED = [
393
+ "-- Development data only. It is applied by `runlot dev`, never by `runlot deploy`.",
394
+ "insert into notes (owner, title)",
395
+ "select 'dev', '\uCCAB \uBA54\uBAA8\uC785\uB2C8\uB2E4'",
396
+ "where not exists (select 1 from notes);",
397
+ ""
398
+ ].join("\n");
399
+ function studioIndexHtml(name) {
400
+ return [
401
+ "<!doctype html>",
402
+ '<html lang="ko">',
403
+ " <head>",
404
+ ' <meta charset="utf-8" />',
405
+ ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
406
+ ` <title>${name}</title>`,
407
+ " </head>",
408
+ " <body>",
409
+ ' <div id="root"></div>',
410
+ ' <script type="module" src="/src/main.tsx"></script>',
411
+ " </body>",
412
+ "</html>",
413
+ ""
414
+ ].join("\n");
415
+ }
416
+ var STUDIO_VITE_CONFIG = [
417
+ 'import tailwindcss from "@tailwindcss/vite";',
418
+ 'import react from "@vitejs/plugin-react";',
419
+ 'import { defineConfig } from "vite";',
420
+ "",
421
+ "export default defineConfig({",
422
+ " plugins: [react(), tailwindcss()],",
423
+ ' // `runlot.json` points "assets" at this directory, and `runlot deploy` ships it as',
424
+ " // it stands on disk \u2014 it does not build it. That is why `npm run deploy` is two",
425
+ " // commands.",
426
+ ' build: { outDir: "assets", emptyOutDir: true },',
427
+ " server: {",
428
+ " // /api belongs to the worker. `npm run dev:api` runs it under workerd on 8787,",
429
+ " // with env.db, env.auth and env.storage wired up.",
430
+ ' proxy: { "/api": "http://127.0.0.1:8787" },',
431
+ " },",
432
+ "});",
433
+ ""
434
+ ].join("\n");
435
+ var STUDIO_MAIN_TSX = [
436
+ 'import { createRoot } from "react-dom/client";',
437
+ 'import { App } from "./App";',
438
+ 'import "./styles.css";',
439
+ "",
440
+ "// This module exports no component, so editing it is a full reload rather than a",
441
+ "// Fast Refresh. That is Vite's rule, and it is why App.tsx holds the component.",
442
+ 'const root = document.getElementById("root");',
443
+ 'if (!root) throw new Error("#root is missing in index.html");',
444
+ "createRoot(root).render(<App />);",
445
+ ""
446
+ ].join("\n");
447
+ var STUDIO_STYLES_CSS = [
448
+ '@import "tailwindcss";',
449
+ ""
450
+ ].join("\n");
451
+ var STUDIO_API_TS = [
452
+ "// The worker's answers, typed, and the two calls the page makes.",
453
+ "//",
454
+ "// The paths are relative. The same bundle is served on the dev port, on a Studio",
455
+ "// preview, on a share link and on the project's own hostname, so a hard-coded",
456
+ "// origin would be wrong on three of the four.",
457
+ "export interface Note {",
458
+ " id: number;",
459
+ " title: string;",
460
+ "}",
461
+ "",
462
+ "export interface NotesView {",
463
+ " signedIn: boolean;",
464
+ " notes: Note[];",
465
+ "}",
466
+ "",
467
+ "export async function listNotes(): Promise<NotesView> {",
468
+ ' const res = await fetch("/api/notes");',
469
+ " // env.auth.user() answers null until someone signs in, which is every first",
470
+ " // visit \u2014 not an error.",
471
+ " if (res.status === 401) return { signedIn: false, notes: [] };",
472
+ " if (!res.ok) throw new Error(`GET /api/notes: ${res.status}`);",
473
+ " return { signedIn: true, notes: (await res.json()) as Note[] };",
474
+ "}",
475
+ "",
476
+ "export async function addNote(title: string): Promise<void> {",
477
+ ' const res = await fetch("/api/notes", {',
478
+ ' method: "POST",',
479
+ ' headers: { "Content-Type": "application/json" },',
480
+ " body: JSON.stringify({ title }),",
481
+ " });",
482
+ " if (!res.ok) throw new Error(`POST /api/notes: ${res.status}`);",
483
+ "}",
484
+ ""
485
+ ].join("\n");
486
+ var STUDIO_APP_TSX = [
487
+ 'import { useEffect, useState } from "react";',
488
+ 'import { addNote, listNotes, type Note } from "./api";',
489
+ "",
490
+ "export function App() {",
491
+ " const [notes, setNotes] = useState<Note[]>([]);",
492
+ " const [signedIn, setSignedIn] = useState(true);",
493
+ ' const [title, setTitle] = useState("");',
494
+ " const [error, setError] = useState<string | null>(null);",
495
+ "",
496
+ " function reload(): void {",
497
+ " listNotes()",
498
+ " .then((v) => {",
499
+ " setSignedIn(v.signedIn);",
500
+ " setNotes(v.notes);",
501
+ " setError(null);",
502
+ " })",
503
+ " .catch((e: unknown) => setError(String(e)));",
504
+ " }",
505
+ "",
506
+ " useEffect(reload, []);",
507
+ "",
508
+ " return (",
509
+ ' <main className="mx-auto max-w-md p-8">',
510
+ ' <h1 className="text-2xl font-semibold">\uBA54\uBAA8</h1>',
511
+ "",
512
+ " {!signedIn && (",
513
+ ' <p className="mt-4 text-sm">',
514
+ ' <a className="underline" href="/__runlot/auth/sign-in">',
515
+ " \uB85C\uADF8\uC778\uD569\uB2C8\uB2E4",
516
+ " </a>",
517
+ " </p>",
518
+ " )}",
519
+ "",
520
+ ' {error && <p className="mt-4 text-sm text-red-600">{error}</p>}',
521
+ "",
522
+ " <form",
523
+ ' className="mt-4 flex gap-2"',
524
+ " onSubmit={(e) => {",
525
+ " e.preventDefault();",
526
+ " if (!title.trim()) return;",
527
+ " addNote(title)",
528
+ " .then(() => {",
529
+ ' setTitle("");',
530
+ " reload();",
531
+ " })",
532
+ " .catch((err: unknown) => setError(String(err)));",
533
+ " }}",
534
+ " >",
535
+ " <input",
536
+ ' className="flex-1 rounded border px-2 py-1"',
537
+ ' placeholder="\uBB34\uC5C7\uC744 \uC801\uC744\uAE4C\uC694?"',
538
+ " value={title}",
539
+ " onChange={(e) => setTitle(e.target.value)}",
540
+ " />",
541
+ ' <button className="rounded bg-black px-3 py-1 text-white" type="submit">',
542
+ " \uCD94\uAC00",
543
+ " </button>",
544
+ " </form>",
545
+ "",
546
+ ' <ul className="mt-6 space-y-1">',
547
+ " {notes.map((n) => (",
548
+ " <li key={n.id}>{n.title}</li>",
549
+ " ))}",
550
+ " </ul>",
551
+ "",
552
+ ' {signedIn && notes.length === 0 && <p className="mt-6 text-sm">\uC544\uC9C1 \uBA54\uBAA8\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.</p>}',
553
+ " </main>",
554
+ " );",
555
+ "}",
556
+ ""
557
+ ].join("\n");
558
+ var STUDIO_WORKER_TS = [
559
+ 'import type { AuthBinding } from "@runlot/auth";',
560
+ 'import { Pool } from "@runlot/pg";',
561
+ 'import type { Db, Storage } from "@runlot/pg";',
562
+ 'import { Hono } from "hono";',
563
+ "",
564
+ "// Declared in runlot.json; `runlot deploy` creates each one and binds it here.",
565
+ "interface Env {",
566
+ " db: Db;",
567
+ " auth: AuthBinding;",
568
+ " storage: Storage;",
569
+ " // The Vite build output. It ships in the same bundle as this worker.",
570
+ " assets: { fetch(request: Request): Promise<Response> };",
571
+ "}",
572
+ "",
573
+ "const app = new Hono<{ Bindings: Env }>();",
574
+ "",
575
+ 'app.get("/api/notes", async (c) => {',
576
+ " const user = await c.env.auth.user(c.req.raw);",
577
+ " // null until someone signs in, which is every first visit.",
578
+ ' if (!user) return c.json({ error: "sign in at /__runlot/auth/sign-in" }, 401);',
579
+ "",
580
+ " const db = new Pool({ db: c.env.db });",
581
+ " const { rows } = await db.query<{ id: number; title: string }>(",
582
+ ' "select id, title from notes where owner = $1 order by id desc limit 50",',
583
+ " [user.id],",
584
+ " );",
585
+ " return c.json(rows);",
586
+ "});",
587
+ "",
588
+ 'app.post("/api/notes", async (c) => {',
589
+ " const user = await c.env.auth.user(c.req.raw);",
590
+ ' if (!user) return c.json({ error: "sign in at /__runlot/auth/sign-in" }, 401);',
591
+ "",
592
+ " const body = (await c.req.json()) as { title?: string };",
593
+ " const title = body.title?.trim();",
594
+ ' if (!title) return c.json({ error: "title is required" }, 400);',
595
+ "",
596
+ " const db = new Pool({ db: c.env.db });",
597
+ " // One statement per request. The node's ceiling is 10 s and a tenant cannot",
598
+ " // raise it, so work that takes longer belongs in a cron, not in a request.",
599
+ ' await db.query("insert into notes (owner, title) values ($1, $2)", [user.id, title]);',
600
+ " return c.json({ ok: true }, 201);",
601
+ "});",
602
+ "",
603
+ 'app.get("/api/me", async (c) => {',
604
+ " const user = await c.env.auth.user(c.req.raw);",
605
+ " // The origin is read out of the request, never written as a constant: this same",
606
+ " // bundle answers on the dev port, on a Studio preview, on a share link and on the",
607
+ " // project's own hostname (docs/studio.md \xA76.8).",
608
+ ' const signIn = new URL("/__runlot/auth/sign-in", c.req.url).toString();',
609
+ " return c.json({ id: user?.id ?? null, signIn });",
610
+ "});",
611
+ "",
612
+ "// With `main` set the worker is the entry point and static files are not served",
613
+ "// ahead of it, so whatever the routes above did not answer is handed to the assets",
614
+ "// binding here. Without this line every file in assets/ is a 404",
615
+ "// (docs/field-notes.md #71).",
616
+ 'app.all("*", (c) => c.env.assets.fetch(c.req.raw));',
617
+ "",
618
+ "export default app;",
619
+ ""
620
+ ].join("\n");
621
+ function studioAgents(name) {
622
+ return [
623
+ `# ${name}`,
624
+ "",
625
+ "This project runs on Runlot. Read this before changing code: the platform refuses a",
626
+ "few things Node and Next allow, and the refusals are not visible in the type system.",
627
+ "",
628
+ "## What is bound",
629
+ "",
630
+ "| Binding | Declared in `runlot.json` | Used as |",
631
+ "|---|---|---|",
632
+ '| `env.db` | `"database": true` | `new Pool({ db: c.env.db })` \u2014 Postgres |',
633
+ '| `env.auth` | `"auth": true` | `c.env.auth.user(c.req.raw)` \u2014 **null** until someone signs in |',
634
+ '| `env.storage` | `"storage": true` | `c.env.storage.put(key, file)` |',
635
+ '| `env.assets` | `"assets": "assets"` | the Vite build output, served by the catch-all route |',
636
+ "",
637
+ '`env.ai` is deliberately **not** declared. Add `"ai": true` in the same change that',
638
+ "first calls it, and say so in that turn: it is metered.",
639
+ "",
640
+ "## Layout",
641
+ "",
642
+ "- `src/` \u2014 Vite + React + Tailwind. `npm run build` writes it into `assets/`.",
643
+ "- `worker/index.ts` \u2014 one Hono worker. Every API path is under `/api`.",
644
+ "- `migrations/NNNN_*.sql` \u2014 the schema, forward only. `seed.sql` is development data.",
645
+ "",
646
+ "## Refusals",
647
+ "",
648
+ "- **No `node:fs`, and `nodejs_compat` is not a single switch.** Importing a node",
649
+ " built-in requires the flag, which the deploy turns on when it sees the import \u2014 but",
650
+ " *which* built-ins that flag hands out is decided by `compatibilityDate`, not by the",
651
+ " build. Prefer Web APIs (`fetch`, `URL`, `crypto`, `TextEncoder`); there is no file",
652
+ " system at any date.",
653
+ "- **No long-running process.** A request ends when its response does. Recurring work",
654
+ ' is a cron (`"triggers": { "crons": [...] }`) or a queue \u2014 never a `setInterval`.',
655
+ "- **10 s per statement.** The node enforces the ceiling and a tenant cannot raise it;",
656
+ " a slower statement ends in `57014`. Split the query or move it to a cron.",
657
+ "- **No install scripts.** A dependency's `postinstall` does not run \u2014 Studio installs",
658
+ " packages itself, and nothing at deploy time runs one either. A package that needs",
659
+ " one to place a native binary does not work here.",
660
+ '- **Build absolute URLs out of the request.** `new URL("/path", request.url)`, never a',
661
+ " hard-coded origin. The same bundle answers on the dev port, on a Studio preview, on",
662
+ " a share link and on the project's own hostname, and a constant is wrong on three of",
663
+ " those four.",
664
+ "- **`runWorkerFirst` is on.** The worker sees every request, so the catch-all",
665
+ ' `app.all("*", \u2026)` at the bottom of `worker/index.ts` is what serves `assets/`.',
666
+ " Delete it and every static file is a 404.",
667
+ "",
668
+ "## Hot reload",
669
+ "",
670
+ "The dev loop is Vite's, so the idiom is Vite's: `import.meta.hot`. What survives an",
671
+ "edit depends on what the module exports.",
672
+ "",
673
+ "- **A module that exports only components hot-swaps and keeps state.** `src/App.tsx`",
674
+ " is one. Keep it that way \u2014 a half-filled form surviving an edit is the whole point.",
675
+ "- Anything that is not a component (types, constants, helpers) goes in its own file,",
676
+ " like `src/api.ts`. Exporting one alongside a component downgrades that module to a",
677
+ " reload.",
678
+ "- `worker/` rebuilds by itself: the page stays and the next `/api` call reaches the",
679
+ " new code. A change to `migrations/` or `package.json` is a full reload.",
680
+ "",
681
+ "## Commands",
682
+ "",
683
+ "```",
684
+ "npm install",
685
+ "npm run dev # the UI on :5173, with /api proxied to the worker",
686
+ "npm run dev:api # the worker under workerd with the bindings (second terminal)",
687
+ "npm run deploy # vite build, then runlot deploy",
688
+ "```",
689
+ "",
690
+ "`runlot deploy` ships `assets/` as it stands on disk and does not build it. That is",
691
+ "why `deploy` is two commands, and why `assets/` is in `.gitignore`.",
692
+ ""
693
+ ].join("\n");
694
+ }
695
+ function studioReadme(name) {
696
+ return [
697
+ `# ${name}`,
698
+ "",
699
+ "Vite + React + Tailwind in `src/`, one Hono worker in `worker/index.ts`, and",
700
+ "Postgres, login and object storage declared in `runlot.json`.",
701
+ "",
702
+ "```",
703
+ "npm install",
704
+ "npm run dev:api # the worker under workerd, with env.db, env.auth, env.storage",
705
+ "npm run dev # the UI, with /api proxied to it",
706
+ "npm run deploy # vite build, then runlot deploy",
707
+ "```",
708
+ "",
709
+ "`npm run deploy` is two commands because `runlot deploy` ships `assets/` as it",
710
+ "stands on disk: the Vite build has to run first.",
711
+ "",
712
+ "`AGENTS.md` states the bindings and what the platform refuses. `CLAUDE.md` is the",
713
+ "same file under the name Claude Code reads.",
714
+ ""
715
+ ].join("\n");
716
+ }
717
+ function studioTemplate(ctx) {
718
+ const { name, org } = ctx;
719
+ const runlotConfig = { name };
720
+ if (org) runlotConfig["org"] = org;
721
+ runlotConfig["main"] = "worker/index.ts";
722
+ runlotConfig["assets"] = "assets";
723
+ runlotConfig["notFound"] = "spa";
724
+ runlotConfig["runWorkerFirst"] = true;
725
+ runlotConfig["database"] = true;
726
+ runlotConfig["auth"] = true;
727
+ runlotConfig["storage"] = true;
728
+ const packageJson = {
729
+ name,
730
+ version: "0.0.0",
731
+ private: true,
732
+ type: "module",
733
+ scripts: {
734
+ dev: "vite",
735
+ "dev:api": "runlot dev",
736
+ build: "vite build",
737
+ // `runlot deploy` does not build. Without the build in front of it, the deploy
738
+ // ships whatever `assets/` last held — or fails because it is not there.
739
+ deploy: "npm run build && runlot deploy",
740
+ typecheck: "tsc --noEmit"
741
+ },
742
+ dependencies: {
743
+ hono: "^4.10.7",
744
+ react: "^19.2.8",
745
+ "react-dom": "^19.2.8",
746
+ // The npm job in release.yml rewrites these ranges with the same number as the
747
+ // tag — what is written here is the last published version.
748
+ "@runlot/pg": "^0.1.0-rc.100",
749
+ "@runlot/auth": "^0.1.0-rc.100"
750
+ },
751
+ devDependencies: {
752
+ "@tailwindcss/vite": "^4.3.3",
753
+ "@types/react": "^19.2.18",
754
+ "@types/react-dom": "^19.2.5",
755
+ "@vitejs/plugin-react": "^6.1.1",
756
+ tailwindcss: "^4.3.3",
757
+ typescript: "^5.7.2",
758
+ vite: "^8.2.2"
759
+ }
760
+ };
761
+ const tsconfig = pkgJson({
762
+ compilerOptions: {
763
+ target: "ES2022",
764
+ lib: ["ES2022", "DOM", "DOM.Iterable"],
765
+ module: "ESNext",
766
+ moduleResolution: "bundler",
767
+ jsx: "react-jsx",
768
+ strict: true,
769
+ skipLibCheck: true,
770
+ noEmit: true,
771
+ // `import.meta.hot` and `import.meta.env` are typed by this and nothing else.
772
+ types: ["vite/client"]
773
+ },
774
+ include: ["src", "worker", "vite.config.ts"]
775
+ });
776
+ const agents = studioAgents(name);
777
+ return {
778
+ "package.json": pkgJson(packageJson),
779
+ "runlot.json": pkgJson(runlotConfig),
780
+ "tsconfig.json": tsconfig,
781
+ "vite.config.ts": STUDIO_VITE_CONFIG,
782
+ "index.html": studioIndexHtml(name),
783
+ "src/main.tsx": STUDIO_MAIN_TSX,
784
+ "src/App.tsx": STUDIO_APP_TSX,
785
+ "src/api.ts": STUDIO_API_TS,
786
+ "src/styles.css": STUDIO_STYLES_CSS,
787
+ "worker/index.ts": STUDIO_WORKER_TS,
788
+ "migrations/0001_init.sql": STUDIO_MIGRATION,
789
+ "seed.sql": STUDIO_SEED,
790
+ // Both names, one text. Claude Code reads CLAUDE.md and other agents read
791
+ // AGENTS.md; generating them together is what keeps them saying the same thing.
792
+ "AGENTS.md": agents,
793
+ "CLAUDE.md": `<!-- The same text as AGENTS.md. Change both, or the two agents disagree. -->
794
+ ${agents}`,
795
+ // `assets/` is build output. `.runlot` holds the unpacked bundle and the local
796
+ // object store, and `.env.runlot` holds secrets.
797
+ ".gitignore": "node_modules\nassets\n.runlot\n.env.runlot\n",
798
+ "README.md": studioReadme(name)
799
+ };
800
+ }
801
+ var TEMPLATES = {
802
+ next: nextTemplate,
803
+ hono: honoTemplate,
804
+ static: staticTemplate,
805
+ studio: studioTemplate
806
+ };
807
+ function templateFiles(template, ctx = {}) {
808
+ if (!isTemplateName(template)) {
809
+ throw new Error(`"${template}" is not a template. Use one of: ${TEMPLATE_NAMES.join(", ")}.`);
810
+ }
811
+ return TEMPLATES[template]({ name: ctx.name ?? "app", org: ctx.org });
812
+ }
813
+ function scaffold(opts) {
814
+ const { target, org, template } = opts;
815
+ const dir = resolve(target);
816
+ const name = target.split("/").filter(Boolean).pop();
817
+ if (!NAME_RE.test(name)) {
818
+ throw new Error(`"${name}" cannot be used as a project name. Use lowercase letters, digits, and hyphens.`);
819
+ }
820
+ if (existsSync(dir)) throw new Error(`${dir} already exists. Choose another name, or remove that directory and run this again.`);
821
+ const files = TEMPLATES[template]({ name, org });
822
+ const relPaths = Object.keys(files).sort();
823
+ for (const rel of relPaths) {
824
+ const path = join(dir, rel);
825
+ mkdirSync(dirname(path), { recursive: true });
826
+ writeFileSync(path, files[rel]);
827
+ }
828
+ console.log(`Created ${dir}.`);
829
+ console.log(` cd ${target}`);
830
+ if (template === "studio") {
831
+ console.log(" npm install");
832
+ console.log(" npm run dev:api # a second terminal: the worker with the bindings");
833
+ console.log(" npm run dev");
834
+ console.log(org ? " npm run deploy" : " npm run deploy -- --org <slug>");
835
+ } else {
836
+ if (template !== "next") console.log(" runlot dev");
837
+ console.log(org ? " runlot deploy" : " runlot deploy --org <slug>");
838
+ }
839
+ return { dir, files: relPaths };
840
+ }
841
+ async function defaultAsk(question) {
842
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
843
+ try {
844
+ return await rl.question(question);
845
+ } finally {
846
+ rl.close();
847
+ }
848
+ }
849
+ var MAX_PROMPT_TRIES = 3;
850
+ async function chooseTemplate(ask) {
851
+ const listText = TEMPLATE_LIST.map((t, i) => ` ${i + 1}. ${t.name.padEnd(6)} ${t.desc}`).join("\n");
852
+ const question = `Which template do you want?
853
+
854
+ ${listText}
855
+
856
+ Enter a name or a number: `;
857
+ for (let attempt = 1; attempt <= MAX_PROMPT_TRIES; attempt++) {
858
+ const answer = (await ask(question)).trim();
859
+ const byIndex = TEMPLATE_LIST[Number(answer) - 1];
860
+ const byName = TEMPLATE_LIST.find((t) => t.name === answer);
861
+ const picked = byIndex ?? byName;
862
+ if (picked) return picked.name;
863
+ console.error(`Unknown answer: "${answer}". Choose one of ${TEMPLATE_NAMES.join(", ")}.`);
864
+ }
865
+ throw new Error(`No template chosen after ${MAX_PROMPT_TRIES} tries. Specify one with --template.`);
866
+ }
867
+ async function run(argv, deps) {
868
+ const args = [];
869
+ let org;
870
+ let templateArg;
871
+ for (let i = 0; i < argv.length; i++) {
872
+ const a = argv[i];
873
+ if (a === "--org") {
874
+ org = argv[++i];
875
+ if (org === void 0) throw new Error("--org requires a value");
876
+ } else if (a === "--template") {
877
+ templateArg = argv[++i];
878
+ if (templateArg === void 0) throw new Error("--template requires a value");
879
+ } else if (a === "-h" || a === "--help") {
880
+ console.log(USAGE);
881
+ return 0;
882
+ } else {
883
+ args.push(a);
884
+ }
885
+ }
886
+ const target = args[0];
887
+ if (!target) {
888
+ console.error(USAGE);
889
+ return 2;
890
+ }
891
+ let template;
892
+ if (templateArg !== void 0) {
893
+ if (!isTemplateName(templateArg)) {
894
+ throw new Error(`Unknown template: "${templateArg}". Choose one of ${TEMPLATE_NAMES.join(", ")}.`);
895
+ }
896
+ template = templateArg;
897
+ } else if (deps.isTTY ?? process.stdin.isTTY === true) {
898
+ template = await chooseTemplate(deps.ask ?? defaultAsk);
899
+ } else {
900
+ throw new Error(`--template is required. Choose one of ${TEMPLATE_NAMES.join(", ")}.`);
901
+ }
902
+ scaffold({ target, org, template });
903
+ return 0;
904
+ }
905
+ async function main(argv, deps = {}) {
906
+ try {
907
+ return await run(argv, deps);
908
+ } catch (err) {
909
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
910
+ return 1;
911
+ }
912
+ }
913
+ function runDirectly(argv1) {
914
+ if (!argv1) return false;
915
+ let path = argv1;
916
+ try {
917
+ path = realpathSync(argv1);
918
+ } catch {
919
+ }
920
+ return path.endsWith(`${sep}dist${sep}index.js`);
921
+ }
922
+ if (runDirectly(process.argv[1])) {
923
+ main(process.argv.slice(2)).then((code) => process.exit(code));
924
+ }
925
+ export {
926
+ TEMPLATE_NAMES,
927
+ main,
928
+ runDirectly,
929
+ scaffold,
930
+ templateFiles
931
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "create-runlot",
3
+ "version": "0.1.0-rc.100",
4
+ "description": "npm create runlot — Next.js, Hono, and static-site templates",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/runlot/runlot",
11
+ "directory": "web/apps/create-runlot"
12
+ },
13
+ "type": "module",
14
+ "exports": {
15
+ ".": "./dist/index.js"
16
+ },
17
+ "bin": {
18
+ "create-runlot": "./dist/index.js"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "devDependencies": {
24
+ "esbuild": "^0.24.2",
25
+ "typescript": "^5.7.2",
26
+ "@types/node": "^22.10.2",
27
+ "@runlot/config": "0.1.0-rc.100"
28
+ },
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "scripts": {
33
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js='#!/usr/bin/env node'",
34
+ "test": "node build.mjs",
35
+ "typecheck": "tsc --noEmit"
36
+ }
37
+ }