create-runlot 0.1.0-rc.21 → 0.1.0-rc.210

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 +681 -66
  2. package/package.json +8 -4
package/dist/index.js CHANGED
@@ -1,24 +1,130 @@
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 as existsSync2, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync2 } from "node:fs";
5
+ import { dirname as dirname2, join as join2, resolve, sep } from "node:path";
6
6
  import { createInterface } from "node:readline/promises";
7
- var TEMPLATE_NAMES = ["next", "hono", "static"];
7
+
8
+ // src/skills.ts
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+
12
+ // src/skills.gen.ts
13
+ var SKILL_FILES = {
14
+ "runlot/SKILL.md": '---\nname: runlot\ndescription: Build and run apps on Runlot (runlot.io), where one project gets a worker runtime plus PostgreSQL (env.db), login (env.auth), file storage, email, AI models (env.ai), cron, queues, Durable Objects and domains, configured by runlot.json and driven by the `runlot` CLI. Use when a project has a runlot.json, imports @runlot/* packages, or the user mentions Runlot or the runlot CLI, or asks where to host a full-stack app with a database. Start here, then load runlot-deploy, runlot-database, runlot-ai or runlot-operate for the task.\n---\n\n# Build on Runlot\n\nRunlot hosts one app per **project**: a worker (the same `workerd` runtime as Cloudflare Workers),\nits static assets, and services the worker reaches through `env` bindings \u2014 no connection\nstrings, no API keys. Projects belong to an **org**. A project\'s address is\n`<project>.<org>.runlot.app`.\n\nYour memory of Runlot may be out of date. Prefer what the installed CLI and the docs say:\n\n- `runlot --help` and `runlot <command> --help` are the command reference for the installed version.\n- Docs: https://docs.runlot.io. A page list for models is at https://docs.runlot.io/llms.txt,\n the whole text at https://docs.runlot.io/llms-full.txt, and every page\'s source at\n `https://docs.runlot.io/docs/<path>.mdx`.\n- With the MCP server (`runlot mcp`), `runlot_docs_search` searches the same docs.\n\n## First look at a project\n\n1. `runlot whoami` \u2014 logged in, and to which orgs. If not: `runlot login` (a browser), or\n `RUNLOT_TOKEN` in CI.\n2. Read `runlot.json` in the project root. `name` and `org` say which project the CLI acts on;\n `--org` and `--project` override them.\n3. `runlot versions` shows what is deployed; `runlot health` shows open capacity risks.\n\n## Start a new project\n\n```bash\nnpm create runlot@latest my-app -- --template hono # next | hono | static | studio\ncd my-app\nnpm install # the template\'s dependencies (hono, @runlot/pg, \u2026)\nrunlot dev # local run on workerd, real bindings where possible\nrunlot deploy # creates the project on the first deploy\n```\n\nThe directory name becomes the project name and the first label of the address\n(lowercase letters, digits and `-`). The minimal `runlot.json`:\n\n```json\n{ "name": "my-app", "org": "acme", "main": "src/index.ts", "assets": "public" }\n```\n\nFull field list: [references/runlot-json.md](references/runlot-json.md).\n\n## What the worker gets\n\n| Binding | What it is | Turned on by |\n|---|---|---|\n| `env.db` | The project\'s PostgreSQL | First use (build imports `@runlot/pg`/an ORM, `migrations/`, or `env.db`) |\n| `env.auth` | App users, sessions, sign-in pages at `/__runlot/auth/*` | Comes with the database |\n| `env.storage` | Object storage: `put`/`get`/`head`/`delete`/`list`/`presign` | Every project |\n| `env.email` | `send()`; an `email()` handler receives mail | Every project |\n| `env.ai` | `run`/`embed`/`models` on a curated model catalog, billed to org credit | Every project |\n| `env.vectors` | Vector store in the project database | Every project |\n| `env.assets` | The `assets` directory | `assets` in runlot.json |\n| `env.<NAME>` | Secrets (uppercase), queue producers, Durable Object namespaces | `runlot secret set`, `queues`, `durable_objects` |\n\nHandlers: `fetch`, `scheduled` (cron), `queue`, `email`. Details and signatures:\n[references/bindings.md](references/bindings.md).\n\n## Limits that change how you write code\n\n| Limit | Value | What to do |\n|---|---|---|\n| CPU per request | 60 s default (Pro: 1\u2013300 s) | Long work goes to a queue or is split across requests |\n| App memory | 256 MiB per app process by default | Stream bodies and query results; do not buffer whole files |\n| SQL statement | 10 s, cannot be raised (`57014`) | Batch big updates; aggregate and join |\n| `compatibilityDate` | Default `2024-09-23` | Agent frameworks (Mastra, LangGraph, OpenAI Agents) need `2026-03-17` or later |\n| Outbound `fetch` | Public addresses only | `localhost` works in `runlot dev` and fails deployed |\n| Free daily budget | 100,000 requests, 1,000 CPU-seconds per org | Past it every project answers `429 budget_exhausted` until 00:00 UTC |\n\nThe full table and the spending limit: [references/limits.md](references/limits.md).\n\n## Pick the next skill\n\n| Task | Skill |\n|---|---|\n| Deploy, previews, rollback, domains, access, CI, deploy errors | `runlot-deploy` |\n| SQL, `@runlot/pg`, ORMs, migrations, backups and restore, database errors | `runlot-database` |\n| `env.ai`, models, tools, structured output, agent frameworks, AI usage and credit | `runlot-ai` |\n| Logs, errors, health, limits, spending limit, load tests, reporting a Runlot problem | `runlot-operate` |\n\nIf a sibling skill is not installed, `runlot skills add` installs all of them into the project.\n\n## Do\n\n- Read bindings from `env` inside the handler; pass `request` to `env.auth.user(request)`.\n- Pass SQL parameters as `$1, $2` with an array, never by string concatenation.\n- Keep secrets in `runlot secret set` (and `.env.runlot` for `runlot dev`), never in runlot.json.\n- Run `runlot dev` before `runlot deploy`, and `runlot health` after it.\n\n## Don\'t\n\n- Don\'t invent a connection string, API key or SDK client for a Runlot service \u2014 the binding is the credential.\n- Don\'t call `env.ai` from outside the worker: there is no external endpoint and no CLI command that calls a model.\n- Don\'t put `compatibilityFlags` in runlot.json; `nodejs_compat` is added by the build when it is needed.\n- Don\'t deploy with `--db shared` to try a migration \u2014 that preview writes to production\'s database.\n',
15
+ "runlot/references/bindings.md": '# Bindings and handlers\n\nEverything a worker uses arrives on `env`. Lowercase names are Runlot\'s; uppercase names are\nyours (secrets, queue producers, Durable Object namespaces), so they never collide.\n\n## The worker\n\n```ts\nexport default {\n async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {\n return new Response("hello");\n },\n async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {}, // triggers.crons\n async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) {}, // queues.consumers\n async email(message, env, ctx) {}, // mail to the project address\n};\n```\n\nHono needs no adapter (`export default app`). In Next.js, `import { env } from "@runlot/next"`\ninside a request; it throws at module top level or during static rendering.\n\n## env.db \u2014 PostgreSQL\n\n`exec(sql, params)` \u2192 rows, `tryExec(sql, params)` \u2192 `{ ok, rows | error.code }`,\n`session()` for transactions, `identity()`. Or `new Pool({ db: env.db })` from `@runlot/pg`.\nSee the `runlot-database` skill.\n\n## env.auth \u2014 your app\'s users\n\n```ts\nconst user = await env.auth.user(request); // always pass the request\nif (!user) return Response.redirect(new URL("/__runlot/auth/sign-in", request.url));\n// user.id (uuid), email, emailVerified, name, avatarUrl, providers, createdAt, lastSignInAt\n```\n\nUsers live in `runlot_auth.users` in the project database. Sign-out:\n`headers: { location: "/", ...(await env.auth.signOut(request)) }` on a 302.\nConfigure providers and branding with `runlot auth` (`runlot auth --help`). Auth identifies\napp users; **access control** (`runlot access set public|org|password|app`) decides who can\nreach the deployment at all.\n\n## env.storage \u2014 objects\n\n```ts\nawait env.storage.put("users/42/avatar.png", body, { contentType: "image/png" });\nconst obj = await env.storage.get("users/42/avatar.png"); // null when missing, not an error\nconst page = await env.storage.list({ prefix: "users/42/", limit: 100 }); // { objects, cursor }\nconst url = await env.storage.presign(key, { method: "PUT", ttl: 600, size: file.size });\n```\n\nKeys are `/`-separated, no empty segments, no `.` or `..`, at most 1024 bytes. `presign`\nsupports GET and PUT, ttl up to 3600 s. Public prefixes: `runlot storage public add <prefix>`,\nserved at `https://objects.runlot.io/<org>/<project>/<key>`.\n\n## env.email\n\n```ts\nawait env.email.send({ to: "buyer@example.com", subject: "Order received", text: "Thanks." });\n```\n\nThe sender is the project address `<project>@<project>.<org>.runlot.email` unless you add a\nsending domain (`runlot email domain add <domain>`). Mail to `*@<project>.<org>.runlot.email`\ncalls the `email()` handler; `message.raw` is the full message. Free: 30 sends per hour per\nproject; a 429 means the hourly limit.\n\n## env.ai and env.vectors\n\n`env.ai.run(model, { messages | prompt, system, maxTokens, tools, responseFormat, stream })`,\n`env.ai.embed(model, texts)`, `env.ai.models()`. `env.vectors.upsert/query` keeps vectors in\nthe project database. See the `runlot-ai` skill.\n\n## Secrets\n\n```bash\nrunlot secret set STRIPE_KEY # reads the value from stdin; keeps it out of shell history\nrunlot secret list\n```\n\nRead as `env.STRIPE_KEY`. Names match `^[A-Z][A-Z0-9_]{0,63}$`. A changed value takes effect when\nthe worker restarts. `secret set` takes no directory argument; use `--org`/`--project`.\nFor `runlot dev`, put `NAME=value` lines in `.env.runlot` (gitignored).\n\n## Cron\n\n`"triggers": { "crons": ["*/15 * * * *"] }`, UTC, 5 fields. `controller.scheduledTime` is the\ntime the run was due. A run may last 30 s for schedules under an hour, 5 min otherwise. Free: 5\nschedules per org. Locally: `runlot dev --cron "*/5 * * * *"` or\n`curl localhost:8787/__runlot/cron`. Recent runs: `runlot cron`.\n\n## Queues\n\n`await env.JOBS.send(body, { delaySeconds })`, `sendBatch([...])`. In `queue(batch)`, call\n`message.retry()` for failures; returning without throwing acks the rest, throwing retries the\nwhole batch. Messages are at most 128 KiB and live in the project database (`runlot_queue`\nschema). Free: 5 queues per org, 10,000 operations a day per project; past it `send` throws\n`QueueLimitError`. Inspect with `runlot queue`, `runlot queue peek <queue>`.\n\n## Durable Objects\n\n```ts\nimport { DurableObject } from "cloudflare:workers";\nexport class Room extends DurableObject {\n async join(user: string) { /* this.ctx.storage.sql.exec(...) */ }\n}\n// in fetch: const room = env.ROOMS.getByName("lobby"); await room.join("me");\n```\n\nDeclare `durable_objects.bindings` and `migrations` in runlot.json. Types come from\n`@cloudflare/workers-types`. Reserved binding names: `db`, `auth`, `storage`, `email`, `ai`,\n`vectors`, `assets`, `ASSETS`, and the `RUNLOT_` prefix.\n\n## Local development\n\n`runlot dev` runs the worker on the real `workerd` at `http://127.0.0.1:8787`:\n\n| Binding | Locally |\n|---|---|\n| `env.db` | A local database in `.runlot/dev` that applies `migrations/` (and `seed.sql` on a fresh one) at start; your own PostgreSQL when `RUNLOT_DEV_DATABASE_URL` (or `dev.database`) is set |\n| `env.storage` | Real bytes in `.runlot/dev/storage` |\n| `env.auth` | One dev user and a stub sign-in page (`RUNLOT_DEV_USER=me@example.com` signs in from the first request) |\n| `env.email` | Writes `.eml` files to `.runlot/dev/mail`, sends nothing |\n| `env.ai` | Relayed when logged in (runlot.json needs `org`) once the project exists \u2014 deploy it once first, or calls fail `not_found`; billed to org credit as lane `dev` with Free caps; `stream: true` streams |\n| Durable Objects | Real, state in `.runlot/dev/objects` |\n\nAdd `.runlot` and `.env.runlot` to `.gitignore`. `runlot db migrate` targets the deployed\ndatabase; the local one applies `migrations/` itself when `runlot dev` starts.\n',
16
+ "runlot/references/limits.md": "# Plan limits\n\nSource: https://docs.runlot.io/docs/manage/limits.mdx \u2014 re-read it before quoting a number to a\nuser; plans change.\n\n| Item | Free | Pro | When exceeded |\n|---|---|---|---|\n| Requests | 100,000 a day per org | 10 million a month included | `429 budget_exhausted` until 00:00 UTC (Free) |\n| CPU time | 1,000 s a day per org | 30 million ms a month included | Same |\n| CPU per request | 60 s (can be lowered) | 60 s, settable 1\u2013300 s | That request: `500`, outcome `exceededCpu` |\n| App memory | 256 MiB per app process; 512 MiB per project | 256 MiB; 1 GiB per project; an app can go to 2 GiB | App restarted, `app_out_of_memory` issue |\n| Spending limit | \u2014 | $20 a month above included, settable $0\u2013$1,000 | Every request of the org: `429 budget_exhausted` until the 1st |\n| Projects per org | 3 | 30 | `409 project_limit` |\n| Custom domains per project | 1 | 50 | `409 hostname_limit` |\n| Previews per project | 2 | 10 | `409 preview_limit` |\n| Preview idle lifetime | 24 h | 7 days | Preview and its database branch deleted |\n| Database size | 500 MiB | 8 GiB | Writes continue; `db_near_cap` notice at 90 % |\n| SQL statement | 10 s | 10 s | `57014`, cannot be raised |\n| Backup retention | 7 days | 30 days | Oldest first |\n| File storage | 10 GiB | 100 GiB included | Free: `507 quota_exceeded` on writes |\n| Outgoing email | 30 per hour per project | 300 per hour | Delayed |\n| Cron schedules | 5 per org | 250 | `400 cron_limit` |\n| Queues | 5 per org, 10,000 ops a day per project | 25, 1,000,000 a day | `400 queue_limit`; `send` throws `QueueLimitError` |\n| Vectors | 5,000 per project | 25,000 | `VectorsError` `vector_limit` |\n| Build minutes (push to deploy) | 300 a month per org | 3,000 | `minutes_exhausted` |\n| Request rate | 60/s per client (burst 120), 2,000/s per project | Same | `429` with `Retry-After` |\n| Logs kept | 3 days, 200,000 events a day | 7 days, 2,000,000 | Sampled 1 in 100 for the rest of the day |\n| AI (`env.ai`) | 2 concurrent, 30 calls/min per project, 4,096 `maxTokens` | Unlimited / model maximum | `429 too_many_concurrent` / `rate_limited` |\n\nThings that follow from these:\n\n- The budget is **per org**, not per project. A preview under load, or a load test, spends\n production's budget.\n- CPU per request counts CPU only, not time waiting on `fetch`, the database or a timer.\n- Memory is a ceiling, not a reservation. The usual cause of `app_out_of_memory` is holding a\n whole file or query result in memory; stream it.\n- A project with no requests for 5 minutes is parked; the next request wakes it in about 0.4 s.\n- Enforcement of daily and monthly budgets lags two to three minutes.\n\nChange limits with `runlot project limits --memory <size> --cpu <dur>` and\n`runlot org spending-limit set <usd>` \u2014 see the `runlot-operate` skill.\n",
17
+ "runlot/references/runlot-json.md": '# runlot.json\n\nThe project\'s configuration file, in the project root. The deploy reads it and generates a\nseparate manifest inside the bundle; you never write the manifest. Generated reference:\nhttps://docs.runlot.io/docs/reference/runlot-json.mdx\n\n`name` is required, and at least one of `main` or `assets` (except with `framework`).\nUnknown top-level keys are ignored, so a misspelled key does nothing \u2014 check the spelling.\n\n| Field | Type | Notes |\n|---|---|---|\n| `name` | string | Project name, first label of the address. `^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`, no `--` |\n| `org` | string | Org slug. `--org` wins over it |\n| `main` | string | Worker entry (TypeScript or JavaScript). Bundled with esbuild on your machine |\n| `assets` | string | Static assets directory, served through `env.assets` |\n| `notFound` | `"404"` \\| `"spa"` | `spa` answers extensionless misses with `/index.html` |\n| `runWorkerFirst` | boolean | Default `true` (worker is the entry). `false` serves an exact asset first \u2014 needed for React Router, Astro, SvelteKit, Nuxt server builds |\n| `compatibilityDate` | `YYYY-MM-DD` | Default `2024-09-23`. Decides which Node built-ins `nodejs_compat` provides; a future date is refused |\n| `framework` | `"next"` | Next.js through `@runlot/next`; `main`/`assets` are generated |\n| `database` | boolean | `true` creates the database on deploy, `false` never creates one automatically, absent lets the build decide |\n| `databases` | `[{ binding, database, mode? }]` | Other databases of the organization this project reads as `env.<binding>`; `mode` is `write` (default) or `read`. Up to two; the deploy writes the bindings (Pro). Previews get none |\n| `triggers` | `{ crons: string[], names?: {} }` | 5-field cron, UTC; calls `scheduled()` |\n| `queues` | `{ producers?, consumers? }` | Producers bind `env.<BINDING>.send`; consumers are drained by `queue()`. Needs `main` and a database |\n| `durable_objects` | `{ bindings: [{ name, class_name }] }` | snake_case keys on purpose |\n| `migrations` | `[{ tag, new_sqlite_classes?, new_classes?, ... }]` | Durable Object class history, append-only, top level |\n| `observability` | object | `errors` (on by default), `logs` and `traces` (off by default, with `head_sampling_rate`), `tags`, `routes`, `browser` |\n| `trustedCertificates` | string[] | Extra PEM files for outbound HTTPS to an origin with a broken chain |\n| `tail_consumers` | `[{ entrypoint }]` | Named exports that receive this worker\'s traces |\n| `routes` | string[] | Paths a publish checks after deploy (default `/`) |\n| `dev` | `{ database?, publicPrefixes? }` | Read only by `runlot dev`; `RUNLOT_DEV_DATABASE_URL` wins over `dev.database` |\n| `storage`, `email`, `ai`, `auth` | block | Reserved for settings; none are defined, so a key inside is refused. `true` is legacy and only warns |\n\n## Examples\n\nWorker with assets and a nightly job:\n\n```json\n{\n "name": "shop",\n "org": "acme",\n "main": "src/index.ts",\n "assets": "public",\n "compatibilityDate": "2026-03-17",\n "triggers": { "crons": ["30 18 * * *"], "names": { "30 18 * * *": "nightly recompute" } }\n}\n```\n\nStatic single-page app:\n\n```json\n{ "name": "site", "assets": "dist", "notFound": "spa" }\n```\n\nA framework that builds its own `workerd` server bundle:\n\n```json\n{ "name": "app", "main": "build/server/index.js", "assets": "build/client", "runWorkerFirst": false }\n```\n\nQueues:\n\n```json\n{\n "name": "crawler",\n "main": "src/index.ts",\n "queues": {\n "producers": [{ "binding": "JOBS", "queue": "jobs" }],\n "consumers": [{ "queue": "jobs", "maxBatchSize": 10, "maxBatchTimeout": 5, "maxRetries": 3, "deadLetterQueue": "jobs-dead" }]\n }\n}\n```\n\nDurable Objects:\n\n```json\n{\n "name": "chat",\n "main": "src/index.ts",\n "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Room" }] },\n "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }]\n}\n```\n\n## Bundle limits\n\n64 MiB compressed upload, 512 MiB uncompressed, 64 MiB per file, 20,000 entries. No symlinks,\nno paths outside the project. `bundle_too_large` usually means `node_modules` or a build cache\nended up in `assets`.\n',
18
+ "runlot-ai/SKILL.md": '---\nname: runlot-ai\ndescription: Call language and embedding models from a Runlot worker with env.ai \u2014 env.ai.run options (messages, system, reasoning effort, responseFormat JSON Schema, prompt cache, streaming), server-side tools and toolMode "return" for your own tool loop, embeddings with env.vectors, usage and usage.unreported, credit, limits and errors, team subscription lending (BYOS), and agent frameworks (Vercel AI SDK, Mastra, OpenAI Agents SDK, LangGraph) through @runlot/ai-sdk-provider. Use when adding AI features, chat, agents, RAG or structured extraction to a Runlot app, or when an env.ai call fails or costs more than expected.\n---\n\n# AI on Runlot\n\nEvery project\'s worker has `env.ai`. The binding is the credential: there is no API key, no\nexternal endpoint, and no CLI command that calls a model. Calls are billed to the org\'s credit\n(frontier models at the provider\'s list price) or a small free daily allowance for value models.\n\nDocs: https://docs.runlot.io/docs/data/ai/index.mdx\n\n## Pick a model from the catalog, not from memory\n\n```bash\nrunlot ai models # ids, kind (chat/embedding), prices per 1M tokens\nrunlot ai models --json # effortLevels, structuredOutput, cacheTTLs per model\n```\n\nIn code, `await env.ai.models()` returns the same rows. Model ids look like\n`anthropic/claude-sonnet-5`, `openai/gpt-6-luna`, `google/\u2026` (frontier, need credit) and\n`@runlot/nova-micro` (value tier, covered by the $0.11/day free allowance). An id that is not in\nthe catalog is `400 unknown_model`.\n\n## Call it\n\n```ts\nconst r = await env.ai.run("anthropic/claude-sonnet-5", {\n system: "You write order emails.", // the system prompt goes here, not in messages\n messages: [{ role: "user", content: "Write the confirmation for order A-123." }],\n maxTokens: 1024,\n});\nr.text; r.stopReason; r.usage; r.usageComplete; r.model;\n```\n\n`text` can be empty on success \u2014 a reasoning model can spend `maxTokens` on reasoning and stop\nwith `stopReason: "max_tokens"`. Log `stopReason`, `usage` and `usageComplete` together.\n\n| Need | Option | Reference |\n|---|---|---|\n| JSON in a fixed shape | `responseFormat: { type: "json_schema", schema }` | [references/run.md](references/run.md) |\n| Think harder or faster | `reasoning: { effort }` | [references/run.md](references/run.md) |\n| Long shared prefix, many calls | `cache`, `cacheBreakpoint` | [references/run.md](references/run.md) |\n| Stream to the browser | `stream: true` \u2192 `ReadableStream` of SSE | [references/run.md](references/run.md) |\n| Model calls your functions | `tools: [{ name, description, inputJsonSchema, execute }]` | [references/tools.md](references/tools.md) |\n| Your loop or a framework runs the tools | `tools` without `execute` + `toolMode: "return"` | [references/tools.md](references/tools.md) |\n| AI SDK, Mastra, OpenAI Agents, LangGraph | `@runlot/ai-sdk-provider` | [references/agent-frameworks.md](references/agent-frameworks.md) |\n| Embeddings and search | `env.ai.embed`, `env.vectors` | [references/embeddings.md](references/embeddings.md) |\n| Costs, usage, limits, errors, BYOS | \u2014 | [references/usage-and-errors.md](references/usage-and-errors.md) |\n\n## Check spend and limits\n\n```bash\nrunlot ai # calls, tokens, spend and limits for this project\nrunlot ai credit # the org\'s balance and automatic top-up\n```\n\nThe dashboard\'s **AI** section lists every call (model, lane, tokens, price, request link).\nPass `metadata: { sessionId, userId, tags }` on calls to group them there.\n\n## Do\n\n- Choose ids and capabilities from `runlot ai models` / `env.ai.models()`.\n- Set `maxTokens`; on Free it is capped at 4,096 per call.\n- Handle `429` (`rate_limited`, `too_many_concurrent`) and `402 no_credit` explicitly.\n- Validate model output (`JSON.parse` + schema check) before acting on it.\n- Treat a tool with side effects as possibly executed when a run throws; check your own records before retrying.\n\n## Don\'t\n\n- Don\'t put `role: "system"` in `messages`; roles are `user` and `assistant` only.\n- Don\'t `JSON.parse` the text of a tool run with `responseFormat` before checking `r.formatted`.\n- Don\'t add a provider SDK with your own API key; there is no bring-your-own-key path.\n- Don\'t run long agent loops inside one request on Free (2 concurrent calls, 30 per minute per project); move them to a queue consumer.\n- Don\'t call `env.ai` at module top level; call it inside a handler.\n',
19
+ "runlot-ai/references/agent-frameworks.md": '# Agent frameworks\n\nThe framework runs the agent loop inside your worker; `env.ai` is the model.\n`@runlot/ai-sdk-provider` turns `env.ai` into an AI SDK model, the model type the Vercel AI SDK,\nMastra and the OpenAI Agents SDK (through `aisdk()`) accept.\nDocs: https://docs.runlot.io/docs/data/ai/agent-frameworks.mdx\n\nBefore installing, check the package is published: `npm view @runlot/ai-sdk-provider version`.\nIf it is not, tell the user \u2014 do not substitute another provider package with an API key.\n\n```ts\nimport { createRunlot } from "@runlot/ai-sdk-provider";\nconst runlot = createRunlot({ ai: env.ai }); // inside the handler\nconst model = runlot("anthropic/claude-sonnet-5");\n```\n\n| Framework | Model | `compatibilityDate` | State that must outlive a request |\n|---|---|---|---|\n| Vercel AI SDK (`ai`) | `runlot(id)` | default is enough | none |\n| Mastra (`@mastra/core`) | `runlot(id)` | `2026-03-17` or later | memory, workflow snapshots \u2192 `PostgresStore` on `env.db` |\n| OpenAI Agents SDK (`@openai/agents`) | `aisdk(runlot(id))` | `2026-03-17` or later | none |\n| LangGraph (`@langchain/langgraph`) | call `env.ai.run` in a node | `2026-03-17` or later | checkpoints \u2192 `PostgresSaver` on `env.db` |\n| Cloudflare Agents SDK (`agents`) | \u2014 | default | loads, not supported |\n\nSet the date in runlot.json; without it the build stops and names the date:\n\n```json\n{ "name": "agent", "main": "src/index.ts", "compatibilityDate": "2026-03-17" }\n```\n\n## Vercel AI SDK\n\n```ts\nimport { generateText, stepCountIs, tool } from "ai";\nimport { z } from "zod";\nimport { createRunlot } from "@runlot/ai-sdk-provider";\n\nexport default {\n async fetch(request: Request, env: Env): Promise<Response> {\n const runlot = createRunlot({ ai: env.ai });\n const r = await generateText({\n model: runlot("anthropic/claude-sonnet-5"),\n prompt: "Weather in Seoul?",\n tools: {\n weather: tool({\n description: "Current weather for a city",\n inputSchema: z.object({ city: z.string() }),\n execute: async ({ city }) => ({ city, tempC: 18 }),\n }),\n },\n stopWhen: stepCountIs(4),\n });\n return Response.json({ text: r.text });\n },\n};\n```\n\n## Mastra memory on the project database\n\n```ts\nimport { PostgresStore } from "@mastra/pg";\nimport { Pool } from "pg"; // becomes @runlot/pg in a Runlot build\nconst storage = new PostgresStore({ id: "main", pool: new Pool({ db: env.db }) });\n```\n\nConfigure storage on every Mastra instance that keeps memory or runs workflows. The default\nstore is in-memory and is lost when the app is parked or restarted.\n\n## What the provider does\n\n- Each framework step is one `env.ai.run` with `toolMode: "return"`; every tool call of the\n step comes back together, and earlier calls and results go back as native tool parts.\n- Maps `system` \u2192 `system`, `maxOutputTokens` \u2192 `maxTokens`, `reasoning` \u2192 `reasoning.effort`,\n `responseFormat` \u2192 JSON Schema (refused with tools), `runlot(id, { cache, metadata })`.\n- `topP`, `topK`, `stopSequences`, `seed`, penalties and forced `toolChoice` are not sent\n (an `unsupported` warning).\n- Usage: cache counts `env.ai` did not report are `undefined`, not `0`;\n `providerMetadata.runlot.usageComplete` says whether the numbers are final.\n- Errors are `APICallError` with `statusCode` and `data.code`; only rate limits and upstream\n failures are retryable.\n- Images and files in user messages are refused, not dropped.\n- Embeddings: `runlot.embeddingModel("google/gemini-embedding-001", { taskType })`.\n\n## Local and limits\n\n- `runlot dev` (logged in) serves `generateText` / `agent.generate`; `streamText` and\n `agent.stream` are refused there. Deployed, streaming works.\n- Free: 2 concurrent calls and 30 per minute per project. A multi-step agent per request hits\n that quickly \u2014 run long agents from a queue consumer.\n- Agent loops spend CPU between model calls; the per-request CPU limit (60 s) still applies,\n but waiting on the model does not count.\n',
20
+ "runlot-ai/references/embeddings.md": '# Embeddings and vectors\n\n`env.ai.embed` turns text into vectors; `env.vectors` stores them in the project\'s own database\nand finds the nearest (exact k-NN). Docs: https://docs.runlot.io/docs/data/ai/vectors.mdx\n\n```ts\n// Index\nconst e = await env.ai.embed("@runlot/titan-embed-v2", texts); // e.vectors[i] belongs to texts[i]\nawait env.vectors.upsert(texts.map((text, i) => ({ id: ids[i], values: e.vectors[i], metadata: { text } })));\n\n// Query\nconst q = await env.ai.embed("@runlot/titan-embed-v2", "how long do refunds take?");\nconst hits = await env.vectors.query(q.vectors[0], { topK: 3, returnMetadata: true });\n```\n\n- Use the same embedding model (and dimensions) for indexing and querying. `runlot ai models`\n lists embedding rows (`kind: "embedding"`) with their dimensions.\n- `embed` throws like `run` (`e.status`, `e.code`), plus `wrong_kind` (a chat model passed to\n `embed`, or the reverse) and `bad_dimensions`.\n- Limits: 5,000 vectors per project on Free (25,000 on Pro), up to 3,072 dimensions. Past the\n bound `upsert` throws `VectorsError` (`vector_limit`) and writes nothing.\n- Inspect: `runlot vectors` (namespaces, counts), `runlot vectors describe <ns>`,\n `runlot vectors delete <ns>`.\n- Because vectors live in the database, they count toward its size and come back with a restore.\n- In `runlot dev` (logged in), `embed` is relayed and billed; `env.vectors` uses the local\n database.\n\nFor larger corpora, store the documents in `env.storage` or tables, keep only ids and short\nmetadata in vectors, and batch `embed` calls.\n',
21
+ "runlot-ai/references/run.md": '# env.ai.run\n\n```ts\nenv.ai.run(model: string, options): Promise<Result | ReadableStream>\n```\n\n| Option | |\n|---|---|\n| `messages` | `[{ role: "user" \\| "assistant", content, cacheBreakpoint? }]`; `content` is a string or an array of parts |\n| `prompt` | Shorthand for one user message |\n| `system` | System prompt |\n| `maxTokens` | Output cap (Free: at most 4,096) |\n| `temperature` | Sampling temperature |\n| `reasoning` | `{ effort }`, one of the model\'s `effortLevels`; otherwise `400 bad_effort` |\n| `responseFormat` | `{ type: "json_schema", name?, schema }` on models with `structuredOutput: true` |\n| `cache` | `{ mode?, ttl?, key?, system? }` on models with `cacheTTLs` |\n| `tools`, `maxToolRounds`, `toolMode` | See [tools.md](tools.md) |\n| `stream` | `true` returns a `ReadableStream` of SSE frames |\n| `metadata` | `{ sessionId?, userId?, tags? }` \u2014 recorded, never sent to the model |\n\nResult: `{ text, model, stopReason, usage, usageComplete }` (plus `toolCalls`/`content` with\n`toolMode: "return"`, `finalTool` after a tool stop).\n\n## Structured output\n\n```ts\nconst r = await env.ai.run("openai/gpt-6-luna", {\n prompt: "Order A-123 shipped Tuesday. Extract it.",\n responseFormat: {\n type: "json_schema",\n schema: {\n type: "object",\n properties: { orderId: { type: "string" }, status: { type: "string" } },\n required: ["orderId", "status"],\n additionalProperties: false,\n },\n },\n});\nconst order = JSON.parse(r.text);\n```\n\nOnly models whose catalog row has `structuredOutput: true` take it; others answer\n`400 format_unsupported`. It works with `stream` (the deltas are pieces of the JSON) and with\n`tools`: the model still calls the tools, and in that run `r.text` is the final round\'s JSON and\n`r.formatted` is `true` only when the model finished it \u2014 `false` on `max_tokens`, on\n`tool_stop`, and on the `tool_round_limit` error. On a model without `structuredOutput`, add a\n`final_answer` tool whose `execute` returns `{ stop: true, data: args }` and read\n`r.finalTool.args` when `r.stopReason === "tool_stop"`. A project lending subscriptions (BYOS)\ndoes not lend a format beside `tools` or `stream`: credit serves it when fallback is on.\n\n## Reasoning effort\n\nValues differ per family (`env.ai.models()` \u2192 `effortLevels`): OpenAI rows take `none` \u2026 `max`,\nClaude rows `low` \u2026 `max`, Gemini `low`/`medium`/`high`. Omit it for the provider default.\n\n## Prompt cache\n\nFor many calls sharing a long opening (instructions, a document):\n\n```ts\nawait env.ai.run("openai/gpt-6.1-terra", {\n system: RULES, // shared, put first\n messages: [{ role: "user", content: details }], // the part that changes\n cache: { mode: "explicit", system: true, key: "rules-v3" },\n});\n```\n\n- `ttl` must be one of the model\'s `cacheTTLs` (`openai/*`: `"30m"`, `anthropic/*`: `"5m"`).\n- `key` (\u2264 64 chars) routes calls together; not on `anthropic/*`.\n- `cacheBreakpoint: true` on a **user** message ends a prefix there; on an assistant message it\n is `400 bad_cache`.\n- Cache writes cost 1.25\xD7 input, reads \u2264 10 %. The default `implicit` mode adds a breakpoint at\n the end of every call; if calls differ after the shared part, use `explicit` so you do not\n pay for writes that are never read.\n- Models without `cacheTTLs` answer `400 cache_unsupported`.\n\n## Streaming\n\n```ts\nconst stream = await env.ai.run("@runlot/nova-micro", { prompt, stream: true });\nreturn new Response(stream, { headers: { "content-type": "text/event-stream" } });\n```\n\nFrames: `data: {"delta":"\u2026"}`, optional `event: usage`, then `event: done` with\n`{ text, model, stopReason, usage, usageComplete }`. Once the stream has started, a failure\narrives as a final `event: error` frame, not as a thrown error \u2014 handle it in the client. In\n`runlot dev`, `stream: true` is refused (the local relay answers one JSON body).\n\n## Messages with parts\n\n`content` may be an array of parts: `{ type: "text", text }`, and with tools\n`{ type: "tool_call", id, name, args }` (assistant), `{ type: "tool_result", callId, text | data,\nisError? }` (user), `{ type: "reasoning", \u2026 }` (assistant, sent back as returned).\n`env.ai.features` lists `"contentParts"` and `"toolModeReturn"` where the runtime supports them.\n\n## env.ai.judge\n\n`env.ai.judge(model, state, questions, options?)` calls a decision model: named questions\n(`noul`, `choice`, `score`, and `box` where the row allows it) in, one calibrated answer per\nname out (`{ type: "refusal" }` is a possible answer). `options.assets` is a map of\nname \u2192 `{ data, mediaType }` for models that read media. Check the model\'s `decision` row in\n`runlot ai models` / `env.ai.models()` first \u2014 `questionTypes`, `mediaMode` and `media` say\nwhat it takes, and anything else is a 400 by name; where the row is not live, calls answer\n`503 model_unavailable`. `@runlot/next` exports `noul`, `choice`, `score`, `box` builders and\ntypes each answer by its question.\n',
22
+ "runlot-ai/references/tools.md": '# Tools\n\nTools work on `anthropic/*` and `openai/*` models; other families answer `tools_unsupported`.\nThere are two modes.\n\n## Runlot runs the loop (`execute`)\n\n```ts\nconst r = await env.ai.run("anthropic/claude-sonnet-5", {\n prompt: "Where is order A-123?",\n maxToolRounds: 4, // 1\u201332, default 8\n tools: [{\n name: "lookup_order",\n description: "Look up an order owned by the current user.",\n inputJsonSchema: {\n type: "object",\n properties: { orderId: { type: "string" } },\n required: ["orderId"],\n additionalProperties: false,\n },\n async execute({ orderId }, { signal }) {\n const order = await lookupOrder(orderId, { signal });\n if (order.status === "cancelled") return { stop: true, text: "The order is cancelled.", data: order };\n return { data: order };\n },\n }],\n});\n```\n\n- `execute` runs in your worker; the model only sees name, description and schema.\n- Return `{ text }`, `{ data }`, `{ isError: true, text }`, or `{ stop: true, text?, data? }`.\n A thrown error becomes an error result the model can react to; it does not end the run.\n- `{ stop: true }` ends the run: `stopReason: "tool_stop"`, `finalTool: { id, name, args, result }`.\n- If `env.ai.run` throws after a tool already ran (`e.finalTool` is set), the side effect may be\n committed. Check your own idempotency record before calling the tool again.\n- Tool schemas are at most 64 KiB (`400 bad_tools`).\n\n## Your code runs the loop (`toolMode: "return"`)\n\nPass tool definitions **without** `execute`. Each call is one model step; you run the tools and\nsend the results back:\n\n```ts\nconst tools = [{\n name: "weather",\n description: "Current weather for a city",\n inputJsonSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },\n}];\nconst messages: any[] = [{ role: "user", content: "Weather in Seoul and Busan?" }];\n\nfor (let step = 0; step < 4; step++) {\n const r = await env.ai.run("anthropic/claude-sonnet-5", { messages, tools, toolMode: "return" });\n if (r.toolCalls.length === 0) return r.text;\n messages.push({ role: "assistant", content: r.content }); // send it back unchanged\n messages.push({\n role: "user",\n content: await Promise.all(r.toolCalls.map(async (call) => ({\n type: "tool_result",\n callId: call.id,\n data: await weather(call.args.city),\n }))),\n });\n}\n```\n\n- `r.toolCalls` holds **every** call of the step (`[]` when none; then `stopReason` is not\n `"tool_use"`). `r.content` is the assistant turn as parts \u2014 reasoning, text and calls. Send it\n back unchanged: Claude needs its thinking signature returned.\n- Every call whose messages contain a `tool_call` must pass the tools it names.\n- A transcript the model could not have produced is `400 bad_messages`: a result answering no\n call, a call left without a result in the next message, an unknown tool, a part on the wrong\n role.\n- `maxToolRounds` does not apply; your loop decides when to stop. Each step is billed and\n rate-limited as one call.\n- `toolMode: "return"` with a tool that has `execute`, or without tools, is refused.\n- Check `env.ai.features?.includes("toolModeReturn")` if the code must run on older runtimes.\n\n## Where tool calls do not go\n\n- A project lending members\' Claude/Codex subscriptions (BYOS) never lends tool-part calls:\n with Credit fallback on they run on credit, with it off they fail `503 byos_unavailable`.\n- `runlot dev` runs them on the development credit lane, without streaming.\n',
23
+ "runlot-ai/references/usage-and-errors.md": "# Usage, credit, limits and errors\n\n## Usage on a result\n\n```ts\nr.usage = { inputTokens, outputTokens, cachedInputTokens, cacheWriteInputTokens, unreported? }\nr.usageComplete // true when usage is the exact final total\n```\n\n- `cachedInputTokens` and `cacheWriteInputTokens` are **parts of** `inputTokens`, not extra.\n- `usage.unreported` lists cache counts the provider did not report (`\"cachedInputTokens\"`,\n `\"cacheWriteInputTokens\"`). Those counts are `0` meaning \"not reported\", and are charged as\n 0 (an unreported read at the input rate, an unreported write at the input rate). The key is\n absent when everything was reported. Don't compute a cache hit rate from unreported zeros.\n- `usageComplete: false` (an interrupted stream, a stopped tool run on a lent subscription)\n means `usage` is only what was observed before the stop; the provider may have done more.\n\n## Money\n\n- Credit is a USD balance; a call deducts at the price table's rate at call time. `--json`\n outputs and the API use micro-cents (1e-8 USD).\n- Value models (`@runlot/\u2026`): $0.11 a day free per org, reset at 00:00 UTC.\n- Frontier models (`anthropic/\u2026`, `openai/\u2026`, `google/\u2026`): list price, no markup, credit\n required \u2014 without it `402 no_credit`.\n- `runlot ai` shows the deployed app's calls and tokens in the last 24 hours (from the node's usage\n reports, a few minutes behind) and the spend from the credit ledger. Calls from `runlot dev`\n are billed (lane `dev`) but not counted as the app's calls. `runlot ai credit` shows the balance\n and auto top-up.\n Top-ups happen in the dashboard (**Billing**).\n\n## Limits\n\n| | Free | Pro |\n|---|---|---|\n| Concurrent calls per project | 2 | Unlimited |\n| Calls per minute per project | 30 | Unlimited |\n| `maxTokens` per call | 4,096 | Model maximum |\n| Input per call | 128k tokens | Model maximum |\n\n## Errors\n\nErrors thrown before a stream starts carry `e.status` and `e.code`. A failed tool run also\ncarries `e.text`, `e.usage`, `e.usageComplete: false`, and `e.finalTool` if a stopping tool\nalready ran.\n\n| Status | `code` | Fix |\n|---|---|---|\n| 400 | `unknown_model` | Use an id from `env.ai.models()` |\n| 400 | `bad_effort` | Use one of the model's `effortLevels` |\n| 400 | `bad_format` / `format_unsupported` | Fix the schema; use a `structuredOutput` model |\n| 400 | `bad_messages` | The transcript's tool calls and results do not pair up |\n| 400 | `bad_tools` / `tools_unsupported` | Fix the tool definition; use `anthropic/*` or `openai/*` |\n| 400 | `bad_cache` / `cache_unsupported` | Breakpoint on a user message only; a model with `cacheTTLs` |\n| 402 | `no_credit` | Top up, or use a value model |\n| 413 | `input_too_large` | Shorten or chunk the input |\n| 429 | `rate_limited` | Per-minute limit; honour `Retry-After` |\n| 429 | `too_many_concurrent` | Wait for an in-flight call; no `Retry-After` |\n| 429 | `upstream_rate_limited` | Provider busy; `Retry-After` |\n| 502 / 504 | `upstream_error` / `upstream_timeout` | Retry with backoff |\n| 503 | `model_unavailable` | The model cannot be called on this deployment; pick another |\n| 503 / 502 / 504 | `byos_unavailable` / `byos_failed` / `byos_timeout` | Lending on, fallback off, no lender answered; nothing charged |\n\nRetry only `rate_limited`, `upstream_rate_limited`, `upstream_error`, `upstream_timeout` and\n`too_many_concurrent` (after a pause). A `429 budget_exhausted` from the platform is the org's\nrequest budget, not an AI limit.\n\n## Team subscriptions (BYOS)\n\nAn org can let members' Claude and Codex subscriptions answer the project's `anthropic/*` and\n`openai/*` calls at no credit: a member runs Runlot Studio on their desktop with lending on, and\nan admin enables it per project.\n\n```bash\nrunlot ai --byos on # admin\nrunlot ai --byos-fallback on # when no desktop can take a call: on = run on credit, off = refuse\n```\n\nLending does not carry `cache`, tool parts or `toolMode: \"return\"` calls; an old Studio cannot\ncarry `reasoning` or `responseFormat`. Those calls go to credit when fallback is on. The\ndashboard's AI overview has a **Lending** panel that says why calls were not lent.\n\n## Seeing calls\n\nEvery deployed call leaves a record (model, lane, lending outcome, tokens, price, request ID),\nkept 3 days on Free and 7 on Pro, in the dashboard's **AI \u2192 Calls** and **Sessions**. Prompts are\nnot stored unless an admin turns on **Prompt capture**.\n",
24
+ "runlot-database/SKILL.md": '---\nname: runlot-database\ndescription: Use the PostgreSQL database of a Runlot project \u2014 env.db (exec, tryExec, session), @runlot/pg\'s Pool, ORMs (Prisma, Drizzle, Kysely, TypeORM, Sequelize, Knex), migrations in migrations/NNNN_*.sql, psql and runlot db commands, backups and point-in-time restore, and database errors such as 57014 (10-second statement limit), 53300, 57P03 and DatabaseStarting. Use when writing SQL or schema changes for a Runlot app, choosing a driver or ORM, running or debugging migrations, restoring data, or a Runlot database call fails.\n---\n\n# The Runlot database\n\nEach project has one PostgreSQL-compatible database, created the first time the app uses one.\nThe worker reaches it as `env.db` \u2014 no connection string, no password, no pool to size. It runs\non the same machine as the app, so a call has no network round trip.\n\nDocs: https://docs.runlot.io/docs/data/database/index.mdx\n\n## Write queries\n\n```ts\n// Plain binding: rows back, throws on error\nconst rows = await env.db.exec("select id, title from posts where owner_id = $1", [user.id]);\n\n// Need the SQLSTATE? tryExec returns it instead of throwing\nconst r = await env.db.tryExec("insert into users (email) values ($1)", [email]);\nif (!r.ok && r.error.code === "23505") return new Response("Already registered", { status: 409 });\n```\n\nOr the node-postgres API, which also gives `err.code` and `Date` objects:\n\n```ts\nimport { Pool } from "@runlot/pg";\nconst pool = new Pool({ db: env.db }); // `db` is the only option that matters\nconst { rows } = await pool.query("select * from posts where id = $1", [id]);\n```\n\n`import { Pool } from "pg"` resolves to `@runlot/pg` in a Runlot build. Each `pool.query` is its\nown session; use `pool.connect()` (or `env.db.session()`) for a transaction. ORM setup:\n[references/drivers-and-orms.md](references/drivers-and-orms.md). Value mapping and sessions:\n[references/env-db.md](references/env-db.md).\n\n## Change the schema\n\n```text\nmigrations/\n 0001_init.sql\n 0002_posts.sql\n```\n\n`runlot deploy` applies new files in order, each in one transaction, and records them in\n`schema_migrations`. `runlot db migrate` applies them without a deploy. Never edit an applied\nfile \u2014 add the next number. Details: [references/migrations.md](references/migrations.md).\n\n## Look at the data\n\n```bash\nrunlot db execute -c "select count(*) from posts"\nrunlot db shell # psql; arguments after -- go to psql\nrunlot db connect # host, user, password for another client\n```\n\nWith the MCP server, `runlot_pg_execute` runs SQL read-only unless the call passes `allow_write`.\n\n## Limits that shape the code\n\n| Limit | Value | Consequence |\n|---|---|---|\n| One statement | 10 s, cannot be raised | `57014`; batch by key range, aggregate then join |\n| Waiting for a connection | 10 s | `53300`; too many concurrent calls or long transactions |\n| Size | 500 MiB Free, 8 GiB Pro | Notified at 90 %; budget 10\u201315\xD7 the raw data for indexes |\n| Engine | One statement at a time per project | A slow query delays every other request |\n\nErrors and their fixes: [references/errors.md](references/errors.md).\n\n## Backups\n\nAutomatic, about once a minute while the database is in use; any commit in the last 24 hours\nis a restore point. `runlot db backups` lists them; `runlot db restore <epoch>/<seq>` (or\n`latest`, `txn:<id>`, a UTC time) restores after taking a backup of the current state. See\n[references/backups.md](references/backups.md).\n\n## Do\n\n- Parameterise every value (`$1`, `$2`); never build SQL by concatenation.\n- Treat `int8`, `numeric` and timestamps from `env.db.exec` as strings (they are never rounded).\n- Keep transactions short and inside one session; commit explicitly \u2014 `close()` rolls back.\n- Put files and images in `env.storage`, not in `bytea` columns.\n- Read `runlot health` (`db_admission`, `db_size`) when the database is slow or growing.\n\n## Don\'t\n\n- Don\'t open a TCP connection with a URL from `runlot db connect` inside the worker; use `env.db`.\n- Don\'t run `prisma migrate deploy` or `drizzle-kit push` against production; generate SQL into `migrations/`.\n- Don\'t rely on `SET statement_timeout` to raise the 10-second ceiling (it can only lower it).\n- Don\'t use `--db shared` previews to test migrations.\n- Don\'t bind more than two other databases in `databases`, and don\'t expect a preview to get\n them (previews skip bound databases). Databases belong to the organization: `runlot db list`\n shows them, `runlot db create <name>` makes one that no app is named after (Pro), and a\n project reads another with `"databases": [{"binding": "shop", "database": "shop"}]` in its\n runlot.json \u2014 the deploy writes the binding. Deleting a project keeps its database.\n',
25
+ "runlot-database/references/backups.md": "# Backups and restore\n\nNothing to turn on. While the database is in use, a backup is taken about once a minute, and\none more right before the project pauses, redeploys or shuts down. Each backup after the first\nfull one carries every commit since the previous backup, so **every commit is a restore point**\nfor the last 24 hours. Older points thin out to one an hour (1\u20137 days) and one a day (7\u201330\ndays, Pro). Retention: 7 days on Free, 30 on Pro. Backups are encrypted and kept in another\nregion.\n\n```bash\nrunlot db backups # epoch, seq, time, type, size; --json for raw\nrunlot db restore 3/15 # the end of backup 3/15 (epoch/seq)\nrunlot db restore latest\nrunlot db restore txn:8812 # exactly that commit\nrunlot db restore 2026-09-12T05:32:07Z # the last commit at or before that time (UTC)\n```\n\n- Restore needs the `admin` role and asks you to **type the project name**. It takes a backup of\n the current state first, so a wrong restore can itself be undone.\n- A restore stops and restarts the project. For up to 3 s after, database calls can fail with\n `57P03` \u2014 retry them.\n- Anything committed after the chosen point is lost from the live database (but kept in the\n pre-restore backup).\n- The last minute or so after the newest backup lives only on the node until the next backup.\n\n## Previews from a backup\n\n`runlot deploy --preview <name> --from 3/15` pours that backup into a preview's own database \u2014\nthe way to inspect old data without restoring production.\n\n## Exports\n\n`runlot db export` writes a diagnostic dump to share with support. It is **not** a backup and\ncannot be restored with `runlot db restore`. Use `pg_dump` through `runlot db shell` /\n`runlot db connect` credentials if you need a portable copy.\n\n## Deleting\n\nDatabases belong to the organization. `runlot db delete <name>` (admin, asks for the name)\ndeletes one and its data; it is refused while a project binds it, and the error names each\nproject (`--unbind-all` removes the bindings first). A database that shares its name with an\napp is backed up once (`pre-delete`) before it goes; one that stands alone is not, so run\n`runlot db export` or `pg_dump` first. A later deploy does not recreate it;\n`runlot db create <name>` makes a new, empty one.\n`runlot project delete <name>` deletes the app and keeps its database, listed in\n`runlot db list` until you delete it.\n",
26
+ "runlot-database/references/drivers-and-orms.md": '# Drivers and ORMs\n\nEvery adapter takes `env.db`. There is no host, port, user or password to configure; options\nthat name one are ignored. Docs: https://docs.runlot.io/docs/data/database/orms.mdx\n\n## @runlot/pg (node-postgres API)\n\n```ts\nimport { Pool, DatabaseError } from "@runlot/pg";\n\nconst pool = new Pool({ db: env.db });\ntry {\n await pool.query("insert into users (email) values ($1)", [email]);\n} catch (e) {\n if (e instanceof DatabaseError && e.code === "23505") return new Response("taken", { status: 409 });\n throw e;\n}\n\nconst client = await pool.connect(); // one session: transactions, SET, prepared statements\ntry {\n await client.query("begin");\n await client.query("update accounts set balance = balance - $1 where id = $2", [100, 1]);\n await client.query("commit");\n} catch (e) {\n await client.query("rollback");\n throw e;\n} finally {\n client.release();\n}\n```\n\n`Pool` does no pooling \u2014 every `connect()` is a new engine session. It imports no Node\nbuilt-ins, so it needs no `nodejs_compat`. `types.setTypeParser(oid, fn)` works as in `pg`.\nLibraries that `import { Pool } from "pg"` get `@runlot/pg` from the Runlot bundler, and\n`new Pool({ db: env.db })` is then the project database.\n\n## ORMs\n\n| ORM | Import | Note |\n|---|---|---|\n| Drizzle | `import { drizzle } from "@runlot/pg/drizzle"` \u2192 `drizzle(env.db)` | Small bundle; a good default |\n| Kysely | `import { kysely } from "@runlot/pg/kysely"` \u2192 `kysely<DB>(env.db)` | Small bundle |\n| Prisma 7 | `new PrismaClient({ adapter: new PrismaRunlot(env.db) })`, `PrismaRunlot` from `@runlot/pg/prisma` | WASM query compiler shipped automatically; unique violation is `P2002` |\n| TypeORM | `dataSource(env.db, { entities, synchronize: false })` from `@runlot/pg/typeorm` | Use `EntitySchema`; decorators are not supported |\n| Sequelize | `sequelize(env.db)` from `@runlot/pg/sequelize` | `nodejs_compat` added automatically |\n| Knex | `knex(env.db)` from `@runlot/pg/knex` | `nodejs_compat` added automatically |\n\nCreate the client inside the handler (or lazily per request) from that request\'s `env`; do not\nkeep a module-level client built from a stale `env`.\n\n```ts\nimport { drizzle } from "@runlot/pg/drizzle";\nimport { eq } from "drizzle-orm";\nimport { integer, pgTable, text } from "drizzle-orm/pg-core";\n\nconst users = pgTable("users", { id: integer("id").primaryKey(), name: text("name").notNull() });\n\nexport default {\n async fetch(req: Request, env: Env) {\n const db = drizzle(env.db);\n const rows = await db.select().from(users).where(eq(users.id, 1));\n return Response.json(rows);\n },\n};\n```\n\n## Migrations with an ORM\n\nUse the ORM\'s tool **only to generate SQL**, then put the SQL in `migrations/NNNN_name.sql`:\n\n```bash\nnpx drizzle-kit generate\ncp drizzle/0003_*.sql migrations/0003_posts.sql\n\nnpx prisma migrate diff --from-schema-datamodel prisma/schema.prisma \\\n --to-schema-datasource prisma/schema.prisma --script > migrations/0003_posts.sql\n```\n\n`prisma migrate deploy` cannot run in a worker. TypeORM `synchronize` and Sequelize\n`sync({ force: true })` run DDL at runtime \u2014 fine in development, not as a production strategy.\n\n## From outside the worker\n\n`runlot db connect` prints host, user and password for psql, GUI clients or scripts.\n`runlot db token` issues a time-limited credential for a machine (`--ttl 1h`).\n`runlot db forward` forwards a local port (default 15432) to the database using your login.\nThese are for people and tools, never for the deployed worker.\n',
27
+ "runlot-database/references/env-db.md": '# env.db\n\n```ts\ninterface Db {\n exec(sql: string, params?: unknown[]): Promise<unknown[]>; // rows; throws on error\n tryExec(sql: string, params?: unknown[]): Promise<TryExecResult>;\n session(): Promise<DbSession>; // one engine session\n identity(): Promise<{ project: string; epoch: number }>;\n}\n```\n\nThere is no `env.db.query`. Use `exec`, or `@runlot/pg` for `query`.\n\n## exec and tryExec\n\n`exec` returns an array of row objects. Its thrown `Error` has **no `.code`** (custom\nproperties do not survive the worker boundary); the SQLSTATE is only in the message as\n`[42P01] \u2026`. When code must branch on the SQLSTATE, use `tryExec`:\n\n```ts\nconst r = await env.db.tryExec("insert into users (email) values ($1) returning id", [email]);\nif (!r.ok) {\n // r.error: { code, message, detail, hint, position, severity }\n if (r.error.code === "23505") return new Response("taken", { status: 409 });\n throw new Error(r.error.message);\n}\nr.rows; // [{ id: 1 }]\nr.values; // [[1]] \u2014 positional, keeps duplicate column names\nr.commandTag; // "INSERT 0 1"\n```\n\n## Sessions and transactions\n\nEvery `exec` call is its own session. A transaction needs one session:\n\n```ts\nconst s = await env.db.session();\ntry {\n await s.exec("begin");\n await s.exec("update accounts set balance = balance - $1 where id = $2", [100, 1]);\n await s.exec("update accounts set balance = balance + $1 where id = $2", [100, 2]);\n await s.exec("commit");\n} finally {\n await s.close(); // safe to call twice; rolls back an open transaction\n}\n```\n\nSessions are not pooled or reused. `SET`, prepared statements and temp tables live only as long\nas the session.\n\n## Values\n\n| SQL | `env.db` gives | `@runlot/pg` gives |\n|---|---|---|\n| `int2`, `int4`, `float4`, `float8` | number | number |\n| `int8`, `numeric` | **string** | string |\n| `bool` | boolean | boolean |\n| `text`, `varchar`, `uuid` | string | string |\n| `bytea` | ArrayBuffer | Uint8Array |\n| `json`, `jsonb` | parsed value | parsed value |\n| `timestamp`, `timestamptz`, `date` | **string** (`2026-09-04 13:38:14+00`) | `Date` |\n\nSafari\'s `new Date()` does not parse PostgreSQL\'s timestamp text; select `to_json(col)` for ISO\n8601, or use `@runlot/pg`.\n\n## When the database is created\n\nThe first deploy whose build imports `@runlot/pg`, `pg`, an ORM or `@runlot/auth`, has\n`migrations/`, or mentions `env.db`, creates it before the version goes live. `runlot db create <project>`\ncreates it explicitly. `"database": false` in runlot.json prevents automatic creation (then\n`env.db` rejects with `DatabaseNotEnabled`). A database deleted with `runlot db delete <name>` is not\nrecreated by a deploy.\n\n## Locally\n\n`runlot dev` gives `env.db` a local database in `.runlot/dev` and applies `migrations/` (and\n`seed.sql` on a fresh one) when it starts, so queries return real rows. With\n`RUNLOT_DEV_DATABASE_URL` (or `"dev": { "database": \u2026 }`) it uses that PostgreSQL instead, and\nmigrations are yours to apply there. Delete `.runlot/dev` to start from an empty database.\n\n## Row-level security\n\nWith `runlot auth set rls on`, the database sees the signed-in app user. Write policies against\nthe user id; see https://docs.runlot.io/docs/data/auth/rls.mdx before enabling it.\n',
28
+ "runlot-database/references/errors.md": '# Database errors\n\n`env.db.exec` throws an `Error` whose message starts with the SQLSTATE in brackets\n(`[57014] canceling statement due to statement timeout`); `tryExec` returns it as\n`error.code`; `@runlot/pg` sets `err.code`.\n\n| Code / name | Meaning | What to do |\n|---|---|---|\n| `57014` | The statement ran past the 10-second ceiling (or a lower `statement_timeout` you set) | Split it: batches of ~1,000 rows, key ranges, aggregate-then-join. The ceiling cannot be raised |\n| `53300` | The call waited 10 s for a database connection and gave up | Too many concurrent `env.db` calls or long-held sessions. Close sessions in `finally`, keep transactions short, don\'t hold a session across a `fetch` or an AI call. `runlot health` reports it as `db_admission` |\n| `57P03` | The database is not accepting connections yet \u2014 for up to 3 s after a restore or a wake | Retry a few times with a short delay |\n| `DatabaseStarting` | The first call created the database; it is still starting | Retry after a few seconds (happens once) |\n| `DatabaseNotEnabled` | `"database": false` in runlot.json, or no database | Remove the setting or run `runlot db create <project>`, then deploy |\n| `23505` | Unique violation (`P2002` in Prisma) | Answer 409, or `on conflict do \u2026` |\n| `40001`, `40P01` | Serialization failure, deadlock | Retry the whole transaction |\n| `42P01` | Table does not exist | The migration has not run on this database \u2014 `runlot db migrate`, or check the preview\'s branch |\n\nA retry helper for the transient ones:\n\n```ts\nasync function withRetry<T>(fn: () => Promise<T>, tries = 5): Promise<T> {\n for (let i = 1; ; i++) {\n try {\n return await fn();\n } catch (e) {\n const err = e as Error;\n const text = `${err.name}: ${err.message}`; // the name may arrive folded into the message\n const code = (e as { code?: string }).code; // set by @runlot/pg\n const transient =\n ["57P03", "40001", "40P01"].includes(code ?? "") ||\n /\\[(57P03|40001|40P01)\\]/.test(text) ||\n text.includes("DatabaseStarting");\n if (!transient || i >= tries) throw e;\n await new Promise((r) => setTimeout(r, 200 * i));\n }\n }\n}\n```\n\nDon\'t retry `57014` or `53300` in a tight loop \u2014 both mean the database is already overloaded,\nand a retry adds load. Fix the query or the concurrency instead.\n\n## Slow or saturated database\n\n1. `runlot health` \u2014 `db_admission` (waits and timeouts in 24 h, CPU use) and `db_memory`.\n2. The dashboard\'s **Observability \u2192 Database** lists statement fingerprints by time.\n3. Look for N+1 query loops, missing indexes (`explain analyze` in `runlot db shell`), and\n sessions held open while waiting on something else.\n\nThe database runs one statement at a time per project, so one slow statement delays every\nrequest that touches the database.\n\n## Size\n\n`runlot usage` shows the size; the cap is 500 MiB (Free) and 8 GiB (Pro), with a `db_near_cap`\nnotice at 90 %. Nothing rejects a write at the cap today, but restores slow down with size.\nStore files in `env.storage`. Indexes and page overhead are often 10\u201315\xD7 the raw data.\n',
29
+ "runlot-database/references/migrations.md": '# Migrations\n\n```text\nmigrations/\n 0001_init.sql\n 0002_add_posts.sql\n```\n\n- Names are `NNNN_name.sql`; the number is a positive integer. Another directory:\n `runlot db migrate --from db/migrations`.\n- `runlot deploy` applies new files after the upload (unless `--no-migrate`). Without a deploy:\n `runlot db migrate`.\n- Each file runs in **one transaction** together with its history row in `schema_migrations`\n (`version`, `name`, `sha256`, `applied_at`). A failure rolls back the whole file.\n- The run stops and applies nothing when an applied file\'s content changed (`sha256`\n mismatch, `Migration changed after it was applied`), an applied file is missing, or a new\n file has a lower number than one already applied. **Never edit an applied file; add the\n next number.**\n- On a preview with `--db branch`, the server applies `migrations/` to the branch once it is\n poured. With `--db shared` the preview\'s migrations are production\'s.\n\nCheck what ran:\n\n```bash\nrunlot db execute -c "select version, name, applied_at from schema_migrations order by version"\n```\n\n## Writing migrations that survive data\n\nA statement that passes on an empty table can exceed the 10-second statement limit once the\ntable has rows (`57014`). For backfills:\n\n- Add the column nullable or with a constant default, then backfill in batches by key range,\n then add the constraint.\n- Replace correlated-subquery `UPDATE`s with one aggregate joined back.\n- Large backfills belong in a script run with `runlot db execute -f backfill.sql` in chunks, or\n in a queue consumer \u2014 not in one migration statement.\n\n```sql\n-- 0007_best_rank.sql: aggregate once and join, instead of a subquery per row\nupdate places p set best_rank = b.min_rank\n from (select place_id, min(rank) as min_rank from rankings group by place_id) b\n where b.place_id = p.id;\n```\n\nDestructive changes (drop a column, rename a table) go out **after** the code that stops using\nthem is live; `runlot rollback` does not roll the schema back.\n\n## One-off SQL\n\n```bash\nrunlot db execute -c "alter table posts add column pinned boolean not null default false"\nrunlot db execute -f ./fix.sql\n```\n\nSQL run this way is not recorded in `schema_migrations`. Prefer a migration file for anything\nthat defines the schema.\n\n## Locally\n\n`runlot dev` applies `migrations/` to its own local database when it starts, and records them\nthe same way a deploy does; a new file is applied at the next start. `runlot db migrate`\ntargets the deployed database. With `RUNLOT_DEV_DATABASE_URL` set, the local database is yours,\nso apply the files to it yourself:\n\n```bash\nfor f in migrations/*.sql; do psql "$RUNLOT_DEV_DATABASE_URL" -v ON_ERROR_STOP=1 -f "$f"; done\n```\n\nThat loop keeps no history, so run it against a fresh database.\n',
30
+ "runlot-deploy/SKILL.md": "---\nname: runlot-deploy\ndescription: Deploy and ship apps on Runlot with the runlot CLI \u2014 first deploy, `runlot deploy`, preview deployments with their own database branch, versions and rollback, custom domains, access control, secrets, deploying from CI with RUNLOT_TOKEN, push-to-deploy, and reading deploy errors such as a compatibilityDate refusal or bundle_too_large. Use when deploying, releasing, rolling back, previewing a branch, connecting a domain, or a Runlot deploy fails.\n---\n\n# Deploy on Runlot\n\n`runlot deploy` bundles the worker **on your machine** with esbuild, uploads the result, and\nswitches the project to the new version. There is no remote build (except push-to-deploy), so\na build error shows up before anything is uploaded.\n\n## The loop\n\n```bash\nrunlot dev # run it locally on workerd first\nrunlot deploy # bundle, upload, create the project if missing, apply migrations/\nrunlot health # open capacity risks after the deploy\nrunlot logs # live tail; Ctrl-C to stop\n```\n\n`runlot deploy [dir]` reads `name` and `org` from `runlot.json`; `--org` and `--project`\noverride them. A successful deploy prints the version line, then the addresses:\n\n```text\nDeploying\u2026\nv3 <deploymentId> <artifact sha> <createdAt>\n https://my-app.acme.runlot.app\n```\n\nWhat a deploy also does, in order: creates the project if it does not exist, creates the\ndatabase when the build uses one, applies `migrations/NNNN_*.sql` (skip with `runlot deploy --no-migrate`),\nprints one line if capacity risks are open. Read every `Warning:` line it prints \u2014 each names\nthe exact edit.\n\n## Before the first deploy\n\n- `runlot whoami` must succeed (or `RUNLOT_TOKEN` is set).\n- `runlot.json` has `name`, and `main` or `assets` (or `framework: \"next\"`).\n- If the worker imports agent frameworks or late Node built-ins, set `compatibilityDate`\n (see [references/deploy-errors.md](references/deploy-errors.md)).\n- Secrets the code reads exist: `runlot secret list`.\n\n## Choose the right command\n\n| Goal | Command |\n|---|---|\n| Try a change at a second address with a copy of the data | `runlot deploy --preview <name>` |\n| Ship a preview's exact code to production | `runlot preview promote <name>` |\n| Undo a bad release | `runlot versions`, then `runlot rollback <version>` |\n| Put the app on your own domain | `runlot domain add <host>` |\n| Limit who can open the deployment | `runlot access set org` (or `password`, `app`, `public`) |\n| Deploy from CI | `RUNLOT_TOKEN=<token> runlot deploy` |\n| Open the live address | `runlot open` |\n\nDetails: [references/previews-and-rollback.md](references/previews-and-rollback.md),\n[references/domains-and-access.md](references/domains-and-access.md),\n[references/ci.md](references/ci.md), [references/frameworks.md](references/frameworks.md).\n\n## When a deploy fails\n\nRead the error code in `Error [code] message`. The common ones and their fixes are in\n[references/deploy-errors.md](references/deploy-errors.md). For an error that looks like\nRunlot's fault (5xx, a deploy that hangs, an error that contradicts the docs), the CLI prints\na hint; follow it with `runlot bug-report --last` (see the `runlot-operate` skill).\n\n## Do\n\n- Deploy previews for risky changes and load tests; they get their own database branch.\n- Keep migrations forward-only and deploy them before the code that needs them, or together.\n- Check `runlot versions` before a rollback so you name the right version.\n- Use a scoped token in CI (`--scope deploy --scope project:read`), not a personal login.\n\n## Don't\n\n- Don't use `--db shared` on a preview unless you mean to write to production's database.\n- Don't expect `runlot rollback` to roll back the database; it changes only the code.\n- Don't commit `.env.runlot` or `.runlot/`; don't put `node_modules` or build caches in `assets`.\n- Don't rename a project casually: `runlot project rename` changes its address with no alias.\n",
31
+ "runlot-deploy/references/ci.md": "# Deploying from CI\n\nThe CLI reads `RUNLOT_TOKEN` in preference to a stored login, so a CI job needs no\n`runlot login`.\n\n## 1. Issue a scoped token\n\n```bash\nrunlot token create gh-actions --scope deploy --scope project:read --org acme --project shop --expires 90d\n```\n\n- `deploy` + `project:read` is the minimum: the deploy reads the project back at the end, and a\n `deploy`-only token gets a 403 naming `project:read` there.\n- Add `--scope db:write` only if the job applies migrations.\n- Keep `--org` and `--project` so a leaked token reaches one project.\n- `--expires` defaults to 90 days (max 365); `--no-expiry` exists but avoid it.\n- Scopes: `project:read`, `deploy`, `db:read`, `db:write`, `repo:read`, `repo:write`, `org:admin`.\n The scope check is on top of the user's org role: a viewer's `deploy` token deploys nothing.\n\n`runlot token list` shows tokens and when they were last used; `runlot token revoke <tokenId>`\nrevokes one.\n\n## 2. GitHub Actions\n\nStore the value as the repository secret `RUNLOT_TOKEN`.\n\n```yaml\nname: deploy\non:\n push:\n branches: [main]\njobs:\n deploy:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 22\n - run: npm ci\n - run: npx runlot deploy\n env:\n RUNLOT_TOKEN: ${{ secrets.RUNLOT_TOKEN }}\n```\n\n`runlot health --fail-on alert` exits 1 when a capacity risk is at alert, which makes it a\nusable gate after a deploy. Health is an observability read; if the token is refused, the\nerror names what it lacks.\n\n## Commands that ask for confirmation\n\n`runlot rollback`, `runlot secret delete`, `runlot db restore` and similar ask first. In CI pass\n`-y` where the command accepts it. Deleting a project or an org never accepts `-y`; scripts\npass `--confirm <name>`.\n\n## A protected deployment in CI\n\nFor a project with `runlot access set org|password|app`, create a bypass secret with\n`runlot access bypass --new` and send it as the `Runlot-Access-Bypass` header from test jobs.\n",
32
+ "runlot-deploy/references/deploy-errors.md": '# Reading deploy errors\n\nThe CLI prints server errors as `Error [code] message`. Search for the code, not the message.\nFull list: https://docs.runlot.io/docs/reference/errors.mdx\n\n## Build-time (on your machine, before upload)\n\n**A Node built-in the compatibility date does not provide.**\n\n```text\nThe worker imports node:http (from 2025-08-15), node:worker_threads (from 2026-03-17), which nodejs_compat\nprovides only from those dates; runlot.json names no compatibilityDate, so the default 2024-09-23 applies.\nThe worker could not load at that date. Set "compatibilityDate": "2026-03-17" (or later) in runlot.json.\n```\n\nFix: set the date the message names in `runlot.json`, then deploy again.\n\n| From | Built-ins |\n|---|---|\n| `2025-08-15` | `node:http`, `node:https` |\n| `2025-09-01` | `node:http2` |\n| `2025-09-21` | `node:console` |\n| `2025-10-01` | `node:vm` |\n| `2025-12-04` | `node:cluster`, `node:domain`, `node:punycode`, `node:trace_events`, `node:wasi` |\n| `2026-01-29` | `node:dgram`, `node:inspector` |\n| `2026-03-17` | `node:child_process`, `node:perf_hooks`, `node:readline`, `node:repl`, `node:v8`, `node:worker_threads` |\n\n`node:fs`, `node:os` and `node:tty` load at every date as stand-ins; calling a `node:fs`\nfunction throws at runtime. There is no filesystem \u2014 use `env.storage`.\n\n**A build-time virtual module cannot be resolved** (`virtual:react-router/server-build` and\nsimilar): Runlot does not run your framework\'s build. Run it first and point `main` at the\nbuilt `workerd` server bundle (see [frameworks.md](frameworks.md)).\n\n**`new WebAssembly.Module(bytes)` fails** at runtime: WASM must ship as a module. The bundler\ndoes this for Prisma; for your own `.wasm`, import it so esbuild emits it as a module.\n\n## From the server\n\n| Code | Meaning | Fix |\n|---|---|---|\n| `compatibility_date_unsupported` | The date is later than the runtime supports | Lower `compatibilityDate` to the date the message names |\n| `bundle_too_large` | Over 64 MiB compressed / 512 MiB total / 64 MiB a file / 20,000 entries | Remove `node_modules` or caches from `assets` |\n| `not_a_member` | Not a member of that org | Check `runlot whoami` and `--org` |\n| `not_found` `org "\u2026" does not exist` | Old or misspelled org slug in runlot.json | Use the slug from `runlot org list` |\n| `project_limit` (409) | Free allows 3 projects per org | Delete one, or upgrade |\n| `preview_limit` (409) | Free allows 2 previews per project | `runlot preview rm <name>` |\n| `chain_broken` (409) | `--from` names a backup whose chain is no longer whole | Pick another from `runlot db backups` |\n| `cron_limit`, `queue_limit` (400) | Too many schedules or queues for the plan | Remove some |\n| `insufficient_scope` (403) | The CI token lacks a scope; `details.requiredScope` names it | Reissue with that `--scope` |\n| `suspended` | The project is suspended | Contact support |\n| `database_not_found` (409) | runlot.json `databases` binds a name this organization has no database for | `runlot db create <name>`, or fix the name; `runlot db list` shows them |\n\n## After the deploy\n\n- `This project\'s database was deleted, so the deploy did not create a new one.` \u2014 run\n `runlot db create <project>` if you want a new, empty database.\n- `Warning: \u2026` lines (legacy runlot.json forms, access rules that protect nothing) each name\n the edit; make it.\n- `N capacity risks are open \u2014 runlot health`: the deploy succeeded; read `runlot health`.\n- The address answers with a TLS error for a few minutes after the first deploy of a new org:\n the org certificate is still being issued. The CLI says so under the address.\n\n## When it is not your bug\n\nA 5xx from the API, an error that contradicts the docs, or a deploy that succeeded but serves\nthe old version: the CLI ends with a hint to run `runlot bug-report --last`. That attaches the\nfailed command (values removed) and its request IDs.\n',
33
+ "runlot-deploy/references/domains-and-access.md": "# Domains and access control\n\n## Custom domains\n\n```bash\nrunlot domain add app.example.com # prints the records to create\nrunlot domain verify app.example.com # check now instead of waiting for the per-minute check\nrunlot domain list # verifying \u2192 issuing certificate \u2192 connected\nrunlot domain rm app.example.com\n```\n\nFor a subdomain, create both records the command prints:\n\n```text\nCNAME app.example.com \u2192 apps.runlot.app\nTXT _runlot-verify.app.example.com \u2192 <token>\n```\n\nThe TXT proves ownership; without the CNAME no traffic arrives. A root domain cannot hold a\nCNAME, so the command asks for an `A` record (or use ALIAS/ANAME flattening to\n`apps.runlot.app`). A domain bought through Runlot (`runlot domains buy`) has its DNS hosted\nby Runlot and needs no records. Free allows 1 custom domain per project.\n\n`runlot domain add <host> --login` (Pro) makes the name the project's sign-in page domain\ninstead of an app address.\n\nDNS zones hosted by Runlot: `runlot dns zones`, `runlot dns records <zone>`,\n`runlot dns set <zone> <name> <type> <value>` (org commands; they need `--org`).\n\n## Who can open the deployment\n\nAuth (`env.auth`) identifies your app's users. Access control decides who reaches the\ndeployment at all, before your code runs.\n\n```bash\nrunlot access # the current mode\nrunlot access set public # anyone (default)\nrunlot access set org # members of the org only\nrunlot access set password --password 'shared-secret'\nrunlot access set app # people signed in to this app\nrunlot access bypass --new # a secret for CI and load tests: send it as Runlot-Access-Bypass\nrunlot access bypass --revoke\n```\n\nPreviews copy the project's access mode when they are created. Path rules in runlot.json\n(`access`) only narrow `org` and `password` modes; on a public project the deploy warns that\nthey protect nothing.\n\n## Addresses\n\nThe default address is `<project>.<org>.runlot.app` and cannot be changed except by renaming\nthe project (`runlot project rename <old> <new>`), which keeps no alias \u2014 update runlot.json,\ngit remotes and anything that links to the old address. `runlot open` opens the address the\nserver reports.\n",
34
+ "runlot-deploy/references/frameworks.md": '# Frameworks\n\nThe unit of a deploy is a worker (`main`) plus a static directory (`assets`). Frameworks sit on\ntop of that. Docs: https://docs.runlot.io/docs/start/frameworks.mdx\n\n## Hono\n\nNo adapter. `export default app`, and type the bindings:\n\n```ts\nimport { Hono } from "hono";\nimport { Pool } from "@runlot/pg";\n\nconst app = new Hono<{ Bindings: Env }>();\napp.get("/posts", async (c) => {\n const db = new Pool({ db: c.env.db });\n const { rows } = await db.query("select id, title from posts order by id desc limit 20");\n return c.json(rows);\n});\nexport default app;\n```\n\n## Static sites and SPAs\n\n`{ "name": "site", "assets": "dist" }` with no `main`. Add `"notFound": "spa"` for client-side\nrouting. Run the site\'s build (`npm run build`) before `runlot deploy`; Runlot does not run it.\n\n## Next.js\n\n`{ "name": "app", "framework": "next" }` and install `next` and `@runlot/next`. The deploy runs\n`next build` and the OpenNext build. In server code:\n\n```tsx\nimport { env } from "@runlot/next";\nconst user = await env.auth.user(); // no request argument in Next.js\n```\n\n`env` exists only while a request is handled \u2014 not at module top level, not during static\nrendering at build time. `next/image` optimisation is not supported (originals are served).\n`npm create runlot@latest my-app -- --template next` generates the setup.\n\n## React Router, Astro, SvelteKit, Nuxt\n\nBuild to the framework\'s `workerd` server target first, then point Runlot at the output:\n\n```json\n{ "name": "app", "main": "build/server/index.js", "assets": "build/client", "runWorkerFirst": false }\n```\n\n`runWorkerFirst: false` matters: Runlot\'s default is worker-first, and these server builds\nexpect their client files to be served ahead of the worker. Astro: `dist/server`, `dist/client`.\n\n## Vite + React + an API worker\n\n`npm create runlot@latest my-app -- --template studio` scaffolds a Vite UI plus a Hono API\nworker; it deploys with `npm run deploy` because the UI has to be built into `assets/` first.\n\n## Packages that do not run\n\n- Anything that needs a real filesystem, child processes at runtime, or native addons.\n- `prisma migrate deploy` inside a worker (generate SQL with Prisma, apply it with Runlot\n migrations instead).\n- A local `localhost` API called from the deployed worker \u2014 outbound `fetch` reaches public\n addresses only.\n',
35
+ "runlot-deploy/references/previews-and-rollback.md": "# Previews, versions and rollback\n\n## Previews\n\n```bash\nrunlot deploy --preview feat-42 # same bundle, second address, own database branch\nrunlot deploy --preview bug-812 --from 3/15 # branch from an older backup (keys from `runlot db backups`)\nrunlot deploy --preview static-check --db none\nrunlot preview ls # address, database mode, status, expiry\nrunlot preview promote feat-42 # make the preview's code production's live version\nrunlot preview rm feat-42 # delete the preview and its database branch\n```\n\n- Address: `<preview>--<project>.<org>.runlot.app` (each name at most 30 characters).\n- `--db branch` (default) pours a full copy of the newest backup into the preview's own\n database. Writes stay there. A branch is **never merged back**; to move data out, export it.\n- `--db shared` points the preview at **production's** database \u2014 a migration or `DELETE` there\n happens in production. `--db none` gives no `env.db`.\n- `migrations/` is applied to the preview's branch by the server once the branch is poured,\n not by the CLI.\n- `promote` promotes code only: a new version number, the same artifact, no rebuild, no data.\n- A preview inherits the project's access policy, and its requests count against the org's\n budget. It expires after 24 h without a request on Free, 7 days on Pro.\n- Free: 2 previews per project (`409 preview_limit`).\n- A branch is a full copy, so it counts toward the database size.\n\n## Versions and rollback\n\n```bash\nrunlot versions # history, * marks the live version\nrunlot rollback v2 # asks first; -y skips the question (CI)\nrunlot rollback 3 # back again \u2014 rollback only moves the live pointer\n```\n\nRollback reuses the stored artifact (no rebuild) and **does not touch the database**. If the\nrelease also ran a migration, a rollback leaves the new schema in place; restoring data is\n`runlot db restore` (see the `runlot-database` skill). Don't ship a destructive migration (drop\na column) in the same deploy as the code that stops using it \u2014 split it into two releases.\n\n## Push to deploy\n\nA Runlot-hosted Git repository can deploy on push:\n\n```bash\nrunlot repo create web # creates the repository and sets git origin\nrunlot repo hook set web --branch main # deploy every push to main (--root-dir for a monorepo)\nrunlot repo hook status web\nrunlot builds # build history; runlot builds log <buildId> for output\n```\n\nThis is the one remote build: the control plane runs the same bundler. Free gets 300 build\nminutes a month; a failed build leaves the previous deployment serving.\n",
36
+ "runlot-operate/SKILL.md": "---\nname: runlot-operate\ndescription: Operate a deployed Runlot app \u2014 live and stored logs (`runlot logs`), request lookup by Runlot-Request-Id, Runlot Errors issues, capacity risks with `runlot health`, usage, per-project memory and CPU limits, the org spending limit and 429 budget_exhausted, out-of-memory restarts and exceededCpu, load testing a preview with k6, and filing a problem with Runlot via `runlot bug-report`. Use when a Runlot app is slow, failing, restarting, over a limit or about to launch, or when the fault looks like the platform's rather than the app's.\n---\n\n# Operate a Runlot app\n\nRunlot collects request records, worker exits, memory, CPU and errors for every project with\nno SDK. Start from the symptom, find the request or the check that explains it, then fix the\ncode or the limit.\n\n## Triage in four commands\n\n```bash\nrunlot health # open capacity risks, the numbers behind them, what to do next\nrunlot logs --since 1h --level error\nrunlot errors # Runlot Errors issues: unresolved, events, users, last seen\nrunlot logs request <id> # one request: outcome, logs, span waterfall, related issues\n```\n\nEvery response carries a `Runlot-Request-Id` header (32 hex characters); error pages show the\nsame ID. Ask the user for it, or read it from the response, and look it up.\n\n## Symptoms\n\n| Symptom | Likely cause | Look at |\n|---|---|---|\n| `429 budget_exhausted` on every project of the org | Daily Free budget or Pro spending limit used up | `runlot usage`, `runlot org spending-limit --org <org>` |\n| `500`, outcome `exceededCpu` | One request used more CPU than its limit (60 s default) | `runlot health` (`request_cpu`); move work to a queue |\n| App restarts, `app_out_of_memory` issue | App process over its memory limit (256 MiB default) | `runlot health` (`app_memory`), `runlot project limits` |\n| `57014` / `53300` from the database | Statement over 10 s / waited 10 s for a connection | `runlot health` (`db_admission`); the `runlot-database` skill |\n| Slow only when busy | Requests queueing behind CPU-heavy ones | `runlot health` (`busy_latency`, `app_cpu`) |\n| `429 too_many_concurrent` from `env.ai` | Free AI concurrency (2 per project) | `runlot ai` |\n| Works locally, fails deployed | `fetch` to a private address, missing secret, compatibility date | `runlot secret list`, `runlot logs` |\n\nDetails: [references/logs-and-errors.md](references/logs-and-errors.md),\n[references/health-and-limits.md](references/health-and-limits.md).\n\n## Before a launch\n\n1. `runlot health` \u2014 no open alerts; read the capacity estimate if there is one.\n2. `runlot health --users 1000 --requests-per-user 100 --peak-hour-share 0.15` \u2014 your traffic\n next to the measured ceiling.\n3. Load test a **preview**, never production: [references/load-testing.md](references/load-testing.md).\n4. On Pro, check the spending limit covers the expected overage.\n\n## When the fault is Runlot's\n\nIf the evidence points at the platform \u2014 a 5xx the app did not produce, a deploy that succeeded\nbut serves the old version, behaviour that contradicts the docs, a CLI command failing with a\nserver error \u2014 file a report with the evidence attached instead of guessing:\n\n```bash\nrunlot bug-report --last --message \"deploy v42 succeeded but / still serves v41\"\nrunlot bug-report --request 4bf92f3577b34da6a3ce929d0e0e4736 --message \"checkout returns 502 since 10:00 UTC\"\n```\n\nWhat to attach and how it works: [references/bug-report.md](references/bug-report.md).\nShow the user what will be sent (`--dry-run`) and send only with their consent.\n\n## Do\n\n- Quote request IDs, versions and UTC times when you report findings.\n- Prefer stored logs with a window (`--since`) over scrolling the live tail.\n- Fix the cause (stream instead of buffering, batch SQL, queue long work) before raising a limit.\n- Check `unknown` checks in `runlot health` as \"not measured\", never as \"fine\".\n\n## Don't\n\n- Don't load test production or use `--db shared` for a load test.\n- Don't raise memory or CPU limits to hide a leak or a synchronous loop.\n- Don't set the spending limit at or below this month's overage without the user's say-so \u2014\n it blocks every request of the org at once.\n- Don't send a bug report without the user's consent, and don't paste secrets into `--message`.\n",
37
+ "runlot-operate/references/bug-report.md": '# Reporting a problem to Runlot\n\n`runlot bug-report` sends Runlot a report with **references** to the evidence \u2014 request IDs, a\ntime window, the last failed command \u2014 which Runlot resolves into a snapshot that outlives log\nretention. Docs: https://docs.runlot.io/docs/observability/bug-reports.mdx\n\n## When to file one\n\nFile when the evidence points at the platform, not the app:\n\n- the API or CLI fails with a 5xx or an unexpected error (the CLI then prints a hint to run\n `runlot bug-report --last`);\n- the app answers with a Runlot error page (`app_error`) and the request record shows no\n exception from your code;\n- a deploy succeeded but the old version serves, or a preview never becomes ready;\n- behaviour contradicts the docs (quote the page).\n\nDo not file for your own app\'s exceptions, a `429 budget_exhausted` you can explain with\n`runlot usage`, or a limit the docs state.\n\n## What to attach\n\n| Flag | Attach |\n|---|---|\n| `--message "\u2026"` | One or two sentences: what you did, what happened, what you expected, since when (UTC), which version (`runlot versions`) |\n| `--request <id>` | The `Runlot-Request-Id` of failing requests; repeat up to 20 |\n| `--last` | The most recent failed `runlot` command on this machine (arguments reduced to command words, exit status, end of stderr, API request IDs) |\n| `--since 2h` | The window for failing requests, worker exits and issues (default 1h, max 24h) |\n| `--no-logs` | Request records and exits only, no log lines |\n| `--dry-run` | Print exactly what would be sent and stop |\n| `-y` | Send without the confirmation prompt |\n\nWithout flags it uses the linked project (runlot.json) and the last hour, asks for a\ndescription, shows counts and a preview, and asks to confirm. It also adds the CLI version, OS,\narchitecture, Node version and runlot.json; secret-looking values are replaced by the CLI and\nagain by the server.\n\n## The agent\'s procedure\n\n1. Collect evidence first: `runlot logs request <id>`, `runlot versions`, the failing command.\n2. Write the message from facts: command, request IDs, times in UTC, version, the doc that\n disagrees. No guesses about Runlot\'s internals.\n3. Run with `--dry-run` and show the user what will be sent.\n4. Send only after the user agrees:\n\n```bash\nrunlot bug-report --request 4bf92f3577b34da6a3ce929d0e0e4736 --since 2h --message "POST /api/checkout returns 502 app_error since 10:05 UTC on v42; no exception in our logs" -y\n```\n\n5. Give the user the `BR-\u2026` id from the reply. Replies come by email and on the report.\n',
38
+ "runlot-operate/references/health-and-limits.md": "# Health checks and limits\n\n## runlot health\n\n```bash\nrunlot health # the project in runlot.json\nrunlot health --json # the API answer: status, risks, checks, capacity\nrunlot health --all --org acme # every project of the org, worst first, plus the budget\nrunlot health --fail-on warn # exit 1 at warn or alert (CI, agents)\n```\n\nEach open risk has a level (`warn`: a trend, `alert`: failure likely within a day or started),\nthe numbers, a next step and a dashboard link. **`unknown` means not measured** (too little\ndata, no calls of that kind) \u2014 not \"fine\". With the MCP server, the tools are\n`get_project_health` and `get_org_health`.\n\n| Check | Compares | Usual fix |\n|---|---|---|\n| `app_memory` | Peak memory vs the app's limit, growth after a start | Stream bodies; dispose RPC results; then consider `--memory` |\n| `app_restarts` | Unrequested restarts (OOM, crash, failed start) in 24 h | Read the exits in `runlot logs request` / Errors |\n| `rpc_not_disposed` | Requests logging undisposed RPC results | Call `dispose()` or use `using` on RPC results |\n| `app_cpu` | Busiest-minute CPU vs one core | Profile; move work off the request path |\n| `request_cpu` | CPU per request per route vs the per-request limit | Split work, use a queue |\n| `app_stall` | App stopped answering while using its whole core | Find the synchronous loop |\n| `busy_latency` | p95 in the busiest hour vs quieter hours | Queueing; reduce CPU per request |\n| `db_admission` | `53300` waits, `57014` timeouts, database CPU | Shorter statements and transactions |\n| `db_memory`, `db_size` | Database memory pressure; size vs the plan cap | Indexes, archive data, move blobs to storage |\n| `ai_refusals` | `env.ai` calls refused for rate/concurrency/lending | Fewer concurrent calls, Pro, or fallback on |\n| `org_budget` | Requests, CPU and spend vs allowance and spending limit | Reduce traffic or raise the limit |\n| `file_storage`, `log_cap`, `cron_overlap` | Storage quota; log events cap; cron runs overlapping | \u2014 |\n\nThe capacity estimate (after 1,000 requests in 3 days) is CPU arithmetic: requests of today's\naverage cost that fit in 0.7 of a core. It does not see memory per concurrent request, AI\nlimits, locks or slow upstream APIs. Compare it with a load test.\n\n## Per-project limits\n\n```bash\nrunlot project limits # current limits and the last 14 days' use\nrunlot project limits --memory 512MiB # app memory (Free can only lower, min 64 MiB)\nrunlot project limits --cpu 120s # CPU per request (Pro: 1\u2013300 s)\nrunlot project limits --memory default --cpu default\n```\n\nAdmin only; lowering below recent use asks first (`-y` answers yes). A limit is a ceiling, not\nreserved memory. On Pro, memory held above the plan's 1 GiB project cap is billed at $0.01 per\nGiB-hour and counts toward the spending limit.\n\n## Spending limit (Pro)\n\n```bash\nrunlot org spending-limit --org acme # the limit and this month's overage by axis\nrunlot org spending-limit set 50 --org acme # USD a month, $0\u2013$1,000; admin\nrunlot org spending-limit set default --org acme # back to $20\n```\n\nWhen the month's overage reaches it, **every HTTP request of every project in the org** answers\n`429 budget_exhausted` until the 1st of next month, uploads past the storage quota get\n`507 quota_exceeded`, and admins get a `budget_exhausted` mail. Raising it above the overage\nreleases the lock within minutes. Setting it at or below what has accrued locks the org at once,\nso the CLI asks first.\n\nOn Free there is no spending limit: the daily budget (100,000 requests, 1,000 CPU-seconds per\norg) stops serving until 00:00 UTC instead of charging.\n\n## Memory and CPU in code\n\n- Memory: never hold a whole upload, download or large query result; stream with\n `ReadableStream`, paginate SQL, `env.storage.get(key).body` straight into the `Response`.\n- CPU: the limit counts CPU only; waiting on `fetch`, the database or a timer is free. One\n synchronous loop blocks every other request of the project (code runs one turn at a time).\n",
39
+ "runlot-operate/references/load-testing.md": '# Load testing with k6\n\nMeasure the real curve before a launch \u2014 against a **preview**, with a slow ramp and hard stop\nconditions. Docs: https://docs.runlot.io/docs/observability/load-testing.mdx\n\n## Rules\n\n- A preview only: `runlot deploy --preview load`. Its database is a copy, so the test\'s writes\n stay there. Never `--db shared`.\n- Start at 1 request/s and ramp slowly. Stop when more than 1 % fail or p95 passes 2 s.\n- 10 minutes at most, outside production\'s busy hours.\n- It spends the **org\'s** budget: a preview\'s requests and CPU count like production\'s, and if\n the budget runs out production gets `429 budget_exhausted` too. The script below sends about\n 10,000 requests \u2014 a tenth of the Free daily allowance.\n- Get the user\'s go-ahead before running it.\n\n## 1. Deploy the preview\n\n```bash\nrunlot deploy --preview load\n```\n\nThe address is printed (`https://load--my-app.acme.runlot.app`). For a non-public project:\n`runlot access bypass --new` and pass the secret as `Runlot-Access-Bypass`.\n\n## 2. load.js\n\n```js\nimport http from "k6/http";\nimport { check } from "k6";\n\nconst BASE = __ENV.BASE_URL;\nconst BYPASS = __ENV.ACCESS_BYPASS;\n\nexport const options = {\n scenarios: {\n ramp: {\n executor: "ramping-arrival-rate",\n startRate: 1,\n timeUnit: "1s",\n preAllocatedVUs: 20,\n maxVUs: 200,\n stages: [\n { target: 5, duration: "2m" },\n { target: 20, duration: "3m" },\n { target: 50, duration: "3m" },\n { target: 0, duration: "1m" },\n ],\n },\n },\n thresholds: {\n http_req_failed: [{ threshold: "rate<0.01", abortOnFail: true, delayAbortEval: "30s" }],\n http_req_duration: [{ threshold: "p(95)<2000", abortOnFail: true, delayAbortEval: "30s" }],\n },\n};\n\nexport default function () {\n const headers = BYPASS ? { "Runlot-Access-Bypass": BYPASS } : {};\n const res = http.get(`${BASE}/`, { headers }); // use the routes users hit most\n check(res, { "status is 2xx": (r) => r.status >= 200 && r.status < 300 });\n}\n```\n\n## 3. Run and watch\n\n```bash\nk6 run -e BASE_URL=https://load--my-app.acme.runlot.app load.js\nrunlot health --project my-app # in another terminal; the preview\'s Infrastructure chart in the dashboard\n```\n\nStop with Ctrl-C if memory approaches the limit.\n\n## 4. Read it\n\n- **The knee**: the rate where p95 climbs or errors start \u2014 the real ceiling for those routes.\n Compare with `runlot health`\'s estimate.\n- **Memory** that rises with the rate and does not come back down will hit the limit in\n production.\n- `429 budget_exhausted` is the org budget, not the app. `500` with outcome `exceededCpu` is the\n per-request CPU limit.\n\n## 5. Clean up\n\n```bash\nrunlot preview rm load\nrunlot access bypass --revoke # if you created one for the test\n```\n',
40
+ "runlot-operate/references/logs-and-errors.md": '# Logs, requests and errors\n\n## Live tail\n\n```bash\nrunlot logs # last 200 lines, then live; Ctrl-C to stop\nrunlot logs --tail 1000 --no-follow # recent lines, then exit (max 2000)\nrunlot logs --level warn # warn and error only\nrunlot logs --source stderr # stdout | stderr | runlot\nrunlot logs --format json # one object per line, the stored event\'s shape\n```\n\nThe live tail reads a per-project ring buffer (2,000 lines / 1 MiB) and can drop lines when the\nreader is slow (`-- dropped N lines`).\n\n## Stored logs\n\n```bash\nrunlot logs --since 2h\nrunlot logs --since 24h --grep ERROR # plain substring, not a regex\nrunlot logs --since 2026-09-20T10:00 --until 2026-09-20T11:00\nrunlot logs --since 6h --level error --source exception\n```\n\n- `--since`/`--until` take an age (`30m`, `2h`, `3d`) or a time. **A time without a zone is\n UTC**, the zone the lines are printed in.\n- Pages are 200 lines (`--limit`, up to 1,000); a wider range ends with\n `-- more available: rerun with --cursor \u2026`.\n- Kept 3 days on Free, 7 on Pro. Past the daily cap (200,000 events Free) only one event in a\n hundred is stored for the rest of the UTC day.\n- Console lines of each request and traces are **off until enabled** in runlot.json\n (`observability.logs.enabled`, `observability.traces.enabled`, with `head_sampling_rate`) or in\n the dashboard\'s Observability settings. Request records, exits and errors are always kept.\n\n## One request\n\n```bash\nrunlot logs request 4bf92f3577b34da6a3ce929d0e0e4736 # outcome, logs, spans, related issues\nrunlot logs --request 4bf92f3577b34da6a3ce929d0e0e4736 # only that request\'s lines\n```\n\nOutcomes: `ok` (even when the handler returned a 500), `exception`, `exceededMemory`, `exceededCpu`, `exceededWallTime`, `canceled`, `internalError`. When Runlot answered instead\nof the app (out of memory, crash, starting), an anonymous visitor sees only `app_error`; members\nsee the cause.\n\n## Runlot Errors\n\nUncaught exceptions, rejected `waitUntil` promises, `console.error(err)`, crashes and\nout-of-memory kills become grouped issues (on by default; `"observability": { "errors": false }`\nturns it off).\n\n```bash\nrunlot errors # unresolved issues\nrunlot errors --status all --query "route:/api/*"\nrunlot errors show ERR-12 # stack, breadcrumbs, request, user\nrunlot errors resolve ERR-12 --in next-release\nrunlot errors archive ERR-12 --until 24h\n```\n\nSource maps ship with the deploy, so stacks point at your source. Alerts and uptime checks:\n`runlot errors alerts`, `runlot errors uptime` (see `runlot errors --help`).\n\n## Usage\n\n```bash\nrunlot usage # by hour, last 24 h\nrunlot usage --hours 168 # a week (max 720)\n```\n\nRequests and CPU are counted per org. A preview\'s usage is listed separately.\n\n## Dashboard\n\nProject \u2192 **Observability**: Overview, Logs, Invocations (query builder), Traces, Database\n(statement fingerprints), Infrastructure (memory against the limit, CPU, restarts, park/wake),\nErrors, Monitors. Members can open a request by ID there too.\n'
41
+ };
42
+
43
+ // src/skills.ts
44
+ var SKILL_AGENTS = ["claude", "codex", "cursor"];
45
+ var SKILL_AGENT_DIRS = {
46
+ claude: ".claude/skills",
47
+ codex: ".agents/skills",
48
+ cursor: ".agents/skills"
49
+ };
50
+ function skillDirs(agents) {
51
+ return [...new Set(agents.map((a) => SKILL_AGENT_DIRS[a]))];
52
+ }
53
+ function skillFiles() {
54
+ const out = {};
55
+ for (const [path, text] of Object.entries(SKILL_FILES)) {
56
+ const slash = path.indexOf("/");
57
+ const name = path.slice(0, slash);
58
+ (out[name] ??= {})[path.slice(slash + 1)] = text;
59
+ }
60
+ return out;
61
+ }
62
+ function frontmatter(text) {
63
+ const m = /^---\n([\s\S]*?)\n---\n/.exec(text);
64
+ if (!m) return {};
65
+ const out = {};
66
+ for (const line of m[1].split("\n")) {
67
+ const kv = /^([a-z][a-z-]*):\s*(.*)$/.exec(line);
68
+ if (kv) out[kv[1]] = kv[2].trim();
69
+ }
70
+ return out;
71
+ }
72
+ function listSkills() {
73
+ return Object.entries(skillFiles()).map(([name, files]) => ({ name, description: frontmatter(files["SKILL.md"] ?? "")["description"] ?? "" })).sort((a, b) => a.name === "runlot" ? -1 : b.name === "runlot" ? 1 : a.name.localeCompare(b.name));
74
+ }
75
+ function installSkills(projectDir, agents) {
76
+ return skillDirs(agents).map((dir) => {
77
+ let written = 0;
78
+ let unchanged = 0;
79
+ for (const [path, text] of Object.entries(SKILL_FILES)) {
80
+ const file = join(projectDir, dir, path);
81
+ if (existsSync(file) && readFileSync(file, "utf8") === text) {
82
+ unchanged++;
83
+ continue;
84
+ }
85
+ mkdirSync(dirname(file), { recursive: true });
86
+ writeFileSync(file, text);
87
+ written++;
88
+ }
89
+ return { dir, written, unchanged };
90
+ });
91
+ }
92
+
93
+ // src/index.ts
94
+ var POSTS_MIGRATION = [
95
+ "-- Applied by `runlot deploy` (and by `runlot db migrate`).",
96
+ "create table if not exists posts (",
97
+ " id serial primary key,",
98
+ " owner text not null,",
99
+ " title text not null",
100
+ ");",
101
+ "",
102
+ "-- One row so the example page has something to show before you write any.",
103
+ "insert into posts (owner, title)",
104
+ "select 'welcome', 'Your first post'",
105
+ "where not exists (select 1 from posts);",
106
+ ""
107
+ ].join("\n");
108
+ var TEMPLATE_NAMES = ["next", "hono", "static", "studio"];
8
109
  function isTemplateName(v) {
9
110
  return TEMPLATE_NAMES.includes(v);
10
111
  }
