@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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/app/commands/BuildCommand.ts +3 -0
  3. package/app/commands/CallCommand.ts +3 -3
  4. package/app/commands/CheckCommand.ts +11 -8
  5. package/app/commands/DevtoolsCommand.ts +92 -0
  6. package/app/commands/ExplainCommand.ts +51 -8
  7. package/app/commands/FreezeCommand.ts +6 -1
  8. package/app/commands/GraphCommand.ts +3 -0
  9. package/app/commands/KeysCommand.ts +1 -1
  10. package/app/commands/MigrateCommand.ts +3 -0
  11. package/app/commands/ServeCommand.ts +7 -16
  12. package/dist/bin.js +4 -5
  13. package/dist/bin.js.map +1 -1
  14. package/dist/bridge.d.ts.map +1 -1
  15. package/dist/bridge.js +11 -4
  16. package/dist/bridge.js.map +1 -1
  17. package/dist/completion.d.ts +4 -1
  18. package/dist/completion.d.ts.map +1 -1
  19. package/dist/completion.js +54 -20
  20. package/dist/completion.js.map +1 -1
  21. package/dist/loader.d.ts +11 -0
  22. package/dist/loader.d.ts.map +1 -0
  23. package/dist/loader.js +23 -0
  24. package/dist/loader.js.map +1 -0
  25. package/dist/machine.d.ts +16 -0
  26. package/dist/machine.d.ts.map +1 -0
  27. package/dist/machine.js +22 -0
  28. package/dist/machine.js.map +1 -0
  29. package/dist/runner.d.ts.map +1 -1
  30. package/dist/runner.js +16 -7
  31. package/dist/runner.js.map +1 -1
  32. package/fronds/analysis/entities/Build.ts +2 -1
  33. package/fronds/analysis/entities/Check.ts +2 -1
  34. package/fronds/analysis/entities/Devtools.ts +8 -0
  35. package/fronds/analysis/entities/Explain.ts +2 -1
  36. package/fronds/analysis/entities/Freeze.ts +2 -1
  37. package/fronds/analysis/entities/Graph.ts +2 -1
  38. package/fronds/analysis/entities/Migrate.ts +1 -0
  39. package/fronds/analysis/handlers/BuildHandler.ts +2 -1
  40. package/fronds/analysis/handlers/DevtoolsHandler.ts +86 -0
  41. package/fronds/analysis/handlers/ExplainHandler.ts +52 -12
  42. package/fronds/analysis/handlers/FreezeHandler.ts +18 -13
  43. package/fronds/analysis/handlers/MigrateHandler.ts +4 -3
  44. package/fronds/scaffold/entities/BuildFrond.ts +1 -1
  45. package/fronds/scaffold/entities/Call.ts +1 -1
  46. package/fronds/scaffold/entities/Sync.ts +1 -1
  47. package/fronds/scaffold/handlers/BuildFrondHandler.ts +5 -5
  48. package/fronds/scaffold/handlers/SyncHandler.ts +17 -17
  49. package/package.json +9 -8
  50. package/src/bin.ts +83 -0
  51. package/src/bridge.ts +70 -0
  52. package/src/completion.ts +152 -0
  53. package/src/index.ts +3 -0
  54. package/src/loader.ts +28 -0
  55. package/src/machine.ts +23 -0
  56. package/src/runner.ts +146 -0
  57. package/src/theme.ts +19 -0
  58. package/src/ui.ts +131 -0
  59. package/templates/blog/fronds/blog/handlers/PostHandler.ts +1 -1
  60. package/templates/frond/fronds/__name__/handlers/PostHandler.ts +1 -1
  61. package/templates/fronds/blog/handlers/PostHandler.ts +1 -1
