@fougere/cli 0.2.0-alpha.2 → 0.4.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.
Files changed (81) hide show
  1. package/README.md +11 -2
  2. package/app/commands/BuildCommand.ts +39 -0
  3. package/app/commands/CallCommand.ts +3 -3
  4. package/app/commands/CheckCommand.ts +2 -1
  5. package/app/commands/ExplainCommand.ts +124 -0
  6. package/app/commands/FreezeCommand.ts +107 -0
  7. package/app/commands/GrantCommand.ts +44 -0
  8. package/app/commands/KeysCommand.ts +56 -0
  9. package/app/commands/MigrateCommand.ts +54 -0
  10. package/app/commands/NewCommand.ts +5 -5
  11. package/app/commands/ServeCommand.ts +74 -7
  12. package/app/commands/grant-material.ts +5 -0
  13. package/dist/bin.js +55 -10
  14. package/dist/bin.js.map +1 -1
  15. package/dist/bridge.d.ts.map +1 -1
  16. package/dist/bridge.js +15 -8
  17. package/dist/bridge.js.map +1 -1
  18. package/dist/completion.d.ts +4 -1
  19. package/dist/completion.d.ts.map +1 -1
  20. package/dist/completion.js +54 -20
  21. package/dist/completion.js.map +1 -1
  22. package/dist/loader.d.ts +11 -0
  23. package/dist/loader.d.ts.map +1 -0
  24. package/dist/loader.js +23 -0
  25. package/dist/loader.js.map +1 -0
  26. package/dist/runner.d.ts.map +1 -1
  27. package/dist/runner.js +7 -6
  28. package/dist/runner.js.map +1 -1
  29. package/fronds/analysis/entities/Build.ts +7 -0
  30. package/fronds/analysis/entities/Explain.ts +9 -0
  31. package/fronds/analysis/entities/Freeze.ts +7 -0
  32. package/fronds/analysis/entities/Migrate.ts +7 -0
  33. package/fronds/analysis/handlers/BuildHandler.ts +60 -0
  34. package/fronds/analysis/handlers/CheckHandler.ts +40 -41
  35. package/fronds/analysis/handlers/ExplainHandler.ts +254 -0
  36. package/fronds/analysis/handlers/FreezeHandler.ts +176 -0
  37. package/fronds/analysis/handlers/MigrateHandler.ts +97 -0
  38. package/fronds/analysis/services/ProjectScan.ts +23 -7
  39. package/fronds/analysis/versions.ts +58 -0
  40. package/fronds/scaffold/entities/BuildFrond.ts +1 -1
  41. package/fronds/scaffold/entities/Call.ts +1 -1
  42. package/fronds/scaffold/entities/Grant.ts +6 -0
  43. package/fronds/scaffold/entities/Keys.ts +4 -0
  44. package/fronds/scaffold/entities/Serve.ts +2 -1
  45. package/fronds/scaffold/entities/Sync.ts +1 -1
  46. package/fronds/scaffold/handlers/BuildFrondHandler.ts +13 -15
  47. package/fronds/scaffold/handlers/GrantHandler.ts +8 -0
  48. package/fronds/scaffold/handlers/KeysHandler.ts +8 -0
  49. package/fronds/scaffold/handlers/SyncHandler.ts +40 -37
  50. package/fronds/scaffold/services/ProjectWriter.ts +9 -8
  51. package/package.json +10 -8
  52. package/src/bin.ts +83 -0
  53. package/src/bridge.ts +70 -0
  54. package/src/completion.ts +152 -0
  55. package/src/index.ts +3 -0
  56. package/src/loader.ts +28 -0
  57. package/src/runner.ts +139 -0
  58. package/src/theme.ts +19 -0
  59. package/src/ui.ts +131 -0
  60. package/templates/admin/fronds/admin/handlers/UserHandler.ts +3 -5
  61. package/templates/admin/fronds/admin/package.json +1 -1
  62. package/templates/api/fronds/api/handlers/TaskHandler.ts +3 -5
  63. package/templates/api/fronds/api/package.json +1 -1
  64. package/templates/apps/nuxt/app/pages/index.vue +1 -1
  65. package/templates/blog/app/pages/posts/index.vue +1 -1
  66. package/templates/blog/app/pages/posts/manage.vue +1 -1
  67. package/templates/blog/app/pages/posts/new.vue +1 -1
  68. package/templates/blog/fronds/blog/handlers/PostHandler.ts +3 -5
  69. package/templates/blog/fronds/blog/package.json +1 -1
  70. package/templates/flat/AGENTS.md +14 -0
  71. package/templates/flat/CLAUDE.md +25 -3
  72. package/templates/frond/AGENTS.md +14 -0
  73. package/templates/frond/CLAUDE.md +25 -3
  74. package/templates/frond/fronds/__name__/handlers/PostHandler.ts +3 -5
  75. package/templates/frond/fronds/__name__/package.json +1 -1
  76. package/templates/frond/serve.mjs +3 -2
  77. package/templates/fronds/blank/package.json +1 -1
  78. package/templates/fronds/blog/handlers/PostHandler.ts +2 -4
  79. package/templates/fronds/blog/package.json +1 -1
  80. package/templates/workspace/AGENTS.md +14 -0
  81. package/templates/workspace/CLAUDE.md +25 -3
