create-runlot 0.1.0-rc.13 → 0.1.0-rc.130

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 +577 -60
  2. package/package.json +6 -3
package/dist/index.js CHANGED
@@ -1,24 +1,40 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
5
- import { dirname, join, resolve } from "node:path";
4
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, resolve, sep } from "node:path";
6
6
  import { createInterface } from "node:readline/promises";
7
- var TEMPLATE_NAMES = ["next", "hono", "static"];
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"];
8
22
  function isTemplateName(v) {
9
23
  return TEMPLATE_NAMES.includes(v);
10
24
  }
11
25
  var TEMPLATE_LIST = [
12
- { name: "next", desc: "Next.js App Router \u2014 \uC11C\uBC84 \uCEF4\uD3EC\uB10C\uD2B8\uC5D0\uC11C env.db\xB7env.auth \uB97C \uC0AC\uC6A9\uD569\uB2C8\uB2E4" },
13
- { name: "hono", desc: "Hono \uC6CC\uCEE4 \u2014 REST API \uC608\uC81C (/posts, /upload)" },
14
- { name: "static", desc: "\uC815\uC801 HTML \uD558\uB098 \u2014 \uC6CC\uCEE4 \uC5C6\uC774 \uC790\uC0B0\uB9CC \uBC30\uD3EC\uD569\uB2C8\uB2E4" }
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" }
15
31
  ];
16
32
  var USAGE = [
17
- "create-runlot <dir> [--org <slug>] [--template next|hono|static]",
33
+ "create-runlot <dir> [--org <slug>] [--template next|hono|static|studio]",
18
34
  "",
19
- " Next.js\xB7Hono\xB7\uC815\uC801 \uC0AC\uC774\uD2B8 \uC911 \uD558\uB098\uB85C \uD504\uB85C\uC81D\uD2B8\uB97C \uB9CC\uB4ED\uB2C8\uB2E4.",
20
- " --template \uC774 \uC5C6\uACE0 \uD130\uBBF8\uB110\uC774\uBA74 \uD15C\uD50C\uB9BF \uC120\uD0DD\uC744 \uC694\uCCAD\uD569\uB2C8\uB2E4.",
21
- " \uB9CC\uB4E0 \uB4A4: cd <dir> && runlot add pg auth storage && runlot deploy"
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"
22
38
  ].join("\n");
23
39
  var NAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
