@shaferllc/keel 0.68.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.
- package/AGENTS.md +2 -0
- package/README.md +14 -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 +91 -0
- package/dist/api/resource.js +188 -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/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 +120 -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 +170 -1
- package/dist/core/testing.js +504 -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 +20 -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 +118 -0
- package/docs/configuration.md +74 -0
- package/docs/console.md +193 -13
- package/docs/database.md +101 -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 +149 -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 +251 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +2370 -248
- package/llms.txt +12 -1
- package/package.json +18 -2
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;
|
|
@@ -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 {};
|