@shaferllc/keel 0.66.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/AGENTS.md +169 -0
  2. package/README.md +44 -6
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/api/api.config.stub +9 -0
  5. package/dist/api/config.d.ts +13 -0
  6. package/dist/api/config.js +14 -0
  7. package/dist/api/index.d.ts +16 -0
  8. package/dist/api/index.js +13 -0
  9. package/dist/api/provider.d.ts +10 -0
  10. package/dist/api/provider.js +17 -0
  11. package/dist/api/query.d.ts +35 -0
  12. package/dist/api/query.js +42 -0
  13. package/dist/api/resource.d.ts +91 -0
  14. package/dist/api/resource.js +188 -0
  15. package/dist/core/application.d.ts +5 -5
  16. package/dist/core/application.js +8 -2
  17. package/dist/core/cache.d.ts +81 -5
  18. package/dist/core/cache.js +188 -23
  19. package/dist/core/cli/stubs.d.ts +26 -0
  20. package/dist/core/cli/stubs.js +225 -0
  21. package/dist/core/console-prompt.d.ts +79 -0
  22. package/dist/core/console-prompt.js +239 -0
  23. package/dist/core/console-ui.d.ts +96 -0
  24. package/dist/core/console-ui.js +187 -0
  25. package/dist/core/console.d.ts +188 -0
  26. package/dist/core/console.js +395 -0
  27. package/dist/core/database.d.ts +70 -1
  28. package/dist/core/database.js +174 -15
  29. package/dist/core/env.d.ts +96 -0
  30. package/dist/core/env.js +140 -0
  31. package/dist/core/events.d.ts +129 -5
  32. package/dist/core/events.js +165 -7
  33. package/dist/core/health.d.ts +141 -0
  34. package/dist/core/health.js +226 -0
  35. package/dist/core/helpers.d.ts +9 -3
  36. package/dist/core/helpers.js +11 -3
  37. package/dist/core/http/kernel.d.ts +2 -0
  38. package/dist/core/http/kernel.js +48 -0
  39. package/dist/core/http/router.d.ts +5 -5
  40. package/dist/core/http/router.js +5 -5
  41. package/dist/core/i18n.d.ts +162 -0
  42. package/dist/core/i18n.js +472 -0
  43. package/dist/core/index.d.ts +39 -13
  44. package/dist/core/index.js +19 -6
  45. package/dist/core/instrumentation.d.ts +113 -0
  46. package/dist/core/instrumentation.js +52 -0
  47. package/dist/core/lock.d.ts +139 -0
  48. package/dist/core/lock.js +215 -0
  49. package/dist/core/logger.d.ts +89 -4
  50. package/dist/core/logger.js +167 -22
  51. package/dist/core/mail.d.ts +128 -7
  52. package/dist/core/mail.js +264 -16
  53. package/dist/core/notification.js +10 -1
  54. package/dist/core/package.d.ts +120 -0
  55. package/dist/core/package.js +169 -0
  56. package/dist/core/pages.d.ts +108 -0
  57. package/dist/core/pages.js +199 -0
  58. package/dist/core/queue.d.ts +134 -9
  59. package/dist/core/queue.js +325 -14
  60. package/dist/core/repl.d.ts +33 -0
  61. package/dist/core/repl.js +88 -0
  62. package/dist/core/scheduler.js +6 -0
  63. package/dist/core/social.d.ts +4 -4
  64. package/dist/core/social.js +4 -4
  65. package/dist/core/storage.d.ts +159 -6
  66. package/dist/core/storage.js +299 -7
  67. package/dist/core/telemetry.d.ts +208 -0
  68. package/dist/core/telemetry.js +383 -0
  69. package/dist/core/template.d.ts +2 -3
  70. package/dist/core/template.js +2 -3
  71. package/dist/core/testing.d.ts +170 -1
  72. package/dist/core/testing.js +504 -2
  73. package/dist/db/d1.js +13 -0
  74. package/dist/db/pg.d.ts +13 -0
  75. package/dist/db/pg.js +46 -4
  76. package/dist/mcp/server.d.ts +19 -0
  77. package/dist/mcp/server.js +355 -0
  78. package/dist/openapi/config.d.ts +28 -0
  79. package/dist/openapi/config.js +25 -0
  80. package/dist/openapi/doc.d.ts +40 -0
  81. package/dist/openapi/doc.js +20 -0
  82. package/dist/openapi/export.d.ts +8 -0
  83. package/dist/openapi/export.js +19 -0
  84. package/dist/openapi/gate.d.ts +15 -0
  85. package/dist/openapi/gate.js +27 -0
  86. package/dist/openapi/index.d.ts +19 -0
  87. package/dist/openapi/index.js +15 -0
  88. package/dist/openapi/openapi.config.stub +29 -0
  89. package/dist/openapi/provider.d.ts +18 -0
  90. package/dist/openapi/provider.js +35 -0
  91. package/dist/openapi/routes.d.ts +9 -0
  92. package/dist/openapi/routes.js +23 -0
  93. package/dist/openapi/spec.d.ts +23 -0
  94. package/dist/openapi/spec.js +132 -0
  95. package/dist/openapi/ui.d.ts +8 -0
  96. package/dist/openapi/ui.js +31 -0
  97. package/dist/openapi/zod.d.ts +12 -0
  98. package/dist/openapi/zod.js +46 -0
  99. package/dist/watch/config.d.ts +33 -0
  100. package/dist/watch/config.js +38 -0
  101. package/dist/watch/entry.d.ts +53 -0
  102. package/dist/watch/entry.js +105 -0
  103. package/dist/watch/gate.d.ts +20 -0
  104. package/dist/watch/gate.js +32 -0
  105. package/dist/watch/index.d.ts +21 -0
  106. package/dist/watch/index.js +17 -0
  107. package/dist/watch/migration.d.ts +7 -0
  108. package/dist/watch/migration.js +20 -0
  109. package/dist/watch/provider.d.ts +22 -0
  110. package/dist/watch/provider.js +58 -0
  111. package/dist/watch/prune.d.ts +11 -0
  112. package/dist/watch/prune.js +20 -0
  113. package/dist/watch/recorder.d.ts +24 -0
  114. package/dist/watch/recorder.js +39 -0
  115. package/dist/watch/routes.d.ts +13 -0
  116. package/dist/watch/routes.js +55 -0
  117. package/dist/watch/store.d.ts +54 -0
  118. package/dist/watch/store.js +158 -0
  119. package/dist/watch/ui/dist/watch.css +1 -0
  120. package/dist/watch/ui/dist/watch.js +555 -0
  121. package/dist/watch/ui-shell.d.ts +12 -0
  122. package/dist/watch/ui-shell.js +24 -0
  123. package/dist/watch/watch.config.stub +47 -0
  124. package/dist/watch/watchers.d.ts +12 -0
  125. package/dist/watch/watchers.js +156 -0
  126. package/docs/ai-manifest.json +3206 -0
  127. package/docs/ai.md +128 -0
  128. package/docs/api-resources.md +118 -0
  129. package/docs/architecture.md +331 -0
  130. package/docs/authentication.md +453 -0
  131. package/docs/authorization.md +167 -0
  132. package/docs/broadcasting.md +137 -0
  133. package/docs/broker.md +500 -0
  134. package/docs/cache.md +558 -0
  135. package/docs/configuration.md +385 -0
  136. package/docs/console.md +536 -0
  137. package/docs/container.md +467 -0
  138. package/docs/controllers.md +265 -0
  139. package/docs/cors.md +51 -0
  140. package/docs/database.md +631 -0
  141. package/docs/debugging.md +129 -0
  142. package/docs/decorators.md +127 -0
  143. package/docs/errors.md +395 -0
  144. package/docs/events.md +496 -0
  145. package/docs/examples/architecture-app.ts +27 -0
  146. package/docs/examples/authentication.ts +61 -0
  147. package/docs/examples/authorization.ts +79 -0
  148. package/docs/examples/broadcasting.ts +60 -0
  149. package/docs/examples/broker-cache-validate.ts +34 -0
  150. package/docs/examples/broker-fault-tolerance.ts +29 -0
  151. package/docs/examples/broker-middleware.ts +27 -0
  152. package/docs/examples/broker.ts +203 -0
  153. package/docs/examples/cache.ts +222 -0
  154. package/docs/examples/configuration.ts +121 -0
  155. package/docs/examples/console.ts +134 -0
  156. package/docs/examples/container.ts +134 -0
  157. package/docs/examples/controllers.ts +86 -0
  158. package/docs/examples/database.ts +208 -0
  159. package/docs/examples/debugging.ts +41 -0
  160. package/docs/examples/decorators.ts +40 -0
  161. package/docs/examples/errors.ts +121 -0
  162. package/docs/examples/events.ts +204 -0
  163. package/docs/examples/factories.ts +84 -0
  164. package/docs/examples/hashing.ts +71 -0
  165. package/docs/examples/health.ts +94 -0
  166. package/docs/examples/helpers.ts +171 -0
  167. package/docs/examples/hooks.ts +54 -0
  168. package/docs/examples/i18n.ts +117 -0
  169. package/docs/examples/inertia.ts +81 -0
  170. package/docs/examples/locks.ts +120 -0
  171. package/docs/examples/logger.ts +166 -0
  172. package/docs/examples/mail.ts +263 -0
  173. package/docs/examples/middleware.ts +119 -0
  174. package/docs/examples/migrations.ts +126 -0
  175. package/docs/examples/models.ts +239 -0
  176. package/docs/examples/notification.ts +124 -0
  177. package/docs/examples/pages.ts +82 -0
  178. package/docs/examples/providers.ts +123 -0
  179. package/docs/examples/queues.ts +254 -0
  180. package/docs/examples/rate-limiting.ts +42 -0
  181. package/docs/examples/redis.ts +99 -0
  182. package/docs/examples/request-response.ts +197 -0
  183. package/docs/examples/routing.ts +186 -0
  184. package/docs/examples/scheduling.ts +62 -0
  185. package/docs/examples/sessions.ts +102 -0
  186. package/docs/examples/static-files.ts +63 -0
  187. package/docs/examples/storage.ts +132 -0
  188. package/docs/examples/telemetry.ts +127 -0
  189. package/docs/examples/templates.ts +58 -0
  190. package/docs/examples/testing.ts +215 -0
  191. package/docs/examples/transformer.ts +141 -0
  192. package/docs/examples/transformers.ts +49 -0
  193. package/docs/examples/url-builder.ts +86 -0
  194. package/docs/examples/validation.ts +102 -0
  195. package/docs/examples/views.tsx +62 -0
  196. package/docs/examples/vite.ts +106 -0
  197. package/docs/factories.md +166 -0
  198. package/docs/getting-started.md +290 -0
  199. package/docs/hashing.md +259 -0
  200. package/docs/health.md +225 -0
  201. package/docs/helpers.md +347 -0
  202. package/docs/hono.md +186 -0
  203. package/docs/hooks.md +118 -0
  204. package/docs/i18n.md +302 -0
  205. package/docs/inertia.md +241 -0
  206. package/docs/locks.md +323 -0
  207. package/docs/logger.md +436 -0
  208. package/docs/mail.md +751 -0
  209. package/docs/middleware.md +425 -0
  210. package/docs/migrations.md +476 -0
  211. package/docs/models.md +810 -0
  212. package/docs/notifications.md +474 -0
  213. package/docs/openapi.md +111 -0
  214. package/docs/packages.md +118 -0
  215. package/docs/pages.md +217 -0
  216. package/docs/providers.md +363 -0
  217. package/docs/queues.md +679 -0
  218. package/docs/rate-limiting.md +155 -0
  219. package/docs/redis.md +178 -0
  220. package/docs/request-response.md +953 -0
  221. package/docs/routing.md +804 -0
  222. package/docs/scheduling.md +110 -0
  223. package/docs/security.md +85 -0
  224. package/docs/sessions.md +354 -0
  225. package/docs/social-auth.md +174 -0
  226. package/docs/static-files.md +211 -0
  227. package/docs/storage.md +452 -0
  228. package/docs/telemetry.md +263 -0
  229. package/docs/templates.md +314 -0
  230. package/docs/testing.md +376 -0
  231. package/docs/transformers.md +381 -0
  232. package/docs/url-builder.md +295 -0
  233. package/docs/validation.md +288 -0
  234. package/docs/views.md +267 -0
  235. package/docs/vite.md +434 -0
  236. package/docs/watch.md +118 -0
  237. package/llms-full.txt +19816 -0
  238. package/llms.txt +127 -0
  239. package/package.json +42 -7
