@escalay/kit 0.0.0-stage → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -2
  3. package/cli.mjs +438 -0
  4. package/docs/STANDARD.md +342 -0
  5. package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
  6. package/docs/decisions/0002-node-and-workers.md +45 -0
  7. package/docs/releasing.md +97 -0
  8. package/package.json +59 -3
  9. package/registry.json +548 -0
  10. package/src/a2a/README.md +25 -0
  11. package/src/a2a/index.ts +262 -0
  12. package/src/boards/README.md +35 -0
  13. package/src/boards/handlers.ts +87 -0
  14. package/src/boards/index.ts +64 -0
  15. package/src/boards/refs.ts +23 -0
  16. package/src/boards/repo.ts +50 -0
  17. package/src/boards/routes.ts +83 -0
  18. package/src/boards/schema.ts +28 -0
  19. package/src/boards/tools.ts +76 -0
  20. package/src/browser/README.md +31 -0
  21. package/src/browser/boards-client.ts +74 -0
  22. package/src/browser/boards-react.ts +73 -0
  23. package/src/browser/search-client.ts +258 -0
  24. package/src/browser/search-react.ts +116 -0
  25. package/src/browser/vector-search-block.tsx +94 -0
  26. package/src/core/README.md +37 -0
  27. package/src/core/api.ts +239 -0
  28. package/src/core/auth.ts +147 -0
  29. package/src/core/cache.ts +53 -0
  30. package/src/core/discovery.ts +315 -0
  31. package/src/core/guards.ts +109 -0
  32. package/src/core/hal.ts +63 -0
  33. package/src/core/kv.ts +29 -0
  34. package/src/core/problem.ts +124 -0
  35. package/src/db/README.md +39 -0
  36. package/src/db/guards.ts +61 -0
  37. package/src/db/index.ts +122 -0
  38. package/src/guide/README.md +38 -0
  39. package/src/guide/guide.ts +152 -0
  40. package/src/guide/reference.ts +204 -0
  41. package/src/index.ts +15 -0
  42. package/src/mcp/README.md +34 -0
  43. package/src/mcp/index.ts +251 -0
  44. package/src/oauth/README.md +29 -0
  45. package/src/oauth/index.ts +439 -0
  46. package/src/oauth/schema.ts +7 -0
  47. package/src/oauth/store.ts +34 -0
  48. package/src/pagination/README.md +31 -0
  49. package/src/pagination/index.ts +65 -0
  50. package/src/testing/README.md +31 -0
  51. package/src/testing/index.ts +162 -0
  52. package/src/vector-search/README.md +27 -0
  53. package/src/vector-search/index.ts +367 -0
  54. package/src/vectors/README.md +30 -0
  55. package/src/vectors/index.ts +326 -0
  56. package/src/web/README.md +21 -0
  57. package/src/web/index.ts +100 -0
  58. package/templates/app/AGENTS.md +41 -0
  59. package/templates/app/CLAUDE.md +3 -0
  60. package/templates/app/Dockerfile +12 -0
  61. package/templates/app/container/entrypoint.sh +7 -0
  62. package/templates/app/gitignore +4 -0
  63. package/templates/app/guide/llms.txt +11 -0
  64. package/templates/app/pnpm-workspace.yaml +5 -0
  65. package/templates/app/scripts/guide.ts +8 -0
  66. package/templates/app/src/app.ts +39 -0
  67. package/templates/app/src/features/index.ts +30 -0
  68. package/templates/app/src/server.ts +14 -0
  69. package/templates/app/test/api.test.ts +34 -0
  70. package/templates/app/tsconfig.json +16 -0
  71. package/templates/feature/feature.test.ts +19 -0
  72. package/templates/feature/handlers.ts +43 -0
  73. package/templates/feature/index.ts +34 -0
  74. package/templates/feature/repo.ts +24 -0
  75. package/templates/feature/routes.ts +43 -0
  76. package/templates/feature/schema.ts +14 -0
  77. package/templates/feature/tools.ts +46 -0
  78. package/templates/npm-release/release-please-config.json +60 -0
  79. package/templates/npm-release/release-please-manifest.json +3 -0
  80. package/templates/runtime-cloudflare/dev.vars.example +3 -0
  81. package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
  82. package/templates/runtime-cloudflare/src/worker.ts +19 -0
  83. package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
  84. package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
  85. package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
  86. package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
  87. package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
  88. package/templates/runtime-cloudflare/vitest.config.ts +23 -0
  89. package/templates/runtime-cloudflare/wrangler.jsonc +16 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Escalay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,143 @@