@@ -8,17 +8,17 @@ export default class BuildFrondHandler {
8
8
  private cwd = process.cwd();
9
9
 
10
10
  /** Build a frond into a standalone deployable package. */
11
- async execute(input: { name: string }): Promise<{ path: string; entities: string[] }> {
11
+ async execute(input: { frond: string }): Promise<{ path: string; entities: string[] }> {
12
12
  const conventions = resolveConventions((await loadConfig(this.cwd)).conventions);
13
- const frondDir = join(this.cwd, conventions.fronds, input.name);
13
+ const frondDir = join(this.cwd, conventions.fronds, input.frond);
14
14
 
15
15
  if (!existsSync(frondDir)) {
16
- throw new Error(`Frond '${input.name}' not found at ${frondDir}`);
16
+ throw new Error(`Frond '${input.frond}' not found at ${frondDir}`);
17
17
  }
18
18
 
19
19
  const entitiesDir = join(frondDir, conventions.dirs.entities);
20
20
  if (!existsSync(entitiesDir)) {
21
- throw new Error(`No ${conventions.dirs.entities}/ directory in frond '${input.name}'`);
21
+ throw new Error(`No ${conventions.dirs.entities}/ directory in frond '${input.frond}'`);
22
22
  }
23
23
 
24
24
  // Discover entity files
@@ -67,7 +67,7 @@ export default class BuildFrondHandler {
67
67
  const pkgPath = join(frondDir, 'package.json');
68
68
  const pkg = existsSync(pkgPath)
69
69
  ? JSON.parse(readFileSync(pkgPath, 'utf-8'))
70
- : { name: frondPackage(input.name, conventions), version: '0.0.1', type: 'module' };
70
+ : { name: frondPackage(input.frond, conventions), version: '0.0.1', type: 'module' };
71
71
 
72
72
  pkg.exports = {
73
73
  '.': {
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { entitySourceOf, facadeTypeSourceOf, type SchemaDescriptor } from '@fougere/schema';
3
+ import { upperFirst, EntityTypeSource, FacadeTypeSource, type SchemaDescriptor } from '@fougere/schema';
4
4
  // The card's shape is declared once, in core, and imported here. A private copy of it
5
5
  // lived in this file and went stale the day an op stopped being a bare name: nothing
6
6
  // compared the copy to the original, so the drift cost nothing until someone read it.
@@ -26,7 +26,7 @@ export function entityClassName(name: string): string {
26
26
  const identifier = name
27
27
  .split('-')
28
28
  .filter(Boolean)
29
- .map((part) => part[0].toUpperCase() + part.slice(1))
29
+ .map(upperFirst)
30
30
  .join('');
31
31
  if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier)) {
32
32
  throw new Error(`Entity name '${name}' cannot be represented as a TypeScript identifier`);
@@ -93,8 +93,8 @@ export default class SyncHandler {
93
93
  private cwd = process.cwd();
94
94
 
95
95
  /** Mirror a remote frond's contract into local entities. */
96
- async execute(input: { name: string; from: string }): Promise<{ path: string; entities: string[]; removed: string[] }> {
97
- assertSafeName('frond', input.name);
96
+ async execute(input: { frond: string; from: string }): Promise<{ path: string; entities: string[]; removed: string[] }> {
97
+ assertSafeName('frond', input.frond);
98
98
  let remoteUrl: URL;
99
99
  try {
100
100
  remoteUrl = new URL(input.from);
@@ -121,9 +121,9 @@ export default class SyncHandler {
121
121
  if (rpc.error) throw new Error(`Remote error: ${rpc.error.message}`);
122
122
  const card = identityCardOf(rpc.result);
123
123
 
124
- const target = card.fronds.find((f) => f.name === input.name);
124
+ const target = card.fronds.find((f) => f.name === input.frond);
125
125
  if (!target) {
126
- throw new Error(`Frond '${input.name}' not found on ${baseUrl}. Available: ${card.fronds.map((f) => f.name).join(', ')}`);
126
+ throw new Error(`Frond '${input.frond}' not found on ${baseUrl}. Available: ${card.fronds.map((f) => f.name).join(', ')}`);
127
127
  }
128
128
 
129
129
  // The consumer's own convention: a synced frond is laid out like the ones they wrote,
@@ -132,7 +132,7 @@ export default class SyncHandler {
132
132
  const entities = conventions.dirs.entities;
133
133
  const handlers = conventions.dirs.handlers;
134
134
 
135
- const frondDir = join(this.cwd, '.fougere', 'remotes', input.name);
135
+ const frondDir = join(this.cwd, '.fougere', 'remotes', input.frond);
136
136
  const entitiesDir = join(frondDir, entities);
137
137
  const handlersDir = join(frondDir, handlers);
138
138
  mkdirSync(entitiesDir, { recursive: true });
@@ -158,7 +158,7 @@ export default class SyncHandler {
158
158
  /**
159
159
  * One card, one class.
160
160
  *
161
- * `reconstruct` gives the JUDGE — validate, from, getFields — and now takes the
161
+ * `Card.toSchema` gives the JUDGE — validate, from, getFields — and now takes the
162
162
  * row shape as a type argument, so the same declaration gives the TYPE. Both
163
163
  * come off the same card: nothing to keep in step, and the file a consumer reads
164
164
  * has the shape of the one they would have written by hand
@@ -167,10 +167,10 @@ export default class SyncHandler {
167
167
  const writeRow = (className: string, descriptor: SchemaDescriptor): void => {
168
168
  written.add(join(entitiesDir, `${className}.ts`));
169
169
  writeFileSync(join(entitiesDir, `${className}.ts`), [
170
- `import { reconstruct } from '@fougere/schema';`,
170
+ `import { Card } from '@fougere/schema';`,
171
171
  ``,
172
172
  `// Generated by \`fougere sync\` from ${baseUrl} — do not edit.`,
173
- entitySourceOf(descriptor, { name: className }),
173
+ EntityTypeSource.of(descriptor).render({ name: className }),
174
174
  ``,
175
175
  `export default ${className};`,
176
176
  ``,
@@ -203,7 +203,7 @@ export default class SyncHandler {
203
203
  `// contract, and a contract that drags a runtime dependency is not one.`,
204
204
  `type Invocation = { params?: Record<string, string>; query?: Record<string, unknown>; body?: unknown; state?: Record<string, unknown> };`,
205
205
  ``,
206
- facadeTypeSourceOf(ops ?? [], {
206
+ FacadeTypeSource.of(ops ?? []).render({
207
207
  name: `${className}Handler`,
208
208
  ...(descriptor !== undefined ? { rowType: className } : {}),
209
209
  }),
@@ -239,10 +239,10 @@ export default class SyncHandler {
239
239
 
240
240
  // Package.json
241
241
  writeFileSync(join(frondDir, 'package.json'), JSON.stringify({
242
- name: frondPackage(input.name, conventions),
242
+ name: frondPackage(input.frond, conventions),
243
243
  version: '0.0.0-synced',
244
244
  type: 'module',
245
- fougere: { frond: input.name, synced: true, source: baseUrl },
245
+ fougere: { frond: input.frond, synced: true, source: baseUrl },
246
246
  exports: {
247
247
  '.': './index.ts',
248
248
  [`./${entities}/*`]: `./${entities}/*.ts`,
@@ -251,11 +251,11 @@ export default class SyncHandler {
251
251
  },
252
252
  }, null, 2) + '\n');
253
253
 
254
- // Update .fougere/remotes.json central registry of synced remotes
255
- this.updateRemotesRegistry(input.name, baseUrl, frondDir);
254
+ // .fougere/remotes.json is the central registry of synced remotes.
255
+ this.updateRemotesRegistry(input.frond, baseUrl, frondDir);
256
256
 
257
- // Update tsconfig paths if tsconfig.json exists (non-Nuxt projects)
258
- this.updateTsconfigPaths(input.name, frondDir, conventions);
257
+ // Non-Nuxt projects only: Nuxt writes its own paths.
258
+ this.updateTsconfigPaths(input.frond, frondDir, conventions);
259
259
 
260
260
  /**
261
261
  * What the host no longer serves stops being importable here.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fougere/cli",
3
- "version": "0.3.0-alpha.0",
3
+ "version": "0.5.0-alpha.0",
4
4
  "description": "The Fougere CLI — compose a workspace, serve a frond, call an operation.",
5
5
  "keywords": [
6
6
  "fougere",
@@ -32,7 +32,8 @@
32
32
  "dist",
33
33
  "app",
34
34
  "fronds",
35
- "templates"
35
+ "templates",
36
+ "src"
36
37
  ],
37
38
  "dependencies": {
38
39
  "@clack/prompts": "^0.10.0",
@@ -40,12 +41,12 @@
40
41
  "consola": "^3.4.2",
41
42
  "jiti": "^2.4.2",
42
43
  "picocolors": "^1.1.1",
43
- "@fougere/container": "0.3.0-alpha.0",
44
- "@fougere/schema": "0.3.0-alpha.0",
45
- "@fougere/adapter-sql": "0.3.0-alpha.0",
46
- "@fougere/transport-http": "0.3.0-alpha.0",
47
- "@fougere/core": "0.3.0-alpha.0",
48
- "@fougere/defaults": "0.3.0-alpha.0"
44
+ "@fougere/adapter-sql": "0.5.0-alpha.0",
45
+ "@fougere/core": "0.5.0-alpha.0",
46
+ "@fougere/container": "0.5.0-alpha.0",
47
+ "@fougere/schema": "0.5.0-alpha.0",
48
+ "@fougere/defaults": "0.5.0-alpha.0",
49
+ "@fougere/transport-http": "0.5.0-alpha.0"
49
50
  },
50
51
  "devDependencies": {
51
52
  "vitest": "^4.1.0"
package/src/bin.ts ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fougere CLI — a Fougere app powered by citty.
4
+ *
5
+ * src/ → compiled (tsc → dist/)
6
+ * fronds/ → loaded at runtime by jiti (domain)
7
+ * app/ → loaded at runtime by jiti (presentation)
8
+ */
9
+ import { createApp, setLogLevel, envLevel, type ScanResult } from '@fougere/core';
10
+ import { scanProject, getModuleLoader, frondDirsOf, DEFAULT_CONVENTIONS } from '@fougere/core/node';
11
+ import { readdir, stat } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { createContainer } from '@fougere/container';
14
+ import { ui } from './ui.js';
15
+ import { run } from './runner.js';
16
+ import { installLoader } from './loader.js';
17
+
18
+ await installLoader(process.cwd());
19
+
20
+ const cliRoot = new URL('..', import.meta.url).pathname;
21
+ const container = createContainer();
22
+ const terminal = ui();
23
+ container.registerValue('ui', terminal);
24
+ container.registerValue('cwd', process.cwd());
25
+
26
+ // The CLI is a Fougere app — silence its boot chatter unless explicitly asked. The
27
+ // threshold is SET, not only announced: a static import evaluates the logger module,
28
+ // its env read included, before this line runs.
29
+ process.env.FOUGERE_LOG_LEVEL ??= 'warn';
30
+ setLogLevel(envLevel() ?? 'warn');
31
+
32
+ /** The newest declaration under `fronds/`, or 0 when there is none to compare against. */
33
+ async function newestDeclaration(root: string): Promise<number> {
34
+ const frondsDir = join(root, DEFAULT_CONVENTIONS.fronds);
35
+ const names = await readdir(frondsDir, { withFileTypes: true }).catch(() => []);
36
+ const dirs = names.filter((entry) => entry.isDirectory())
37
+ .flatMap((entry) => [
38
+ join(frondsDir, entry.name),
39
+ ...frondDirsOf(DEFAULT_CONVENTIONS).map((dir) => join(frondsDir, entry.name, dir)),
40
+ ]);
41
+
42
+ const times = await Promise.all(dirs.map(async (dir) => {
43
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
44
+ const stats = await Promise.all(entries
45
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
46
+ .map((entry) => stat(join(dir, entry.name)).then((s) => s.mtimeMs).catch(() => 0)));
47
+ return Math.max(0, ...stats);
48
+ }));
49
+ return Math.max(0, ...times);
50
+ }
51
+
52
+ /**
53
+ * The CLI is a Fougere app, so it reads its own written-down scan like any deployment —
54
+ * producing the description reads the project, consuming it does not.
55
+ *
56
+ * Reading its own 44 declarations through the compiler cost 617 ms on every invocation,
57
+ * for a domain fixed at publish time. Staleness is decided by mtime rather than by a flag:
58
+ * editing a frond must not need a command, and a published package has nothing newer than
59
+ * its artefact.
60
+ */
61
+ async function scanOf(root: string): Promise<ScanResult> {
62
+ const written = join(root, '.fougere/scan.generated.ts');
63
+ const writtenAt = await stat(written).then((s) => s.mtimeMs).catch(() => 0);
64
+ if (writtenAt > 0 && writtenAt >= await newestDeclaration(root)) {
65
+ return ((await getModuleLoader()(written)) as unknown as { scan: ScanResult }).scan;
66
+ }
67
+
68
+ // The only slow phase, and it announced nothing: the boot states it at `info`, which the
69
+ // threshold above lowers to `warn`. The terminal says it instead, and a pipe keeps its
70
+ // output parsable.
71
+ const spin = process.stdout.isTTY ? terminal.spinner('reading fronds') : undefined;
72
+ const scan = await scanProject(root);
73
+ spin?.stop(`${scan.fronds.length} frond(s)`);
74
+ return scan;
75
+ }
76
+
77
+ const scan = await scanOf(cliRoot);
78
+
79
+ const app = await createApp({ scan, createContainer: () => container });
80
+
81
+ container.registerValue('app', app);
82
+
83
+ await run(app);
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/machine.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A command's machine output — the one shape a pipe reads.
3
+ *
4
+ * There is no list of commands here: `json` is declared by an entity like any other
5
+ * field, so a command that declares it gets the door. The runner used to name `explain`
6
+ * in an `if`, and `graph --json` announced a flag it then ignored.
7
+ */
8
+ export function machineWanted(raw: Record<string, unknown>): boolean {
9
+ return raw.json === true || typeof raw.names === 'string';
10
+ }
11
+
12
+ /**
13
+ * A `Map` serializes to `{}`, so the door converts it rather than each command flattening
14
+ * its own result: `GraphResult.nodes` is a Map, and `graph --json` would have printed a
15
+ * report with an empty graph in it.
16
+ */
17
+ export function machineText(value: unknown): string {
18
+ return JSON.stringify(value, (_key, held) => (held instanceof Map ? Object.fromEntries(held) : held), 2);
19
+ }
20
+
21
+ export function printMachine(value: unknown): void {
22
+ process.stdout.write(machineText(value) + '\n');
23
+ }