@escalay/kit 0.0.0-stage → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +133 -2
- package/cli.mjs +432 -0
- package/docs/STANDARD.md +342 -0
- package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
- package/docs/decisions/0002-node-and-workers.md +45 -0
- package/package.json +58 -3
- package/registry.json +512 -0
- package/src/a2a/README.md +25 -0
- package/src/a2a/index.ts +262 -0
- package/src/boards/README.md +35 -0
- package/src/boards/handlers.ts +87 -0
- package/src/boards/index.ts +64 -0
- package/src/boards/refs.ts +23 -0
- package/src/boards/repo.ts +50 -0
- package/src/boards/routes.ts +83 -0
- package/src/boards/schema.ts +28 -0
- package/src/boards/tools.ts +76 -0
- package/src/browser/README.md +31 -0
- package/src/browser/boards-client.ts +74 -0
- package/src/browser/boards-react.ts +73 -0
- package/src/browser/search-client.ts +258 -0
- package/src/browser/search-react.ts +116 -0
- package/src/browser/vector-search-block.tsx +94 -0
- package/src/core/README.md +37 -0
- package/src/core/api.ts +239 -0
- package/src/core/auth.ts +147 -0
- package/src/core/cache.ts +53 -0
- package/src/core/discovery.ts +315 -0
- package/src/core/guards.ts +109 -0
- package/src/core/hal.ts +63 -0
- package/src/core/kv.ts +29 -0
- package/src/core/problem.ts +124 -0
- package/src/db/README.md +39 -0
- package/src/db/guards.ts +61 -0
- package/src/db/index.ts +122 -0
- package/src/guide/README.md +38 -0
- package/src/guide/guide.ts +152 -0
- package/src/guide/reference.ts +204 -0
- package/src/index.ts +15 -0
- package/src/mcp/README.md +34 -0
- package/src/mcp/index.ts +251 -0
- package/src/oauth/README.md +29 -0
- package/src/oauth/index.ts +439 -0
- package/src/oauth/schema.ts +7 -0
- package/src/oauth/store.ts +34 -0
- package/src/pagination/README.md +31 -0
- package/src/pagination/index.ts +65 -0
- package/src/testing/README.md +31 -0
- package/src/testing/index.ts +162 -0
- package/src/vector-search/README.md +27 -0
- package/src/vector-search/index.ts +367 -0
- package/src/vectors/README.md +30 -0
- package/src/vectors/index.ts +326 -0
- package/src/web/README.md +21 -0
- package/src/web/index.ts +100 -0
- package/templates/app/AGENTS.md +41 -0
- package/templates/app/CLAUDE.md +3 -0
- package/templates/app/Dockerfile +12 -0
- package/templates/app/container/entrypoint.sh +7 -0
- package/templates/app/gitignore +4 -0
- package/templates/app/guide/llms.txt +11 -0
- package/templates/app/pnpm-workspace.yaml +5 -0
- package/templates/app/scripts/guide.ts +8 -0
- package/templates/app/src/app.ts +39 -0
- package/templates/app/src/features/index.ts +30 -0
- package/templates/app/src/server.ts +14 -0
- package/templates/app/test/api.test.ts +34 -0
- package/templates/app/tsconfig.json +16 -0
- package/templates/feature/feature.test.ts +19 -0
- package/templates/feature/handlers.ts +43 -0
- package/templates/feature/index.ts +34 -0
- package/templates/feature/repo.ts +24 -0
- package/templates/feature/routes.ts +43 -0
- package/templates/feature/schema.ts +14 -0
- package/templates/feature/tools.ts +46 -0
- package/templates/runtime-cloudflare/dev.vars.example +3 -0
- package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
- package/templates/runtime-cloudflare/src/worker.ts +19 -0
- package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
- package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
- package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
- package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
- package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
- package/templates/runtime-cloudflare/vitest.config.ts +23 -0
- 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,134 @@
|
|
|
1
|
-
#
|
|
1
|
+
# api-kit
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
|
|
134
|
+
Working on the kit itself: [AGENTS.md](AGENTS.md). Licence: [MIT](LICENSE).
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
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
|
|
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 REGISTRY = Object.fromEntries(
|
|
36
|
+
JSON.parse(readFileSync(join(KIT, 'registry.json'), 'utf8')).items.map((item) => [
|
|
37
|
+
item.name,
|
|
38
|
+
{
|
|
39
|
+
description: item.description,
|
|
40
|
+
files: item.files.map((file) => file.path.replace(/^src\//, '')),
|
|
41
|
+
requires: (item.registryDependencies ?? []).filter((name) => name.startsWith(`${REPO}/`)).map((name) => name.slice(REPO.length + 1)),
|
|
42
|
+
dependencies: Object.fromEntries((item.dependencies ?? []).filter((dependency) => dependency.lastIndexOf('@') > 0).map(rangeOf)),
|
|
43
|
+
entry: item.meta?.entry,
|
|
44
|
+
web: item.meta?.web,
|
|
45
|
+
},
|
|
46
|
+
]),
|
|
47
|
+
)
|
|
48
|
+
const VERSION = JSON.parse(readFileSync(join(KIT, 'package.json'), 'utf8')).version
|
|
49
|
+
const SOURCE = '@escalay/kit'
|
|
50
|
+
|
|
51
|
+
const sha = (text) => createHash('sha256').update(text).digest('hex')
|
|
52
|
+
const fail = (message) => {
|
|
53
|
+
console.error(`kit: ${message}`)
|
|
54
|
+
process.exit(1)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- Arguments ----
|
|
58
|
+
|
|
59
|
+
const [command = 'help', ...rest] = process.argv.slice(2)
|
|
60
|
+
const flags = new Set(rest.filter((arg) => arg.startsWith('--') && !arg.includes('=')))
|
|
61
|
+
const valueOf = (flag) => rest.find((arg, index) => rest[index - 1] === flag) ?? rest.find((arg) => arg.startsWith(`${flag}=`))?.slice(flag.length + 1)
|
|
62
|
+
const dirArg = valueOf('--dir') ?? 'src/kit'
|
|
63
|
+
const webDirArg = valueOf('--web-dir')
|
|
64
|
+
/** Flags that take the next argument as their value, so it is never read as a name. */
|
|
65
|
+
const VALUE_FLAGS = new Set(['--dir', '--web-dir', '--title', '--runtime', '--preset', '--app', '--from', '--out'])
|
|
66
|
+
const names = rest.filter((arg, index) => !arg.startsWith('--') && !VALUE_FLAGS.has(rest[index - 1]))
|
|
67
|
+
const dir = resolve(process.cwd(), dirArg)
|
|
68
|
+
const lockPath = join(dir, 'kit.json')
|
|
69
|
+
|
|
70
|
+
// ---- Modules ----
|
|
71
|
+
|
|
72
|
+
/** The modules asked for plus everything they require, in registry order. */
|
|
73
|
+
const withRequired = (wanted) => {
|
|
74
|
+
const all = new Set()
|
|
75
|
+
const visit = (name) => {
|
|
76
|
+
if (!REGISTRY[name]) fail(`no module "${name}". Modules: ${Object.keys(REGISTRY).join(', ')}`)
|
|
77
|
+
if (all.has(name)) return
|
|
78
|
+
for (const required of REGISTRY[name].requires ?? []) visit(required)
|
|
79
|
+
all.add(name)
|
|
80
|
+
}
|
|
81
|
+
wanted.forEach(visit)
|
|
82
|
+
return Object.keys(REGISTRY).filter((name) => all.has(name))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const upstreamOf = (file) => readFileSync(join(KIT, 'src', file), 'utf8')
|
|
86
|
+
|
|
87
|
+
// A browser module's files sit under the web dir when there is one; kit.json keys them `@web/<file>` (a module folder
|
|
88
|
+
// can be called web/, so the prefix must be something no folder is)
|
|
89
|
+
const WEB = '@web/'
|
|
90
|
+
const WEB_FILES = new Set(Object.values(REGISTRY).filter((module) => module.web).flatMap((module) => module.files))
|
|
91
|
+
// Every module carries its README (what it is for, an example, its rules), so the practices travel with the copy
|
|
92
|
+
const keyOf = (name, file, webDir) => (REGISTRY[name].web && webDir ? WEB + file : file)
|
|
93
|
+
const sourceOf = (key) => (key.startsWith(WEB) ? key.slice(WEB.length) : key)
|
|
94
|
+
const pathOf = (key, webDir) => (key.startsWith(WEB) && webDir ? join(resolve(dir, webDir), key.slice(WEB.length)) : join(dir, key))
|
|
95
|
+
|
|
96
|
+
/** index.ts re-exports every installed module that isn't its own entry point (mcp, testing, the browser modules). */
|
|
97
|
+
const indexFor = (modules) =>
|
|
98
|
+
modules
|
|
99
|
+
.filter((name) => !REGISTRY[name].entry)
|
|
100
|
+
.flatMap((name) => {
|
|
101
|
+
const own = REGISTRY[name].files.find((file) => file.endsWith('/index.ts'))
|
|
102
|
+
return own ? [own] : REGISTRY[name].files.filter((file) => /\.tsx?$/.test(file))
|
|
103
|
+
})
|
|
104
|
+
.sort()
|
|
105
|
+
.map((file) => `export * from './${file.replace(/\.ts$/, '.js')}'`)
|
|
106
|
+
.join('\n') + '\n'
|
|
107
|
+
|
|
108
|
+
/** What each file should contain at this kit version: the module files, plus the generated index when anything goes in it. */
|
|
109
|
+
const expectedFiles = (modules, webDir) => {
|
|
110
|
+
const files = Object.fromEntries(modules.flatMap((name) => REGISTRY[name].files.map((file) => [keyOf(name, file, webDir), upstreamOf(file)])))
|
|
111
|
+
if (modules.some((name) => !REGISTRY[name].entry)) files['index.ts'] = indexFor(modules)
|
|
112
|
+
return files
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** kit.json, with web-dir keys from kit 0.10–1.0.1 (`web/<file>`) read as `@web/<file>`. */
|
|
116
|
+
const readLock = () => {
|
|
117
|
+
if (!existsSync(lockPath)) return null
|
|
118
|
+
const lock = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
119
|
+
if (!lock.webDir) return lock
|
|
120
|
+
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' }
|
|
121
|
+
const keyFor = (key) => (flat[key] ? `${WEB}${flat[key]}` : key.startsWith('web/') && WEB_FILES.has(key.slice(4)) ? WEB + key.slice(4) : key)
|
|
122
|
+
return { ...lock, files: Object.fromEntries(Object.entries(lock.files).map(([key, hash]) => [keyFor(key), flat[key] ? null : hash]).filter(([, hash]) => hash !== null)) }
|
|
123
|
+
}
|
|
124
|
+
const writeLock = (modules, hashes, webDir) =>
|
|
125
|
+
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')
|
|
126
|
+
|
|
127
|
+
const dependenciesOf = (modules) => Object.assign({}, ...modules.map((name) => REGISTRY[name].dependencies ?? {}))
|
|
128
|
+
const installCommand = (modules) => {
|
|
129
|
+
const deps = Object.entries(dependenciesOf(modules)).map(([name, range]) => `${name}@${range}`)
|
|
130
|
+
return deps.length ? `pnpm add ${deps.join(' ')}` : 'none'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const install = (modules) => {
|
|
134
|
+
if (installCommand(modules) === 'none') return
|
|
135
|
+
const result = spawnSync('pnpm', installCommand(modules).split(' ').slice(1), { stdio: 'inherit', cwd: process.cwd() })
|
|
136
|
+
if (result.status !== 0) fail('pnpm add failed; run it yourself: ' + installCommand(modules))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Each file's state against kit.json: did you edit it, did the kit change it. */
|
|
140
|
+
const statusOf = (lock, expected) =>
|
|
141
|
+
Object.keys({ ...lock.files, ...expected }).sort().map((file) => {
|
|
142
|
+
const path = pathOf(file, lock.webDir)
|
|
143
|
+
const local = existsSync(path) ? sha(readFileSync(path, 'utf8')) : null
|
|
144
|
+
const recorded = lock.files[file] ?? null
|
|
145
|
+
const kit = expected[file] === undefined ? null : sha(expected[file])
|
|
146
|
+
const edited = local !== null && recorded !== null && local !== recorded
|
|
147
|
+
const changed = kit !== recorded
|
|
148
|
+
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'
|
|
149
|
+
return { file, state, edited, changed }
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
/** The part a recorded file came with (`index.ts` is the kit's own). */
|
|
153
|
+
const partOf = (key, modules) => modules.find((name) => REGISTRY[name].files.includes(sourceOf(key))) ?? 'kit'
|
|
154
|
+
|
|
155
|
+
/** One line per thing wrong with the copy, each saying how to fix it. */
|
|
156
|
+
const findingsOf = (lock) => {
|
|
157
|
+
const statuses = statusOf(lock, expectedFiles(lock.modules, lock.webDir))
|
|
158
|
+
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)))]
|
|
159
|
+
return [
|
|
160
|
+
...statuses.filter(({ state }) => state === 'missing').map(({ file }) => `missing ${file}: \`kit update --dir ${dirArg}\` writes it again`),
|
|
161
|
+
...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`),
|
|
162
|
+
...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}\``),
|
|
163
|
+
...(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`] : []),
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Writes kit.json for parts that were copied without one (the shadcn CLI): every part whose files are all on disk. */
|
|
168
|
+
const adopt = () => {
|
|
169
|
+
if (existsSync(lockPath)) fail(`${dirArg} already has a kit.json; \`kit doctor --dir ${dirArg}\` checks it`)
|
|
170
|
+
const found = Object.keys(REGISTRY).filter((name) => REGISTRY[name].files.every((file) => existsSync(join(dir, file))))
|
|
171
|
+
if (!found.length) fail(`no parts in ${dirArg}; \`kit add <part> --dir ${dirArg}\` copies one, \`kit list\` names them`)
|
|
172
|
+
const modules = withRequired(found)
|
|
173
|
+
const expected = expectedFiles(modules)
|
|
174
|
+
const index = join(dir, 'index.ts')
|
|
175
|
+
if (expected['index.ts'] && !existsSync(index)) writeFileSync(index, expected['index.ts'])
|
|
176
|
+
// The kit's hashes, not the disk's: a file that already differs shows as edited instead of passing as the kit's
|
|
177
|
+
writeLock(modules, Object.fromEntries(Object.entries(expected).map(([file, text]) => [file, sha(text)])))
|
|
178
|
+
console.log(`Adopted ${found.join(', ')} in ${dirArg} as kit ${VERSION}`)
|
|
179
|
+
const absent = modules.filter((name) => !found.includes(name))
|
|
180
|
+
if (absent.length) console.log(`Recorded ${absent.join(', ')} too (they are required): \`kit update --dir ${dirArg}\` copies them`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- Generators: a new app, a new feature ----
|
|
184
|
+
|
|
185
|
+
/** The modules a new app starts with. */
|
|
186
|
+
const APP_MODULES = ['core', 'db', 'oauth', 'guide', 'mcp', 'testing']
|
|
187
|
+
const RUNTIME = { '@hono/node-server': '^2.1.5', tsx: '^4.23.15' }
|
|
188
|
+
const DEV = { '@types/node': '^24.19.2', typescript: '^7.0.2', vitest: '^5.0.5' }
|
|
189
|
+
/** What the Cloudflare side adds, at the versions the kit tests with. */
|
|
190
|
+
const KIT_DEV = JSON.parse(readFileSync(join(KIT, 'package.json'), 'utf8')).devDependencies
|
|
191
|
+
const WORKERS_DEV = Object.fromEntries(['wrangler', '@cloudflare/vitest-plugin', '@cloudflare/workers-types'].map((name) => [name, KIT_DEV[name]]))
|
|
192
|
+
/** The runtimes an app can run on: `node` (server.ts, a container), `cloudflare` (worker.ts, D1), or `both`. */
|
|
193
|
+
const RUNTIMES = ['both', 'cloudflare', 'node']
|
|
194
|
+
/** The starting combinations: `api` is the kit's parts for an API with one example feature. */
|
|
195
|
+
const PRESETS = ['api', 'selfhosted']
|
|
196
|
+
const scriptsFor = (runtime) => {
|
|
197
|
+
const node = { dev: 'tsx watch src/server.ts', start: 'tsx src/server.ts' }
|
|
198
|
+
const workers = {
|
|
199
|
+
migrations: 'tsx scripts/migrations.ts',
|
|
200
|
+
deploy: 'pnpm guide && pnpm migrations && wrangler d1 migrations apply DB --remote && wrangler deploy',
|
|
201
|
+
}
|
|
202
|
+
const typecheck = runtime === 'node' ? 'tsc --noEmit' : 'tsc --noEmit && tsc --noEmit -p test/workers/tsconfig.json'
|
|
203
|
+
return {
|
|
204
|
+
...(runtime === 'cloudflare' ? { dev: 'wrangler dev' } : node),
|
|
205
|
+
...(runtime === 'both' ? { 'dev:worker': 'wrangler dev' } : {}),
|
|
206
|
+
guide: 'tsx scripts/guide.ts',
|
|
207
|
+
...(runtime === 'node' ? {} : workers),
|
|
208
|
+
test: 'vitest run',
|
|
209
|
+
typecheck,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 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). */
|
|
214
|
+
const writeGuideModule = (from, out) => {
|
|
215
|
+
const files = []
|
|
216
|
+
const walk = (folder) => {
|
|
217
|
+
for (const entry of readdirSync(folder, { withFileTypes: true })) {
|
|
218
|
+
const path = join(folder, entry.name)
|
|
219
|
+
if (entry.isDirectory()) walk(path)
|
|
220
|
+
else if (/\.(md|txt)$/.test(entry.name)) files.push([relative(from, path).split('\\').join('/'), readFileSync(path, 'utf8')])
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
walk(from)
|
|
224
|
+
files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
225
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
226
|
+
writeFileSync(
|
|
227
|
+
out,
|
|
228
|
+
'// Generated by `kit guide` from the guide folder: edit the templates there, then run it again.\n' +
|
|
229
|
+
'export const GUIDE_FILES: Record<string, string> = {\n' +
|
|
230
|
+
files.map(([path, body]) => ` ${JSON.stringify(path)}: ${JSON.stringify(body)},\n`).join('') +
|
|
231
|
+
'}\n',
|
|
232
|
+
)
|
|
233
|
+
return files.length
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Copies a template folder, filling every placeholder in names and contents. */
|
|
237
|
+
const copyTemplate = (from, to, vars, renameOf = (file) => file) => {
|
|
238
|
+
const fill = (text) => Object.entries(vars).reduce((filled, [key, value]) => filled.replaceAll(key, value), text)
|
|
239
|
+
mkdirSync(to, { recursive: true })
|
|
240
|
+
for (const entry of readdirSync(from)) {
|
|
241
|
+
const source = join(from, entry)
|
|
242
|
+
const target = join(to, fill(renameOf(entry)))
|
|
243
|
+
if (statSync(source).isDirectory()) copyTemplate(source, target, vars)
|
|
244
|
+
else writeFileSync(target, fill(readFileSync(source, 'utf8')), { mode: statSync(source).mode })
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const capital = (word) => word[0].toUpperCase() + word.slice(1)
|
|
249
|
+
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)
|
|
250
|
+
|
|
251
|
+
/** Every spelling a feature's files need, from its plural kebab-case name (`reading-lists`). */
|
|
252
|
+
export const namesOf = (name) => {
|
|
253
|
+
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`)
|
|
254
|
+
const many = name.split('-')
|
|
255
|
+
const one = [...many.slice(0, -1), singularOf(many.at(-1))]
|
|
256
|
+
return {
|
|
257
|
+
__name__: name,
|
|
258
|
+
__camel__: many[0] + many.slice(1).map(capital).join(''),
|
|
259
|
+
__Pascal__: many.map(capital).join(''),
|
|
260
|
+
__Singular__: one.map(capital).join(''),
|
|
261
|
+
__CONST__: many.join('_').toUpperCase(),
|
|
262
|
+
__table__: many.join('_'),
|
|
263
|
+
__snake_singular__: one.join('_'),
|
|
264
|
+
__snake__: many.join('_'),
|
|
265
|
+
__labels__: many.join(' '),
|
|
266
|
+
__label__: one.join(' '),
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const addFeature = (root, name) => {
|
|
271
|
+
const vars = namesOf(name)
|
|
272
|
+
const list = join(root, 'src/features/index.ts')
|
|
273
|
+
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>`)
|
|
274
|
+
const folder = join(root, 'src/features', name)
|
|
275
|
+
if (existsSync(folder)) fail(`src/features/${name} already exists`)
|
|
276
|
+
const text = readFileSync(list, 'utf8')
|
|
277
|
+
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')
|
|
278
|
+
copyTemplate(join(KIT, 'templates/feature'), folder, vars, (file) => (file === 'feature.test.ts' ? `${name}.test.ts` : file))
|
|
279
|
+
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`))
|
|
280
|
+
console.log(`Made src/features/${name}/ (schema, repo, routes, handlers, tools, index, test) and listed it in src/features/index.ts`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- Commands ----
|
|
284
|
+
|
|
285
|
+
const commands = {
|
|
286
|
+
help: () => console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').match(/\/\*\*([\s\S]*?)\*\//)[1].replace(/^ \* ?/gm, '').trim()),
|
|
287
|
+
|
|
288
|
+
list: () => {
|
|
289
|
+
const installed = new Set(readLock()?.modules ?? [])
|
|
290
|
+
for (const [name, module] of Object.entries(REGISTRY)) {
|
|
291
|
+
const needs = [...(module.requires ?? []), ...Object.keys(module.dependencies ?? {})]
|
|
292
|
+
console.log(`${installed.has(name) ? '●' : '○'} ${name.padEnd(11)} ${module.description}${needs.length ? `\n ${''.padEnd(11)} needs: ${needs.join(', ')}` : ''}`)
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
add: () => {
|
|
297
|
+
if (!names.length) fail('name the modules to add, e.g. `add core guide mcp`')
|
|
298
|
+
const lock = readLock()
|
|
299
|
+
const webDir = webDirArg ?? lock?.webDir
|
|
300
|
+
const modules = withRequired([...(lock?.modules ?? []), ...names])
|
|
301
|
+
const expected = expectedFiles(modules, webDir)
|
|
302
|
+
const hashes = { ...(lock?.files ?? {}) }
|
|
303
|
+
const skipped = []
|
|
304
|
+
for (const [file, text] of Object.entries(expected)) {
|
|
305
|
+
const path = pathOf(file, webDir)
|
|
306
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
307
|
+
const local = existsSync(path) ? readFileSync(path, 'utf8') : null
|
|
308
|
+
const yours = local !== null && local !== text && sha(local) !== hashes[file]
|
|
309
|
+
if (yours && !flags.has('--force')) {
|
|
310
|
+
skipped.push(file)
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
writeFileSync(path, text)
|
|
314
|
+
hashes[file] = sha(text)
|
|
315
|
+
}
|
|
316
|
+
writeLock(modules, hashes, webDir)
|
|
317
|
+
console.log(`Copied ${modules.join(', ')} (kit ${VERSION}) into ${relative(process.cwd(), dir) || '.'}`)
|
|
318
|
+
if (skipped.length) console.log(`Kept your version of ${skipped.join(', ')} (it differs from the kit); --force overwrites`)
|
|
319
|
+
if (flags.has('--install')) install(modules)
|
|
320
|
+
else console.log(`Dependencies: ${installCommand(modules)}`)
|
|
321
|
+
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))
|
|
322
|
+
const importOf = (key) => `'./${relative(process.cwd(), pathOf(key, webDir)).replace(/\.tsx?$/, '.js')}'`
|
|
323
|
+
console.log(`Import from ${[...(expected['index.ts'] ? ['index.ts'] : []), ...entries].map(importOf).join(', ')}`)
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
guide: () => {
|
|
327
|
+
const from = resolve(process.cwd(), valueOf('--from') ?? 'guide')
|
|
328
|
+
const out = resolve(process.cwd(), valueOf('--out') ?? 'src/guide.gen.ts')
|
|
329
|
+
if (!existsSync(from)) fail(`no guide folder at ${relative(process.cwd(), from)}; pass --from <dir>`)
|
|
330
|
+
console.log(`Wrote ${writeGuideModule(from, out)} guide files to ${relative(process.cwd(), out)}`)
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
diff: () => {
|
|
334
|
+
const lock = readLock() ?? fail(`no kit.json in ${dirArg}; add modules first`)
|
|
335
|
+
const statuses = statusOf(lock, expectedFiles(lock.modules, lock.webDir))
|
|
336
|
+
console.log(`Kit ${lock.version} in ${dirArg} → kit ${VERSION}`)
|
|
337
|
+
for (const { file, state } of statuses) console.log(` ${state.padEnd(15)} ${file}`)
|
|
338
|
+
if (flags.has('--patch'))
|
|
339
|
+
for (const { file, changed } of statuses.filter((status) => status.state !== 'unchanged' && status.state !== 'missing')) {
|
|
340
|
+
if (!changed && file === 'index.ts') continue
|
|
341
|
+
const upstream = file === 'index.ts' ? null : join(KIT, 'src', sourceOf(file))
|
|
342
|
+
if (upstream) spawnSync('git', ['--no-pager', 'diff', '--no-index', '--no-color', upstream, pathOf(file, lock.webDir)], { stdio: 'inherit' })
|
|
343
|
+
}
|
|
344
|
+
const changed = statuses.filter((status) => status.state !== 'unchanged')
|
|
345
|
+
process.exitCode = changed.length ? 1 : 0
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
init: () => {
|
|
349
|
+
const [target] = names
|
|
350
|
+
if (!target) fail('name the folder: init <dir>')
|
|
351
|
+
const preset = valueOf('--preset') ?? 'api'
|
|
352
|
+
if (preset === 'selfhosted') fail('the selfhosted preset arrives with escalay/selfhost-kit; use --preset api')
|
|
353
|
+
if (!PRESETS.includes(preset)) fail(`--preset is one of ${PRESETS.join(', ')}`)
|
|
354
|
+
const root = resolve(process.cwd(), target)
|
|
355
|
+
if (existsSync(root) && readdirSync(root).length) fail(`${target} is not empty`)
|
|
356
|
+
const name = basename(root)
|
|
357
|
+
const title = valueOf('--title') ?? name.split(/[-_]/).map(capital).join(' ')
|
|
358
|
+
const runtime = valueOf('--runtime') ?? 'both'
|
|
359
|
+
if (!RUNTIMES.includes(runtime)) fail(`--runtime is one of ${RUNTIMES.join(', ')}`)
|
|
360
|
+
const vars = { __name__: name, __title__: title, __kit__: VERSION, __date__: new Date().toISOString().slice(0, 10) }
|
|
361
|
+
copyTemplate(join(KIT, 'templates/app'), root, vars, (file) => (file === 'gitignore' ? '.gitignore' : file))
|
|
362
|
+
if (runtime !== 'node') {
|
|
363
|
+
copyTemplate(join(KIT, 'templates/runtime-cloudflare'), root, vars, (file) => (file === 'dev.vars.example' ? '.dev.vars.example' : file))
|
|
364
|
+
mkdirSync(join(root, 'migrations'), { recursive: true })
|
|
365
|
+
}
|
|
366
|
+
if (runtime === 'cloudflare') for (const path of ['src/server.ts', 'Dockerfile', 'container']) rmSync(join(root, path), { recursive: true, force: true })
|
|
367
|
+
// AGENTS.md lines marked for one runtime stay only in apps that run on it
|
|
368
|
+
const keeps = { node: runtime !== 'cloudflare', workers: runtime !== 'node' }
|
|
369
|
+
const agents = join(root, 'AGENTS.md')
|
|
370
|
+
writeFileSync(agents, readFileSync(agents, 'utf8').split('\n').flatMap((line) => {
|
|
371
|
+
const marked = line.match(/^(.*) <!-- runtime:(node|workers) -->$/)
|
|
372
|
+
return !marked ? [line] : keeps[marked[2]] ? [marked[1]] : []
|
|
373
|
+
}).join('\n').replaceAll('__worker_dev__', runtime === 'both' ? 'dev:worker' : 'dev'))
|
|
374
|
+
writeGuideModule(join(root, 'guide'), join(root, 'src/guide.gen.ts'))
|
|
375
|
+
const added = spawnSync(process.execPath, [fileURLToPath(import.meta.url), 'add', ...APP_MODULES, '--dir', 'src/kit'], { cwd: root, encoding: 'utf8' })
|
|
376
|
+
if (added.status !== 0) fail(`copying the kit failed: ${added.stderr}`)
|
|
377
|
+
const modules = withRequired(APP_MODULES)
|
|
378
|
+
const runtimeDeps = runtime === 'cloudflare' ? {} : RUNTIME
|
|
379
|
+
const devDeps = { ...DEV, ...(runtime === 'node' ? {} : WORKERS_DEV), ...(runtime === 'cloudflare' ? { tsx: RUNTIME.tsx } : {}) }
|
|
380
|
+
const sorted = (deps) => Object.fromEntries(Object.entries(deps).sort())
|
|
381
|
+
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')
|
|
382
|
+
if (!flags.has('--bare')) addFeature(root, 'notes')
|
|
383
|
+
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(', ')})`)
|
|
384
|
+
console.log(`Next: cd ${relative(process.cwd(), root)} && pnpm install${runtime === 'node' ? '' : ' && pnpm migrations'} && pnpm test && pnpm dev`)
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
adopt,
|
|
388
|
+
|
|
389
|
+
doctor: () => {
|
|
390
|
+
if (flags.has('--adopt') && !existsSync(lockPath)) adopt()
|
|
391
|
+
const lock = readLock()
|
|
392
|
+
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`]
|
|
393
|
+
for (const finding of findings) console.log(finding)
|
|
394
|
+
console.log(findings.length ? `${findings.length} to fix in ${dirArg}` : `${dirArg} matches kit ${VERSION}: ${lock.modules.join(', ')}`)
|
|
395
|
+
process.exitCode = findings.length ? 1 : 0
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
feature: () => {
|
|
399
|
+
const [name] = names
|
|
400
|
+
if (!name) fail('name the feature, plural and kebab-case: feature reading-lists')
|
|
401
|
+
addFeature(resolve(process.cwd(), valueOf('--app') ?? '.'), name)
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
update: () => {
|
|
405
|
+
const lock = readLock() ?? fail(`no kit.json in ${dirArg}; add modules first`)
|
|
406
|
+
const modules = withRequired([...lock.modules, ...names])
|
|
407
|
+
const expected = expectedFiles(modules, lock.webDir)
|
|
408
|
+
const hashes = { ...lock.files }
|
|
409
|
+
const kept = []
|
|
410
|
+
for (const { file, state } of statusOf(lock, expected)) {
|
|
411
|
+
if (state === 'dropped by kit') {
|
|
412
|
+
delete hashes[file]
|
|
413
|
+
console.log(` dropped ${file} (no longer in the kit; delete it when nothing imports it)`)
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
if (state === 'unchanged') continue
|
|
417
|
+
if ((state === 'both changed' || state === 'you edited') && !flags.has('--force')) {
|
|
418
|
+
if (state === 'both changed') kept.push(file)
|
|
419
|
+
continue
|
|
420
|
+
}
|
|
421
|
+
mkdirSync(dirname(pathOf(file, lock.webDir)), { recursive: true })
|
|
422
|
+
writeFileSync(pathOf(file, lock.webDir), expected[file])
|
|
423
|
+
hashes[file] = sha(expected[file])
|
|
424
|
+
console.log(` updated ${file}`)
|
|
425
|
+
}
|
|
426
|
+
writeLock(modules, hashes, lock.webDir)
|
|
427
|
+
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`)
|
|
428
|
+
console.log(`kit.json → ${VERSION}. Dependencies: ${installCommand(modules)}`)
|
|
429
|
+
},
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
;(commands[command] ?? (() => fail(`unknown command "${command}". Commands: ${Object.keys(commands).join(', ')}`)))()
|