1
- # Temporary Holding Version
1
+ # api-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Hono + zod OpenAPI 3.1 APIs that agents can use without help: HAL links, problem+json errors, cursor pages, ETags,
4
+ per-client API keys and OAuth, discovery documents, an `llms.txt` guide whose code-derived parts are generated,
5
+ skills, an MCP server (spec 2026-07-28) and A2A over the same routes, a database layer on Drizzle, search by words,
6
+ picture and likeness over embeddings, and the headless browser side of all of it.
7
+
8
+ It ships as **parts you copy into your app**, shadcn-style: the app owns the code, `kit.json` records what came from
9
+ which kit version, and the CLI shows your edits next to the kit's changes. Every part carries its own README, so
10
+ the practices travel with the copy into `src/kit/<part>/README.md`.
11
+
12
+ | Word | Means |
13
+ |---|---|
14
+ | **part** | one installable unit (`core`, `mcp`, `boards`); the code calls it a module |
15
+ | **kit** | the set of parts in one repo: this one is `escalay/api-kit`, on npm as `@escalay/kit` |
16
+ | **preset** | a starting combination of parts; `api` is the one `init` has today |
17
+
18
+ ## Start a new API
19
+
20
+ ```bash
21
+ npm create escalay my-api # the `api` preset, with an example `notes` feature
22
+ cd my-api && pnpm install && pnpm test && pnpm dev # http://localhost:4400/docs, /llms.txt, /mcp
23
+ npx @escalay/kit feature reading-lists # a new feature folder, listed and tested
24
+ ```
25
+
26
+ `npm create escalay my-api -- --runtime node --title "My API"` passes flags on: `--runtime both|cloudflare|node`
27
+ (default `both`), `--preset api`, `--bare` (no example feature), `--yes` (it never asks; accepted for scripts).
28
+ `--preset selfhosted` is not built yet: it arrives with `escalay/selfhost-kit`.
29
+
30
+ What `init` makes:
31
+
32
+ ```
33
+ src/server.ts Node: env → openDb → createApp → listen
34
+ src/worker.ts Workers: D1 → openDb → the same createApp (wrangler.jsonc, migrations/ for D1)
35
+ src/app.ts the wiring only: auth + OAuth, every feature, the guide, MCP
36
+ src/features/index.ts FEATURES, the one list (and the Feature shape)
37
+ src/features/<name>/ schema.ts · repo.ts · routes.ts · handlers.ts · tools.ts · index.ts · <name>.test.ts
38
+ src/kit/ the copied kit (kit.json + a README per part)
39
+ guide/ llms.txt and agents/*.md templates; {{api.endpoints}} and {{mcp.tools}} are generated
40
+ test/api.test.ts every route linted, every MCP tool run (toolsRun), every guide placeholder filled
41
+ container/entrypoint.sh the env allowlist: the API sees only the variables it reads
42
+ ```
43
+
44
+ ## The practices it enforces
45
+
46
+ 1. **One shape per feature.** `schema.ts` (Drizzle tables + migration steps) · `repo.ts` (the only queries) ·
47
+ `routes.ts` (contracts) · `handlers.ts` (request → repo → HAL) · `tools.ts` (MCP tools) · `index.ts`. The kit's
48
+ own [`boards`](src/boards/README.md) is the reference.
49
+ 2. **Only repos touch the database.** Moving off the local SQLite file (Turso, D1) changes how `openDb` is called,
50
+ not the queries. A migration step never changes once it has run; existing tables are adopted with step 1.
51
+ 3. **The API is the contract.** Every route documented (camelCase ids, descriptions an agent chooses by, examples,
52
+ problem+json errors); unknown query parameters answer 400; errors say what to do next.
53
+ 4. **MCP and A2A are thin calls to the routes**, so they can't drift; every tool runs in the tests (`toolsRun`).
54
+ 5. **The guide writes only what the code can't know**; endpoint and tool lists are generated placeholders.
55
+ 6. **Money is bounded**: embeddings and model calls go through ledgers that never pay twice, with budgets and capped
56
+ keys; slow work answers 202 with a run.
57
+ 7. **Private by default where it should be**: `private: true` hides everything behind credentials and says `noindex`.
58
+
59
+ The full standard: [docs/STANDARD.md](docs/STANDARD.md). Why the layout is what it is:
60
+ [docs/decisions/0001](docs/decisions/0001-features-drizzle-scaffold.md).
61
+
62
+ ## Parts
63
+
64
+ | Part | What | README |
65
+ |---|---|---|
66
+ | `core` | `createAgentApi`: OpenAPI 3.1 + Scalar, HAL, problem+json, keys + Access, caching, Idempotency-Key, rate limits, discovery, a key-value store type | [core](src/core/README.md) |
67
+ | `db` | the app's database through Drizzle: `openDb`, `migrate` per feature, `testDb` | [db](src/db/README.md) |
68
+ | `oauth` | the app as its own OAuth server (auth code + PKCE, agent registration via auth.md, ES256 tokens) | [oauth](src/oauth/README.md) |
69
+ | `pagination` | cursor pages and sorting | [pagination](src/pagination/README.md) |
70
+ | `guide` | the agent guide from templates and its generated parts | [guide](src/guide/README.md) |
71
+ | `mcp` | an MCP server over the routes: tools, resources, prompts, skills, its Server Card | [mcp](src/mcp/README.md) |
72
+ | `a2a` | A2A 1.0 over the MCP tools | [a2a](src/a2a/README.md) |
73
+ | `vectors` | Gemini Embedding 2 look and meaning vectors: ledgers that never pay twice, `nearest`, `standouts` | [vectors](src/vectors/README.md) |
74
+ | `vector-search` | search by words, picture and likeness: three routes and their tools | [vector-search](src/vector-search/README.md) |
75
+ | `web` | the front door: a public `/` or none, the browser app under `/app` | [web](src/web/README.md) |
76
+ | `boards` | the owner's collections of items with notes; `boardRef` / `parseBoardRef`; the reference feature shape | [boards](src/boards/README.md) |
77
+ | `testing` | `lintOpenApi`, `guideRoutes`, `guideGaps`, `toolRoutes`, `toolsRun` | [testing](src/testing/README.md) |
78
+ | `search-client`, `search-react`, `vector-search-ui`, `boards-client`, `boards-react` | the browser side: typed clients, headless hooks, an optional shadcn block | [browser](src/browser/README.md) |
79
+
80
+ ## Add parts to an existing app
81
+
82
+ ```bash
83
+ npx @escalay/kit add core db oauth guide mcp testing --dir apps/api/src/kit --install
84
+ npx @escalay/kit list # parts, what they need, what's installed
85
+ npx @escalay/kit doctor --dir apps/api/src/kit # missing, edited, behind: one line each with its fix; exits 1
86
+ npx @escalay/kit diff --dir apps/api/src/kit --patch # before upgrading
87
+ npx @escalay/kit update --dir apps/api/src/kit # never overwrites your edits without --force
88
+ ```
89
+
90
+ `--dir` defaults to `src/kit`. The app imports `./kit/index.js` and each entry part's folder (`./kit/mcp/index.js`,
91
+ `./kit/boards/index.js`, `./kit/testing/index.js`). Its container build needs nothing from this repo.
92
+
93
+ The browser parts go into the frontend. Either a kit of their own, or, in a repo that has both, `--web-dir`
94
+ (relative to `--dir`, remembered in `kit.json`) so one `kit.json` tracks the server and browser files:
95
+
96
+ ```bash
97
+ npx @escalay/kit add search-react --dir apps/web/src/search
98
+ npx @escalay/kit add boards boards-react --dir src/kit --web-dir ../web
99
+ ```
100
+
101
+ ### One part with the shadcn CLI
102
+
103
+ The repo is a shadcn registry ([`registry.json`](registry.json), no build step). Each part is a universal item whose
104
+ files land in `src/kit/<part>/`, so no `components.json` is needed; parts it requires and npm packages come with it.
105
+
106
+ ```bash
107
+ npx shadcn add escalay/api-kit/pagination # core comes with it
108
+ npx shadcn add escalay/api-kit/vector-search-ui # the search block; needs shadcn's button and input
109
+ npx @escalay/kit adopt # then write kit.json from what is on disk
110
+ ```
111
+
112
+ The shadcn CLI writes no record, so run `adopt` once (or `doctor --adopt`): it records every part found in `--dir`
113
+ and writes the generated `index.ts`. A file you already changed shows as edited.
114
+
115
+ ### What `kit.json` records
116
+
117
+ `src/kit/kit.json` is the lock file; there is no second one. It holds the kit it came from (`source`), the kit
118
+ `version`, the parts (`modules`), `webDir` when browser files live elsewhere, and a sha256 per file **as the kit
119
+ wrote it**. Comparing that hash with the file on disk and with the current kit is how `diff`, `update` and `doctor`
120
+ tell "you changed it" from "the kit changed it".
121
+
122
+ ## Using it with agents
123
+
124
+ ```bash
125
+ npx skills add escalay/api-kit
126
+ ```
127
+
128
+ | Skill | Use it to |
129
+ |---|---|
130
+ | [`api-kit-build`](skills/api-kit-build/SKILL.md) | start a new API, move an app onto the kit, take a newer kit |
131
+ | [`api-kit-feature`](skills/api-kit-feature/SKILL.md) | add a feature folder: schema, repo, routes, handlers, tools, tests |
132
+ | [`api-kit-review`](skills/api-kit-review/SKILL.md) | check an API is agent-ready: the commands to run and what a failure means |
133
+ | [`npm-release`](skills/npm-release/SKILL.md) | set up or run releases of any npm package from GitHub Actions, or find out why one failed |
134
+
135
+ ## Releases
136
+
137
+ Merging a pull request is the only manual step. Its title, a Conventional Commit, becomes a line of the release
138
+ notes; merging the release pull request publishes `@escalay/kit` and `create-escalay` to npm from GitHub Actions,
139
+ with provenance and no npm token. How it works, and how to set it up in another repo:
140
+ [docs/releasing.md](docs/releasing.md). `pnpm release:check` says whether a release is ready, and
141
+ `npx shadcn add escalay/api-kit/npm-release` copies the setup into another repo.
142
+
143
+ Working on the kit itself: [AGENTS.md](AGENTS.md). Licence: [MIT](LICENSE).
package/cli.mjs ADDED
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * kit: copy the kit's parts into an app (shadcn-style), then keep them in step with the kit.
4
+ * The app owns the copies; `kit.json` beside them records the kit version and the hash of every file as copied, so
5
+ * `diff` can tell your edits from the kit's changes and `update` never overwrites an edit without `--force`.
6
+ * `--web-dir` (relative to `--dir`, remembered in kit.json) puts the browser parts in their own folder, still
7
+ * tracked by the one kit.json (as `@web/<file>`).
8
+ *
9
+ * kit list parts, what they need, which are installed; then the root items
10
+ * kit add core guide mcp testing [--dir src/kit] [--web-dir ../web] [--install]
11
+ * kit diff [--dir src/kit] [--patch] per file: unchanged, you edited, kit changed, both
12
+ * kit update [part…] [--dir src/kit] [--force]
13
+ * kit doctor [--dir src/kit] [--adopt] missing files, edited files, parts behind this kit; exits 1 with a fix per line
14
+ * kit adopt [--dir src/kit] write kit.json for parts copied without it (by the shadcn CLI)
15
+ * kit init <dir> [--preset api] [--title "My API"] [--runtime both|cloudflare|node] [--bare] [--yes]
16
+ * a new app: features/, the kit in src/kit, tests; Node (server.ts, a
17
+ * container) and/or Workers (worker.ts, D1). It never asks; --yes is accepted
18
+ * kit feature <plural-name> [--app .] a new feature folder (schema, repo, routes, handlers, tools), listed
19
+ * kit guide [--from guide] [--out src/guide.gen.ts] the guide folder as a module (GUIDE_FILES), for Workers
20
+ *
21
+ * @example
22
+ * npx @escalay/kit add core pagination guide mcp testing --dir apps/api/src/kit --install
23
+ * import { createAgentApi } from './kit/index.js' // in the app
24
+ */
25
+ import { spawnSync } from 'node:child_process'
26
+ import { createHash } from 'node:crypto'
27
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
28
+ import { basename, dirname, join, relative, resolve } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+
31
+ const KIT = dirname(fileURLToPath(import.meta.url))
32
+ const REPO = 'escalay/api-kit'
33
+ const rangeOf = (dependency) => [dependency.slice(0, dependency.lastIndexOf('@')), dependency.slice(dependency.lastIndexOf('@') + 1)]
34
+ /** registry.json is a shadcn registry; a part's requires are its dependencies on this repo, a dependency without a range is a peer. */
35
+ const ITEMS = JSON.parse(readFileSync(join(KIT, 'registry.json'), 'utf8')).items
36
+ /** Root items (`meta.root`) are repo setup the shadcn CLI installs at the repo root: not parts, so `add`, `adopt` and `doctor` never see them. */
37
+ const ROOT_ITEMS = ITEMS.filter((item) => item.meta?.root)
38
+ const shadcnAdd = (name) => `npx shadcn add ${REPO}/${name}`
39
+ const REGISTRY = Object.fromEntries(
40
+ ITEMS.filter((item) => !item.meta?.root).map((item) => [
41
+ item.name,
42
+ {
43
+ description: item.description,
44
+ files: item.files.map((file) => file.path.replace(/^src\//, '')),
45
+ requires: (item.registryDependencies ?? []).filter((name) => name.startsWith(`${REPO}/`)).map((name) => name.slice(REPO.length + 1)),
46
+ dependencies: Object.fromEntries((item.dependencies ?? []).filter((dependency) => dependency.lastIndexOf('@') > 0).map(rangeOf)),
47
+ entry: item.meta?.entry,
48
+ web: item.meta?.web,
49
+ },
50
+ ]),
51
+ )
52
+ const VERSION = JSON.parse(readFileSync(join(KIT, 'package.json'), 'utf8')).version
53
+ const SOURCE = '@escalay/kit'
54
+
55
+ const sha = (text) => createHash('sha256').update(text).digest('hex')
56
+ const fail = (message) => {
57
+ console.error(`kit: ${message}`)
58
+ process.exit(1)
59
+ }
60
+
61
+ // ---- Arguments ----
62
+
63
+ const [command = 'help', ...rest] = process.argv.slice(2)
64
+ const flags = new Set(rest.filter((arg) => arg.startsWith('--') && !arg.includes('=')))
65
+ const valueOf = (flag) => rest.find((arg, index) => rest[index - 1] === flag) ?? rest.find((arg) => arg.startsWith(`${flag}=`))?.slice(flag.length + 1)
66
+ const dirArg = valueOf('--dir') ?? 'src/kit'
67
+ const webDirArg = valueOf('--web-dir')
68
+ /** Flags that take the next argument as their value, so it is never read as a name. */
69
+ const VALUE_FLAGS = new Set(['--dir', '--web-dir', '--title', '--runtime', '--preset', '--app', '--from', '--out'])
70
+ const names = rest.filter((arg, index) => !arg.startsWith('--') && !VALUE_FLAGS.has(rest[index - 1]))
71
+ const dir = resolve(process.cwd(), dirArg)
72
+ const lockPath = join(dir, 'kit.json')
73
+
74
+ // ---- Modules ----
75
+
76
+ /** The modules asked for plus everything they require, in registry order. */
77
+ const withRequired = (wanted) => {
78
+ const all = new Set()
79
+ const visit = (name) => {
80
+ if (ROOT_ITEMS.some((item) => item.name === name)) fail(`"${name}" goes to the repo root, not into ${dirArg}: \`${shadcnAdd(name)}\``)
81
+ if (!REGISTRY[name]) fail(`no module "${name}". Modules: ${Object.keys(REGISTRY).join(', ')}`)
82
+ if (all.has(name)) return
83
+ for (const required of REGISTRY[name].requires ?? []) visit(required)
84
+ all.add(name)
85
+ }
86
+ wanted.forEach(visit)
87
+ return Object.keys(REGISTRY).filter((name) => all.has(name))
88
+ }
89
+
90
+ const upstreamOf = (file) => readFileSync(join(KIT, 'src', file), 'utf8')
91
+
92
+ // A browser module's files sit under the web dir when there is one; kit.json keys them `@web/<file>` (a module folder
93
+ // can be called web/, so the prefix must be something no folder is)
94
+ const WEB = '@web/'
95
+ const WEB_FILES = new Set(Object.values(REGISTRY).filter((module) => module.web).flatMap((module) => module.files))
96
+ // Every module carries its README (what it is for, an example, its rules), so the practices travel with the copy
97
+ const keyOf = (name, file, webDir) => (REGISTRY[name].web && webDir ? WEB + file : file)
98
+ const sourceOf = (key) => (key.startsWith(WEB) ? key.slice(WEB.length) : key)
99
+ const pathOf = (key, webDir) => (key.startsWith(WEB) && webDir ? join(resolve(dir, webDir), key.slice(WEB.length)) : join(dir, key))
100
+
101
+ /** index.ts re-exports every installed module that isn't its own entry point (mcp, testing, the browser modules). */
102
+ const indexFor = (modules) =>
103
+ modules
104
+ .filter((name) => !REGISTRY[name].entry)
105
+ .flatMap((name) => {
106
+ const own = REGISTRY[name].files.find((file) => file.endsWith('/index.ts'))
107
+ return own ? [own] : REGISTRY[name].files.filter((file) => /\.tsx?$/.test(file))
108
+ })
109
+ .sort()
110
+ .map((file) => `export * from './${file.replace(/\.ts$/, '.js')}'`)
111
+ .join('\n') + '\n'
112
+
113
+ /** What each file should contain at this kit version: the module files, plus the generated index when anything goes in it. */
114
+ const expectedFiles = (modules, webDir) => {
115
+ const files = Object.fromEntries(modules.flatMap((name) => REGISTRY[name].files.map((file) => [keyOf(name, file, webDir), upstreamOf(file)])))
116
+ if (modules.some((name) => !REGISTRY[name].entry)) files['index.ts'] = indexFor(modules)
117
+ return files
118
+ }
119
+
120
+ /** kit.json, with web-dir keys from kit 0.10–1.0.1 (`web/<file>`) read as `@web/<file>`. */
121
+ const readLock = () => {
122
+ if (!existsSync(lockPath)) return null
123
+ const lock = JSON.parse(readFileSync(lockPath, 'utf8'))
124
+ if (!lock.webDir) return lock
125
+ const flat = { 'web/search-client.ts': 'browser/search-client.ts', 'web/search-react.ts': 'browser/search-react.ts', 'web/boards-client.ts': 'browser/boards-client.ts', 'web/boards-react.ts': 'browser/boards-react.ts', 'web/refs.ts': 'boards/refs.ts', 'web/vector-search-block.tsx': 'browser/vector-search-block.tsx' }
126
+ const keyFor = (key) => (flat[key] ? `${WEB}${flat[key]}` : key.startsWith('web/') && WEB_FILES.has(key.slice(4)) ? WEB + key.slice(4) : key)
127
+ return { ...lock, files: Object.fromEntries(Object.entries(lock.files).map(([key, hash]) => [keyFor(key), flat[key] ? null : hash]).filter(([, hash]) => hash !== null)) }
128
+ }
129
+ const writeLock = (modules, hashes, webDir) =>
130
+ writeFileSync(lockPath, JSON.stringify({ $comment: 'Written by @escalay/kit; hashes are of the files as the kit last wrote them.', source: SOURCE, version: VERSION, modules, ...(webDir ? { webDir } : {}), files: Object.fromEntries(Object.entries(hashes).sort()) }, null, 2) + '\n')
131
+
132
+ const dependenciesOf = (modules) => Object.assign({}, ...modules.map((name) => REGISTRY[name].dependencies ?? {}))
133
+ const installCommand = (modules) => {
134
+ const deps = Object.entries(dependenciesOf(modules)).map(([name, range]) => `${name}@${range}`)
135
+ return deps.length ? `pnpm add ${deps.join(' ')}` : 'none'
136
+ }
137
+
138
+ const install = (modules) => {
139
+ if (installCommand(modules) === 'none') return
140
+ const result = spawnSync('pnpm', installCommand(modules).split(' ').slice(1), { stdio: 'inherit', cwd: process.cwd() })
141
+ if (result.status !== 0) fail('pnpm add failed; run it yourself: ' + installCommand(modules))
142
+ }
143
+
144
+ /** Each file's state against kit.json: did you edit it, did the kit change it. */
145
+ const statusOf = (lock, expected) =>
146
+ Object.keys({ ...lock.files, ...expected }).sort().map((file) => {
147
+ const path = pathOf(file, lock.webDir)
148
+ const local = existsSync(path) ? sha(readFileSync(path, 'utf8')) : null
149
+ const recorded = lock.files[file] ?? null
150
+ const kit = expected[file] === undefined ? null : sha(expected[file])
151
+ const edited = local !== null && recorded !== null && local !== recorded
152
+ const changed = kit !== recorded
153
+ const state = local === null ? 'missing' : recorded === null ? 'new in kit' : kit === null ? 'dropped by kit' : edited && changed ? 'both changed' : edited ? 'you edited' : changed ? 'kit changed' : 'unchanged'
154
+ return { file, state, edited, changed }
155
+ })
156
+
157
+ /** The part a recorded file came with (`index.ts` is the kit's own). */
158
+ const partOf = (key, modules) => modules.find((name) => REGISTRY[name].files.includes(sourceOf(key))) ?? 'kit'
159
+
160
+ /** One line per thing wrong with the copy, each saying how to fix it. */
161
+ const findingsOf = (lock) => {
162
+ const statuses = statusOf(lock, expectedFiles(lock.modules, lock.webDir))
163
+ const behind = [...new Set(statuses.filter(({ state }) => ['kit changed', 'both changed', 'new in kit', 'dropped by kit'].includes(state)).map(({ file }) => partOf(file, lock.modules)))]
164
+ return [
165
+ ...statuses.filter(({ state }) => state === 'missing').map(({ file }) => `missing ${file}: \`kit update --dir ${dirArg}\` writes it again`),
166
+ ...statuses.filter(({ edited }) => edited).map(({ file }) => `edited ${file}: yours to keep; \`kit diff --dir ${dirArg} --patch\` shows the change, \`kit update --dir ${dirArg} --force\` takes the kit's`),
167
+ ...behind.map((part) => `behind ${part} was copied from kit ${lock.version} and kit ${VERSION} changed it: \`kit diff --dir ${dirArg} --patch\`, then \`kit update --dir ${dirArg}\``),
168
+ ...(lock.version !== VERSION && !behind.length ? [`behind kit.json says kit ${lock.version}, this is ${VERSION} with the same files: \`kit update --dir ${dirArg}\` records it`] : []),
169
+ ]
170
+ }
171
+
172
+ /** Writes kit.json for parts that were copied without one (the shadcn CLI): every part whose files are all on disk. */
173
+ const adopt = () => {
174
+ if (existsSync(lockPath)) fail(`${dirArg} already has a kit.json; \`kit doctor --dir ${dirArg}\` checks it`)
175
+ const found = Object.keys(REGISTRY).filter((name) => REGISTRY[name].files.every((file) => existsSync(join(dir, file))))
176
+ if (!found.length) fail(`no parts in ${dirArg}; \`kit add <part> --dir ${dirArg}\` copies one, \`kit list\` names them`)
177
+ const modules = withRequired(found)
178
+ const expected = expectedFiles(modules)
179
+ const index = join(dir, 'index.ts')
180
+ if (expected['index.ts'] && !existsSync(index)) writeFileSync(index, expected['index.ts'])
181
+ // The kit's hashes, not the disk's: a file that already differs shows as edited instead of passing as the kit's
182
+ writeLock(modules, Object.fromEntries(Object.entries(expected).map(([file, text]) => [file, sha(text)])))
183
+ console.log(`Adopted ${found.join(', ')} in ${dirArg} as kit ${VERSION}`)
184
+ const absent = modules.filter((name) => !found.includes(name))
185
+ if (absent.length) console.log(`Recorded ${absent.join(', ')} too (they are required): \`kit update --dir ${dirArg}\` copies them`)
186
+ }
187
+
188
+ // ---- Generators: a new app, a new feature ----
189
+
190
+ /** The modules a new app starts with. */
191
+ const APP_MODULES = ['core', 'db', 'oauth', 'guide', 'mcp', 'testing']
192
+ const RUNTIME = { '@hono/node-server': '^2.1.5', tsx: '^4.23.15' }
193
+ const DEV = { '@types/node': '^24.19.2', typescript: '^7.0.2', vitest: '^5.0.5' }
194
+ /** What the Cloudflare side adds, at the versions the kit tests with. */
195
+ const KIT_DEV = JSON.parse(readFileSync(join(KIT, 'package.json'), 'utf8')).devDependencies
196
+ const WORKERS_DEV = Object.fromEntries(['wrangler', '@cloudflare/vitest-plugin', '@cloudflare/workers-types'].map((name) => [name, KIT_DEV[name]]))
197
+ /** The runtimes an app can run on: `node` (server.ts, a container), `cloudflare` (worker.ts, D1), or `both`. */
198
+ const RUNTIMES = ['both', 'cloudflare', 'node']
199
+ /** The starting combinations: `api` is the kit's parts for an API with one example feature. */
200
+ const PRESETS = ['api', 'selfhosted']
201
+ const scriptsFor = (runtime) => {
202
+ const node = { dev: 'tsx watch src/server.ts', start: 'tsx src/server.ts' }
203
+ const workers = {
204
+ migrations: 'tsx scripts/migrations.ts',
205
+ deploy: 'pnpm guide && pnpm migrations && wrangler d1 migrations apply DB --remote && wrangler deploy',
206
+ }
207
+ const typecheck = runtime === 'node' ? 'tsc --noEmit' : 'tsc --noEmit && tsc --noEmit -p test/workers/tsconfig.json'
208
+ return {
209
+ ...(runtime === 'cloudflare' ? { dev: 'wrangler dev' } : node),
210
+ ...(runtime === 'both' ? { 'dev:worker': 'wrangler dev' } : {}),
211
+ guide: 'tsx scripts/guide.ts',
212
+ ...(runtime === 'node' ? {} : workers),
213
+ test: 'vitest run',
214
+ typecheck,
215
+ }
216
+ }
217
+
218
+ /** Writes a guide folder as a module (GUIDE_FILES). Must match guideModuleSource in src/guide/guide.ts byte for byte (test/kit.test.ts checks). */
219
+ const writeGuideModule = (from, out) => {
220
+ const files = []
221
+ const walk = (folder) => {
222
+ for (const entry of readdirSync(folder, { withFileTypes: true })) {
223
+ const path = join(folder, entry.name)
224
+ if (entry.isDirectory()) walk(path)
225
+ else if (/\.(md|txt)$/.test(entry.name)) files.push([relative(from, path).split('\\').join('/'), readFileSync(path, 'utf8')])
226
+ }
227
+ }
228
+ walk(from)
229
+ files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
230
+ mkdirSync(dirname(out), { recursive: true })
231
+ writeFileSync(
232
+ out,
233
+ '// Generated by `kit guide` from the guide folder: edit the templates there, then run it again.\n' +
234
+ 'export const GUIDE_FILES: Record<string, string> = {\n' +
235
+ files.map(([path, body]) => ` ${JSON.stringify(path)}: ${JSON.stringify(body)},\n`).join('') +
236
+ '}\n',
237
+ )
238
+ return files.length
239
+ }
240
+
241
+ /** Copies a template folder, filling every placeholder in names and contents. */
242
+ const copyTemplate = (from, to, vars, renameOf = (file) => file) => {
243
+ const fill = (text) => Object.entries(vars).reduce((filled, [key, value]) => filled.replaceAll(key, value), text)
244
+ mkdirSync(to, { recursive: true })
245
+ for (const entry of readdirSync(from)) {
246
+ const source = join(from, entry)
247
+ const target = join(to, fill(renameOf(entry)))
248
+ if (statSync(source).isDirectory()) copyTemplate(source, target, vars)
249
+ else writeFileSync(target, fill(readFileSync(source, 'utf8')), { mode: statSync(source).mode })
250
+ }
251
+ }
252
+
253
+ const capital = (word) => word[0].toUpperCase() + word.slice(1)
254
+ const singularOf = (word) => (word.endsWith('ies') ? `${word.slice(0, -3)}y` : /(ss|x|ch|sh)es$/.test(word) ? word.slice(0, -2) : word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word)
255
+
256
+ /** Every spelling a feature's files need, from its plural kebab-case name (`reading-lists`). */
257
+ export const namesOf = (name) => {
258
+ if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name)) fail(`"${name}" isn't a plural kebab-case name like notes or reading-lists`)
259
+ const many = name.split('-')
260
+ const one = [...many.slice(0, -1), singularOf(many.at(-1))]
261
+ return {
262
+ __name__: name,
263
+ __camel__: many[0] + many.slice(1).map(capital).join(''),
264
+ __Pascal__: many.map(capital).join(''),
265
+ __Singular__: one.map(capital).join(''),
266
+ __CONST__: many.join('_').toUpperCase(),
267
+ __table__: many.join('_'),
268
+ __snake_singular__: one.join('_'),
269
+ __snake__: many.join('_'),
270
+ __labels__: many.join(' '),
271
+ __label__: one.join(' '),
272
+ }
273
+ }
274
+
275
+ const addFeature = (root, name) => {
276
+ const vars = namesOf(name)
277
+ const list = join(root, 'src/features/index.ts')
278
+ if (!existsSync(list)) fail(`no src/features/index.ts in ${relative(process.cwd(), root) || '.'}: run it inside an app made by init, or pass --app <dir>`)
279
+ const folder = join(root, 'src/features', name)
280
+ if (existsSync(folder)) fail(`src/features/${name} already exists`)
281
+ const text = readFileSync(list, 'utf8')
282
+ if (!text.includes('// kit:imports') || !text.includes(' // kit:features')) fail('src/features/index.ts lost its `// kit:imports` or `// kit:features` line; add the feature there by hand')
283
+ copyTemplate(join(KIT, 'templates/feature'), folder, vars, (file) => (file === 'feature.test.ts' ? `${name}.test.ts` : file))
284
+ writeFileSync(list, text.replace('// kit:imports', `import { feature as ${vars.__camel__} } from './${name}/index.js'\n// kit:imports`).replace(' // kit:features', ` ${vars.__camel__},\n // kit:features`))
285
+ console.log(`Made src/features/${name}/ (schema, repo, routes, handlers, tools, index, test) and listed it in src/features/index.ts`)
286
+ }
287
+
288
+ // ---- Commands ----
289
+
290
+ const commands = {
291
+ help: () => console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').match(/\/\*\*([\s\S]*?)\*\//)[1].replace(/^ \* ?/gm, '').trim()),
292
+
293
+ list: () => {
294
+ const installed = new Set(readLock()?.modules ?? [])
295
+ for (const [name, module] of Object.entries(REGISTRY)) {
296
+ const needs = [...(module.requires ?? []), ...Object.keys(module.dependencies ?? {})]
297
+ console.log(`${installed.has(name) ? '●' : '○'} ${name.padEnd(11)} ${module.description}${needs.length ? `\n ${''.padEnd(11)} needs: ${needs.join(', ')}` : ''}`)
298
+ }
299
+ for (const { name, description } of ROOT_ITEMS) console.log(`◇ ${name.padEnd(11)} ${description}\n ${''.padEnd(11)} at the repo root: ${shadcnAdd(name)}`)
300
+ },
301
+
302
+ add: () => {
303
+ if (!names.length) fail('name the modules to add, e.g. `add core guide mcp`')
304
+ const lock = readLock()
305
+ const webDir = webDirArg ?? lock?.webDir
306
+ const modules = withRequired([...(lock?.modules ?? []), ...names])
307
+ const expected = expectedFiles(modules, webDir)
308
+ const hashes = { ...(lock?.files ?? {}) }
309
+ const skipped = []
310
+ for (const [file, text] of Object.entries(expected)) {
311
+ const path = pathOf(file, webDir)
312
+ mkdirSync(dirname(path), { recursive: true })
313
+ const local = existsSync(path) ? readFileSync(path, 'utf8') : null
314
+ const yours = local !== null && local !== text && sha(local) !== hashes[file]
315
+ if (yours && !flags.has('--force')) {
316
+ skipped.push(file)
317
+ continue
318
+ }
319
+ writeFileSync(path, text)
320
+ hashes[file] = sha(text)
321
+ }
322
+ writeLock(modules, hashes, webDir)
323
+ console.log(`Copied ${modules.join(', ')} (kit ${VERSION}) into ${relative(process.cwd(), dir) || '.'}`)
324
+ if (skipped.length) console.log(`Kept your version of ${skipped.join(', ')} (it differs from the kit); --force overwrites`)
325
+ if (flags.has('--install')) install(modules)
326
+ else console.log(`Dependencies: ${installCommand(modules)}`)
327
+ const entries = modules.filter((name) => REGISTRY[name].entry).map((name) => keyOf(name, REGISTRY[name].files.find((file) => file.endsWith('/index.ts')) ?? REGISTRY[name].files[0], webDir))
328
+ const importOf = (key) => `'./${relative(process.cwd(), pathOf(key, webDir)).replace(/\.tsx?$/, '.js')}'`
329
+ console.log(`Import from ${[...(expected['index.ts'] ? ['index.ts'] : []), ...entries].map(importOf).join(', ')}`)
330
+ },
331
+
332
+ guide: () => {
333
+ const from = resolve(process.cwd(), valueOf('--from') ?? 'guide')
334
+ const out = resolve(process.cwd(), valueOf('--out') ?? 'src/guide.gen.ts')
335
+ if (!existsSync(from)) fail(`no guide folder at ${relative(process.cwd(), from)}; pass --from <dir>`)
336
+ console.log(`Wrote ${writeGuideModule(from, out)} guide files to ${relative(process.cwd(), out)}`)
337
+ },
338
+
339
+ diff: () => {
340
+ const lock = readLock() ?? fail(`no kit.json in ${dirArg}; add modules first`)
341
+ const statuses = statusOf(lock, expectedFiles(lock.modules, lock.webDir))
342
+ console.log(`Kit ${lock.version} in ${dirArg} → kit ${VERSION}`)
343
+ for (const { file, state } of statuses) console.log(` ${state.padEnd(15)} ${file}`)
344
+ if (flags.has('--patch'))
345
+ for (const { file, changed } of statuses.filter((status) => status.state !== 'unchanged' && status.state !== 'missing')) {
346
+ if (!changed && file === 'index.ts') continue
347
+ const upstream = file === 'index.ts' ? null : join(KIT, 'src', sourceOf(file))
348
+ if (upstream) spawnSync('git', ['--no-pager', 'diff', '--no-index', '--no-color', upstream, pathOf(file, lock.webDir)], { stdio: 'inherit' })
349
+ }
350
+ const changed = statuses.filter((status) => status.state !== 'unchanged')
351
+ process.exitCode = changed.length ? 1 : 0
352
+ },
353
+
354
+ init: () => {
355
+ const [target] = names
356
+ if (!target) fail('name the folder: init <dir>')
357
+ const preset = valueOf('--preset') ?? 'api'
358
+ if (preset === 'selfhosted') fail('the selfhosted preset arrives with escalay/selfhost-kit; use --preset api')
359
+ if (!PRESETS.includes(preset)) fail(`--preset is one of ${PRESETS.join(', ')}`)
360
+ const root = resolve(process.cwd(), target)
361
+ if (existsSync(root) && readdirSync(root).length) fail(`${target} is not empty`)
362
+ const name = basename(root)
363
+ const title = valueOf('--title') ?? name.split(/[-_]/).map(capital).join(' ')
364
+ const runtime = valueOf('--runtime') ?? 'both'
365
+ if (!RUNTIMES.includes(runtime)) fail(`--runtime is one of ${RUNTIMES.join(', ')}`)
366
+ const vars = { __name__: name, __title__: title, __kit__: VERSION, __date__: new Date().toISOString().slice(0, 10) }
367
+ copyTemplate(join(KIT, 'templates/app'), root, vars, (file) => (file === 'gitignore' ? '.gitignore' : file))
368
+ if (runtime !== 'node') {
369
+ copyTemplate(join(KIT, 'templates/runtime-cloudflare'), root, vars, (file) => (file === 'dev.vars.example' ? '.dev.vars.example' : file))
370
+ mkdirSync(join(root, 'migrations'), { recursive: true })
371
+ }
372
+ if (runtime === 'cloudflare') for (const path of ['src/server.ts', 'Dockerfile', 'container']) rmSync(join(root, path), { recursive: true, force: true })
373
+ // AGENTS.md lines marked for one runtime stay only in apps that run on it
374
+ const keeps = { node: runtime !== 'cloudflare', workers: runtime !== 'node' }
375
+ const agents = join(root, 'AGENTS.md')
376
+ writeFileSync(agents, readFileSync(agents, 'utf8').split('\n').flatMap((line) => {
377
+ const marked = line.match(/^(.*) <!-- runtime:(node|workers) -->$/)
378
+ return !marked ? [line] : keeps[marked[2]] ? [marked[1]] : []
379
+ }).join('\n').replaceAll('__worker_dev__', runtime === 'both' ? 'dev:worker' : 'dev'))
380
+ writeGuideModule(join(root, 'guide'), join(root, 'src/guide.gen.ts'))
381
+ const added = spawnSync(process.execPath, [fileURLToPath(import.meta.url), 'add', ...APP_MODULES, '--dir', 'src/kit'], { cwd: root, encoding: 'utf8' })
382
+ if (added.status !== 0) fail(`copying the kit failed: ${added.stderr}`)
383
+ const modules = withRequired(APP_MODULES)
384
+ const runtimeDeps = runtime === 'cloudflare' ? {} : RUNTIME
385
+ const devDeps = { ...DEV, ...(runtime === 'node' ? {} : WORKERS_DEV), ...(runtime === 'cloudflare' ? { tsx: RUNTIME.tsx } : {}) }
386
+ const sorted = (deps) => Object.fromEntries(Object.entries(deps).sort())
387
+ writeFileSync(join(root, 'package.json'), JSON.stringify({ name, version: '0.1.0', private: true, type: 'module', engines: { node: '>=22.16.0' }, scripts: scriptsFor(runtime), dependencies: sorted({ ...dependenciesOf(modules), ...runtimeDeps }), devDependencies: sorted(devDeps) }, null, 2) + '\n')
388
+ if (!flags.has('--bare')) addFeature(root, 'notes')
389
+ console.log(`Made ${title} in ${relative(process.cwd(), root)} for ${runtime === 'both' ? 'Node and Cloudflare Workers' : runtime === 'node' ? 'Node' : 'Cloudflare Workers'} with kit ${VERSION} (${modules.join(', ')})`)
390
+ console.log(`Next: cd ${relative(process.cwd(), root)} && pnpm install${runtime === 'node' ? '' : ' && pnpm migrations'} && pnpm test && pnpm dev`)
391
+ },
392
+
393
+ adopt,
394
+
395
+ doctor: () => {
396
+ if (flags.has('--adopt') && !existsSync(lockPath)) adopt()
397
+ const lock = readLock()
398
+ const findings = lock ? findingsOf(lock) : [`no record ${dirArg} has no kit.json: \`kit adopt --dir ${dirArg}\` writes it from the parts on disk, \`kit add <part> --dir ${dirArg}\` copies new ones`]
399
+ for (const finding of findings) console.log(finding)
400
+ console.log(findings.length ? `${findings.length} to fix in ${dirArg}` : `${dirArg} matches kit ${VERSION}: ${lock.modules.join(', ')}`)
401
+ process.exitCode = findings.length ? 1 : 0
402
+ },
403
+
404
+ feature: () => {
405
+ const [name] = names
406
+ if (!name) fail('name the feature, plural and kebab-case: feature reading-lists')
407
+ addFeature(resolve(process.cwd(), valueOf('--app') ?? '.'), name)
408
+ },
409
+
410
+ update: () => {
411
+ const lock = readLock() ?? fail(`no kit.json in ${dirArg}; add modules first`)
412
+ const modules = withRequired([...lock.modules, ...names])
413
+ const expected = expectedFiles(modules, lock.webDir)
414
+ const hashes = { ...lock.files }
415
+ const kept = []
416
+ for (const { file, state } of statusOf(lock, expected)) {
417
+ if (state === 'dropped by kit') {
418
+ delete hashes[file]
419
+ console.log(` dropped ${file} (no longer in the kit; delete it when nothing imports it)`)
420
+ continue
421
+ }
422
+ if (state === 'unchanged') continue
423
+ if ((state === 'both changed' || state === 'you edited') && !flags.has('--force')) {
424
+ if (state === 'both changed') kept.push(file)
425
+ continue
426
+ }
427
+ mkdirSync(dirname(pathOf(file, lock.webDir)), { recursive: true })
428
+ writeFileSync(pathOf(file, lock.webDir), expected[file])
429
+ hashes[file] = sha(expected[file])
430
+ console.log(` updated ${file}`)
431
+ }
432
+ writeLock(modules, hashes, lock.webDir)
433
+ if (kept.length) console.log(`Kept your edits to ${kept.join(', ')} although the kit changed them: \`diff --patch\` shows both, merge by hand, or --force`)
434
+ console.log(`kit.json → ${VERSION}. Dependencies: ${installCommand(modules)}`)
435
+ },
436
+ }
437
+
438
+ ;(commands[command] ?? (() => fail(`unknown command "${command}". Commands: ${Object.keys(commands).join(', ')}`)))()