11
112
  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" }
113
+ { name: "next", desc: "Next.js App Router \u2014 uses env.db and env.auth from server components" },
114
+ { name: "hono", desc: "Hono worker \u2014 REST API example (/posts, /upload)" },
115
+ { name: "static", desc: "A single static HTML page \u2014 assets only, no worker" },
116
+ // Last, so the numbers the other three answer to do not move.
117
+ { name: "studio", desc: "Vite + React + Tailwind and a Hono API \u2014 what runlot Studio builds" }
15
118
  ];
16
119
  var USAGE = [
17
- "create-runlot <dir> [--org <slug>] [--template next|hono|static]",
120
+ "create-runlot <dir> [--org <slug>] [--template next|hono|static|studio] [--no-skills]",
18
121
  "",
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"
122
+ " Creates a project from a Next.js, Hono, static-site, or studio template.",
123
+ " Without --template, in a terminal, it asks which template to use.",
124
+ " It also writes the Runlot agent skills to .claude/skills and .agents/skills, so",
125
+ " Claude Code, Codex and Cursor know how to build and deploy on Runlot.",
126
+ " --no-skills leaves them out; `runlot skills add` writes them later.",
127
+ " Then run: cd <dir> && runlot deploy"
22
128
  ].join("\n");
23
129
  var NAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