24
40
  function pkgJson(obj) {
@@ -62,7 +78,7 @@ function nextLayoutTsx(name) {
62
78
  "",
63
79
  "export default function RootLayout({ children }: { children: ReactNode }) {",
64
80
  " return (",
65
- ' <html lang="ko">',
81
+ '<html lang="en">',
66
82
  " <body>{children}</body>",
67
83
  " </html>",
68
84
  " );",
@@ -74,19 +90,27 @@ var NEXT_PAGE_TSX = [
74
90
  'import { Pool } from "@runlot/pg";',
75
91
  'import { env } from "@runlot/next";',
76
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
+ "",
77
98
  "export default async function PostsPage() {",
78
99
  " const user = await env.auth.user();",
79
100
  " if (!user) {",
80
101
  " return (",
81
102
  " <p>",
82
- ' \uB85C\uADF8\uC778\uC774 \uD544\uC694\uD569\uB2C8\uB2E4. <a href="/login">\uB85C\uADF8\uC778\uD558\uAE30</a>',
103
+ ' Please sign in. <a href="/login">Sign in</a>',
83
104
  " </p>",
84
105
  " );",
85
106
  " }",
86
107
  "",
87
108
  " const db = new Pool({ db: env.db });",
88
109
  "",
89
- " const { rows } = await db.query(",
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 }>(",
90
114
  ' "select id, title from posts where owner = $1 limit 20",',
91
115
  " [user.id],",
92
116
  " );",
@@ -103,16 +127,19 @@ function nextReadme(name) {
103
127
  return [
104
128
  `# ${name}`,
105
129
  "",
106
- "Next.js App Router \uD15C\uD50C\uB9BF\uC785\uB2C8\uB2E4. `app/page.tsx` \uAC00 `env.db`\xB7`env.auth.user()` \uB85C",
107
- "posts 20\uAC1C\uB97C \uADF8\uB9AC\uB294 \uC11C\uBC84 \uCEF4\uD3EC\uB10C\uD2B8 \uC608\uC81C\uC785\uB2C8\uB2E4.",
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()`.",
108
132
  "",
109
133
  "```",
110
- "runlot add pg auth storage",
111
134
  "runlot deploy",
112
135
  "```",
113
136
  "",
114
- "Postgres\xB7\uC778\uC99D\xB7\uC624\uBE0C\uC81D\uD2B8 \uC2A4\uD1A0\uB9AC\uC9C0\uAC00 \uBD99\uC740 \uB2E4\uC74C `runlot deploy` \uD558\uBA74 App Router \uAC00",
115
- "\uADF8\uB300\uB85C workerd \uC704\uC5D0\uC11C \uC2E4\uD589\uB429\uB2C8\uB2E4 (OpenNext, docs/nextjs.md).",
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.",
116
143
  ""
117
144
  ].join("\n");
118
145
  }
@@ -127,7 +154,11 @@ function nextTemplate(ctx) {
127
154
  version: "0.0.0",
128
155
  private: true,
129
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.
130
160
  dev: "next dev",
161
+ preview: "runlot dev",
131
162
  build: "next build",
132
163
  start: "next start"
133
164
  },
@@ -135,10 +166,10 @@ function nextTemplate(ctx) {
135
166
  next: "^16.3.4",
136
167
  react: "^19.2.8",
137
168
  "react-dom": "^19.2.8",
138
- // 버전 범위는 release.yml 의 npm 잡이 태그와 같은 번호로 다시 쓴다 —
139
- // 여기 적힌 값은 마지막 공개 버전이다.
140
- "@runlot/next": "^0.1.0-rc.13",
141
- "@runlot/pg": "^0.1.0-rc.13"
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.130",
172
+ "@runlot/pg": "^0.1.0-rc.130"
142
173
  },
143
174
  devDependencies: {
144
175
  typescript: "^5.7.2",
@@ -155,9 +186,11 @@ function nextTemplate(ctx) {
155
186
  "app/layout.tsx": nextLayoutTsx(name),
156
187
  "app/page.tsx": NEXT_PAGE_TSX,
157
188
  "public/robots.txt": "User-agent: *\nAllow: /\n",
158
- // runlot deploy 가 `.runlot-next` 에 생성된 OpenNext 설정을 쓴다
159
- // (docs/nextjs.md §2) — 소스가 아니니 커밋 대상이 아니다.
160
- ".gitignore": "node_modules\n.next\n.open-next\n.runlot-next\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",
161
194
  "README.md": nextReadme(name)
162
195
  };
163
196
  }
@@ -167,17 +200,26 @@ var HONO_INDEX_TS = [
167
200
  'import type { Db, Storage } from "@runlot/pg";',
168
201
  'import type { AuthBinding } from "@runlot/auth";',
169
202
  "",
170
- "// db\xB7auth\xB7storage \uB294 `runlot add pg auth storage` \uB4A4\uC5D0 \uC2E4\uC81C \uAC12\uC774 \uC628\uB2E4.",
203
+ "// db, auth, and storage come with every runlot project.",
171
204
  "interface Env {",
172
205
  " db: Db;",
173
206
  " auth: AuthBinding;",
174
207
  " storage: Storage;",
208
+ " // public/ ships in the same bundle and is served through this.",
209
+ " assets: { fetch(request: Request): Promise<Response> };",
175
210
  "}",
176
211
  "",
177
212
  "const app = new Hono<{ Bindings: Env }>();",
178
213
  "",
179
214
  'app.get("/posts", async (c) => {',
180
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
+ "",
181
223
  " const db = new Pool({ db: c.env.db });",
182
224
  "",
183
225
  " const { rows } = await db.query(",
@@ -194,6 +236,12 @@ var HONO_INDEX_TS = [
194
236
  " return c.json({ url });",
195
237
  "});",
196
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
+ "",
197
245
  "export default app;",
198
246
  ""
199
247
  ].join("\n");
@@ -201,15 +249,19 @@ function honoReadme(name) {
201
249
  return [
202
250
  `# ${name}`,
203
251
  "",
204
- "Hono \uC6CC\uCEE4 \uD15C\uD50C\uB9BF\uC785\uB2C8\uB2E4. `src/index.ts` \uC5D0 `/posts`(\uBAA9\uB85D)\uC640 `/upload`(\uC624\uBE0C\uC81D\uD2B8",
205
- "\uC2A4\uD1A0\uB9AC\uC9C0 \uC5C5\uB85C\uB4DC) \uB450 \uACBD\uB85C\uAC00 \uC788\uC2B5\uB2C8\uB2E4.",
252
+ "A Hono worker template. `src/index.ts` has two routes: `/posts` (a list) and",
253
+ "`/upload` (an object storage upload).",
206
254
  "",
207
255
  "```",
208
- "runlot add pg auth storage",
256
+ "runlot dev",
209
257
  "runlot deploy",
210
258
  "```",
211
259
  "",
212
- "\uC704 \uB458\uC744 \uC2E4\uD589\uD558\uBA74 `Env` \uC758 `db`\xB7`auth`\xB7`storage` \uAC00 \uC2E4\uC81C \uBC14\uC778\uB529\uC744 \uBC1B\uC2B5\uB2C8\uB2E4.",
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.",
213
265
  ""
214
266
  ].join("\n");
215
267
  }
@@ -224,10 +276,16 @@ function honoTemplate(ctx) {
224
276
  version: "0.0.0",
225
277
  private: true,
226
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
+ },
227
285
  dependencies: {
228
286
  hono: "^4.10.7",
229
- "@runlot/pg": "^0.1.0-rc.13",
230
- "@runlot/auth": "^0.1.0-rc.13"
287
+ "@runlot/pg": "^0.1.0-rc.130",
288
+ "@runlot/auth": "^0.1.0-rc.130"
231
289
  },
232
290
  devDependencies: {
233
291
  typescript: "^5.7.2",
@@ -251,15 +309,18 @@ function honoTemplate(ctx) {
251
309
  "runlot.json": pkgJson(runlotConfig),
252
310
  "tsconfig.json": tsconfig,
253
311
  "src/index.ts": HONO_INDEX_TS,
254
- "public/hello.txt": "\uC815\uC801 \uC790\uC0B0\uB3C4 \uAC19\uC740 \uBC88\uB4E4\uB85C \uBC30\uD3EC\uB429\uB2C8\uB2E4.\n",
255
- ".gitignore": "node_modules\n",
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",
256
317
  "README.md": honoReadme(name)
257
318
  };
258
319
  }
259
320
  function staticIndexHtml(name) {
260
321
  return [
261
322
  "<!doctype html>",
262
- '<html lang="ko">',
323
+ '<html lang="en">',
263
324
  " <head>",
264
325
  ' <meta charset="utf-8" />',
265
326
  ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
@@ -269,8 +330,8 @@ function staticIndexHtml(name) {
269
330
  " <main>",
270
331
  ` <h1>${name}</h1>`,
271
332
  " <p>",
272
- " \uC774 \uD398\uC774\uC9C0\uB294 \uC815\uC801 \uC790\uC0B0\uC73C\uB85C \uBC30\uD3EC\uB429\uB2C8\uB2E4. <code>public/</code> \uC544\uB798 \uD30C\uC77C\uC744",
273
- " \uACE0\uCE58\uACE0 <code>runlot deploy</code>\uB97C \uC2E4\uD589\uD558\uBA74 \uADF8\uB300\uB85C \uBC18\uC601\uB429\uB2C8\uB2E4.",
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.",
274
335
  " </p>",
275
336
  " </main>",
276
337
  " </body>",
@@ -282,14 +343,18 @@ function staticReadme(name) {
282
343
  return [
283
344
  `# ${name}`,
284
345
  "",
285
- "\uC815\uC801 \uC0AC\uC774\uD2B8 \uD15C\uD50C\uB9BF\uC785\uB2C8\uB2E4. `public/index.html` \uD558\uB098\uBFD0\uC774\uACE0 \uC6CC\uCEE4\uB294 \uC5C6\uC2B5\uB2C8\uB2E4.",
346
+ "A static site template. It is a single `public/index.html` with no worker.",
286
347
  "",
287
348
  "```",
288
- "runlot add pg auth storage",
349
+ "runlot dev",
289
350
  "runlot deploy",
290
351
  "```",
291
352
  "",
292
- "\uC6CC\uCEE4\uAC00 \uD544\uC694 \uC5C6\uC73C\uBA74 \uC704 \uCCAB \uC904\uC740 \uAC74\uB108\uB6F0\uC5B4\uB3C4 \uB429\uB2C8\uB2E4 \u2014 \uC790\uC0B0\uB9CC \uADF8\uB300\uB85C \uBC30\uD3EC\uB429\uB2C8\uB2E4.",
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.",
293
358
  ""
294
359
  ].join("\n");
295
360
  }
@@ -299,27 +364,460 @@ function staticTemplate(ctx) {
299
364
  if (org) runlotConfig["org"] = org;
300
365
  runlotConfig["assets"] = "public";
301
366
  return {
302
- // 클라이언트 라우팅(React Router 등)을 쓰면 `"notFound": "spa"` 를 더한다 —
303
- // 확장자 없는 경로가 index.html 로 답한다.
367
+ // If you use client-side routing (React Router and so on), add
368
+ // `"notFound": "spa"` — extensionless paths then answer with index.html.
304
369
  "runlot.json": pkgJson(runlotConfig),
305
370
  "public/index.html": staticIndexHtml(name),
306
- ".gitignore": "node_modules\n",
371
+ ".gitignore": "node_modules\n.runlot\n",
307
372
  "README.md": staticReadme(name)
308
373
  };
309
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.130",
749
+ "@runlot/auth": "^0.1.0-rc.130"
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
+ }
310
801
  var TEMPLATES = {
311
802
  next: nextTemplate,
312
803
  hono: honoTemplate,
313
- static: staticTemplate
804
+ static: staticTemplate,
805
+ studio: studioTemplate
314
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
+ }
315
813
  function scaffold(opts) {
316
814
  const { target, org, template } = opts;
317
815
  const dir = resolve(target);
318
816
  const name = target.split("/").filter(Boolean).pop();
319
817
  if (!NAME_RE.test(name)) {
320
- throw new Error(`${name} \uC740 \uD504\uB85C\uC81D\uD2B8 \uC774\uB984\uC73C\uB85C \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4 (\uC18C\uBB38\uC790\xB7\uC22B\uC790\xB7\uD558\uC774\uD508)`);
818
+ throw new Error(`"${name}" cannot be used as a project name. Use lowercase letters, digits, and hyphens.`);
321
819
  }
322
- if (existsSync(dir)) throw new Error(`${dir} \uC774 \uC774\uBBF8 \uC788\uC2B5\uB2C8\uB2E4`);
820
+ if (existsSync(dir)) throw new Error(`${dir} already exists. Choose another name, or remove that directory and run this again.`);
323
821
  const files = TEMPLATES[template]({ name, org });
324
822
  const relPaths = Object.keys(files).sort();
325
823
  for (const rel of relPaths) {
@@ -327,10 +825,17 @@ function scaffold(opts) {
327
825
  mkdirSync(dirname(path), { recursive: true });
328
826
  writeFileSync(path, files[rel]);
329
827
  }
330
- console.log(`${dir} \uC744 \uB9CC\uB4E4\uC5C8\uC2B5\uB2C8\uB2E4.`);
828
+ console.log(`Created ${dir}.`);
331
829
  console.log(` cd ${target}`);
332
- console.log(" runlot add pg auth storage");
333
- console.log(org ? " runlot deploy" : " runlot deploy --org <slug>");
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
+ }
334
839
  return { dir, files: relPaths };
335
840
  }
336
841
  async function defaultAsk(question) {
@@ -344,20 +849,20 @@ async function defaultAsk(question) {
344
849
  var MAX_PROMPT_TRIES = 3;
345
850
  async function chooseTemplate(ask) {
346
851
  const listText = TEMPLATE_LIST.map((t, i) => ` ${i + 1}. ${t.name.padEnd(6)} ${t.desc}`).join("\n");
347
- const question = `\uC5B4\uB5A4 \uD15C\uD50C\uB9BF\uC744 \uC4F8\uAE4C\uC694?
852
+ const question = `Which template do you want?
348
853
 
349
854
  ${listText}
350
855
 
351
- \uC774\uB984\uC774\uB098 \uBC88\uD638\uB97C \uC785\uB825\uD558\uC138\uC694: `;
856
+ Enter a name or a number: `;
352
857
  for (let attempt = 1; attempt <= MAX_PROMPT_TRIES; attempt++) {
353
858
  const answer = (await ask(question)).trim();
354
859
  const byIndex = TEMPLATE_LIST[Number(answer) - 1];
355
860
  const byName = TEMPLATE_LIST.find((t) => t.name === answer);
356
861
  const picked = byIndex ?? byName;
357
862
  if (picked) return picked.name;
358
- console.error(`\uBAA8\uB974\uB294 \uB2F5\uC785\uB2C8\uB2E4: "${answer}". ${TEMPLATE_NAMES.join(", ")} \uC911\uC5D0\uC11C \uACE0\uB974\uC138\uC694.`);
863
+ console.error(`Unknown answer: "${answer}". Choose one of ${TEMPLATE_NAMES.join(", ")}.`);
359
864
  }
360
- throw new Error(`${MAX_PROMPT_TRIES}\uBC88 \uC2DC\uB3C4\uD574\uB3C4 \uACE0\uB974\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. --template \uC73C\uB85C \uC9C1\uC811 \uC9C0\uC815\uD558\uC138\uC694.`);
865
+ throw new Error(`No template chosen after ${MAX_PROMPT_TRIES} tries. Specify one with --template.`);
361
866
  }
362
867
  async function run(argv, deps) {
363
868
  const args = [];
@@ -367,10 +872,10 @@ async function run(argv, deps) {
367
872
  const a = argv[i];
368
873
  if (a === "--org") {
369
874
  org = argv[++i];
370
- if (org === void 0) throw new Error("--org \uC5D0 \uAC12\uC774 \uD544\uC694\uD569\uB2C8\uB2E4");
875
+ if (org === void 0) throw new Error("--org requires a value");
371
876
  } else if (a === "--template") {
372
877
  templateArg = argv[++i];
373
- if (templateArg === void 0) throw new Error("--template \uC5D0 \uAC12\uC774 \uD544\uC694\uD569\uB2C8\uB2E4");
878
+ if (templateArg === void 0) throw new Error("--template requires a value");
374
879
  } else if (a === "-h" || a === "--help") {
375
880
  console.log(USAGE);
376
881
  return 0;
@@ -386,13 +891,13 @@ async function run(argv, deps) {
386
891
  let template;
387
892
  if (templateArg !== void 0) {
388
893
  if (!isTemplateName(templateArg)) {
389
- throw new Error(`\uC54C \uC218 \uC5C6\uB294 \uD15C\uD50C\uB9BF\uC785\uB2C8\uB2E4: "${templateArg}" (${TEMPLATE_NAMES.join(", ")} \uC911\uC5D0\uC11C \uACE0\uB974\uC138\uC694)`);
894
+ throw new Error(`Unknown template: "${templateArg}". Choose one of ${TEMPLATE_NAMES.join(", ")}.`);
390
895
  }
391
896
  template = templateArg;
392
897
  } else if (deps.isTTY ?? process.stdin.isTTY === true) {
393
898
  template = await chooseTemplate(deps.ask ?? defaultAsk);
394
899
  } else {
395
- throw new Error(`--template \uC774 \uD544\uC694\uD569\uB2C8\uB2E4 (${TEMPLATE_NAMES.join(", ")} \uC911\uC5D0\uC11C \uACE0\uB974\uC138\uC694)`);
900
+ throw new Error(`--template is required. Choose one of ${TEMPLATE_NAMES.join(", ")}.`);
396
901
  }
397
902
  scaffold({ target, org, template });
398
903
  return 0;
@@ -401,14 +906,26 @@ async function main(argv, deps = {}) {
401
906
  try {
402
907
  return await run(argv, deps);
403
908
  } catch (err) {
404
- console.error(`\uC624\uB958: ${err instanceof Error ? err.message : String(err)}`);
909
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
405
910
  return 1;
406
911
  }
407
912
  }
408
- if (process.argv[1] && process.argv[1].endsWith("index.js")) {
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])) {
409
923
  main(process.argv.slice(2)).then((code) => process.exit(code));
410
924
  }
411
925
  export {
926
+ TEMPLATE_NAMES,
412
927
  main,
413
- scaffold
928
+ runDirectly,
929
+ scaffold,
930
+ templateFiles
414
931
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-runlot",
3
- "version": "0.1.0-rc.13",
4
- "description": "npm create runlot — Next.js·Hono·정적 사이트 템플릿",
3
+ "version": "0.1.0-rc.130",
4
+ "description": "npm create runlot — Next.js, Hono, and static-site templates",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -11,6 +11,9 @@
11
11
  "directory": "web/apps/create-runlot"
12
12
  },
13
13
  "type": "module",
14
+ "exports": {
15
+ ".": "./dist/index.js"
16
+ },
14
17
  "bin": {
15
18
  "create-runlot": "./dist/index.js"
16
19
  },
@@ -21,7 +24,7 @@
21
24
  "esbuild": "^0.24.2",
22
25
  "typescript": "^5.7.2",
23
26
  "@types/node": "^22.10.2",
24
- "@runlot/config": "0.1.0-rc.13"
27
+ "@runlot/config": "0.1.0-rc.130"
25
28
  },
26
29
  "engines": {
27
30
  "node": ">=20"