package/src/bridge.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { Lifecycle, Role } from '@fougere/schema';
2
+ /**
3
+ * Entity → citty bridge.
4
+ *
5
+ * Converts Entity fields into citty ArgsDef.
6
+ * The Entity IS the CLI definition — no duplicate schema.
7
+ */
8
+ import type { Fields } from '@fougere/schema';
9
+ import { Anatomy, Visibility } from '@fougere/schema';
10
+ import type { ArgsDef, ArgDef } from 'citty';
11
+
12
+ function toKebab(name: string): string {
13
+ return name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
14
+ }
15
+
16
+ /** Convert an Entity's fields into citty args definition. */
17
+ export function entityToArgs(fields: Fields): ArgsDef {
18
+ const args: ArgsDef = {};
19
+ let positionalIndex = 0;
20
+
21
+ // Axes-derived ingress membership; the CLI additionally skips ALL relations
22
+ // (a ref is not a flag — supplying related rows is not a CLI gesture).
23
+ for (const [key, field] of Object.entries(Visibility.of(fields).input)) {
24
+ if (Role.of(field).relation) continue;
25
+
26
+ // A `default(v)` travels as the create rule `{ value }` — citty shows it.
27
+ const defaultValue = Lifecycle.of(field).literal?.value;
28
+ const { base: shape, nullable } = Anatomy.of(field.shape);
29
+
30
+ const kebab = toKebab(key);
31
+ const def: ArgDef = {
32
+ description: field.meta?.description,
33
+ required: !nullable && Lifecycle.of(field).requiredAtCreate,
34
+ };
35
+
36
+ switch (shape?.type) {
37
+ case 'boolean':
38
+ (def as Record<string, unknown>).type = 'boolean';
39
+ if (defaultValue !== undefined) def.default = defaultValue as boolean;
40
+ break;
41
+ case 'number':
42
+ case 'integer':
43
+ case 'string':
44
+ // A closed set is citty's `enum`: the shape already names the legal values, so the
45
+ // refusal and the `--help` listing come from the declaration rather than a check
46
+ // written beside it.
47
+ if (shape.type === 'string' && shape.enum?.length) {
48
+ (def as Record<string, unknown>).type = 'enum';
49
+ (def as Record<string, unknown>).options = shape.enum.filter((v) => v !== null);
50
+ // A date-time string stays a named string — never a positional arg.
51
+ } else if (shape.type === 'string' && shape.format === 'date-time') {
52
+ (def as Record<string, unknown>).type = 'string';
53
+ } else if (positionalIndex === 0 && def.required && key !== 'force') {
54
+ // First non-bool required field becomes positional
55
+ (def as Record<string, unknown>).type = 'positional';
56
+ positionalIndex++;
57
+ } else {
58
+ (def as Record<string, unknown>).type = 'string';
59
+ }
60
+ if (defaultValue !== undefined) def.default = String(defaultValue);
61
+ break;
62
+ default:
63
+ (def as Record<string, unknown>).type = 'string';
64
+ }
65
+
66
+ args[kebab === key ? key : kebab] = def;
67
+ }
68
+
69
+ return args;
70
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Shell completion generator — outputs a script for bash/zsh.
3
+ *
4
+ * Commands and flags are the CLI's own entities, read once at generation. The VALUES a
5
+ * positional accepts belong to the project the shell sits in, so they are not written
6
+ * down here: the script asks `fougere explain --names` at the moment of the TAB. A list
7
+ * frozen into a script is stale the first time a handler is added.
8
+ */
9
+ import type { App } from '@fougere/core';
10
+ import { entityToArgs } from './bridge.js';
11
+
12
+ function toKebab(name: string): string {
13
+ return name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()).replace(/^-/, '');
14
+ }
15
+
16
+ /** What a positional names, said by the field's own name — no table to keep in step. */
17
+ const NAMES: Record<string, 'operations' | 'fronds' | undefined> = {
18
+ operation: 'operations',
19
+ frond: 'fronds',
20
+ };
21
+
22
+ interface CommandMeta {
23
+ name: string;
24
+ flags: string[];
25
+ names?: 'operations' | 'fronds';
26
+ }
27
+
28
+ function extractCommandMeta(app: App): CommandMeta[] {
29
+ const commands: CommandMeta[] = [];
30
+
31
+ for (const frond of app.fronds) {
32
+ const handlerMap = new Map(frond.handlers.map((h) => [h.address, h]));
33
+
34
+ for (const entity of frond.entities) {
35
+ const handler = handlerMap.get(entity.name);
36
+ if (!handler) continue;
37
+
38
+ let facade: Record<string, Function>;
39
+ try { facade = app.resolve(handler.name); } catch { continue; }
40
+ if (typeof facade.execute !== 'function') continue;
41
+
42
+ // The same function the runner builds citty's args from, so the positional and the
43
+ // flags are the ones the command actually has.
44
+ const args = Object.entries(entityToArgs(entity.entityClass.getFields())) as
45
+ [string, { type?: string }][];
46
+ const positional = args.find(([, def]) => def.type === 'positional')?.[0];
47
+
48
+ commands.push({
49
+ name: toKebab(entity.name),
50
+ flags: args.filter(([, def]) => def.type !== 'positional').map(([key]) => `--${key}`),
51
+ ...(positional && NAMES[positional] ? { names: NAMES[positional]! } : {}),
52
+ });
53
+ }
54
+ }
55
+
56
+ return commands;
57
+ }
58
+
59
+ export function generateZshCompletion(app: App, binName = 'fougere'): string {
60
+ const commands = extractCommandMeta(app);
61
+
62
+ const valueCases = commands
63
+ .filter((cmd) => cmd.names)
64
+ .map((cmd) => ` ${cmd.name}) _${binName}_names ${cmd.names} ;;`)
65
+ .join('\n');
66
+
67
+ const flagCases = commands
68
+ .map((cmd) => ` ${cmd.name}) flags="${cmd.flags.join(' ')}" ;;`)
69
+ .join('\n');
70
+
71
+ return `# Auto-generated by @fougere/cli
72
+
73
+ # The project answers; a stale list in this file would not.
74
+ _${binName}_names() {
75
+ local -a values
76
+ values=(\${(f)"$(${binName} explain --names $1 2>/dev/null)"})
77
+ (( \${#values} )) && compadd -- \${values}
78
+ }
79
+
80
+ _${binName}() {
81
+ local -a commands flags
82
+ commands=(${commands.map((c) => `'${c.name}'`).join(' ')})
83
+
84
+ if (( CURRENT == 2 )); then
85
+ _describe 'command' commands
86
+ return
87
+ fi
88
+
89
+ local cmd=\${words[2]}
90
+
91
+ if [[ "\${words[CURRENT]}" != --* ]]; then
92
+ case "$cmd" in
93
+ ${valueCases}
94
+ esac
95
+ return
96
+ fi
97
+
98
+ case "$cmd" in
99
+ ${flagCases}
100
+ *) flags="" ;;
101
+ esac
102
+ _values 'flags' \${(s: :)flags}
103
+ }
104
+
105
+ compdef _${binName} ${binName}
106
+ `;
107
+ }
108
+
109
+ export function generateBashCompletion(app: App, binName = 'fougere'): string {
110
+ const commands = extractCommandMeta(app);
111
+ const cmdNames = commands.map((c) => c.name).join(' ');
112
+
113
+ const valueCases = commands
114
+ .filter((cmd) => cmd.names)
115
+ .map((cmd) => ` ${cmd.name}) names="${cmd.names}" ;;`)
116
+ .join('\n');
117
+
118
+ const flagCases = commands
119
+ .map((cmd) => ` ${cmd.name}) COMPREPLY=( $(compgen -W "${cmd.flags.join(' ')}" -- "\${cur}") ) ;;`)
120
+ .join('\n');
121
+
122
+ return `# Auto-generated by @fougere/cli
123
+ _${binName}_completions() {
124
+ local cur cmd names
125
+ cur="\${COMP_WORDS[COMP_CWORD]}"
126
+ cmd="\${COMP_WORDS[1]}"
127
+
128
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
129
+ COMPREPLY=( $(compgen -W "${cmdNames}" -- "\${cur}") )
130
+ return
131
+ fi
132
+
133
+ if [[ "\${cur}" != --* ]]; then
134
+ names=""
135
+ case "$cmd" in
136
+ ${valueCases}
137
+ esac
138
+ if [[ -n "\${names}" ]]; then
139
+ # The project answers; a stale list in this file would not.
140
+ COMPREPLY=( $(compgen -W "$(${binName} explain --names "\${names}" 2>/dev/null | tr '\\n' ' ')" -- "\${cur}") )
141
+ return
142
+ fi
143
+ fi
144
+
145
+ case "$cmd" in
146
+ ${flagCases}
147
+ esac
148
+ }
149
+
150
+ complete -F _${binName}_completions ${binName}
151
+ `;
152
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { run } from './runner.js';
2
+ export { entityToArgs } from './bridge.js';
3
+ export { generateZshCompletion, generateBashCompletion } from './completion.js';
package/src/loader.ts ADDED
@@ -0,0 +1,28 @@
1
+ import {
2
+ setModuleLoader, loadConfig, resolveConventions, frondAliases,
3
+ } from '@fougere/core/node';
4
+ import type { Conventions } from '@fougere/core/node';
5
+
6
+ /**
7
+ * The loader every command needs: `alias` is what makes `@fronds/user/entities/User.js`
8
+ * resolve when one frond names its neighbour, in any command that loads user code.
9
+ *
10
+ * Two jitis, because the config is read BEFORE the aliases — it names the scope they are
11
+ * built from. `reread` drops the module cache: every loader caches by specifier, so a
12
+ * second boot in one process would be handed what the first one read.
13
+ */
14
+ export async function installLoader(root: string, reread = false): Promise<Conventions> {
15
+ const { createJiti } = await import('jiti');
16
+ const bare = createJiti(import.meta.url, { interopDefault: true });
17
+ setModuleLoader((filePath) => bare.import(filePath) as Promise<Record<string, unknown>>);
18
+
19
+ const conventions = resolveConventions((await loadConfig(root)).conventions);
20
+ const jiti = createJiti(import.meta.url, {
21
+ interopDefault: true,
22
+ alias: await frondAliases(root, conventions),
23
+ ...(reread ? { moduleCache: false } : {}),
24
+ });
25
+ setModuleLoader((filePath) => jiti.import(filePath) as Promise<Record<string, unknown>>);
26
+
27
+ return conventions;
28
+ }
package/src/runner.ts ADDED
@@ -0,0 +1,139 @@
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 { entityToArgs } from './bridge.js';
16
+ import { readdir } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+
19
+ function toKebab(name: string): string {
20
+ return name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()).replace(/^-/, '');
21
+ }
22
+
23
+ function toCamel(kebab: string): string {
24
+ return kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
25
+ }
26
+
27
+ /** Scan app/commands/ for command classes. */
28
+ async function loadAppCommands(
29
+ cliRoot: string,
30
+ loader: (path: string) => Promise<Record<string, unknown>>,
31
+ ): Promise<Map<string, new (...args: unknown[]) => { run: (raw: Record<string, unknown>) => Promise<void> }>> {
32
+ const map = new Map();
33
+ const dir = join(cliRoot, 'app', 'commands');
34
+ const files = await readdir(dir, { withFileTypes: true }).catch(() => []);
35
+
36
+ for (const f of files) {
37
+ if (!f.isFile() || !(f.name.endsWith('.ts') || f.name.endsWith('.js'))) continue;
38
+ const name = f.name.replace(/Command\.(ts|js)$/, '').replace(/\.(ts|js)$/, '');
39
+ const kebab = toKebab(name);
40
+ const mod = await loader(join(dir, f.name));
41
+ if (mod.default && typeof mod.default === 'function') {
42
+ map.set(kebab, mod.default);
43
+ }
44
+ }
45
+
46
+ return map;
47
+ }
48
+
49
+ export async function run(app: App): Promise<void> {
50
+ const terminal = ui();
51
+ const cliRoot = new URL('..', import.meta.url).pathname;
52
+
53
+ // Load app commands (presentation layer)
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. The
102
+ // explain command owns its machine output and therefore gets a clean stdout.
103
+ const machineOutput = cmdName === 'explain'
104
+ && (parsed.json === true || typeof parsed.names === 'string');
105
+ if (cmdName !== 'completion' && !machineOutput) terminal.intro();
106
+
107
+ // citty adds `_` (raw positionals) and `--` (passthrough); strip them
108
+ // so only the entity's own fields reach the handler.
109
+ const input = { ...(parsed as Record<string, unknown>) };
110
+ delete input._;
111
+ delete input['--'];
112
+
113
+ try {
114
+ if (AppCommand) {
115
+ const cmd = new (AppCommand as new (...a: unknown[]) => { run: (raw: Record<string, unknown>) => Promise<void> })(app, terminal);
116
+ await cmd.run(input);
117
+ } else {
118
+ // Ride the call contract — the same envelope every consumer uses.
119
+ await createAppRunner(app)(
120
+ { entity: lowerFirst(entity.name), op: 'execute' },
121
+ { params: {}, query: {}, body: input, state: {} },
122
+ );
123
+ }
124
+ } catch (err) {
125
+ terminal.error(err instanceof Error ? err.message : String(err));
126
+ process.exit(1);
127
+ }
128
+ },
129
+ });
130
+ }
131
+ }
132
+
133
+ const main = defineCommand({
134
+ meta: { name: 'fougere', description: 'Fougere CLI' },
135
+ subCommands,
136
+ });
137
+
138
+ await runMain(main);
139
+ }
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
+ }
@@ -13,7 +13,7 @@ export default class UserHandler extends Crud(User) {
13
13
  if (!user) {
14
14
  throw new FougereError({ code: ErrorCode.NOT_FOUND, message: `User '${id}' not found`, entity: 'user', operation: 'deactivate' });
15
15
  }
16
- if ((user as { status?: string }).status === 'inactive') {
16
+ if (user.status === 'inactive') {
17
17
  throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already inactive', entity: 'user', operation: 'deactivate' });
18
18
  }
19
19
  return this.orm.update(id, { status: 'inactive' });
@@ -21,9 +21,7 @@ export default class UserHandler extends Crud(User) {
21
21
 
22
22
  /** Active users, projected to the card contract. */
23
23
  async active(): Promise<UserCard[]> {
24
- const all = await this.orm.list();
25
- return all
26
- .filter((u) => (u as { status?: string }).status === 'active')
27
- .map((u) => ({ id: String(u.id), name: String(u.name), status: 'active' })) as UserCard[];
24
+ const users = await this.orm.list({ where: { status: 'active' } });
25
+ return users.map(({ id, name, status }) => ({ id, name, status }));
28
26
  }
29
27
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "@frond/admin",
2
+ "name": "@fronds/admin",
3
3
  "version": "0.0.1",
4
4
  "type": "module",
5
5
  "exports": {
@@ -13,7 +13,7 @@ export default class TaskHandler extends Crud(Task) {
13
13
  if (!task) {
14
14
  throw new FougereError({ code: ErrorCode.NOT_FOUND, message: `Task '${id}' not found`, entity: 'task', operation: 'complete' });
15
15
  }
16
- if ((task as { status?: string }).status === 'done') {
16
+ if (task.status === 'done') {
17
17
  throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already done', entity: 'task', operation: 'complete' });
18
18
  }
19
19
  return this.orm.update(id, { status: 'done' });
@@ -21,9 +21,7 @@ export default class TaskHandler extends Crud(Task) {
21
21
 
22
22
  /** Still-open tasks, projected to the card contract. */
23
23
  async open(): Promise<TaskCard[]> {
24
- const all = await this.orm.list();
25
- return all
26
- .filter((t) => (t as { status?: string }).status === 'open')
27
- .map((t) => ({ id: String(t.id), title: String(t.title), status: 'open' })) as TaskCard[];
24
+ const tasks = await this.orm.list({ where: { status: 'open' } });
25
+ return tasks.map(({ id, title, status }) => ({ id, title, status }));
28
26
  }
29
27
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "@frond/api",
2
+ "name": "@fronds/api",
3
3
  "version": "0.0.1",
4
4
  "type": "module",
5
5
  "exports": {
@@ -3,7 +3,7 @@
3
3
  // They take an ENTITY, so this page names none: what is composed here is yours, and a
4
4
  // scaffold that guessed at an entity shipped a page that could not run.
5
5
  //
6
- // import Post from '@frond/<your-frond>/entities/Post'
6
+ // import Post from '@fronds/<your-frond>/entities/Post'
7
7
  // const { items, loading } = await useQuery(Post, 'list')
8
8
  // const { values, errors, submit } = useFormFor(Post)
9
9
  // const { execute } = useCommand(Post, 'publish')
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import Post from '@frond/blog/entities/Post';
2
+ import Post from '@fronds/blog/entities/Post';
3
3
 
4
4
  interface Card { id: string; title: string; status: string }
5
5
  const { items: posts, loading, error } = await useQuery<Card>(Post, 'published');
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import Post from '@frond/blog/entities/Post';
2
+ import Post from '@fronds/blog/entities/Post';
3
3
 
4
4
  interface Row { id: string; title: string; status: 'draft' | 'published' }
5
5
  const { items: posts, loading } = await useQuery<Row>(Post, 'list');
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import Post from '@frond/blog/entities/Post';
2
+ import Post from '@fronds/blog/entities/Post';
3
3
 
4
4
  const { values, errors, submit, loading, error } = useFormFor(Post);
5
5
 
@@ -19,7 +19,7 @@ export default class PostHandler extends Crud(Post) {
19
19
  if (!post) {
20
20
  throw new FougereError({ code: ErrorCode.NOT_FOUND, message: `Post '${id}' not found`, entity: 'post', operation: 'publish' });
21
21
  }
22
- if ((post as { status?: string }).status === 'published') {
22
+ if (post.status === 'published') {
23
23
  throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already published', entity: 'post', operation: 'publish' });
24
24
  }
25
25
  return this.orm.update(id, { status: 'published' });
@@ -27,9 +27,7 @@ export default class PostHandler extends Crud(Post) {
27
27
 
28
28
  /** Only published posts exist for the outside world, projected to the card. */
29
29
  async published(): Promise<PostCard[]> {
30
- const all = await this.orm.list();
31
- return all
32
- .filter((p) => (p as { status?: string }).status === 'published')
33
- .map((p) => ({ id: String(p.id), title: String(p.title), status: 'published' })) as PostCard[];
30
+ const posts = await this.orm.list({ where: { status: 'published' } });
31
+ return posts.map(({ id, title, status }) => ({ id, title, status }));
34
32
  }
35
33
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "@frond/blog",
2
+ "name": "@fronds/blog",
3
3
  "version": "0.0.1",
4
4
  "type": "module",
5
5
  "exports": {