@fougere/cli 0.3.0-alpha.0 → 0.5.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/app/commands/BuildCommand.ts +3 -0
- package/app/commands/CallCommand.ts +3 -3
- package/app/commands/CheckCommand.ts +11 -8
- package/app/commands/DevtoolsCommand.ts +92 -0
- package/app/commands/ExplainCommand.ts +51 -8
- package/app/commands/FreezeCommand.ts +6 -1
- package/app/commands/GraphCommand.ts +3 -0
- package/app/commands/KeysCommand.ts +1 -1
- package/app/commands/MigrateCommand.ts +3 -0
- package/app/commands/ServeCommand.ts +7 -16
- package/dist/bin.js +4 -5
- package/dist/bin.js.map +1 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +11 -4
- package/dist/bridge.js.map +1 -1
- package/dist/completion.d.ts +4 -1
- package/dist/completion.d.ts.map +1 -1
- package/dist/completion.js +54 -20
- package/dist/completion.js.map +1 -1
- package/dist/loader.d.ts +11 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +23 -0
- package/dist/loader.js.map +1 -0
- package/dist/machine.d.ts +16 -0
- package/dist/machine.d.ts.map +1 -0
- package/dist/machine.js +22 -0
- package/dist/machine.js.map +1 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +16 -7
- package/dist/runner.js.map +1 -1
- package/fronds/analysis/entities/Build.ts +2 -1
- package/fronds/analysis/entities/Check.ts +2 -1
- package/fronds/analysis/entities/Devtools.ts +8 -0
- package/fronds/analysis/entities/Explain.ts +2 -1
- package/fronds/analysis/entities/Freeze.ts +2 -1
- package/fronds/analysis/entities/Graph.ts +2 -1
- package/fronds/analysis/entities/Migrate.ts +1 -0
- package/fronds/analysis/handlers/BuildHandler.ts +2 -1
- package/fronds/analysis/handlers/DevtoolsHandler.ts +86 -0
- package/fronds/analysis/handlers/ExplainHandler.ts +52 -12
- package/fronds/analysis/handlers/FreezeHandler.ts +18 -13
- package/fronds/analysis/handlers/MigrateHandler.ts +4 -3
- package/fronds/scaffold/entities/BuildFrond.ts +1 -1
- package/fronds/scaffold/entities/Call.ts +1 -1
- package/fronds/scaffold/entities/Sync.ts +1 -1
- package/fronds/scaffold/handlers/BuildFrondHandler.ts +5 -5
- package/fronds/scaffold/handlers/SyncHandler.ts +17 -17
- package/package.json +9 -8
- package/src/bin.ts +83 -0
- package/src/bridge.ts +70 -0
- package/src/completion.ts +152 -0
- package/src/index.ts +3 -0
- package/src/loader.ts +28 -0
- package/src/machine.ts +23 -0
- package/src/runner.ts +146 -0
- package/src/theme.ts +19 -0
- package/src/ui.ts +131 -0
- package/templates/blog/fronds/blog/handlers/PostHandler.ts +1 -1
- package/templates/frond/fronds/__name__/handlers/PostHandler.ts +1 -1
- package/templates/fronds/blog/handlers/PostHandler.ts +1 -1
package/src/runner.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI runner — scans frond entities for flags, looks for app commands
|
|
3
|
+
* for presentation, dispatches via citty.
|
|
4
|
+
*
|
|
5
|
+
* Architecture:
|
|
6
|
+
* - fronds/ → entities (flags) + handlers (domain logic)
|
|
7
|
+
* - app/ → commands (prompts, TUI, presentation)
|
|
8
|
+
* - src/ → runner + bridge (framework)
|
|
9
|
+
*/
|
|
10
|
+
import type { App } from '@fougere/core';
|
|
11
|
+
import { createAppRunner } from '@fougere/core';
|
|
12
|
+
import { lowerFirst } from '@fougere/core/contract';
|
|
13
|
+
import { defineCommand, runMain } from 'citty';
|
|
14
|
+
import { ui } from './ui.js';
|
|
15
|
+
import { machineWanted } from './machine.js';
|
|
16
|
+
import { entityToArgs } from './bridge.js';
|
|
17
|
+
import { readdir } from 'node:fs/promises';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
function toKebab(name: string): string {
|
|
21
|
+
return name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()).replace(/^-/, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toCamel(kebab: string): string {
|
|
25
|
+
return kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Scan app/commands/ for command classes. */
|
|
29
|
+
async function loadAppCommands(
|
|
30
|
+
cliRoot: string,
|
|
31
|
+
loader: (path: string) => Promise<Record<string, unknown>>,
|
|
32
|
+
): Promise<Map<string, new (...args: unknown[]) => { run: (raw: Record<string, unknown>) => Promise<void> }>> {
|
|
33
|
+
const map = new Map();
|
|
34
|
+
const dir = join(cliRoot, 'app', 'commands');
|
|
35
|
+
const files = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
36
|
+
|
|
37
|
+
for (const f of files) {
|
|
38
|
+
if (!f.isFile() || !(f.name.endsWith('.ts') || f.name.endsWith('.js'))) continue;
|
|
39
|
+
const name = f.name.replace(/Command\.(ts|js)$/, '').replace(/\.(ts|js)$/, '');
|
|
40
|
+
const kebab = toKebab(name);
|
|
41
|
+
const mod = await loader(join(dir, f.name));
|
|
42
|
+
if (mod.default && typeof mod.default === 'function') {
|
|
43
|
+
map.set(kebab, mod.default);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return map;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function run(app: App): Promise<void> {
|
|
51
|
+
const terminal = ui();
|
|
52
|
+
const cliRoot = new URL('..', import.meta.url).pathname;
|
|
53
|
+
|
|
54
|
+
const { createJiti } = await import('jiti');
|
|
55
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
56
|
+
const loader = (path: string) => jiti.import(path) as Promise<Record<string, unknown>>;
|
|
57
|
+
const appCommands = await loadAppCommands(cliRoot, loader);
|
|
58
|
+
|
|
59
|
+
const subCommands: Record<string, ReturnType<typeof defineCommand>> = {};
|
|
60
|
+
|
|
61
|
+
for (const frond of app.fronds) {
|
|
62
|
+
const handlerMap = new Map(frond.handlers.map((h) => [h.address, h]));
|
|
63
|
+
|
|
64
|
+
for (const entity of frond.entities) {
|
|
65
|
+
const handlerEntry = handlerMap.get(entity.name);
|
|
66
|
+
if (!handlerEntry) continue;
|
|
67
|
+
|
|
68
|
+
const handlerName = `${entity.name}Handler`;
|
|
69
|
+
let handler: Record<string, Function>;
|
|
70
|
+
try {
|
|
71
|
+
handler = app.resolve<Record<string, Function>>(handlerName);
|
|
72
|
+
} catch { continue; }
|
|
73
|
+
|
|
74
|
+
if (typeof handler.execute !== 'function') continue;
|
|
75
|
+
|
|
76
|
+
const cmdName = toKebab(entity.name);
|
|
77
|
+
const fields = entity.entityClass.getFields();
|
|
78
|
+
const args = entityToArgs(fields);
|
|
79
|
+
|
|
80
|
+
// Check for an app command (presentation layer)
|
|
81
|
+
const AppCommand = appCommands.get(cmdName);
|
|
82
|
+
|
|
83
|
+
// App commands handle their own prompting — don't let citty reject missing args
|
|
84
|
+
if (AppCommand) {
|
|
85
|
+
for (const def of Object.values(args)) {
|
|
86
|
+
if (typeof def === 'object' && def) (def as Record<string, unknown>).required = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
subCommands[cmdName] = defineCommand({
|
|
91
|
+
meta: {
|
|
92
|
+
name: cmdName,
|
|
93
|
+
// `--help` reads the operation's own doc sentence, which the scan already
|
|
94
|
+
// carries for every door (`OperationContract.description`). A table here
|
|
95
|
+
// would be the same fact written twice, and it drifted: it described `add`
|
|
96
|
+
// and `doctor`, which do not exist, and had nothing for `call` or `serve`.
|
|
97
|
+
description: handlerEntry.operations.get('execute')?.description,
|
|
98
|
+
},
|
|
99
|
+
args,
|
|
100
|
+
run: async ({ args: parsed }) => {
|
|
101
|
+
// JSON is a protocol: a branded intro before `{` makes it unparsable. Read from
|
|
102
|
+
// the invocation's own args, so any command declaring `json` gets a clean stdout
|
|
103
|
+
// — naming `explain` here is what let `graph --json` print its decorated box.
|
|
104
|
+
const machineOutput = machineWanted(parsed as Record<string, unknown>);
|
|
105
|
+
|
|
106
|
+
// `completion` is exempt by nature, not by flag: its output IS a shell script,
|
|
107
|
+
// there is no invocation of it that wants decoration.
|
|
108
|
+
if (cmdName !== 'completion' && !machineOutput) terminal.intro();
|
|
109
|
+
|
|
110
|
+
// citty adds `_` (raw positionals) and `--` (passthrough); strip them
|
|
111
|
+
// so only the entity's own fields reach the handler.
|
|
112
|
+
const input = { ...(parsed as Record<string, unknown>) };
|
|
113
|
+
delete input._;
|
|
114
|
+
delete input['--'];
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
if (AppCommand) {
|
|
118
|
+
const cmd = new (AppCommand as new (...a: unknown[]) => { run: (raw: Record<string, unknown>) => Promise<void> })(app, terminal);
|
|
119
|
+
await cmd.run(input);
|
|
120
|
+
} else {
|
|
121
|
+
// Ride the call contract — the same envelope every consumer uses.
|
|
122
|
+
await createAppRunner(app)(
|
|
123
|
+
{ entity: lowerFirst(entity.name), op: 'execute' },
|
|
124
|
+
{ params: {}, query: {}, body: input, state: {} },
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
const said = err instanceof Error ? err.message : String(err);
|
|
129
|
+
// A machine reader parses stdout: a refusal printed there is a refusal that
|
|
130
|
+
// breaks the parse instead of being read. stderr is where it belongs.
|
|
131
|
+
if (machineOutput) process.stderr.write(said + '\n');
|
|
132
|
+
else terminal.error(said);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const main = defineCommand({
|
|
141
|
+
meta: { name: 'fougere', description: 'Fougere CLI' },
|
|
142
|
+
subCommands,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await runMain(main);
|
|
146
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
|
|
3
|
+
export interface ThemeColors {
|
|
4
|
+
brand: (text: string) => string;
|
|
5
|
+
success: (text: string) => string;
|
|
6
|
+
error: (text: string) => string;
|
|
7
|
+
warn: (text: string) => string;
|
|
8
|
+
muted: (text: string) => string;
|
|
9
|
+
bold: (text: string) => string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const defaultTheme: ThemeColors = {
|
|
13
|
+
brand: pc.green,
|
|
14
|
+
success: pc.green,
|
|
15
|
+
error: pc.red,
|
|
16
|
+
warn: pc.yellow,
|
|
17
|
+
muted: pc.dim,
|
|
18
|
+
bold: pc.bold,
|
|
19
|
+
};
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fougere CLI UI — beautiful terminal interface.
|
|
3
|
+
*
|
|
4
|
+
* Wraps @clack/prompts + picocolors + consola into a cohesive API.
|
|
5
|
+
*
|
|
6
|
+
* This was `@fougere/cli-ui`, a published package with exactly one consumer —
|
|
7
|
+
* the CLI it is named after. A second name in the registry that nobody would
|
|
8
|
+
* ever install on purpose is a name, not a boundary. Same dependency profile,
|
|
9
|
+
* so it folds in as a module; a subpath export is one line the day something
|
|
10
|
+
* outside the CLI wants it.
|
|
11
|
+
*/
|
|
12
|
+
import * as clack from '@clack/prompts';
|
|
13
|
+
import pc from 'picocolors';
|
|
14
|
+
import { consola } from 'consola';
|
|
15
|
+
import { defaultTheme, type ThemeColors } from './theme.js';
|
|
16
|
+
|
|
17
|
+
export interface UiTheme {
|
|
18
|
+
colors?: Partial<ThemeColors>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ui(options?: UiTheme) {
|
|
22
|
+
const c = { ...defaultTheme, ...options?.colors };
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
// ── Lifecycle ─────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** Start a new CLI session with a branded header. */
|
|
28
|
+
intro(title = 'Fougere') {
|
|
29
|
+
clack.intro(c.brand(title));
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/** End the session with a message. */
|
|
33
|
+
outro(message: string) {
|
|
34
|
+
clack.outro(c.success(message));
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/** Cancel and exit. */
|
|
38
|
+
cancel(message = 'Cancelled.') {
|
|
39
|
+
clack.cancel(c.muted(message));
|
|
40
|
+
process.exit(0);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
// ── Prompts ───────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** Text input. */
|
|
46
|
+
async text(opts: { message: string; placeholder?: string; defaultValue?: string; validate?: (value: string) => string | undefined }) {
|
|
47
|
+
const result = await clack.text(opts);
|
|
48
|
+
if (clack.isCancel(result)) { this.cancel(); return ''; }
|
|
49
|
+
return result as string;
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/** Yes/no confirmation. */
|
|
53
|
+
async confirm(opts: { message: string; initialValue?: boolean }) {
|
|
54
|
+
const result = await clack.confirm(opts);
|
|
55
|
+
if (clack.isCancel(result)) { this.cancel(); return false; }
|
|
56
|
+
return result as boolean;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/** Select one from a list. */
|
|
60
|
+
async select(opts: {
|
|
61
|
+
message: string;
|
|
62
|
+
options: { value: string; label?: string; hint?: string }[];
|
|
63
|
+
initialValue?: string;
|
|
64
|
+
}) {
|
|
65
|
+
const result = await clack.select(opts as Parameters<typeof clack.select>[0]);
|
|
66
|
+
if (clack.isCancel(result)) { this.cancel(); return ''; }
|
|
67
|
+
return result as string;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/** Multi-select from a list. */
|
|
71
|
+
async multiselect(opts: {
|
|
72
|
+
message: string;
|
|
73
|
+
options: { value: string; label?: string; hint?: string }[];
|
|
74
|
+
required?: boolean;
|
|
75
|
+
}) {
|
|
76
|
+
const result = await clack.multiselect(opts as Parameters<typeof clack.multiselect>[0]);
|
|
77
|
+
if (clack.isCancel(result)) { this.cancel(); return [] as string[]; }
|
|
78
|
+
return result as string[];
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// ── Spinner ───────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/** Start a spinner. Returns stop/update functions. */
|
|
84
|
+
spinner(message?: string) {
|
|
85
|
+
const s = clack.spinner();
|
|
86
|
+
s.start(message);
|
|
87
|
+
return {
|
|
88
|
+
update: (msg: string) => s.message(msg),
|
|
89
|
+
stop: (msg?: string) => s.stop(msg),
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// ── Output ────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/** Informational message. */
|
|
96
|
+
info(message: string) {
|
|
97
|
+
clack.log.info(message);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/** Success message. */
|
|
101
|
+
success(message: string) {
|
|
102
|
+
clack.log.success(c.success(message));
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/** Warning message. */
|
|
106
|
+
warn(message: string) {
|
|
107
|
+
clack.log.warn(c.warn(message));
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/** Error message. */
|
|
111
|
+
error(message: string) {
|
|
112
|
+
clack.log.error(c.error(message));
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/** Step indicator. */
|
|
116
|
+
step(message: string) {
|
|
117
|
+
clack.log.step(message);
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/** Note box — multiline content in a box. */
|
|
121
|
+
note(message: string, title?: string) {
|
|
122
|
+
clack.note(message, title);
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
// ── Raw colors/consola ────────────────────────
|
|
126
|
+
|
|
127
|
+
colors: c,
|
|
128
|
+
pc,
|
|
129
|
+
consola,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -26,7 +26,7 @@ export default class PostHandler extends Crud(Post) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** Only published posts exist for the outside world, projected to the card. */
|
|
29
|
-
async
|
|
29
|
+
async listPublished(): Promise<PostCard[]> {
|
|
30
30
|
const posts = await this.orm.list({ where: { status: 'published' } });
|
|
31
31
|
return posts.map(({ id, title, status }) => ({ id, title, status }));
|
|
32
32
|
}
|
|
@@ -17,7 +17,7 @@ export default class PostHandler extends Crud(Post) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** Only published posts, projected to the card. */
|
|
20
|
-
async
|
|
20
|
+
async listPublished(): Promise<PostCard[]> {
|
|
21
21
|
const posts = await this.orm.list({ where: { status: 'published' } });
|
|
22
22
|
return posts.map(({ id, title, status }) => ({ id, title, status }));
|
|
23
23
|
}
|
|
@@ -29,7 +29,7 @@ export default class PostHandler extends Crud(Post) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** Only published posts, projected to the card. */
|
|
32
|
-
async
|
|
32
|
+
async listPublished(): Promise<PostCard[]> {
|
|
33
33
|
const posts = await this.orm.list({ where: { status: 'published' } });
|
|
34
34
|
return posts.map(({ id, title, status }) => ({ id, title, status }));
|
|
35
35
|
}
|