@shaferllc/keel 0.74.0 → 0.77.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/README.md CHANGED
@@ -214,6 +214,7 @@ See [docs/architecture.md](./docs/architecture.md) for the full picture.
214
214
  | [Task Scheduling](./docs/scheduling.md) | Cron-style recurring tasks, one trigger |
215
215
  | [Notifications](./docs/notifications.md) | Multi-channel (mail/db), queueable |
216
216
  | [Broadcasting](./docs/broadcasting.md) | Real-time channels, pluggable, presence auth |
217
+ | [API Resources](./docs/api-resources.md) | CRUD REST API from a model; deny-by-default access, row-level scope |
217
218
  | [Transformers](./docs/transformers.md) | Shape models into API JSON; conditional fields, relations |
218
219
  | [Events](./docs/events.md) | Emit/listen decoupling, async listeners |
219
220
  | [Service Broker](./docs/broker.md) | Moleculer-style services, call/emit, pluggable transport |
@@ -1,7 +1,7 @@
1
1
  /**
2
- * `apiResource(router, Model, options)` — a full CRUD REST API from a Keel model,
3
- * the Remult idea done the Keel way: explicit, server-side, and composed from
4
- * pieces you already have. It registers real routes (so the OpenAPI package
2
+ * `apiResource(router, Model, options)` — a full CRUD REST API from a Keel model:
3
+ * explicit, server-side, and composed from pieces you already have. It registers
4
+ * real routes (so the OpenAPI package
5
5
  * documents them for free), runs writes through the model's mass-assignment guard
6
6
  * and your Zod schema, paginates and allow-list-filters reads, and gates every
7
7
  * action.
@@ -87,5 +87,7 @@ export interface ApiResourceOptions {
87
87
  beforeWrite?: (data: Row, c: Ctx, action: "create" | "update") => Row | Promise<Row>;
88
88
  /** OpenAPI tags for these routes. Default: `[path]`. */
89
89
  tags?: string[];
90
+ /** Singular display name used in generated doc summaries. Default: the model's class name. */
91
+ label?: string;
90
92
  }
91
93
  export declare function apiResource(router: Router, model: ModelStatic, options?: ApiResourceOptions): void;