24
130
  function pkgJson(obj) {
@@ -62,7 +168,7 @@ function nextLayoutTsx(name) {
62
168
  "",
63
169
  "export default function RootLayout({ children }: { children: ReactNode }) {",
64
170
  " return (",
65
- ' <html lang="ko">',
171
+ '<html lang="en">',
66
172
  " <body>{children}</body>",
67
173
  " </html>",
68
174
  " );",
@@ -74,19 +180,27 @@ var NEXT_PAGE_TSX = [
74
180
  'import { Pool } from "@runlot/pg";',
75
181
  'import { env } from "@runlot/next";',
76
182
  "",
183
+ "// `env` is request-scoped. Without this, Next prerenders this page during",
184
+ "// `next build`, where there is no request, and the build fails on the",
185
+ "// adapter's own error (field-notes #68).",
186
+ 'export const dynamic = "force-dynamic";',
187
+ "",
77
188
  "export default async function PostsPage() {",
78
189
  " const user = await env.auth.user();",
79
190
  " if (!user) {",
80
191
  " return (",
81
192
  " <p>",
82
- ' \uB85C\uADF8\uC778\uC774 \uD544\uC694\uD569\uB2C8\uB2E4. <a href="/login">\uB85C\uADF8\uC778\uD558\uAE30</a>',
193
+ ' Please sign in. <a href="/login">Sign in</a>',
83
194
  " </p>",
84
195
  " );",
85
196
  " }",
86
197
  "",
87
198
  " const db = new Pool({ db: env.db });",
88
199
  "",
89
- " const { rows } = await db.query(",
200
+ // `query` defaults its row type to Record<string, unknown>. Without the type
201
+ // argument `p.id` and `p.title` are `unknown`, and `next build` — which type
202
+ // checks — fails before the bundler is ever reached.
203
+ " const { rows } = await db.query<{ id: number; title: string }>(",
90
204
  ' "select id, title from posts where owner = $1 limit 20",',
91
205
  " [user.id],",
92
206
  " );",
@@ -103,16 +217,19 @@ function nextReadme(name) {
103
217
  return [
104
218
  `# ${name}`,
105
219
  "",
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.",
220
+ "A Next.js App Router template. `app/page.tsx` is a server component that lists",
221
+ "20 posts using `env.db` and `env.auth.user()`.",
108
222
  "",
109
223
  "```",
110
- "runlot add pg auth storage",
111
224
  "runlot deploy",
112
225
  "```",
113
226
  "",
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).",
227
+ "Postgres, login and object storage come with the project \u2014 there is nothing to set up.",
228
+ "The App Router itself runs on workerd (OpenNext).",
229
+ "",
230
+ "`npm run dev` is `next dev` \u2014 the fast UI loop, without runlot's bindings.",
231
+ "`npm run preview` is `runlot dev`: it builds through OpenNext and serves the result",
232
+ "under workerd with the bindings wired up, which is where you check what will ship.",
116
233
  ""
117
234
  ].join("\n");
118
235
  }
@@ -127,7 +244,11 @@ function nextTemplate(ctx) {
127
244
  version: "0.0.0",
128
245
  private: true,
129
246
  scripts: {
247
+ // `dev` stays `next dev`: for a Next project the UI loop is the thing you
248
+ // run all day, and `runlot dev` has to rebuild through OpenNext on every
249
+ // change. `preview` is the one that runs what will actually ship.
130
250
  dev: "next dev",
251
+ preview: "runlot dev",
131
252
  build: "next build",
132
253
  start: "next start"
133
254
  },
@@ -135,10 +256,10 @@ function nextTemplate(ctx) {
135
256
  next: "^16.3.4",
136
257
  react: "^19.2.8",
137
258
  "react-dom": "^19.2.8",
138
- // 버전 범위는 release.yml 의 npm 잡이 태그와 같은 번호로 다시 쓴다 —
139
- // 여기 적힌 값은 마지막 공개 버전이다.
140
- "@runlot/next": "^0.1.0-rc.21",
141
- "@runlot/pg": "^0.1.0-rc.21"
259
+ // The npm job in release.yml rewrites the version range with the same
260
+ // number as the tag — the value written here is the last published version.
261
+ "@runlot/next": "^0.1.0-rc.210",
262
+ "@runlot/pg": "^0.1.0-rc.210"
142
263
  },
143
264
  devDependencies: {
144
265
  typescript: "^5.7.2",
@@ -155,9 +276,11 @@ function nextTemplate(ctx) {
155
276
  "app/layout.tsx": nextLayoutTsx(name),
156
277
  "app/page.tsx": NEXT_PAGE_TSX,
157
278
  "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",
279
+ "migrations/0001_posts.sql": POSTS_MIGRATION,
280
+ // runlot deploy writes the generated OpenNext configuration into
281
+ // `.runlot-next` (docs/nextjs.md §2) — it is not source, so it is not
282
+ // something to commit.
283
+ ".gitignore": "node_modules\n.next\n.open-next\n.runlot-next\n.runlot\n.env.runlot\n",
161
284
  "README.md": nextReadme(name)
162
285
  };
163
286
  }
@@ -167,17 +290,26 @@ var HONO_INDEX_TS = [
167
290
  'import type { Db, Storage } from "@runlot/pg";',
168
291
  'import type { AuthBinding } from "@runlot/auth";',
169
292
  "",
170
- "// db\xB7auth\xB7storage \uB294 `runlot add pg auth storage` \uB4A4\uC5D0 \uC2E4\uC81C \uAC12\uC774 \uC628\uB2E4.",
293
+ "// db, auth, and storage come with every runlot project.",
171
294
  "interface Env {",
172
295
  " db: Db;",
173
296
  " auth: AuthBinding;",
174
297
  " storage: Storage;",
298
+ " // public/ ships in the same bundle and is served through this.",
299
+ " assets: { fetch(request: Request): Promise<Response> };",
175
300
  "}",
176
301
  "",
177
302
  "const app = new Hono<{ Bindings: Env }>();",
178
303
  "",
179
304
  'app.get("/posts", async (c) => {',
180
305
  " const user = await c.env.auth.user(c.req.raw);",
306
+ " // `user` is null until someone signs in, which is every first visit. The",
307
+ " // landing sample leaves this out because it is for display; a project that",
308
+ " // runs cannot (field-notes #69).",
309
+ " if (!user) {",
310
+ ' return c.json({ error: "sign in at /__runlot/auth/sign-in" }, 401);',
311
+ " }",
312
+ "",
181
313
  " const db = new Pool({ db: c.env.db });",
182
314
  "",
183
315
  " const { rows } = await db.query(",
@@ -194,6 +326,12 @@ var HONO_INDEX_TS = [
194
326
  " return c.json({ url });",
195
327
  "});",
196
328
  "",
329
+ "// With `main` set, the worker is the entry point and static files are not",
330
+ "// served ahead of it \u2014 so whatever the routes above did not answer is handed",
331
+ "// to the assets binding. Without this line public/hello.txt is a 404, which",
332
+ "// is what the template shipped until field-notes #71.",
333
+ 'app.all("*", (c) => c.env.assets.fetch(c.req.raw));',
334
+ "",
197
335
  "export default app;",
198
336
  ""
199
337
  ].join("\n");
@@ -201,15 +339,19 @@ function honoReadme(name) {
201
339
  return [
202
340
  `# ${name}`,
203
341
  "",
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.",
342
+ "A Hono worker template. `src/index.ts` has two routes: `/posts` (a list) and",
343
+ "`/upload` (an object storage upload).",
206
344
  "",
207
345
  "```",
208
- "runlot add pg auth storage",
346
+ "runlot dev",
209
347
  "runlot deploy",
210
348
  "```",
211
349
  "",
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.",
350
+ "`runlot dev` runs this worker on your own machine under workerd, with `env.storage`",
351
+ "and the static files real and `env.db` and `env.auth` as local stand-ins. It prints",
352
+ "which is which. Point `env.db` at a real database with `RUNLOT_DEV_DATABASE_URL`.",
353
+ "",
354
+ "`db`, `auth` and `storage` on `Env` come with the project \u2014 there is nothing to set up.",
213
355
  ""
214
356
  ].join("\n");
215
357
  }
@@ -224,10 +366,16 @@ function honoTemplate(ctx) {
224
366
  version: "0.0.0",
225
367
  private: true,
226
368
  type: "module",
369
+ // `npm run dev` is the first thing a person types in a new directory, and
370
+ // for this template it should be the local run — not a deploy.
371
+ scripts: {
372
+ dev: "runlot dev",
373
+ deploy: "runlot deploy"
374
+ },
227
375
  dependencies: {
228
376
  hono: "^4.10.7",
229
- "@runlot/pg": "^0.1.0-rc.21",
230
- "@runlot/auth": "^0.1.0-rc.21"
377
+ "@runlot/pg": "^0.1.0-rc.210",
378
+ "@runlot/auth": "^0.1.0-rc.210"
231
379
  },
232
380
  devDependencies: {
233
381
  typescript: "^5.7.2",
@@ -251,15 +399,18 @@ function honoTemplate(ctx) {
251
399
  "runlot.json": pkgJson(runlotConfig),
252
400
  "tsconfig.json": tsconfig,
253
401
  "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",
402
+ "public/hello.txt": "Static assets ship in the same bundle.\n",
403
+ "migrations/0001_posts.sql": POSTS_MIGRATION,
404
+ // `.runlot` is where `runlot dev` keeps the unpacked bundle, the local
405
+ // object store and the mail it did not send.
406
+ ".gitignore": "node_modules\n.runlot\n.env.runlot\n",
256
407
  "README.md": honoReadme(name)
257
408
  };
258
409
  }
259
410
  function staticIndexHtml(name) {
260
411
  return [
261
412
  "<!doctype html>",
262
- '<html lang="ko">',
413
+ '<html lang="en">',
263
414
  " <head>",
264
415
  ' <meta charset="utf-8" />',
265
416
  ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
@@ -269,8 +420,8 @@ function staticIndexHtml(name) {
269
420
  " <main>",
270
421
  ` <h1>${name}</h1>`,
271
422
  " <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.",
423
+ " This page is deployed as a static asset. Edit the files under <code>public/</code>",
424
+ " and run <code>runlot deploy</code> to publish the change.",
274
425
  " </p>",
275
426
  " </main>",
276
427
  " </body>",
@@ -282,14 +433,18 @@ function staticReadme(name) {
282
433
  return [
283
434
  `# ${name}`,
284
435
  "",
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.",
436
+ "A static site template. It is a single `public/index.html` with no worker.",
286
437
  "",
287
438
  "```",
288
- "runlot add pg auth storage",
439
+ "runlot dev",
289
440
  "runlot deploy",
290
441
  "```",
291
442
  "",
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.",
443
+ "`runlot dev` serves `public/` the way the deployment will, including the `notFound`",
444
+ "setting, and reloads when you save.",
445
+ "",
446
+ 'The project already has a database, login and storage. Add a worker (`"main"` in',
447
+ "`runlot.json`) when you want to use them \u2014 `env.db`, `env.auth` and `env.storage` are there.",
293
448
  ""
294
449
  ].join("\n");
295
450
  }
@@ -299,39 +454,483 @@ function staticTemplate(ctx) {
299
454
  if (org) runlotConfig["org"] = org;
300
455
  runlotConfig["assets"] = "public";
301
456
  return {
302
- // 클라이언트 라우팅(React Router 등)을 쓰면 `"notFound": "spa"` 를 더한다 —
303
- // 확장자 없는 경로가 index.html 로 답한다.
457
+ // If you use client-side routing (React Router and so on), add
458
+ // `"notFound": "spa"` — extensionless paths then answer with index.html.
304
459
  "runlot.json": pkgJson(runlotConfig),
305
460
  "public/index.html": staticIndexHtml(name),
306
- ".gitignore": "node_modules\n",
461
+ ".gitignore": "node_modules\n.runlot\n",
307
462
  "README.md": staticReadme(name)
308
463
  };
309
464
  }
465
+ var STUDIO_MIGRATION = [
466
+ "-- Applied by `runlot deploy` (and by `runlot db migrate`).",
467
+ "--",
468
+ "-- Forward only. An applied migration is compared by content hash on the next run",
469
+ "-- (`Migration changed after it was applied`), so change the schema by adding",
470
+ "-- 0002_*.sql rather than by editing this file.",
471
+ "create table if not exists notes (",
472
+ " id serial primary key,",
473
+ " owner text not null,",
474
+ " title text not null,",
475
+ " created_at timestamptz not null default now()",
476
+ ");",
477
+ "",
478
+ "-- The list is per signed-in user, newest first.",
479
+ "create index if not exists notes_owner_id on notes (owner, id desc);",
480
+ ""
481
+ ].join("\n");
482
+ var STUDIO_SEED = [
483
+ "-- Development data only. It is applied by `runlot dev`, never by `runlot deploy`.",
484
+ "insert into notes (owner, title)",
485
+ "select 'dev', '\uCCAB \uBA54\uBAA8\uC785\uB2C8\uB2E4'",
486
+ "where not exists (select 1 from notes);",
487
+ ""
488
+ ].join("\n");
489
+ function studioIndexHtml(name) {
490
+ return [
491
+ "<!doctype html>",
492
+ '<html lang="ko">',
493
+ " <head>",
494
+ ' <meta charset="utf-8" />',
495
+ ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
496
+ ` <title>${name}</title>`,
497
+ " </head>",
498
+ " <body>",
499
+ ' <div id="root"></div>',
500
+ ' <script type="module" src="/src/main.tsx"></script>',
501
+ " </body>",
502
+ "</html>",
503
+ ""
504
+ ].join("\n");
505
+ }
506
+ var STUDIO_VITE_CONFIG = [
507
+ 'import tailwindcss from "@tailwindcss/vite";',
508
+ 'import react from "@vitejs/plugin-react";',
509
+ 'import { defineConfig } from "vite";',
510
+ "",
511
+ "export default defineConfig({",
512
+ " plugins: [react(), tailwindcss()],",
513
+ ' // `runlot.json` points "assets" at this directory, and `runlot deploy` ships it as',
514
+ " // it stands on disk \u2014 it does not build it. That is why `npm run deploy` is two",
515
+ " // commands.",
516
+ ' build: { outDir: "assets", emptyOutDir: true },',
517
+ " server: {",
518
+ " // /api belongs to the worker. `npm run dev:api` runs it under workerd on 8787,",
519
+ " // with env.db, env.auth and env.storage wired up.",
520
+ ' proxy: { "/api": "http://127.0.0.1:8787" },',
521
+ " },",
522
+ "});",
523
+ ""
524
+ ].join("\n");
525
+ var STUDIO_MAIN_TSX = [
526
+ 'import { createRoot } from "react-dom/client";',
527
+ 'import { App } from "./App";',
528
+ 'import "./styles.css";',
529
+ "",
530
+ "// This module exports no component, so editing it is a full reload rather than a",
531
+ "// Fast Refresh. That is Vite's rule, and it is why App.tsx holds the component.",
532
+ 'const root = document.getElementById("root");',
533
+ 'if (!root) throw new Error("#root is missing in index.html");',
534
+ "createRoot(root).render(<App />);",
535
+ ""
536
+ ].join("\n");
537
+ var STUDIO_STYLES_CSS = [
538
+ '@import "tailwindcss";',
539
+ ""
540
+ ].join("\n");
541
+ var STUDIO_API_TS = [
542
+ "// The worker's answers, typed, and the two calls the page makes.",
543
+ "//",
544
+ "// The paths are relative. The same bundle is served on the dev port, on a Studio",
545
+ "// preview, on a share link and on the project's own hostname, so a hard-coded",
546
+ "// origin would be wrong on three of the four.",
547
+ "export interface Note {",
548
+ " id: number;",
549
+ " title: string;",
550
+ "}",
551
+ "",
552
+ "export interface NotesView {",
553
+ " signedIn: boolean;",
554
+ " notes: Note[];",
555
+ "}",
556
+ "",
557
+ "export async function listNotes(): Promise<NotesView> {",
558
+ ' const res = await fetch("/api/notes");',
559
+ " // env.auth.user() answers null until someone signs in, which is every first",
560
+ " // visit \u2014 not an error.",
561
+ " if (res.status === 401) return { signedIn: false, notes: [] };",
562
+ " if (!res.ok) throw new Error(`GET /api/notes: ${res.status}`);",
563
+ " return { signedIn: true, notes: (await res.json()) as Note[] };",
564
+ "}",
565
+ "",
566
+ "export async function addNote(title: string): Promise<void> {",
567
+ ' const res = await fetch("/api/notes", {',
568
+ ' method: "POST",',
569
+ ' headers: { "Content-Type": "application/json" },',
570
+ " body: JSON.stringify({ title }),",
571
+ " });",
572
+ " if (!res.ok) throw new Error(`POST /api/notes: ${res.status}`);",
573
+ "}",
574
+ ""
575
+ ].join("\n");
576
+ var STUDIO_APP_TSX = [
577
+ 'import { useEffect, useState } from "react";',
578
+ 'import { addNote, listNotes, type Note } from "./api";',
579
+ "",
580
+ "export function App() {",
581
+ " const [notes, setNotes] = useState<Note[]>([]);",
582
+ " const [signedIn, setSignedIn] = useState(true);",
583
+ ' const [title, setTitle] = useState("");',
584
+ " const [error, setError] = useState<string | null>(null);",
585
+ "",
586
+ " function reload(): void {",
587
+ " listNotes()",
588
+ " .then((v) => {",
589
+ " setSignedIn(v.signedIn);",
590
+ " setNotes(v.notes);",
591
+ " setError(null);",
592
+ " })",
593
+ " .catch((e: unknown) => setError(String(e)));",
594
+ " }",
595
+ "",
596
+ " useEffect(reload, []);",
597
+ "",
598
+ " return (",
599
+ ' <main className="mx-auto max-w-md p-8">',
600
+ ' <h1 className="text-2xl font-semibold">\uBA54\uBAA8</h1>',
601
+ "",
602
+ " {!signedIn && (",
603
+ ' <p className="mt-4 text-sm">',
604
+ ' <a className="underline" href="/__runlot/auth/sign-in">',
605
+ " \uB85C\uADF8\uC778\uD569\uB2C8\uB2E4",
606
+ " </a>",
607
+ " </p>",
608
+ " )}",
609
+ "",
610
+ ' {error && <p className="mt-4 text-sm text-red-600">{error}</p>}',
611
+ "",
612
+ " <form",
613
+ ' className="mt-4 flex gap-2"',
614
+ " onSubmit={(e) => {",
615
+ " e.preventDefault();",
616
+ " if (!title.trim()) return;",
617
+ " addNote(title)",
618
+ " .then(() => {",
619
+ ' setTitle("");',
620
+ " reload();",
621
+ " })",
622
+ " .catch((err: unknown) => setError(String(err)));",
623
+ " }}",
624
+ " >",
625
+ " <input",
626
+ ' className="flex-1 rounded border px-2 py-1"',
627
+ ' placeholder="\uBB34\uC5C7\uC744 \uC801\uC744\uAE4C\uC694?"',
628
+ " value={title}",
629
+ " onChange={(e) => setTitle(e.target.value)}",
630
+ " />",
631
+ ' <button className="rounded bg-black px-3 py-1 text-white" type="submit">',
632
+ " \uCD94\uAC00",
633
+ " </button>",
634
+ " </form>",
635
+ "",
636
+ ' <ul className="mt-6 space-y-1">',
637
+ " {notes.map((n) => (",
638
+ " <li key={n.id}>{n.title}</li>",
639
+ " ))}",
640
+ " </ul>",
641
+ "",
642
+ ' {signedIn && notes.length === 0 && <p className="mt-6 text-sm">\uC544\uC9C1 \uBA54\uBAA8\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.</p>}',
643
+ " </main>",
644
+ " );",
645
+ "}",
646
+ ""
647
+ ].join("\n");
648
+ var STUDIO_WORKER_TS = [
649
+ 'import type { AuthBinding } from "@runlot/auth";',
650
+ 'import { Pool } from "@runlot/pg";',
651
+ 'import type { Db, Storage } from "@runlot/pg";',
652
+ 'import { Hono } from "hono";',
653
+ "",
654
+ "// Declared in runlot.json; `runlot deploy` creates each one and binds it here.",
655
+ "interface Env {",
656
+ " db: Db;",
657
+ " auth: AuthBinding;",
658
+ " storage: Storage;",
659
+ " // The Vite build output. It ships in the same bundle as this worker.",
660
+ " assets: { fetch(request: Request): Promise<Response> };",
661
+ "}",
662
+ "",
663
+ "const app = new Hono<{ Bindings: Env }>();",
664
+ "",
665
+ 'app.get("/api/notes", async (c) => {',
666
+ " const user = await c.env.auth.user(c.req.raw);",
667
+ " // null until someone signs in, which is every first visit.",
668
+ ' if (!user) return c.json({ error: "sign in at /__runlot/auth/sign-in" }, 401);',
669
+ "",
670
+ " const db = new Pool({ db: c.env.db });",
671
+ " const { rows } = await db.query<{ id: number; title: string }>(",
672
+ ' "select id, title from notes where owner = $1 order by id desc limit 50",',
673
+ " [user.id],",
674
+ " );",
675
+ " return c.json(rows);",
676
+ "});",
677
+ "",
678
+ 'app.post("/api/notes", async (c) => {',
679
+ " const user = await c.env.auth.user(c.req.raw);",
680
+ ' if (!user) return c.json({ error: "sign in at /__runlot/auth/sign-in" }, 401);',
681
+ "",
682
+ " const body = (await c.req.json()) as { title?: string };",
683
+ " const title = body.title?.trim();",
684
+ ' if (!title) return c.json({ error: "title is required" }, 400);',
685
+ "",
686
+ " const db = new Pool({ db: c.env.db });",
687
+ " // One statement per request. The node's ceiling is 10 s and a tenant cannot",
688
+ " // raise it, so work that takes longer belongs in a cron, not in a request.",
689
+ ' await db.query("insert into notes (owner, title) values ($1, $2)", [user.id, title]);',
690
+ " return c.json({ ok: true }, 201);",
691
+ "});",
692
+ "",
693
+ 'app.get("/api/me", async (c) => {',
694
+ " const user = await c.env.auth.user(c.req.raw);",
695
+ " // The origin is read out of the request, never written as a constant: this same",
696
+ " // bundle answers on the dev port, on a Studio preview, on a share link and on the",
697
+ " // project's own hostname (docs/studio.md \xA76.8).",
698
+ ' const signIn = new URL("/__runlot/auth/sign-in", c.req.url).toString();',
699
+ " return c.json({ id: user?.id ?? null, signIn });",
700
+ "});",
701
+ "",
702
+ "// With `main` set the worker is the entry point and static files are not served",
703
+ "// ahead of it, so whatever the routes above did not answer is handed to the assets",
704
+ "// binding here. Without this line every file in assets/ is a 404",
705
+ "// (docs/field-notes.md #71).",
706
+ 'app.all("*", (c) => c.env.assets.fetch(c.req.raw));',
707
+ "",
708
+ "export default app;",
709
+ ""
710
+ ].join("\n");
711
+ function studioAgents(name) {
712
+ return [
713
+ `# ${name}`,
714
+ "",
715
+ "This project runs on Runlot. Read this before changing code: the platform refuses a",
716
+ "few things Node and Next allow, and the refusals are not visible in the type system.",
717
+ "",
718
+ "## What is bound",
719
+ "",
720
+ "| Binding | Declared in `runlot.json` | Used as |",
721
+ "|---|---|---|",
722
+ '| `env.db` | `"database": true` | `new Pool({ db: c.env.db })` \u2014 Postgres |',
723
+ '| `env.auth` | `"auth": true` | `c.env.auth.user(c.req.raw)` \u2014 **null** until someone signs in |',
724
+ '| `env.storage` | `"storage": true` | `c.env.storage.put(key, file)` |',
725
+ '| `env.assets` | `"assets": "assets"` | the Vite build output, served by the catch-all route |',
726
+ "",
727
+ '`env.ai` is deliberately **not** declared. Add `"ai": true` in the same change that',
728
+ "first calls it, and say so in that turn: it is metered.",
729
+ "",
730
+ "## Layout",
731
+ "",
732
+ "- `src/` \u2014 Vite + React + Tailwind. `npm run build` writes it into `assets/`.",
733
+ "- `worker/index.ts` \u2014 one Hono worker. Every API path is under `/api`.",
734
+ "- `migrations/NNNN_*.sql` \u2014 the schema, forward only. `seed.sql` is development data.",
735
+ "",
736
+ "## Refusals",
737
+ "",
738
+ "- **No `node:fs`, and `nodejs_compat` is not a single switch.** Importing a node",
739
+ " built-in requires the flag, which the deploy turns on when it sees the import \u2014 but",
740
+ " *which* built-ins that flag hands out is decided by `compatibilityDate`, not by the",
741
+ " build. Prefer Web APIs (`fetch`, `URL`, `crypto`, `TextEncoder`); there is no file",
742
+ " system at any date.",
743
+ "- **No long-running process.** A request ends when its response does. Recurring work",
744
+ ' is a cron (`"triggers": { "crons": [...] }`) or a queue \u2014 never a `setInterval`.',
745
+ "- **10 s per statement.** The node enforces the ceiling and a tenant cannot raise it;",
746
+ " a slower statement ends in `57014`. Split the query or move it to a cron.",
747
+ "- **No install scripts.** A dependency's `postinstall` does not run \u2014 Studio installs",
748
+ " packages itself, and nothing at deploy time runs one either. A package that needs",
749
+ " one to place a native binary does not work here.",
750
+ '- **Build absolute URLs out of the request.** `new URL("/path", request.url)`, never a',
751
+ " hard-coded origin. The same bundle answers on the dev port, on a Studio preview, on",
752
+ " a share link and on the project's own hostname, and a constant is wrong on three of",
753
+ " those four.",
754
+ "- **`runWorkerFirst` is on.** The worker sees every request, so the catch-all",
755
+ ' `app.all("*", \u2026)` at the bottom of `worker/index.ts` is what serves `assets/`.',
756
+ " Delete it and every static file is a 404.",
757
+ "",
758
+ "## Hot reload",
759
+ "",
760
+ "The dev loop is Vite's, so the idiom is Vite's: `import.meta.hot`. What survives an",
761
+ "edit depends on what the module exports.",
762
+ "",
763
+ "- **A module that exports only components hot-swaps and keeps state.** `src/App.tsx`",
764
+ " is one. Keep it that way \u2014 a half-filled form surviving an edit is the whole point.",
765
+ "- Anything that is not a component (types, constants, helpers) goes in its own file,",
766
+ " like `src/api.ts`. Exporting one alongside a component downgrades that module to a",
767
+ " reload.",
768
+ "- `worker/` rebuilds by itself: the page stays and the next `/api` call reaches the",
769
+ " new code. A change to `migrations/` or `package.json` is a full reload.",
770
+ "",
771
+ "## Commands",
772
+ "",
773
+ "```",
774
+ "npm install",
775
+ "npm run dev # the UI on :5173, with /api proxied to the worker",
776
+ "npm run dev:api # the worker under workerd with the bindings (second terminal)",
777
+ "npm run deploy # vite build, then runlot deploy",
778
+ "```",
779
+ "",
780
+ "`runlot deploy` ships `assets/` as it stands on disk and does not build it. That is",
781
+ "why `deploy` is two commands, and why `assets/` is in `.gitignore`.",
782
+ ""
783
+ ].join("\n");
784
+ }
785
+ function studioReadme(name) {
786
+ return [
787
+ `# ${name}`,
788
+ "",
789
+ "Vite + React + Tailwind in `src/`, one Hono worker in `worker/index.ts`, and",
790
+ "Postgres, login and object storage declared in `runlot.json`.",
791
+ "",
792
+ "```",
793
+ "npm install",
794
+ "npm run dev:api # the worker under workerd, with env.db, env.auth, env.storage",
795
+ "npm run dev # the UI, with /api proxied to it",
796
+ "npm run deploy # vite build, then runlot deploy",
797
+ "```",
798
+ "",
799
+ "`npm run deploy` is two commands because `runlot deploy` ships `assets/` as it",
800
+ "stands on disk: the Vite build has to run first.",
801
+ "",
802
+ "`AGENTS.md` states the bindings and what the platform refuses. `CLAUDE.md` is the",
803
+ "same file under the name Claude Code reads.",
804
+ ""
805
+ ].join("\n");
806
+ }
807
+ function studioTemplate(ctx) {
808
+ const { name, org } = ctx;
809
+ const runlotConfig = { name };
810
+ if (org) runlotConfig["org"] = org;
811
+ runlotConfig["main"] = "worker/index.ts";
812
+ runlotConfig["assets"] = "assets";
813
+ runlotConfig["notFound"] = "spa";
814
+ runlotConfig["runWorkerFirst"] = true;
815
+ runlotConfig["database"] = true;
816
+ runlotConfig["auth"] = true;
817
+ runlotConfig["storage"] = true;
818
+ const packageJson = {
819
+ name,
820
+ version: "0.0.0",
821
+ private: true,
822
+ type: "module",
823
+ scripts: {
824
+ dev: "vite",
825
+ "dev:api": "runlot dev",
826
+ build: "vite build",
827
+ // `runlot deploy` does not build. Without the build in front of it, the deploy
828
+ // ships whatever `assets/` last held — or fails because it is not there.
829
+ deploy: "npm run build && runlot deploy",
830
+ typecheck: "tsc --noEmit"
831
+ },
832
+ dependencies: {
833
+ hono: "^4.10.7",
834
+ react: "^19.2.8",
835
+ "react-dom": "^19.2.8",
836
+ // The npm job in release.yml rewrites these ranges with the same number as the
837
+ // tag — what is written here is the last published version.
838
+ "@runlot/pg": "^0.1.0-rc.210",
839
+ "@runlot/auth": "^0.1.0-rc.210"
840
+ },
841
+ devDependencies: {
842
+ "@tailwindcss/vite": "^4.3.3",
843
+ "@types/react": "^19.2.18",
844
+ "@types/react-dom": "^19.2.5",
845
+ "@vitejs/plugin-react": "^6.1.1",
846
+ tailwindcss: "^4.3.3",
847
+ typescript: "^5.7.2",
848
+ vite: "^8.2.2"
849
+ }
850
+ };
851
+ const tsconfig = pkgJson({
852
+ compilerOptions: {
853
+ target: "ES2022",
854
+ lib: ["ES2022", "DOM", "DOM.Iterable"],
855
+ module: "ESNext",
856
+ moduleResolution: "bundler",
857
+ jsx: "react-jsx",
858
+ strict: true,
859
+ skipLibCheck: true,
860
+ noEmit: true,
861
+ // `import.meta.hot` and `import.meta.env` are typed by this and nothing else.
862
+ types: ["vite/client"]
863
+ },
864
+ include: ["src", "worker", "vite.config.ts"]
865
+ });
866
+ const agents = studioAgents(name);
867
+ return {
868
+ "package.json": pkgJson(packageJson),
869
+ "runlot.json": pkgJson(runlotConfig),
870
+ "tsconfig.json": tsconfig,
871
+ "vite.config.ts": STUDIO_VITE_CONFIG,
872
+ "index.html": studioIndexHtml(name),
873
+ "src/main.tsx": STUDIO_MAIN_TSX,
874
+ "src/App.tsx": STUDIO_APP_TSX,
875
+ "src/api.ts": STUDIO_API_TS,
876
+ "src/styles.css": STUDIO_STYLES_CSS,
877
+ "worker/index.ts": STUDIO_WORKER_TS,
878
+ "migrations/0001_init.sql": STUDIO_MIGRATION,
879
+ "seed.sql": STUDIO_SEED,
880
+ // Both names, one text. Claude Code reads CLAUDE.md and other agents read
881
+ // AGENTS.md; generating them together is what keeps them saying the same thing.
882
+ "AGENTS.md": agents,
883
+ "CLAUDE.md": `<!-- The same text as AGENTS.md. Change both, or the two agents disagree. -->
884
+ ${agents}`,
885
+ // `assets/` is build output. `.runlot` holds the unpacked bundle and the local
886
+ // object store, and `.env.runlot` holds secrets.
887
+ ".gitignore": "node_modules\nassets\n.runlot\n.env.runlot\n",
888
+ "README.md": studioReadme(name)
889
+ };
890
+ }
310
891
  var TEMPLATES = {
311
892
  next: nextTemplate,
312
893
  hono: honoTemplate,
313
- static: staticTemplate
894
+ static: staticTemplate,
895
+ studio: studioTemplate
314
896
  };
897
+ function templateFiles(template, ctx = {}) {
898
+ if (!isTemplateName(template)) {
899
+ throw new Error(`"${template}" is not a template. Use one of: ${TEMPLATE_NAMES.join(", ")}.`);
900
+ }
901
+ return TEMPLATES[template]({ name: ctx.name ?? "app", org: ctx.org });
902
+ }
315
903
  function scaffold(opts) {
316
- const { target, org, template } = opts;
904
+ const { target, org, template, skills = [] } = opts;
317
905
  const dir = resolve(target);
318
906
  const name = target.split("/").filter(Boolean).pop();
319
907
  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)`);
908
+ throw new Error(`"${name}" cannot be used as a project name. Use lowercase letters, digits, and hyphens.`);
321
909
  }
322
- if (existsSync(dir)) throw new Error(`${dir} \uC774 \uC774\uBBF8 \uC788\uC2B5\uB2C8\uB2E4`);
910
+ if (existsSync2(dir)) throw new Error(`${dir} already exists. Choose another name, or remove that directory and run this again.`);
323
911
  const files = TEMPLATES[template]({ name, org });
324
912
  const relPaths = Object.keys(files).sort();
325
913
  for (const rel of relPaths) {
326
- const path = join(dir, rel);
327
- mkdirSync(dirname(path), { recursive: true });
328
- writeFileSync(path, files[rel]);
914
+ const path = join2(dir, rel);
915
+ mkdirSync2(dirname2(path), { recursive: true });
916
+ writeFileSync2(path, files[rel]);
917
+ }
918
+ const written = installSkills(dir, skills).map((r) => r.dir);
919
+ console.log(`Created ${dir}.`);
920
+ if (written.length > 0) {
921
+ console.log(`Agent skills (${listSkills().map((s) => s.name).join(", ")}) in ${written.join(" and ")}.`);
329
922
  }
330
- console.log(`${dir} \uC744 \uB9CC\uB4E4\uC5C8\uC2B5\uB2C8\uB2E4.`);
331
923
  console.log(` cd ${target}`);
332
- console.log(" runlot add pg auth storage");
333
- console.log(org ? " runlot deploy" : " runlot deploy --org <slug>");
334
- return { dir, files: relPaths };
924
+ if (template === "studio") {
925
+ console.log(" npm install");
926
+ console.log(" npm run dev:api # a second terminal: the worker with the bindings");
927
+ console.log(" npm run dev");
928
+ console.log(org ? " npm run deploy" : " npm run deploy -- --org <slug>");
929
+ } else {
930
+ if (template !== "next") console.log(" runlot dev");
931
+ console.log(org ? " runlot deploy" : " runlot deploy --org <slug>");
932
+ }
933
+ return { dir, files: relPaths, skillDirs: written };
335
934
  }
336
935
  async function defaultAsk(question) {
337
936
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -344,33 +943,36 @@ async function defaultAsk(question) {
344
943
  var MAX_PROMPT_TRIES = 3;
345
944
  async function chooseTemplate(ask) {
346
945
  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?
946
+ const question = `Which template do you want?
348
947
 
349
948
  ${listText}
350
949
 
351
- \uC774\uB984\uC774\uB098 \uBC88\uD638\uB97C \uC785\uB825\uD558\uC138\uC694: `;
950
+ Enter a name or a number: `;
352
951
  for (let attempt = 1; attempt <= MAX_PROMPT_TRIES; attempt++) {
353
952
  const answer = (await ask(question)).trim();
354
953
  const byIndex = TEMPLATE_LIST[Number(answer) - 1];
355
954
  const byName = TEMPLATE_LIST.find((t) => t.name === answer);
356
955
  const picked = byIndex ?? byName;
357
956
  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.`);
957
+ console.error(`Unknown answer: "${answer}". Choose one of ${TEMPLATE_NAMES.join(", ")}.`);
359
958
  }
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.`);
959
+ throw new Error(`No template chosen after ${MAX_PROMPT_TRIES} tries. Specify one with --template.`);
361
960
  }
362
961
  async function run(argv, deps) {
363
962
  const args = [];
364
963
  let org;
365
964
  let templateArg;
965
+ let skills = true;
366
966
  for (let i = 0; i < argv.length; i++) {
367
967
  const a = argv[i];
368
968
  if (a === "--org") {
369
969
  org = argv[++i];
370
- if (org === void 0) throw new Error("--org \uC5D0 \uAC12\uC774 \uD544\uC694\uD569\uB2C8\uB2E4");
970
+ if (org === void 0) throw new Error("--org requires a value");
371
971
  } else if (a === "--template") {
372
972
  templateArg = argv[++i];
373
- if (templateArg === void 0) throw new Error("--template \uC5D0 \uAC12\uC774 \uD544\uC694\uD569\uB2C8\uB2E4");
973
+ if (templateArg === void 0) throw new Error("--template requires a value");
974
+ } else if (a === "--no-skills") {
975
+ skills = false;
374
976
  } else if (a === "-h" || a === "--help") {
375
977
  console.log(USAGE);
376
978
  return 0;
@@ -386,29 +988,42 @@ async function run(argv, deps) {
386
988
  let template;
387
989
  if (templateArg !== void 0) {
388
990
  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)`);
991
+ throw new Error(`Unknown template: "${templateArg}". Choose one of ${TEMPLATE_NAMES.join(", ")}.`);
390
992
  }
391
993
  template = templateArg;
392
994
  } else if (deps.isTTY ?? process.stdin.isTTY === true) {
393
995
  template = await chooseTemplate(deps.ask ?? defaultAsk);
394
996
  } else {
395
- throw new Error(`--template \uC774 \uD544\uC694\uD569\uB2C8\uB2E4 (${TEMPLATE_NAMES.join(", ")} \uC911\uC5D0\uC11C \uACE0\uB974\uC138\uC694)`);
997
+ throw new Error(`--template is required. Choose one of ${TEMPLATE_NAMES.join(", ")}.`);
396
998
  }
397
- scaffold({ target, org, template });
999
+ scaffold({ target, org, template, skills: skills ? SKILL_AGENTS : [] });
398
1000
  return 0;
399
1001
  }
400
1002
  async function main(argv, deps = {}) {
401
1003
  try {
402
1004
  return await run(argv, deps);
403
1005
  } catch (err) {
404
- console.error(`\uC624\uB958: ${err instanceof Error ? err.message : String(err)}`);
1006
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
405
1007
  return 1;
406
1008
  }
407
1009
  }
408
- if (process.argv[1] && process.argv[1].endsWith("index.js")) {
1010
+ function runDirectly(argv1) {
1011
+ if (!argv1) return false;
1012
+ let path = argv1;
1013
+ try {
1014
+ path = realpathSync(argv1);
1015
+ } catch {
1016
+ }
1017
+ return path.endsWith(`${sep}dist${sep}index.js`);
1018
+ }
1019
+ if (runDirectly(process.argv[1])) {
409
1020
  main(process.argv.slice(2)).then((code) => process.exit(code));
410
1021
  }
411
1022
  export {
1023
+ TEMPLATE_NAMES,
1024
+ USAGE,
412
1025
  main,
413
- scaffold
1026
+ runDirectly,
1027
+ scaffold,
1028
+ templateFiles
414
1029
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-runlot",
3
- "version": "0.1.0-rc.21",
4
- "description": "npm create runlot — Next.js·Hono·정적 사이트 템플릿",
3
+ "version": "0.1.0-rc.210",
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,14 +24,15 @@
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.21"
27
+ "@runlot/config": "0.1.0-rc.210"
25
28
  },
26
29
  "engines": {
27
30
  "node": ">=20"
28
31
  },
29
32
  "scripts": {
30
- "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js='#!/usr/bin/env node'",
33
+ "build": "node scripts/gen-skills.mjs && esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js='#!/usr/bin/env node'",
31
34
  "test": "node build.mjs",
35
+ "gen-skills": "node scripts/gen-skills.mjs",
32
36
  "typecheck": "tsc --noEmit"
33
37
  }
34
38
  }