@@ -0,0 +1,225 @@
1
+ /** Code-generation templates for `keel make:*`. */
2
+ export function controllerStub(name) {
3
+ return `import type { Ctx } from "@shaferllc/keel/core";
4
+
5
+ export class ${name} {
6
+ index(c: Ctx) {
7
+ return c.json({ controller: "${name}", action: "index" });
8
+ }
9
+ }
10
+ `;
11
+ }
12
+ export function resourceControllerStub(name) {
13
+ const actions = ["index", "create", "store", "show", "edit", "update", "destroy"];
14
+ const body = actions
15
+ .map((a) => ` ${a}(c: Ctx) {\n return c.json({ action: "${a}" });\n }`)
16
+ .join("\n\n");
17
+ return `import type { Ctx } from "@shaferllc/keel/core";
18
+
19
+ export class ${name} {
20
+ ${body}
21
+ }
22
+ `;
23
+ }
24
+ export function providerStub(name) {
25
+ return `import { ServiceProvider } from "@shaferllc/keel/core";
26
+
27
+ export class ${name} extends ServiceProvider {
28
+ register(): void {
29
+ // Bind services into the container here.
30
+ }
31
+
32
+ boot(): void {
33
+ // Resolve and wire things up here.
34
+ }
35
+ }
36
+ `;
37
+ }
38
+ export function middlewareStub(name) {
39
+ const fn = name.charAt(0).toLowerCase() + name.slice(1);
40
+ return `import type { MiddlewareHandler } from "hono";
41
+
42
+ export const ${fn}: MiddlewareHandler = async (c, next) => {
43
+ // ...before
44
+ await next();
45
+ // ...after
46
+ };
47
+ `;
48
+ }
49
+ /** `name` is the model class (e.g. "User") the factory builds. */
50
+ export function factoryStub(model) {
51
+ return `import { factory } from "@shaferllc/keel/core";
52
+ import { ${model} } from "../../app/Models/${model}.js";
53
+
54
+ export const ${model.toLowerCase()}Factory = factory(${model}, (f) => ({
55
+ // Describe one ${model}'s attributes; \`f\` is a Faker.
56
+ name: f.name(),
57
+ email: f.email(),
58
+ }));
59
+ `;
60
+ }
61
+ export function seederStub(name) {
62
+ return `import { Seeder } from "@shaferllc/keel/core";
63
+
64
+ export class ${name} extends Seeder {
65
+ async run(): Promise<void> {
66
+ // Populate the database, e.g.:
67
+ // await userFactory.count(10).create();
68
+ }
69
+ }
70
+ `;
71
+ }
72
+ export function jobStub(name) {
73
+ return `import { Job } from "@shaferllc/keel/core";
74
+
75
+ export class ${name} extends Job {
76
+ constructor(/* pass the data this job needs */) {
77
+ super();
78
+ }
79
+
80
+ async handle(): Promise<void> {
81
+ // Do the background work here.
82
+ }
83
+ }
84
+ `;
85
+ }
86
+ export function notificationStub(name) {
87
+ return `import { Notification, type Notifiable, type MailContent } from "@shaferllc/keel/core";
88
+
89
+ export class ${name} extends Notification {
90
+ via(_notifiable: Notifiable): string[] {
91
+ return ["mail"];
92
+ }
93
+
94
+ toMail(_notifiable: Notifiable): MailContent {
95
+ return {
96
+ subject: "${name}",
97
+ text: "Notification body.",
98
+ };
99
+ }
100
+ }
101
+ `;
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
+ }
142
+ /** `name` is the class (e.g. "UserTransformer"); `model` is the value it maps. */
143
+ export function transformerStub(name, model) {
144
+ return `import { Transformer, type Attributes } from "@shaferllc/keel/core";
145
+ // import { ${model} } from "../Models/${model}.js";
146
+
147
+ export class ${name} extends Transformer</* ${model} */ any> {
148
+ transform(item: /* ${model} */ any): Attributes {
149
+ // Map the value to the exact shape your API exposes.
150
+ return {
151
+ id: item.id,
152
+ // name: item.name,
153
+ // email: this.when(canSeeEmail, item.email),
154
+ // posts: this.whenLoaded(item, "posts", new PostTransformer()),
155
+ };
156
+ }
157
+ }
158
+ `;
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;
@@ -0,0 +1,239 @@
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
+ const valueOf = (choice) => (typeof choice === "string" ? choice : choice.value);
24
+ const labelOf = (choice) => typeof choice === "string" ? choice : (choice.label ?? choice.value);
25
+ export function createPrompt(options = {}) {
26
+ const traps = new Map();
27
+ const trapping = options.trap ?? false;
28
+ /** Read one line from the terminal. */
29
+ async function readLine(question, mask = false) {
30
+ const readline = await import("node:readline");
31
+ const rl = readline.createInterface({
32
+ input: process.stdin,
33
+ output: process.stdout,
34
+ terminal: true,
35
+ });
36
+ // For a secure prompt, swallow the echo so the password isn't left on screen.
37
+ if (mask) {
38
+ const output = rl;
39
+ const original = output.output.write.bind(output.output);
40
+ output.output.write = (chunk) => {
41
+ if (chunk.includes(question))
42
+ original(chunk);
43
+ };
44
+ }
45
+ return new Promise((resolve) => {
46
+ rl.question(question, (answer) => {
47
+ rl.close();
48
+ if (mask)
49
+ process.stdout.write("\n");
50
+ resolve(answer);
51
+ });
52
+ });
53
+ }
54
+ /**
55
+ * Resolve a question from its trap, running whatever assertions were attached.
56
+ * Returns `undefined` when there's no trap — the caller decides what that means.
57
+ */
58
+ function fromTrap(question, validate) {
59
+ const state = traps.get(question);
60
+ if (!state)
61
+ return undefined;
62
+ for (const { value, message } of state.expectFail) {
63
+ const result = validate ? validate(value) : true;
64
+ if (result === true) {
65
+ throw new Error(`Expected the prompt "${question}" to reject ${JSON.stringify(value)}, but it passed.`);
66
+ }
67
+ if (message !== undefined && result !== message) {
68
+ throw new Error(`Expected the prompt "${question}" to reject ${JSON.stringify(value)} with "${message}", got "${result}".`);
69
+ }
70
+ }
71
+ for (const value of state.expectPass) {
72
+ const result = validate ? validate(value) : true;
73
+ if (result !== true) {
74
+ throw new Error(`Expected the prompt "${question}" to accept ${JSON.stringify(value)}, but it said "${result}".`);
75
+ }
76
+ }
77
+ state.answered = true;
78
+ return state.answer;
79
+ }
80
+ /** Nothing scripted this question, and there's no terminal to ask. */
81
+ function untrapped(question) {
82
+ throw new Error(`The prompt "${question}" was not trapped. Script it with prompt.trap(${JSON.stringify(question)}).replyWith(…) — ` +
83
+ `otherwise the command would block forever waiting for input a test can't give it.`);
84
+ }
85
+ function check(question, value, options) {
86
+ const problem = options?.validate?.(value);
87
+ if (problem !== undefined && problem !== true)
88
+ throw new Error(`${question}: ${problem}`);
89
+ return options?.result ? options.result(value) : value;
90
+ }
91
+ const prompt = {
92
+ async ask(question, options) {
93
+ if (trapping) {
94
+ const answer = fromTrap(question, options?.validate);
95
+ if (answer === undefined)
96
+ untrapped(question);
97
+ return check(question, answer, options);
98
+ }
99
+ const hint = options?.default ? ` (${options.default})` : "";
100
+ const raw = (await readLine(`${question}${hint} `)).trim();
101
+ const value = raw || options?.default || "";
102
+ const problem = options?.validate?.(value);
103
+ // Ask again rather than dying — a typo shouldn't cost them the whole command.
104
+ if (problem !== undefined && problem !== true) {
105
+ console.error(problem);
106
+ return prompt.ask(question, options);
107
+ }
108
+ return options?.result ? options.result(value) : value;
109
+ },
110
+ async secure(question, options) {
111
+ if (trapping) {
112
+ const answer = fromTrap(question, options?.validate);
113
+ if (answer === undefined)
114
+ untrapped(question);
115
+ return check(question, answer, options);
116
+ }
117
+ const value = (await readLine(`${question} `, true)).trim();
118
+ return check(question, value, options);
119
+ },
120
+ async confirm(question, options) {
121
+ return prompt.toggle(question, ["y", "n"], options);
122
+ },
123
+ async toggle(question, labels, options) {
124
+ if (trapping) {
125
+ const answer = fromTrap(question, options?.validate);
126
+ if (answer === undefined)
127
+ untrapped(question);
128
+ return check(question, answer, options);
129
+ }
130
+ const fallback = options?.default ?? false;
131
+ const hint = fallback ? `[${labels[0].toUpperCase()}/${labels[1]}]` : `[${labels[0]}/${labels[1].toUpperCase()}]`;
132
+ const raw = (await readLine(`${question} ${hint} `)).trim().toLowerCase();
133
+ const value = raw === "" ? fallback : raw === labels[0].toLowerCase() || raw === "yes" || raw === "true";
134
+ return check(question, value, options);
135
+ },
136
+ async choice(question, choices, options) {
137
+ if (trapping) {
138
+ const index = fromTrap(question);
139
+ if (index === undefined)
140
+ untrapped(question);
141
+ const choice = choices[index];
142
+ if (!choice)
143
+ throw new Error(`The prompt "${question}" has no option at index ${index}.`);
144
+ return check(question, valueOf(choice), options);
145
+ }
146
+ console.log(question);
147
+ choices.forEach((choice, i) => console.log(` ${i + 1}) ${labelOf(choice)}`));
148
+ const raw = (await readLine("> ")).trim();
149
+ const index = Number(raw) - 1;
150
+ const choice = choices[index];
151
+ if (!choice) {
152
+ console.error(`Pick a number between 1 and ${choices.length}.`);
153
+ return prompt.choice(question, choices, options);
154
+ }
155
+ return check(question, valueOf(choice), options);
156
+ },
157
+ async multiple(question, choices, options) {
158
+ if (trapping) {
159
+ const indexes = fromTrap(question);
160
+ if (indexes === undefined)
161
+ untrapped(question);
162
+ const values = indexes.map((i) => {
163
+ const choice = choices[i];
164
+ if (!choice)
165
+ throw new Error(`The prompt "${question}" has no option at index ${i}.`);
166
+ return valueOf(choice);
167
+ });
168
+ return check(question, values, options);
169
+ }
170
+ console.log(`${question} (comma-separated numbers)`);
171
+ choices.forEach((choice, i) => console.log(` ${i + 1}) ${labelOf(choice)}`));
172
+ const raw = (await readLine("> ")).trim();
173
+ const values = raw
174
+ .split(",")
175
+ .map((part) => choices[Number(part.trim()) - 1])
176
+ .filter((choice) => choice !== undefined)
177
+ .map(valueOf);
178
+ return check(question, values, options);
179
+ },
180
+ async autocomplete(question, choices, options) {
181
+ if (trapping) {
182
+ const answer = fromTrap(question);
183
+ if (answer === undefined)
184
+ untrapped(question);
185
+ return check(question, answer, options);
186
+ }
187
+ // Without a raw-mode TTY there's no live filtering to do, so it degrades to
188
+ // a plain choice list rather than pretending.
189
+ return prompt.choice(question, choices, options);
190
+ },
191
+ trap(question) {
192
+ const state = {
193
+ question,
194
+ answered: false,
195
+ expectFail: [],
196
+ expectPass: [],
197
+ };
198
+ traps.set(question, state);
199
+ const api = {
200
+ replyWith(value) {
201
+ state.answer = value;
202
+ return api;
203
+ },
204
+ accept() {
205
+ state.answer = true;
206
+ return api;
207
+ },
208
+ reject() {
209
+ state.answer = false;
210
+ return api;
211
+ },
212
+ chooseOption(index) {
213
+ state.answer = index;
214
+ return api;
215
+ },
216
+ chooseOptions(indexes) {
217
+ state.answer = indexes;
218
+ return api;
219
+ },
220
+ assertFails(value, message) {
221
+ state.expectFail.push({ value, message });
222
+ return api;
223
+ },
224
+ assertPasses(value) {
225
+ state.expectPass.push(value);
226
+ return api;
227
+ },
228
+ };
229
+ return api;
230
+ },
231
+ assertAllTrapsUsed() {
232
+ const unused = [...traps.values()].filter((t) => !t.answered).map((t) => t.question);
233
+ if (unused.length) {
234
+ throw new Error(`These prompts were trapped but never asked: ${unused.map((q) => `"${q}"`).join(", ")}.`);
235
+ }
236
+ },
237
+ };
238
+ return prompt;
239
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Terminal UI for console commands — colors, a logger, tables, boxes, and a task
3
+ * runner. No dependency: ANSI codes are a dozen escape sequences, not a package.
4
+ *
5
+ * ui.success("Migrated 3 tables");
6
+ * ui.table(["Name", "Rows"]).row(["users", "42"]).render();
7
+ *
8
+ * Everything goes through the same `write`, which is what makes the whole thing
9
+ * testable: `createUi({ raw: true })` strips the colors and buffers the output
10
+ * instead of printing it, so a test can assert on exactly what a command said.
11
+ */
12
+ declare const CODES: {
13
+ readonly reset: 0;
14
+ readonly bold: 1;
15
+ readonly dim: 2;
16
+ readonly italic: 3;
17
+ readonly underline: 4;
18
+ readonly red: 31;
19
+ readonly green: 32;
20
+ readonly yellow: 33;
21
+ readonly blue: 34;
22
+ readonly magenta: 35;
23
+ readonly cyan: 36;
24
+ readonly white: 37;
25
+ readonly gray: 90;
26
+ readonly bgRed: 41;
27
+ readonly bgGreen: 42;
28
+ readonly bgYellow: 43;
29
+ };
30
+ export type ColorName = keyof typeof CODES;
31
+ /** Paint text, or don't — the same call site works either way. */
32
+ export interface Colors {
33
+ (name: ColorName, text: string): string;
34
+ readonly enabled: boolean;
35
+ }
36
+ /** Strip ANSI escapes — used by the raw mode, and handy in assertions. */
37
+ export declare function stripAnsi(text: string): string;
38
+ export interface Table {
39
+ head(...columns: string[]): Table;
40
+ row(cells: string[]): Table;
41
+ rows(rows: string[][]): Table;
42
+ /** Render it and write it out. */
43
+ render(): void;
44
+ /** Render it to a string instead of writing it. */
45
+ toString(): string;
46
+ }
47
+ /** What a single task can report while it runs. */
48
+ export interface TaskHandle {
49
+ update(message: string): void;
50
+ }
51
+ export interface Tasks {
52
+ add(title: string, run: (task: TaskHandle) => Promise<string | void> | string | void): Tasks;
53
+ /** Run them in order. Resolves to false if any task failed. */
54
+ run(): Promise<boolean>;
55
+ }
56
+ export interface UiOptions {
57
+ /**
58
+ * Buffer output instead of printing it, and drop the colors. What tests use —
59
+ * `ui.logs` then holds every line.
60
+ */
61
+ raw?: boolean;
62
+ /** Force colors on or off. Defaults to on unless raw, or NO_COLOR is set. */
63
+ colors?: boolean;
64
+ }
65
+ export interface Ui {
66
+ readonly colors: Colors;
67
+ /** In raw mode, every line written. Empty otherwise. */
68
+ readonly logs: string[];
69
+ /** In raw mode, every line written to stderr. */
70
+ readonly errors: string[];
71
+ /** Write a line verbatim. */
72
+ write(line: string): void;
73
+ debug(message: string): void;
74
+ info(message: string): void;
75
+ success(message: string): void;
76
+ warning(message: string): void;
77
+ /** Goes to stderr. */
78
+ error(message: string): void;
79
+ /** Goes to stderr. */
80
+ fatal(message: string): void;
81
+ /**
82
+ * A one-line status for something you did: `CREATE app/Models/User.ts`.
83
+ * The verb is padded so a run of them lines up.
84
+ */
85
+ action(verb: string, target: string, status?: "done" | "skipped" | "failed"): void;
86
+ table(head?: string[]): Table;
87
+ /** A boxed message — for the thing you want them to actually read. */
88
+ sticker(lines: string[], title?: string): void;
89
+ /** Numbered next-steps. */
90
+ instructions(lines: string[], title?: string): void;
91
+ tasks(): Tasks;
92
+ /** Clear the captured buffers (raw mode). */
93
+ clear(): void;
94
+ }
95
+ export declare function createUi(options?: UiOptions): Ui;
96
+ export {};