@@ -1,7 +1,7 @@
1
1
  /**
2
- * `apiResource(router, Model, options)` — a full CRUD REST API from a Keel model,
3
- * the Remult idea done the Keel way: explicit, server-side, and composed from
4
- * pieces you already have. It registers real routes (so the OpenAPI package
2
+ * `apiResource(router, Model, options)` — a full CRUD REST API from a Keel model:
3
+ * explicit, server-side, and composed from pieces you already have. It registers
4
+ * real routes (so the OpenAPI package
5
5
  * documents them for free), runs writes through the model's mass-assignment guard
6
6
  * and your Zod schema, paginates and allow-list-filters reads, and gates every
7
7
  * action.
@@ -72,16 +72,13 @@ function transformMany(t, models, c) {
72
72
  return models.map((m) => m.toJSON());
73
73
  return typeof t === "function" ? models.map((m) => t(m, c)) : t.collection(models);
74
74
  }
75
- function singular(word) {
76
- return word.endsWith("s") ? word.slice(0, -1) : word;
77
- }
78
75
  export function apiResource(router, model, options = {}) {
79
76
  const defaults = apiDefaults();
80
77
  const path = options.path ?? model.table;
81
78
  const base = "/" + path.replace(/^\/|\/$/g, "");
82
79
  const name = options.name ?? path;
83
80
  const pk = model.primaryKey;
84
- const one = singular(path);
81
+ const label = options.label ?? model.name;
85
82
  const tags = options.tags ?? [path];
86
83
  const perPage = options.perPage ?? defaults.perPage;
87
84
  const maxPerPage = options.maxPerPage ?? defaults.maxPerPage;
@@ -138,7 +135,7 @@ export function apiResource(router, model, options = {}) {
138
135
  return c.json({ data: transformOne(options.transform, found, c) });
139
136
  })
140
137
  .name(`${name}.read`)
141
- .config(openApiConfig({ summary: `Fetch a ${one}`, tags }));
138
+ .config(openApiConfig({ summary: `Fetch ${label}`, tags }));
142
139
  }
143
140
  if (actions.has("create")) {
144
141
  const schema = options.createBody ?? options.body;
@@ -153,10 +150,10 @@ export function apiResource(router, model, options = {}) {
153
150
  })
154
151
  .name(`${name}.create`)
155
152
  .config(openApiConfig({
156
- summary: `Create a ${one}`,
153
+ summary: `Create ${label}`,
157
154
  tags,
158
155
  ...(schema ? { request: { body: schema } } : {}),
159
- responses: { 201: { description: `The created ${one}` } },
156
+ responses: { 201: { description: `The created ${label}` } },
160
157
  }));
161
158
  }
162
159
  if (actions.has("update")) {
@@ -172,7 +169,7 @@ export function apiResource(router, model, options = {}) {
172
169
  return c.json({ data: transformOne(options.transform, found, c) });
173
170
  })
174
171
  .name(`${name}.update`)
175
- .config(openApiConfig({ summary: `Update a ${one}`, tags, ...(schema ? { request: { body: schema } } : {}) }));
172
+ .config(openApiConfig({ summary: `Update ${label}`, tags, ...(schema ? { request: { body: schema } } : {}) }));
176
173
  }
177
174
  if (actions.has("delete")) {
178
175
  router
@@ -183,6 +180,6 @@ export function apiResource(router, model, options = {}) {
183
180
  return c.body(null, 204);
184
181
  })
185
182
  .name(`${name}.delete`)
186
- .config(openApiConfig({ summary: `Delete a ${one}`, tags, responses: { 204: { description: "Deleted" } } }));
183
+ .config(openApiConfig({ summary: `Delete ${label}`, tags, responses: { 204: { description: "Deleted" } } }));
187
184
  }
188
185
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The Keel console. `keel <command>`.
3
+ *
4
+ * Commands:
5
+ * keel serve start the HTTP server
6
+ * keel make:controller Foo generate app/Controllers/FooController.ts
7
+ * keel make:provider Foo generate app/Providers/FooServiceProvider.ts
8
+ * keel make:middleware Foo generate app/Http/Middleware/foo.ts
9
+ * keel make:factory User generate database/factories/UserFactory.ts
10
+ * keel make:seeder Database generate database/seeders/DatabaseSeeder.ts
11
+ * keel make:job SendWelcome generate app/Jobs/SendWelcomeJob.ts
12
+ * keel make:notification Paid generate app/Notifications/PaidNotification.ts
13
+ * keel make:transformer User generate app/Transformers/UserTransformer.ts
14
+ * keel routes list registered routes
15
+ * keel mcp start the MCP server (docs + API for AI agents)
16
+ */
17
+ import type { Application } from "../application.js";
18
+ /** What the console needs from your app: a way to build it. */
19
+ export interface ConsoleOptions {
20
+ /**
21
+ * Build and boot the application. The console is handed this rather than
22
+ * importing it, because a framework that imports an *application* has its
23
+ * dependency pointing the wrong way — and that one import is what kept this
24
+ * file out of the published build for so long.
25
+ */
26
+ createApplication: () => Promise<Application>;
27
+ }
28
+ export declare function run(argv: string[], options: ConsoleOptions): Promise<void>;
@@ -0,0 +1,427 @@
1
+ /**
2
+ * The Keel console. `keel <command>`.
3
+ *
4
+ * Commands:
5
+ * keel serve start the HTTP server
6
+ * keel make:controller Foo generate app/Controllers/FooController.ts
7
+ * keel make:provider Foo generate app/Providers/FooServiceProvider.ts
8
+ * keel make:middleware Foo generate app/Http/Middleware/foo.ts
9
+ * keel make:factory User generate database/factories/UserFactory.ts
10
+ * keel make:seeder Database generate database/seeders/DatabaseSeeder.ts
11
+ * keel make:job SendWelcome generate app/Jobs/SendWelcomeJob.ts
12
+ * keel make:notification Paid generate app/Notifications/PaidNotification.ts
13
+ * keel make:transformer User generate app/Transformers/UserTransformer.ts
14
+ * keel routes list registered routes
15
+ * keel mcp start the MCP server (docs + API for AI agents)
16
+ */
17
+ import { mkdir, writeFile, access, readdir, stat, copyFile } from "node:fs/promises";
18
+ import { dirname, join } from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+ import { ConsoleKernel, defineCommand, arg, flag } from "../console.js";
21
+ import { HttpKernel } from "../http/kernel.js";
22
+ import { Router } from "../http/router.js";
23
+ import { getConnection } from "../database.js";
24
+ import { Migrator } from "../migrations.js";
25
+ import { MigrationRegistry, CommandRegistry, PublishRegistry } from "../package.js";
26
+ import { controllerStub, resourceControllerStub, providerStub, middlewareStub, factoryStub, seederStub, jobStub, notificationStub, transformerStub, packageProviderStub, pageStub, commandStub, } from "./stubs.js";
27
+ const basePath = process.cwd();
28
+ async function generate(relPath, contents, label, ui) {
29
+ const full = join(basePath, relPath);
30
+ try {
31
+ await access(full);
32
+ // Never clobber a file someone has written code in.
33
+ ui.error(`${label} already exists: ${relPath}`);
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+ catch {
38
+ // does not exist — proceed
39
+ }
40
+ await mkdir(dirname(full), { recursive: true });
41
+ await writeFile(full, contents);
42
+ ui.action("create", relPath);
43
+ }
44
+ /** Normalize "foo" / "FooController" into a canonical suffixed class name. */
45
+ function className(name, suffix) {
46
+ const base = name.replace(new RegExp(`${suffix}$`, "i"), "");
47
+ const pascal = base.charAt(0).toUpperCase() + base.slice(1);
48
+ return `${pascal}${suffix}`;
49
+ }
50
+ /** Load every migration under database/migrations/, in filename order. */
51
+ async function discoverMigrations() {
52
+ const dir = join(basePath, "database/migrations");
53
+ let files;
54
+ try {
55
+ files = await readdir(dir);
56
+ }
57
+ catch {
58
+ return []; // no migrations directory — fine
59
+ }
60
+ const out = [];
61
+ for (const file of files.sort()) {
62
+ if (!/\.(ts|js|mjs)$/.test(file))
63
+ continue;
64
+ const mod = await import(pathToFileURL(join(dir, file)).href);
65
+ const def = (mod.default ?? mod.migration ?? mod);
66
+ if (Array.isArray(def))
67
+ out.push(...def);
68
+ else if (def && typeof def.up === "function")
69
+ out.push(def);
70
+ }
71
+ return out;
72
+ }
73
+ /** Every migration to run: the app's discovered ones, then package-contributed. */
74
+ async function collectMigrations(app) {
75
+ return [...(await discoverMigrations()), ...app.make(MigrationRegistry).all()];
76
+ }
77
+ /** A `Migrator` on the default connection, or a friendly error if none is set. */
78
+ function migratorFor() {
79
+ const { connection, dialect } = getConnection(); // throws if no connection registered
80
+ return new Migrator(connection, dialect);
81
+ }
82
+ /** Copy a published file/dir into the app, skipping existing files unless forced. */
83
+ async function publishPath(from, toRel, force, ui) {
84
+ const dest = join(basePath, toRel);
85
+ const s = await stat(from).catch(() => null);
86
+ if (!s) {
87
+ ui.error(`Source not found: ${from}`);
88
+ return;
89
+ }
90
+ if (s.isDirectory()) {
91
+ for (const item of await readdir(from)) {
92
+ await publishPath(join(from, item), join(toRel, item), force, ui);
93
+ }
94
+ return;
95
+ }
96
+ let exists = false;
97
+ try {
98
+ await access(dest);
99
+ exists = true;
100
+ }
101
+ catch {
102
+ // destination is free
103
+ }
104
+ if (exists && !force) {
105
+ ui.action("skip", `${toRel} (exists; pass --force to overwrite)`, "skipped");
106
+ return;
107
+ }
108
+ await mkdir(dirname(dest), { recursive: true });
109
+ await copyFile(from, dest);
110
+ ui.action("publish", toRel);
111
+ }
112
+ export async function run(argv, options) {
113
+ // Boot the app once so commands share it, and so package providers get a
114
+ // chance to contribute migrations, commands, and publishables. Scaffolding
115
+ // commands (`make:*`) don't need it, so a boot failure isn't fatal — it's
116
+ // surfaced only when a command that needs the app actually runs.
117
+ let app = null;
118
+ let bootError;
119
+ try {
120
+ app = await options.createApplication();
121
+ }
122
+ catch (err) {
123
+ bootError = err;
124
+ }
125
+ const requireApp = () => {
126
+ if (!app) {
127
+ console.error("✗ Could not boot the application:");
128
+ console.error(bootError);
129
+ process.exit(1);
130
+ }
131
+ return app;
132
+ };
133
+ /**
134
+ * Commands your app defines with `defineCommand()`, discovered from
135
+ * `app/Commands`. They register alongside the built-ins — same kernel, same
136
+ * typed args and flags — and an app command of the same name wins, so you can
137
+ * override one.
138
+ */
139
+ async function appCommands() {
140
+ const dir = join(basePath, "app/Commands");
141
+ const found = [];
142
+ const files = await readdir(dir).catch(() => null);
143
+ if (!files)
144
+ return found; // no app/Commands — the only thing we swallow
145
+ for (const file of files) {
146
+ if (!/\.(ts|js|mjs)$/.test(file))
147
+ continue;
148
+ let module;
149
+ try {
150
+ module = (await import(pathToFileURL(join(dir, file)).href));
151
+ }
152
+ catch (error) {
153
+ // A command file that won't load is a bug you need to see, not a command
154
+ // that quietly doesn't exist.
155
+ console.error(`✗ Could not load app/Commands/${file}:`);
156
+ console.error(error instanceof Error ? error.message : error);
157
+ process.exitCode = 1;
158
+ continue;
159
+ }
160
+ // Any export that looks like a command counts, so one file can hold several.
161
+ for (const value of Object.values(module)) {
162
+ const candidate = value;
163
+ if (candidate &&
164
+ typeof candidate === "object" &&
165
+ typeof candidate.name === "string" &&
166
+ typeof candidate.run === "function") {
167
+ found.push(candidate);
168
+ }
169
+ }
170
+ }
171
+ return found;
172
+ }
173
+ /* ------------------------------- the built-ins ------------------------------ */
174
+ const serve = defineCommand({
175
+ name: "serve",
176
+ description: "Start the HTTP server",
177
+ flags: { port: flag.number({ alias: "p", description: "port to listen on" }) },
178
+ async run({ flags, ui }) {
179
+ const application = requireApp();
180
+ const kernel = application.bound(HttpKernel)
181
+ ? application.make(HttpKernel)
182
+ : new HttpKernel(application);
183
+ const hono = kernel.build();
184
+ const port = flags.port ?? Number(application.config().get("app.port", 3000));
185
+ // Imported here, not at the top: the Node server is only needed to *serve*,
186
+ // and the console must still load on a machine (or a Worker) that has no
187
+ // reason to install it.
188
+ const { serve: listen } = await import("@hono/node-server").catch(() => {
189
+ throw new Error("`keel serve` needs @hono/node-server. Install it: npm i @hono/node-server");
190
+ });
191
+ const server = listen({ fetch: hono.fetch, port }, (info) => {
192
+ ui.success(`${application.config().get("app.name", "Keel")} listening on http://localhost:${info.port}`);
193
+ });
194
+ // Graceful shutdown: stop accepting connections, run the app's shutdown
195
+ // hooks (and every provider's shutdown()), then exit. `once` so a second
196
+ // signal isn't swallowed if cleanup hangs — hit Ctrl-C again to force it.
197
+ const shutdown = async (signal) => {
198
+ ui.write(`\n${signal} received — shutting down…`);
199
+ server.close();
200
+ try {
201
+ await application.terminate();
202
+ }
203
+ catch (err) {
204
+ ui.error(`Error during shutdown: ${String(err)}`);
205
+ process.exit(1);
206
+ }
207
+ process.exit(0);
208
+ };
209
+ process.once("SIGINT", () => void shutdown("SIGINT"));
210
+ process.once("SIGTERM", () => void shutdown("SIGTERM"));
211
+ // `serve` stays alive on purpose — the server is the command.
212
+ await new Promise(() => { });
213
+ },
214
+ });
215
+ const repl = defineCommand({
216
+ name: "repl",
217
+ description: "An interactive shell with the application booted",
218
+ async run() {
219
+ const { startRepl } = await import("../repl.js");
220
+ await startRepl(requireApp());
221
+ },
222
+ });
223
+ const mcp = defineCommand({
224
+ name: "mcp",
225
+ description: "Start the MCP server (Keel's docs, API, and generators, over stdio)",
226
+ async run() {
227
+ const { runMcpServer } = await import("../../mcp/server.js");
228
+ await runMcpServer();
229
+ },
230
+ });
231
+ const routes = defineCommand({
232
+ name: "routes",
233
+ description: "List registered routes",
234
+ run({ ui }) {
235
+ const rows = requireApp().make(Router).all();
236
+ if (!rows.length)
237
+ return void ui.info("No routes registered.");
238
+ const table = ui.table(["Method", "Path", "Handler", "Name"]);
239
+ for (const r of rows) {
240
+ const handler = Array.isArray(r.handler)
241
+ ? `${r.handler[0].name}@${r.handler[1]}`
242
+ : r.handler instanceof Response
243
+ ? "Static"
244
+ : "Closure";
245
+ table.row([r.methods.join("|"), r.path, handler, r.name ?? ""]);
246
+ }
247
+ table.render();
248
+ },
249
+ });
250
+ /* -------------------------------- generators -------------------------------- */
251
+ const makeController = defineCommand({
252
+ name: "make:controller",
253
+ description: "Generate a controller",
254
+ args: { name: arg.string({ description: "e.g. Post" }) },
255
+ flags: { resource: flag.boolean({ alias: "r", description: "a RESTful resource controller" }) },
256
+ async run({ args, flags, ui }) {
257
+ const cls = className(args.name, "Controller");
258
+ const stub = flags.resource ? resourceControllerStub(cls) : controllerStub(cls);
259
+ await generate(`app/Controllers/${cls}.ts`, stub, "Controller", ui);
260
+ },
261
+ });
262
+ const makeProvider = defineCommand({
263
+ name: "make:provider",
264
+ description: "Generate a service provider",
265
+ args: { name: arg.string() },
266
+ async run({ args, ui }) {
267
+ const cls = className(args.name, "ServiceProvider");
268
+ await generate(`app/Providers/${cls}.ts`, providerStub(cls), "Provider", ui);
269
+ },
270
+ });
271
+ const makeMiddleware = defineCommand({
272
+ name: "make:middleware",
273
+ description: "Generate an HTTP middleware",
274
+ args: { name: arg.string() },
275
+ async run({ args, ui }) {
276
+ const cls = className(args.name, "Middleware");
277
+ const file = cls.charAt(0).toLowerCase() + cls.slice(1);
278
+ await generate(`app/Http/Middleware/${file}.ts`, middlewareStub(cls), "Middleware", ui);
279
+ },
280
+ });
281
+ const makeFactory = defineCommand({
282
+ name: "make:factory",
283
+ description: "Generate a model factory",
284
+ args: { model: arg.string({ description: "e.g. User" }) },
285
+ async run({ args, ui }) {
286
+ const cls = className(args.model, "");
287
+ await generate(`database/factories/${cls}Factory.ts`, factoryStub(cls), "Factory", ui);
288
+ },
289
+ });
290
+ const makeSeeder = defineCommand({
291
+ name: "make:seeder",
292
+ description: "Generate a database seeder",
293
+ args: { name: arg.string() },
294
+ async run({ args, ui }) {
295
+ const cls = className(args.name, "Seeder");
296
+ await generate(`database/seeders/${cls}.ts`, seederStub(cls), "Seeder", ui);
297
+ },
298
+ });
299
+ const makeJob = defineCommand({
300
+ name: "make:job",
301
+ description: "Generate a queued job",
302
+ args: { name: arg.string() },
303
+ async run({ args, ui }) {
304
+ const cls = className(args.name, "Job");
305
+ await generate(`app/Jobs/${cls}.ts`, jobStub(cls), "Job", ui);
306
+ },
307
+ });
308
+ const makePage = defineCommand({
309
+ name: "make:page",
310
+ description: "Generate a page — its path is its URL: make:page users/[id]",
311
+ args: { path: arg.string({ description: "e.g. users/[id]" }) },
312
+ async run({ args, ui }) {
313
+ // The path IS the route, so it's used verbatim — no class-name munging.
314
+ const file = args.path.replace(/^\/+/, "").replace(/\.(tsx|jsx)$/, "");
315
+ await generate(`resources/pages/${file}.tsx`, pageStub(file), "Page", ui);
316
+ },
317
+ });
318
+ const makeCommand = defineCommand({
319
+ name: "make:command",
320
+ description: "Generate a console command (typed args + flags, prompts, UI)",
321
+ args: { name: arg.string() },
322
+ async run({ args, ui }) {
323
+ const file = args.name.replace(/[^a-zA-Z0-9:_-]/g, "");
324
+ await generate(`app/Commands/${file.replace(/:/g, "-")}.ts`, commandStub(file), "Command", ui);
325
+ },
326
+ });
327
+ const makeNotification = defineCommand({
328
+ name: "make:notification",
329
+ description: "Generate a notification",
330
+ args: { name: arg.string() },
331
+ async run({ args, ui }) {
332
+ const cls = className(args.name, "Notification");
333
+ await generate(`app/Notifications/${cls}.ts`, notificationStub(cls), "Notification", ui);
334
+ },
335
+ });
336
+ const makeTransformer = defineCommand({
337
+ name: "make:transformer",
338
+ description: "Generate an API transformer",
339
+ args: { name: arg.string() },
340
+ flags: { model: flag.string({ alias: "m", description: "the value it maps (e.g. User)" }) },
341
+ async run({ args, flags, ui }) {
342
+ const cls = className(args.name, "Transformer");
343
+ const model = flags.model ? className(flags.model, "") : cls.replace(/Transformer$/, "");
344
+ await generate(`app/Transformers/${cls}.ts`, transformerStub(cls, model), "Transformer", ui);
345
+ },
346
+ });
347
+ const makePackage = defineCommand({
348
+ name: "make:package",
349
+ description: "Scaffold a Keel package (a PackageProvider skeleton)",
350
+ args: { name: arg.string() },
351
+ async run({ args, ui }) {
352
+ const slug = args.name.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
353
+ const cls = slug.charAt(0).toUpperCase() + slug.slice(1);
354
+ await generate(`packages/${slug}/${cls}ServiceProvider.ts`, packageProviderStub(slug), "Package", ui);
355
+ },
356
+ });
357
+ /* -------------------------------- migrations -------------------------------- */
358
+ const migrate = defineCommand({
359
+ name: "migrate",
360
+ description: "Run pending database migrations (app + package)",
361
+ async run({ ui }) {
362
+ const applied = await migratorFor().up(await collectMigrations(requireApp()));
363
+ if (!applied.length)
364
+ return void ui.info("Nothing to migrate.");
365
+ for (const name of applied)
366
+ ui.success(`Migrated ${name}`);
367
+ },
368
+ });
369
+ const migrateRollback = defineCommand({
370
+ name: "migrate:rollback",
371
+ description: "Roll back the most recent batch of migrations",
372
+ async run({ ui }) {
373
+ const rolled = await migratorFor().down(await collectMigrations(requireApp()));
374
+ if (!rolled.length)
375
+ return void ui.info("Nothing to roll back.");
376
+ for (const name of rolled)
377
+ ui.success(`Rolled back ${name}`);
378
+ },
379
+ });
380
+ const migrateStatus = defineCommand({
381
+ name: "migrate:status",
382
+ description: "Show which migrations have run and which are pending",
383
+ async run({ ui }) {
384
+ const migrator = migratorFor();
385
+ const ran = new Set(await migrator.ran());
386
+ const migrations = await collectMigrations(requireApp());
387
+ if (!migrations.length)
388
+ return void ui.info("No migrations found.");
389
+ const table = ui.table(["Status", "Migration"]);
390
+ for (const m of migrations)
391
+ table.row([ran.has(m.name) ? "ran" : "pending", m.name]);
392
+ table.render();
393
+ },
394
+ });
395
+ const vendorPublish = defineCommand({
396
+ name: "vendor:publish",
397
+ description: "Copy package-published files (config, assets) into this app",
398
+ flags: {
399
+ tag: flag.string({ description: "only publish files under this tag" }),
400
+ force: flag.boolean({ description: "overwrite files that already exist" }),
401
+ },
402
+ async run({ flags, ui }) {
403
+ const registry = requireApp().make(PublishRegistry);
404
+ const entries = registry.all(flags.tag);
405
+ if (!entries.length) {
406
+ ui.info(flags.tag ? `Nothing published under tag "${flags.tag}".` : "Nothing to publish.");
407
+ const tags = registry.tags();
408
+ if (!flags.tag && tags.length)
409
+ ui.info(`Available tags: ${tags.join(", ")}`);
410
+ return;
411
+ }
412
+ for (const entry of entries) {
413
+ for (const [from, to] of Object.entries(entry.files)) {
414
+ await publishPath(from, to, flags.force, ui);
415
+ }
416
+ }
417
+ },
418
+ });
419
+ /* ---------------------------------- dispatch -------------------------------- */
420
+ const kernel = new ConsoleKernel({ binary: "keel" }).register(serve, repl, mcp, routes, makeController, makeProvider, makeMiddleware, makeFactory, makeSeeder, makeJob, makePage, makeCommand, makeNotification, makeTransformer, makePackage, migrate, migrateRollback, migrateStatus, vendorPublish);
421
+ // Package-contributed commands (a package's `commands([...])`), then the app's —
422
+ // registered last, so an app command of the same name overrides anything above.
423
+ if (app)
424
+ kernel.register(...app.make(CommandRegistry).all());
425
+ kernel.register(...(await appCommands()));
426
+ process.exitCode = await kernel.run(argv.slice(2));
427
+ }
@@ -80,19 +80,19 @@ type ArgValue<O, T> = Present<O, true> extends true ? T : T | undefined;
80
80
  type FlagValue<O, T> = Present<O, false> extends true ? T : T | undefined;
81
81
  /** Positional arguments: `keel greet <name>`. */
82
82
  export declare const arg: {
83
- string<const O extends ArgOptions<string> = Record<never, never>>(options?: O): ArgSpec<ArgValue<O, string>>;
84
- number<const O extends ArgOptions<number> = Record<never, never>>(options?: O): ArgSpec<ArgValue<O, number>>;
83
+ string<const O extends ArgOptions<string> = Record<never, never>>(options?: O & ArgOptions<string>): ArgSpec<ArgValue<O, string>>;
84
+ number<const O extends ArgOptions<number> = Record<never, never>>(options?: O & ArgOptions<number>): ArgSpec<ArgValue<O, number>>;
85
85
  /** Swallows every remaining value. Must be the last argument. */
86
- spread<const O extends ArgOptions<string[]> = Record<never, never>>(options?: O): ArgSpec<ArgValue<O, string[]>>;
86
+ spread<const O extends ArgOptions<string[]> = Record<never, never>>(options?: O & ArgOptions<string[]>): ArgSpec<ArgValue<O, string[]>>;
87
87
  };
88
88
  /** Named options: `--force`, `-f`, `--name=Ada`. */
89
89
  export declare const flag: {
90
90
  /** `--force` / `-f`, and `--no-force`. Defaults to `false`, so it's never undefined. */
91
- boolean<const O extends FlagOptions<boolean> = Record<never, never>>(options?: O): FlagSpec<boolean>;
92
- string<const O extends FlagOptions<string> = Record<never, never>>(options?: O): FlagSpec<FlagValue<O, string>>;
93
- number<const O extends FlagOptions<number> = Record<never, never>>(options?: O): FlagSpec<FlagValue<O, number>>;
91
+ boolean<const O extends FlagOptions<boolean> = Record<never, never>>(options?: O & FlagOptions<boolean>): FlagSpec<boolean>;
92
+ string<const O extends FlagOptions<string> = Record<never, never>>(options?: O & FlagOptions<string>): FlagSpec<FlagValue<O, string>>;
93
+ number<const O extends FlagOptions<number> = Record<never, never>>(options?: O & FlagOptions<number>): FlagSpec<FlagValue<O, number>>;
94
94
  /** Repeatable: `--tag a --tag b` gives `["a", "b"]`. Defaults to `[]`. */
95
- array<const O extends FlagOptions<string[]> = Record<never, never>>(options?: O): FlagSpec<string[]>;
95
+ array<const O extends FlagOptions<string[]> = Record<never, never>>(options?: O & FlagOptions<string[]>): FlagSpec<string[]>;
96
96
  };
97
97
  export type ArgsSpec = Record<string, ArgSpec<unknown>>;
98
98
  export type FlagsSpec = Record<string, FlagSpec<unknown>>;
@@ -25,7 +25,7 @@
25
25
  * Application constructor) — routes are compiled onto the kernel later, at build.
