@shaferllc/keel 0.68.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/AGENTS.md +2 -0
- package/README.md +15 -5
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +93 -0
- package/dist/api/resource.js +185 -0
- package/dist/core/application.js +6 -0
- package/dist/core/cache.d.ts +1 -2
- package/dist/core/cache.js +9 -2
- package/dist/core/cli/index.d.ts +28 -0
- package/dist/core/cli/index.js +427 -0
- package/dist/core/cli/stubs.d.ts +14 -0
- package/dist/core/cli/stubs.js +105 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/health.d.ts +2 -2
- package/dist/core/health.js +2 -2
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +25 -4
- package/dist/core/index.js +13 -3
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/logger.d.ts +7 -0
- package/dist/core/logger.js +28 -1
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +117 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.js +26 -5
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.js +15 -3
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +171 -1
- package/dist/core/testing.js +505 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +22 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +737 -3
- package/docs/api-resources.md +119 -0
- package/docs/configuration.md +74 -0
- package/docs/console.md +217 -13
- package/docs/database.md +101 -0
- package/docs/examples/api-resources.ts +86 -0
- package/docs/examples/configuration.ts +40 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/database.ts +90 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/logger.ts +74 -0
- package/docs/examples/mail.ts +103 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/testing.ts +150 -0
- package/docs/health.md +4 -4
- package/docs/i18n.md +302 -0
- package/docs/logger.md +156 -10
- package/docs/mail.md +73 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/storage.md +5 -3
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +3 -4
- package/docs/testing.md +252 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +2389 -241
- package/llms.txt +13 -1
- package/package.json +32 -6
|
@@ -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
|
+
}
|
package/dist/core/cli/stubs.d.ts
CHANGED
|
@@ -8,5 +8,19 @@ export declare function factoryStub(model: string): string;
|
|
|
8
8
|
export declare function seederStub(name: string): string;
|
|
9
9
|
export declare function jobStub(name: string): string;
|
|
10
10
|
export declare function notificationStub(name: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* A package skeleton: a `PackageProvider` wired for the common jobs — merging
|
|
13
|
+
* config, contributing a migration, mounting routes + assets, and declaring a
|
|
14
|
+
* publishable config stub. `name` is the short package name (e.g. "billing").
|
|
15
|
+
*/
|
|
16
|
+
export declare function packageProviderStub(name: string): string;
|
|
11
17
|
/** `name` is the class (e.g. "UserTransformer"); `model` is the value it maps. */
|
|
12
18
|
export declare function transformerStub(name: string, model: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* A page — `resources/pages/<path>.tsx`. The file's location *is* its URL, so the
|
|
21
|
+
* stub is deliberately bare: a component, and a loader only when the path takes a
|
|
22
|
+
* parameter to load anything by.
|
|
23
|
+
*/
|
|
24
|
+
export declare function pageStub(file: string): string;
|
|
25
|
+
/** A console command — `app/Commands/<name>.ts`. */
|
|
26
|
+
export declare function commandStub(name: string): string;
|
package/dist/core/cli/stubs.js
CHANGED
|
@@ -100,6 +100,45 @@ export class ${name} extends Notification {
|
|
|
100
100
|
}
|
|
101
101
|
`;
|
|
102
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* A package skeleton: a `PackageProvider` wired for the common jobs — merging
|
|
105
|
+
* config, contributing a migration, mounting routes + assets, and declaring a
|
|
106
|
+
* publishable config stub. `name` is the short package name (e.g. "billing").
|
|
107
|
+
*/
|
|
108
|
+
export function packageProviderStub(name) {
|
|
109
|
+
const cls = name.charAt(0).toUpperCase() + name.slice(1);
|
|
110
|
+
return `import { PackageProvider, type Router } from "@shaferllc/keel/core";
|
|
111
|
+
import { fileURLToPath } from "node:url";
|
|
112
|
+
import { dirname, join } from "node:path";
|
|
113
|
+
|
|
114
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The ${cls} package. Registered like any provider:
|
|
118
|
+
* app.register(${cls}ServiceProvider)
|
|
119
|
+
*/
|
|
120
|
+
export class ${cls}ServiceProvider extends PackageProvider {
|
|
121
|
+
readonly name = "${name}";
|
|
122
|
+
|
|
123
|
+
register(): void {
|
|
124
|
+
this.mergeConfig("${name}", {
|
|
125
|
+
enabled: true,
|
|
126
|
+
path: "${name}",
|
|
127
|
+
});
|
|
128
|
+
// this.migrations([${name}Migration]);
|
|
129
|
+
this.publishes({ [join(here, "config.stub")]: "config/${name}.ts" }, "${name}-config");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
boot(): void {
|
|
133
|
+
if (this.app.config().get("${name}.enabled") === false) return;
|
|
134
|
+
// Serve a bundled UI (after \`vite build\`): this.assets("${name}/assets", join(here, "ui"), { immutable: true });
|
|
135
|
+
this.routes((r: Router) => {
|
|
136
|
+
r.get("/", (c) => c.json({ package: "${name}", ok: true }));
|
|
137
|
+
}, { prefix: this.app.config().get("${name}.path", "${name}"), as: "${name}" });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
103
142
|
/** `name` is the class (e.g. "UserTransformer"); `model` is the value it maps. */
|
|
104
143
|
export function transformerStub(name, model) {
|
|
105
144
|
return `import { Transformer, type Attributes } from "@shaferllc/keel/core";
|
|
@@ -118,3 +157,69 @@ export class ${name} extends Transformer</* ${model} */ any> {
|
|
|
118
157
|
}
|
|
119
158
|
`;
|
|
120
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* A page — `resources/pages/<path>.tsx`. The file's location *is* its URL, so the
|
|
162
|
+
* stub is deliberately bare: a component, and a loader only when the path takes a
|
|
163
|
+
* parameter to load anything by.
|
|
164
|
+
*/
|
|
165
|
+
export function pageStub(file) {
|
|
166
|
+
const params = [...file.matchAll(/\[(?:\.\.\.)?([^\]]+)\]/g)].map((m) => m[1]);
|
|
167
|
+
const propsType = params.length
|
|
168
|
+
? `{ ${params.map((p) => `${p}: string`).join("; ")} }`
|
|
169
|
+
: "Record<string, never>";
|
|
170
|
+
const loader = params.length
|
|
171
|
+
? `
|
|
172
|
+
export const loader = async (c: Ctx) => {
|
|
173
|
+
// Load whatever this page needs; it arrives as \`data\`.
|
|
174
|
+
return { ${params.map((p) => `${p}: c.req.param("${p}")`).join(", ")} };
|
|
175
|
+
};
|
|
176
|
+
`
|
|
177
|
+
: "";
|
|
178
|
+
const dataType = params.length ? propsType : "undefined";
|
|
179
|
+
// Only import Ctx when there's a loader to use it — an unused import would
|
|
180
|
+
// fail the very typecheck the generated file is supposed to pass.
|
|
181
|
+
const imports = params.length ? "Ctx, PageProps" : "PageProps";
|
|
182
|
+
return `import type { ${imports} } from "@shaferllc/keel/core";
|
|
183
|
+
${loader}
|
|
184
|
+
export default function Page({ params, data }: PageProps<${propsType}, ${dataType}>) {
|
|
185
|
+
void params;
|
|
186
|
+
void data;
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<main>
|
|
190
|
+
<h1>${file}</h1>
|
|
191
|
+
</main>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
`;
|
|
195
|
+
}
|
|
196
|
+
/** A console command — `app/Commands/<name>.ts`. */
|
|
197
|
+
export function commandStub(name) {
|
|
198
|
+
const varName = name.replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase()).replace(/^./, (c) => c.toLowerCase());
|
|
199
|
+
return `import { defineCommand, arg, flag } from "@shaferllc/keel/core";
|
|
200
|
+
|
|
201
|
+
export const ${varName} = defineCommand({
|
|
202
|
+
name: "${name}",
|
|
203
|
+
description: "Describe what this command does",
|
|
204
|
+
|
|
205
|
+
args: {
|
|
206
|
+
// \`args.target\` is a string, inferred from this spec.
|
|
207
|
+
target: arg.string({ description: "what to act on" }),
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
flags: {
|
|
211
|
+
// \`flags.force\` is a boolean.
|
|
212
|
+
force: flag.boolean({ alias: "f", description: "skip the confirmation" }),
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async run({ args, flags, ui, prompt }) {
|
|
216
|
+
if (!flags.force && !(await prompt.confirm(\`Really do this to \${args.target}?\`))) {
|
|
217
|
+
ui.warning("Cancelled.");
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
ui.success(\`Done: \${args.target}\`);
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive prompts for console commands — ask, confirm, choice, and friends.
|
|
3
|
+
*
|
|
4
|
+
* const name = await prompt.ask("What is your name?");
|
|
5
|
+
* const ok = await prompt.confirm("Delete everything?");
|
|
6
|
+
* const driver = await prompt.choice("Pick a driver", ["sqlite", "postgres"]);
|
|
7
|
+
*
|
|
8
|
+
* `node:readline` is imported dynamically, so the core still loads on the edge.
|
|
9
|
+
*
|
|
10
|
+
* ## Prompts are testable, which is the whole point
|
|
11
|
+
*
|
|
12
|
+
* A command that asks questions is normally a command you can't test. So a prompt
|
|
13
|
+
* can be **trapped**: you script the answer up front, and the prompt never
|
|
14
|
+
* touches the terminal.
|
|
15
|
+
*
|
|
16
|
+
* const prompt = createPrompt({ trap: true });
|
|
17
|
+
* prompt.trap("What is your name?").replyWith("Ada");
|
|
18
|
+
* prompt.trap("Delete everything?").reject();
|
|
19
|
+
*
|
|
20
|
+
* An untrapped prompt in trap mode **throws** rather than hanging forever waiting
|
|
21
|
+
* on stdin that a test will never provide.
|
|
22
|
+
*/
|
|
23
|
+
export interface PromptOptions<T> {
|
|
24
|
+
/** Pressing enter accepts this. */
|
|
25
|
+
default?: T;
|
|
26
|
+
/** Shown in dim text beside the question. */
|
|
27
|
+
hint?: string;
|
|
28
|
+
/** Return true to accept, or a string explaining why not. */
|
|
29
|
+
validate?: (value: T) => true | string;
|
|
30
|
+
/** Transform the value before it's returned. */
|
|
31
|
+
result?: (value: T) => T;
|
|
32
|
+
}
|
|
33
|
+
export interface ChoiceOptions<T> extends PromptOptions<T> {
|
|
34
|
+
/** Show this many options at once. */
|
|
35
|
+
limit?: number;
|
|
36
|
+
}
|
|
37
|
+
/** A choice: a bare string, or a value with a label. */
|
|
38
|
+
export type Choice = string | {
|
|
39
|
+
value: string;
|
|
40
|
+
label?: string;
|
|
41
|
+
hint?: string;
|
|
42
|
+
};
|
|
43
|
+
/** A scripted answer, waiting for the prompt that matches it. */
|
|
44
|
+
export interface Trap {
|
|
45
|
+
/** Answer an `ask` / `secure` / `autocomplete`. */
|
|
46
|
+
replyWith(value: string): Trap;
|
|
47
|
+
/** Answer a `confirm` / `toggle` with yes. */
|
|
48
|
+
accept(): Trap;
|
|
49
|
+
/** Answer a `confirm` / `toggle` with no. */
|
|
50
|
+
reject(): Trap;
|
|
51
|
+
/** Answer a `choice` by index. */
|
|
52
|
+
chooseOption(index: number): Trap;
|
|
53
|
+
/** Answer a `multiple` by indexes. */
|
|
54
|
+
chooseOptions(indexes: number[]): Trap;
|
|
55
|
+
/** Assert the prompt's `validate` rejects this input, with this message. */
|
|
56
|
+
assertFails(value: string, message?: string): Trap;
|
|
57
|
+
/** Assert the prompt's `validate` accepts this input. */
|
|
58
|
+
assertPasses(value: string): Trap;
|
|
59
|
+
}
|
|
60
|
+
export interface Prompt {
|
|
61
|
+
ask(question: string, options?: PromptOptions<string>): Promise<string>;
|
|
62
|
+
/** Like `ask`, but the input isn't echoed. */
|
|
63
|
+
secure(question: string, options?: PromptOptions<string>): Promise<string>;
|
|
64
|
+
confirm(question: string, options?: PromptOptions<boolean>): Promise<boolean>;
|
|
65
|
+
/** A confirm with your own labels. */
|
|
66
|
+
toggle(question: string, labels: [string, string], options?: PromptOptions<boolean>): Promise<boolean>;
|
|
67
|
+
choice(question: string, choices: Choice[], options?: ChoiceOptions<string>): Promise<string>;
|
|
68
|
+
multiple(question: string, choices: Choice[], options?: ChoiceOptions<string[]>): Promise<string[]>;
|
|
69
|
+
autocomplete(question: string, choices: Choice[], options?: ChoiceOptions<string>): Promise<string>;
|
|
70
|
+
/** Script an answer for a question, so a test never blocks on stdin. */
|
|
71
|
+
trap(question: string): Trap;
|
|
72
|
+
/** Whether every trapped question was actually asked. */
|
|
73
|
+
assertAllTrapsUsed(): void;
|
|
74
|
+
}
|
|
75
|
+
export interface PromptFactoryOptions {
|
|
76
|
+
/** Answer from traps instead of the terminal. Tests set this. */
|
|
77
|
+
trap?: boolean;
|
|
78
|
+
}
|
|
79
|
+
export declare function createPrompt(options?: PromptFactoryOptions): Prompt;
|