26
26
  */
27
27
  import type { MiddlewareHandler } from "hono";
28
- import type { Command } from "commander";
28
+ import type { AnyCommand } from "./console.js";
29
29
  import { ServiceProvider } from "./provider.js";
30
30
  import type { Application } from "./application.js";
31
31
  import type { Router, RouteGroup, MiddlewareRef } from "./http/router.js";
@@ -40,15 +40,12 @@ export declare class MigrationRegistry {
40
40
  all(): Migration[];
41
41
  }
42
42
  /** A console command a package adds to `keel`. */
43
- export interface PackageCommand {
44
- /** The command name, e.g. `"watch:prune"`. */
45
- name: string;
46
- description?: string;
47
- /** Add arguments/options to the commander command before its action. */
48
- configure?: (cmd: Command) => void;
49
- /** What the command does. Receives parsed options and the command. */
50
- action: (opts: Record<string, unknown>, cmd: Command) => void | Promise<void>;
51
- }
43
+ /**
44
+ * A command a package contributes to the console — the same `defineCommand()`
45
+ * shape an app uses, so a package's command gets typed args and flags, generated
46
+ * help, the terminal UI, and the prompt-trapping test story for free.
47
+ */
48
+ export type PackageCommand = AnyCommand;
52
49
  /** Package-contributed console commands, mounted by the CLI after boot. */
53
50
  export declare class CommandRegistry {
54
51
  private list;
@@ -211,9 +211,10 @@ export declare class CommandResult {
211
211
  * You pass the command in, because the console entry point belongs to your app
212
212
  * (it's the thing that knows how to build your application), not to the core:
213
213
  *
214
- * import { run } from "@shaferllc/keel/core/cli"; // or your own bin
214
+ * import { run } from "@shaferllc/keel/cli";
215
+ * import { createApplication } from "../bootstrap/app.js";
215
216
  *
216
- * const result = await runCommand(() => run(["node", "keel", "routes"]));
217
+ * const result = await runCommand(() => run(["node", "keel", "routes"], { createApplication }));
217
218
  * result.assertSucceeded().assertOutputContains("GET /users");
218
219
  *
219
220
  * `console.log`/`warn` are captured as stdout, `console.error` as stderr, and
@@ -608,9 +608,10 @@ export class CommandResult {
608
608
  * You pass the command in, because the console entry point belongs to your app
609
609
  * (it's the thing that knows how to build your application), not to the core:
610
610
  *
611
- * import { run } from "@shaferllc/keel/core/cli"; // or your own bin
611
+ * import { run } from "@shaferllc/keel/cli";
612
+ * import { createApplication } from "../bootstrap/app.js";
612
613
  *
613
- * const result = await runCommand(() => run(["node", "keel", "routes"]));
614
+ * const result = await runCommand(() => run(["node", "keel", "routes"], { createApplication }));
614
615
  * result.assertSucceeded().assertOutputContains("GET /users");
615
616
  *
616
617
  * `console.log`/`warn` are captured as stdout, `console.error` as stderr, and
@@ -2,18 +2,18 @@
2
2
  * `keel openapi:export [--out openapi.json]` — write the spec to a file, for CI,
3
3
  * client generation, or committing it. Contributed as a package command.
4
4
  */
5
+ import { defineCommand, flag } from "../core/console.js";
5
6
  import { buildSpec } from "./spec.js";
6
7
  export function exportCommand(getRouter, config, base) {
7
- return {
8
+ return defineCommand({
8
9
  name: "openapi:export",
9
10
  description: "Write the OpenAPI spec to a file",
10
- configure: (cmd) => cmd.option("--out <file>", "output path", "openapi.json"),
11
- action: async (opts) => {
11
+ flags: { out: flag.string({ description: "output path", default: "openapi.json" }) },
12
+ async run({ flags, ui }) {
12
13
  const spec = buildSpec(getRouter().all(), config, base);
13
- const out = String(opts.out ?? "openapi.json");
14
14
  const { writeFile } = await import("node:fs/promises");
15
- await writeFile(out, JSON.stringify(spec, null, 2));
16
- console.log(`✓ Wrote ${out} (${Object.keys(spec.paths).length} paths)`);
15
+ await writeFile(flags.out, JSON.stringify(spec, null, 2));
16
+ ui.success(`Wrote ${flags.out} (${Object.keys(spec.paths).length} paths)`);
17
17
  },
18
- };
18
+ });
19
19
  }
@@ -5,16 +5,18 @@
5
5
  * buffer bounds itself per process, so there's nothing for a separate CLI
6
6
  * process to prune.
7
7
  */
8
+ import { defineCommand, flag } from "../core/console.js";
8
9
  export function pruneCommand(store, config) {
9
- return {
10
+ return defineCommand({
10
11
  name: "watch:prune",
11
12
  description: "Delete Watch entries older than the retention window",
12
- configure: (cmd) => cmd.option("--hours <hours>", "override the retention window (hours)"),
13
- action: async (opts) => {
14
- const hours = opts.hours ? Number(opts.hours) : config.retentionHours;
15
- const before = Date.now() - hours * 3_600_000;
16
- const removed = await store.prune(before);
17
- console.log(`Pruned ${removed} Watch entr${removed === 1 ? "y" : "ies"} older than ${hours}h.`);
13
+ flags: { hours: flag.number({ description: "override the retention window (hours)" }) },
14
+ async run({ flags, ui }) {
15
+ // A typed flag, so `--hours nonsense` is now a usage error rather than a NaN
16
+ // that silently prunes everything.
17
+ const hours = flags.hours ?? config.retentionHours;
18
+ const removed = await store.prune(Date.now() - hours * 3_600_000);
19
+ ui.success(`Pruned ${removed} Watch entr${removed === 1 ? "y" : "ies"} older than ${hours}h.`);
18
20
  },
19
- };
21
+ });
20
22
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.74.0",
3
+ "version": "0.77.0",
4
4
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
5
5
  "repo": "https://github.com/shaferllc/keel",
6
6
  "generated": "run `npm run build:ai` to regenerate",
@@ -15,9 +15,9 @@
15
15
  {
16
16
  "slug": "api-resources",
17
17
  "title": "API Resources",
18
- "summary": "apiResource(router, Model, options) generates a full CRUD REST API from a Keel model — the Remult idea done the Keel way: explicit, server-side, and composed from pieces you already have.",
18
+ "summary": "apiResource(router, Model, options) generates a full CRUD REST API from a Keel model — explicit, server-side, and composed from pieces you already have.",
19
19
  "path": "docs/api-resources.md",
20
- "example": null
20
+ "example": "docs/examples/api-resources.ts"
21
21
  },
22
22
  {
23
23
  "slug": "architecture",
@@ -1,8 +1,8 @@
1
1
  # API Resources
2
2
 
3
3
  `apiResource(router, Model, options)` generates a full CRUD REST API from a Keel
4
- [model](./models.md) — the [Remult](https://remult.dev) idea done the Keel way:
5
- explicit, server-side, and composed from pieces you already have. It's imported
4
+ [model](./models.md) — explicit, server-side, and composed from pieces you already
5
+ have. It's imported
6
6
  from `@shaferllc/keel/api`.
7
7
 
8
8
  ```ts
@@ -105,14 +105,15 @@ apiResource(router, Post, {
105
105
  | `transform` | Output shaping. |
106
106
  | `beforeWrite` | Mutate the payload before save. |
107
107
  | `tags` | OpenAPI tags for the routes. |
108
+ | `label` | Singular name in doc summaries (default: the model's class name). |
108
109
 
109
110
  Global pagination defaults live in `config/api.ts` (register the optional
110
111
  `ApiServiceProvider`, then `keel vendor:publish --tag api-config`).
111
112
 
112
113
  ## What this isn't
113
114
 
114
- Remult also ships an isomorphic, type-safe **frontend** client that shares the
115
- model with the server. Keel deliberately stops at the server boundary: this
115
+ There's no isomorphic frontend client here — no shared model object that runs on
116
+ both sides of the wire. Keel deliberately stops at the server boundary: this
116
117
  generates a plain REST API you call however you like (fetch, your Inertia pages,
117
118
  a mobile app). That keeps the model server-only and the wire contract explicit —
118
119
  and it's exactly the contract the OpenAPI docs describe.
package/docs/console.md CHANGED
@@ -336,6 +336,30 @@ write the stub, confirming with:
336
336
 
337
337
  Delete the existing file first if you truly mean to regenerate it.
338
338
 
339
+ ## Your console entry point
340
+
341
+ The console ships in the package, and takes your application factory:
342
+
343
+ ```ts
344
+ #!/usr/bin/env tsx
345
+ // bin/keel.ts
346
+ import { run } from "@shaferllc/keel/cli";
347
+ import { createApplication } from "../bootstrap/app.js";
348
+
349
+ run(process.argv, { createApplication }).catch((error) => {
350
+ console.error(error);
351
+ process.exit(1);
352
+ });
353
+ ```
354
+
355
+ It's handed `createApplication` rather than importing it, because a framework that
356
+ imports an *application* has its dependency pointing the wrong way — and that one
357
+ import is what kept the console out of the published build until now.
358
+
359
+ Commands that need the app (`serve`, `routes`, `migrate`) boot it once and share it.
360
+ Scaffolding commands (`make:*`) don't, so a boot failure isn't fatal — it's surfaced
361
+ only when a command that actually needs the app runs.
362
+
339
363
  ## Writing your own commands
340
364
 
341
365
  `keel make:command greet` scaffolds `app/Commands/greet.ts`. Everything in
@@ -0,0 +1,86 @@
1
+ // Type-check harness for docs/api-resources.md. Compile-only — never executed.
2
+ import { z } from "zod";
3
+
4
+ import { apiResource, type ApiResourceOptions, type ApiAccess } from "@shaferllc/keel/api";
5
+ import { Model, Router, make, type Ctx } from "@shaferllc/keel/core";
6
+
7
+ class Post extends Model {
8
+ static override table = "posts";
9
+ static override fillable = ["title", "body", "status", "authorId"];
10
+ declare id: number;
11
+ declare title: string;
12
+ declare status: string;
13
+ declare authorId: number;
14
+ }
15
+
16
+ declare function isEditor(c: Ctx): boolean;
17
+ declare function currentUserId(c: Ctx): number;
18
+
19
+ const PostSchema = z.object({
20
+ title: z.string().min(1),
21
+ body: z.string(),
22
+ status: z.enum(["draft", "published"]),
23
+ });
24
+
25
+ export function basic() {
26
+ apiResource(make(Router), Post, {
27
+ filter: ["status", "authorId"],
28
+ sort: ["createdAt", "title"],
29
+ body: PostSchema,
30
+ access: { read: true, write: (c) => isEditor(c) },
31
+ scope: (q) => q.where("deleted", false),
32
+ });
33
+ }
34
+
35
+ /** Access is deny-by-default; these are the ways to open a route. */
36
+ export function access(): ApiAccess[] {
37
+ return [
38
+ { all: true },
39
+ { read: true }, // list + get
40
+ { write: (c) => isEditor(c) }, // create + update + delete
41
+ { list: true, get: true, create: false, update: false, delete: false },
42
+ ];
43
+ }
44
+
45
+ /** Row-level security: a row outside the scope 404s for read, update and delete. */
46
+ export function rowLevelSecurity() {
47
+ apiResource(make(Router), Post, {
48
+ access: { all: true },
49
+ scope: (q, c) => q.where("authorId", currentUserId(c)),
50
+ });
51
+ }
52
+
53
+ export function shapingInputAndOutput() {
54
+ apiResource(make(Router), Post, {
55
+ access: { all: true },
56
+ createBody: PostSchema,
57
+ updateBody: PostSchema.partial(),
58
+ beforeWrite: (data, c, action) => ({
59
+ ...data,
60
+ ...(action === "create" ? { authorId: currentUserId(c) } : {}),
61
+ }),
62
+ transform: (model) => ({ id: (model as Post).id, title: (model as Post).title }),
63
+ });
64
+ }
65
+
66
+ export function everyOption(): ApiResourceOptions {
67
+ return {
68
+ path: "articles",
69
+ name: "articles",
70
+ only: ["list", "read"],
71
+ except: ["delete"],
72
+ filter: ["status"],
73
+ sort: ["title"],
74
+ perPage: 25,
75
+ maxPerPage: 100,
76
+ body: PostSchema,
77
+ createBody: PostSchema,
78
+ updateBody: PostSchema.partial(),
79
+ access: { read: true },
80
+ scope: (q) => q.where("deleted", false),
81
+ transform: (model) => model.toJSON(),
82
+ beforeWrite: (data) => data,
83
+ tags: ["Posts"],
84
+ label: "Post",
85
+ };
86
+ }
@@ -196,10 +196,11 @@ export async function databaseAssertions() {
196
196
  await assertDatabaseEmpty("sessions");
197
197
  }
198
198
 
199
- declare function run(argv: string[]): Promise<void>;
199
+ declare function run(argv: string[], options: { createApplication: () => Promise<Application> }): Promise<void>;
200
+ declare function createApplication(): Promise<Application>;
200
201
 
201
202
  export async function consoleTests(): Promise<CommandResult> {
202
- const result = await runCommand(() => run(["node", "keel", "routes"]));
203
+ const result = await runCommand(() => run(["node", "keel", "routes"], { createApplication }));
203
204
 
204
205
  result
205
206
  .assertSucceeded()
package/docs/testing.md CHANGED
@@ -290,9 +290,10 @@ Run a command in-process — no subprocess, so it's fast and you can assert on i
290
290
 
291
291
  ```ts
292
292
  import { runCommand } from "@shaferllc/keel/core";
293
- import { run } from "../bin/keel"; // your app's console entry point
293
+ import { run } from "@shaferllc/keel/cli";
294
+ import { createApplication } from "../bootstrap/app.js";
294
295
 
295
- const result = await runCommand(() => run(["node", "keel", "routes"]));
296
+ const result = await runCommand(() => run(["node", "keel", "routes"], { createApplication }));
296
297
 
297
298
  result
298
299
  .assertSucceeded() // exit code 0
@@ -300,10 +301,10 @@ result
300
301
  .assertOutputMatches(/POST\s+\/users/);
301
302
  ```
302
303
 
303
- You pass the command **in**, because the console entry point belongs to your app —
304
- it's the thing that knows how to build your application not to the core. Anything
305
- that prints and sets an exit code works, so it's equally good for testing a
306
- function you wrote yourself.
304
+ You pass the command **in**, because a command needs an *application*, and only your
305
+ app knows how to build one. That's also why `run()` takes a `createApplication`
306
+ factory rather than importing one. Anything that prints and sets an exit code works,
307
+ so this is equally good for testing a function you wrote yourself.
307
308
 
308
309
  `console.log`/`warn` are captured as stdout and `console.error` as stderr; a
309
310
  command that *throws* is recorded as a failure rather than blowing up the test.
package/llms-full.txt CHANGED
@@ -4495,8 +4495,8 @@ output.
4495
4495
  # API Resources
4496
4496
 
4497
4497
  `apiResource(router, Model, options)` generates a full CRUD REST API from a Keel
4498
- [model](./models.md) — the [Remult](https://remult.dev) idea done the Keel way:
4499
- explicit, server-side, and composed from pieces you already have. It's imported
4498
+ [model](./models.md) — explicit, server-side, and composed from pieces you already
4499
+ have. It's imported
4500
4500
  from `@shaferllc/keel/api`.
4501
4501
 
4502
4502
  ```ts
@@ -4599,14 +4599,15 @@ apiResource(router, Post, {
4599
4599
  | `transform` | Output shaping. |
4600
4600
  | `beforeWrite` | Mutate the payload before save. |
4601
4601
  | `tags` | OpenAPI tags for the routes. |
4602
+ | `label` | Singular name in doc summaries (default: the model's class name). |
4602
4603
 
4603
4604
  Global pagination defaults live in `config/api.ts` (register the optional
4604
4605
  `ApiServiceProvider`, then `keel vendor:publish --tag api-config`).
4605
4606
 
4606
4607
  ## What this isn't
4607
4608
 
4608
- Remult also ships an isomorphic, type-safe **frontend** client that shares the
4609
- model with the server. Keel deliberately stops at the server boundary: this
4609
+ There's no isomorphic frontend client here — no shared model object that runs on
4610
+ both sides of the wire. Keel deliberately stops at the server boundary: this
4610
4611
  generates a plain REST API you call however you like (fetch, your Inertia pages,
4611
4612
  a mobile app). That keeps the model server-only and the wire contract explicit —
4612
4613
  and it's exactly the contract the OpenAPI docs describe.
@@ -6805,6 +6806,30 @@ write the stub, confirming with:
6805
6806
 
6806
6807
  Delete the existing file first if you truly mean to regenerate it.
6807
6808
 
6809
+ ## Your console entry point
6810
+
6811
+ The console ships in the package, and takes your application factory:
6812
+
6813
+ ```ts
6814
+ #!/usr/bin/env tsx
6815
+ // bin/keel.ts
6816
+ import { run } from "@shaferllc/keel/cli";
6817
+ import { createApplication } from "../bootstrap/app.js";
6818
+
6819
+ run(process.argv, { createApplication }).catch((error) => {
6820
+ console.error(error);
6821
+ process.exit(1);
6822
+ });
6823
+ ```
6824
+
6825
+ It's handed `createApplication` rather than importing it, because a framework that
6826
+ imports an *application* has its dependency pointing the wrong way — and that one
6827
+ import is what kept the console out of the published build until now.
6828
+
6829
+ Commands that need the app (`serve`, `routes`, `migrate`) boot it once and share it.
6830
+ Scaffolding commands (`make:*`) don't, so a boot failure isn't fatal — it's surfaced
6831
+ only when a command that actually needs the app runs.
6832
+
6808
6833
  ## Writing your own commands
6809
6834
 
6810
6835
  `keel make:command greet` scaffolds `app/Commands/greet.ts`. Everything in
@@ -17904,9 +17929,10 @@ Run a command in-process — no subprocess, so it's fast and you can assert on i
17904
17929
 
17905
17930
  ```ts
17906
17931
  import { runCommand } from "@shaferllc/keel/core";
17907
- import { run } from "../bin/keel"; // your app's console entry point
17932
+ import { run } from "@shaferllc/keel/cli";
17933
+ import { createApplication } from "../bootstrap/app.js";
17908
17934
 
17909
- const result = await runCommand(() => run(["node", "keel", "routes"]));
17935
+ const result = await runCommand(() => run(["node", "keel", "routes"], { createApplication }));
17910
17936
 
17911
17937
  result
17912
17938
  .assertSucceeded() // exit code 0
@@ -17914,10 +17940,10 @@ result
17914
17940
  .assertOutputMatches(/POST\s+\/users/);
17915
17941
  ```
17916
17942
 
17917
- You pass the command **in**, because the console entry point belongs to your app —
17918
- it's the thing that knows how to build your application not to the core. Anything
17919
- that prints and sets an exit code works, so it's equally good for testing a
17920
- function you wrote yourself.
17943
+ You pass the command **in**, because a command needs an *application*, and only your
17944
+ app knows how to build one. That's also why `run()` takes a `createApplication`
17945
+ factory rather than importing one. Anything that prints and sets an exit code works,
17946
+ so this is equally good for testing a function you wrote yourself.
17921
17947
 
17922
17948
  `console.log`/`warn` are captured as stdout and `console.error` as stderr; a
17923
17949
  command that *throws* is recorded as a failure rather than blowing up the test.
package/llms.txt CHANGED
@@ -10,7 +10,7 @@ Userland imports everything from `@shaferllc/keel/core`.
10
10
  ## Docs
11
11
 
12
12
  - [Building Keel apps with AI](https://github.com/shaferllc/keel/blob/main/docs/ai.md): Keel is built to be written with an AI agent.
13
- - [API Resources](https://github.com/shaferllc/keel/blob/main/docs/api-resources.md): apiResource(router, Model, options) generates a full CRUD REST API from a Keel model — the Remult idea done the Keel way: explicit, server-side, and composed from pieces you already have.
13
+ - [API Resources](https://github.com/shaferllc/keel/blob/main/docs/api-resources.md): apiResource(router, Model, options) generates a full CRUD REST API from a Keel model — explicit, server-side, and composed from pieces you already have.
14
14
  - [Architecture](https://github.com/shaferllc/keel/blob/main/docs/architecture.md): Keel is small on purpose. This page maps the pieces and traces a request from socket to response. Nothing here is magic — every layer is a short, readable file in src/core/, and this guide is mostly a reading order for it.
15
15
  - [Authentication](https://github.com/shaferllc/keel/blob/main/docs/authentication.md): Session-based auth built on the pieces you already have: sessions hold the login, hashing checks passwords.
16
16
  - [Authorization](https://github.com/shaferllc/keel/blob/main/docs/authorization.md): Where authentication answers who you are, authorization answers what you're allowed to do.
@@ -72,6 +72,7 @@ Userland imports everything from `@shaferllc/keel/core`.
72
72
 
73
73
  Every topic has a runnable, type-checked example:
74
74
 
75
+ - [API Resources example](https://github.com/shaferllc/keel/blob/main/docs/examples/api-resources.ts)
75
76
  - [Authentication example](https://github.com/shaferllc/keel/blob/main/docs/examples/authentication.ts)
76
77
  - [Authorization example](https://github.com/shaferllc/keel/blob/main/docs/examples/authorization.ts)
77
78
  - [Broadcasting example](https://github.com/shaferllc/keel/blob/main/docs/examples/broadcasting.ts)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.74.0",
3
+ "version": "0.77.0",
4
4
  "type": "module",
5
5
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
6
6
  "license": "MIT",
@@ -60,6 +60,10 @@
60
60
  "./api": {
61
61
  "types": "./dist/api/index.d.ts",
62
62
  "import": "./dist/api/index.js"
63
+ },
64
+ "./cli": {
65
+ "types": "./dist/core/cli/index.d.ts",
66
+ "import": "./dist/core/cli/index.js"
63
67
  }
64
68
  },
65
69
  "bin": {
@@ -81,7 +85,7 @@
81
85
  "serve": "tsx bin/keel.ts serve",
82
86
  "dev": "tsx watch bin/keel.ts serve",
83
87
  "mcp": "tsx bin/keel-mcp.ts",
84
- "typecheck": "tsc --noEmit",
88
+ "typecheck": "tsc --noEmit && npm run typecheck:tests",
85
89
  "typecheck:docs": "npm run build && tsc -p tsconfig.docs.json",
86
90
  "test": "node --import tsx --test tests/*.test.ts",
87
91
  "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' tests/*.test.ts",
@@ -91,7 +95,10 @@
91
95
  "build": "npm run build:ai && npm run build:watch-ui && rm -rf dist && tsc -p tsconfig.build.json && npm run build:watch-copy",
92
96
  "dev:client": "vite",
93
97
  "build:client": "vite build",
94
- "prepare": "npm run build"
98
+ "prepare": "npm run build",
99
+ "typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",
100
+ "verify:release": "bash scripts/verify-release.sh",
101
+ "version": "npm run build:ai && git add docs/ai-manifest.json llms.txt llms-full.txt"
95
102
  },
96
103
  "dependencies": {
97
104
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -100,18 +107,21 @@
100
107
  "zod": "^4.4.3"
101
108
  },
102
109
  "peerDependencies": {
103
- "vite": "^5 || ^6 || ^7"
110
+ "vite": "^5 || ^6 || ^7",
111
+ "@hono/node-server": "^1.13.7"
104
112
  },
105
113
  "peerDependenciesMeta": {
106
114
  "vite": {
107
115
  "optional": true
116
+ },
117
+ "@hono/node-server": {
118
+ "optional": true
108
119
  }
109
120
  },
110
121
  "devDependencies": {
111
122
  "@hono/node-server": "^1.13.7",
112
123
  "@libsql/client": "^0.17.4",
113
124
  "@types/node": "^26.1.1",
114
- "commander": "^12.1.0",
115
125
  "preact": "^10.29.7",
116
126
  "tsx": "^4.19.2",
117
127
  "typescript": "^5.7.2",