@fougere/cli 0.3.0-alpha.0 → 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 (40) hide show
  1. package/README.md +1 -1
  2. package/app/commands/CallCommand.ts +3 -3
  3. package/app/commands/ExplainCommand.ts +51 -4
  4. package/app/commands/FreezeCommand.ts +1 -1
  5. package/app/commands/KeysCommand.ts +1 -1
  6. package/app/commands/ServeCommand.ts +7 -16
  7. package/dist/bin.js +4 -5
  8. package/dist/bin.js.map +1 -1
  9. package/dist/bridge.d.ts.map +1 -1
  10. package/dist/bridge.js +11 -4
  11. package/dist/bridge.js.map +1 -1
  12. package/dist/completion.d.ts +4 -1
  13. package/dist/completion.d.ts.map +1 -1
  14. package/dist/completion.js +54 -20
  15. package/dist/completion.js.map +1 -1
  16. package/dist/loader.d.ts +11 -0
  17. package/dist/loader.d.ts.map +1 -0
  18. package/dist/loader.js +23 -0
  19. package/dist/loader.js.map +1 -0
  20. package/dist/runner.d.ts.map +1 -1
  21. package/dist/runner.js +4 -3
  22. package/dist/runner.js.map +1 -1
  23. package/fronds/analysis/entities/Explain.ts +2 -1
  24. package/fronds/analysis/handlers/ExplainHandler.ts +52 -12
  25. package/fronds/analysis/handlers/FreezeHandler.ts +15 -11
  26. package/fronds/analysis/handlers/MigrateHandler.ts +2 -2
  27. package/fronds/scaffold/entities/BuildFrond.ts +1 -1
  28. package/fronds/scaffold/entities/Call.ts +1 -1
  29. package/fronds/scaffold/entities/Sync.ts +1 -1
  30. package/fronds/scaffold/handlers/BuildFrondHandler.ts +5 -5
  31. package/fronds/scaffold/handlers/SyncHandler.ts +15 -15
  32. package/package.json +9 -8
  33. package/src/bin.ts +83 -0
  34. package/src/bridge.ts +70 -0
  35. package/src/completion.ts +152 -0
  36. package/src/index.ts +3 -0
  37. package/src/loader.ts +28 -0
  38. package/src/runner.ts +139 -0
  39. package/src/theme.ts +19 -0
  40. package/src/ui.ts +131 -0
@@ -5,6 +5,7 @@ import {
5
5
  } from '@fougere/core';
6
6
  import { relative } from 'node:path';
7
7
  import ProjectScan from '../services/ProjectScan.js';
8
+ import { ANONYMOUS_SCHEMA_NAME, lowerFirst, type SchemaView } from '@fougere/schema';
8
9
 
9
10
  type Cardinality = NonNullable<OperationContract['cardinality']>;
10
11
  type Binding = EffectiveOperation['binding'][number];
@@ -60,6 +61,17 @@ export interface ExplainResult {
60
61
  };
61
62
  }
62
63
 
64
+ /** What this project serves, when no single operation was named. */
65
+ export interface ExplainListing {
66
+ fronds: {
67
+ name: string;
68
+ runtime: 'local' | 'remote';
69
+ remote: string | null;
70
+ operations: number;
71
+ }[];
72
+ operations: string[];
73
+ }
74
+
63
75
  interface Selector {
64
76
  frond?: string;
65
77
  surface?: string;
@@ -71,17 +83,46 @@ interface Selector {
71
83
  export default class ExplainHandler {
72
84
  constructor(private projectScan: ProjectScan) {}
73
85
 
86
+ /** The names this project serves — the list `explain` used to spell in a refusal only. */
87
+ async list(input: { root?: string }): Promise<ExplainListing> {
88
+ const { scan, model } = await this.modelOf(input.root);
89
+ const remotes = scan.config.remotes ?? {};
90
+ const counted = new Map<string, number>();
91
+ for (const operation of model.operations) {
92
+ const frond = operation.placement.frond;
93
+ counted.set(frond, (counted.get(frond) ?? 0) + 1);
94
+ }
95
+
96
+ return {
97
+ fronds: scan.fronds.map((frond) => ({
98
+ name: frond.name,
99
+ runtime: remotes[frond.name] ? 'remote' as const : 'local' as const,
100
+ remote: remotes[frond.name] ?? null,
101
+ operations: counted.get(frond.name) ?? 0,
102
+ })).sort((a, b) => a.name.localeCompare(b.name)),
103
+ operations: model.operations.map((operation) => operation.id).sort(),
104
+ };
105
+ }
106
+
107
+ private async modelOf(root?: string) {
108
+ const scan = await this.projectScan.at(root);
109
+ return {
110
+ scan,
111
+ model: resolveEffectiveOperations(scan.fronds, {
112
+ diagnostics: scan.diagnostics,
113
+ remotes: scan.config.remotes,
114
+ adapters: scan.config.adapters,
115
+ }),
116
+ };
117
+ }
118
+
119
+ /** Print the contract one operation will be served under. */
74
120
  async execute(input: { operation?: string; root?: string; json?: boolean }): Promise<ExplainResult> {
75
121
  const requested = input.operation?.trim();
76
122
  if (!requested) throw new Error('Usage: fougere explain <Operation> [--json] [--root <directory>]');
77
123
 
78
124
  const selector = parseSelector(requested);
79
- const scan = await this.projectScan.at(input.root);
80
- const model = resolveEffectiveOperations(scan.fronds, {
81
- diagnostics: scan.diagnostics,
82
- remotes: scan.config.remotes,
83
- adapters: scan.config.adapters,
84
- });
125
+ const { scan, model } = await this.modelOf(input.root);
85
126
  const candidates = model.operations.filter((operation) => matches(operation, selector));
86
127
 
87
128
  if (candidates.length === 0) {
@@ -184,7 +225,7 @@ function matches(operation: EffectiveOperation, selector: Selector): boolean {
184
225
 
185
226
  function addressOf(value: string): string {
186
227
  const base = value.endsWith('Handler') ? value.slice(0, -'Handler'.length) : value;
187
- return base ? base[0]!.toLowerCase() + base.slice(1) : base;
228
+ return lowerFirst(base);
188
229
  }
189
230
 
190
231
  function inputTypeOf(operation: EffectiveOperation): string | null {
@@ -197,11 +238,10 @@ function outputOf(operation: EffectiveOperation): ExplainResult['output'] {
197
238
  return type ? { type, cardinality: operation.cardinality ?? null } : null;
198
239
  }
199
240
 
200
- function schemaName(schema: unknown): string | undefined {
201
- const view = schema as { name?: string; source?: { name?: string } } | undefined;
202
- if (!view) return undefined;
203
- if (view.name && view.name !== 'Schema') return view.name;
204
- return view.source?.name;
241
+ function schemaName(schema: SchemaView | undefined): string | undefined {
242
+ if (!schema) return undefined;
243
+ if (schema.name && schema.name !== ANONYMOUS_SCHEMA_NAME) return schema.name;
244
+ return schema.derivation?.sourceName;
205
245
  }
206
246
 
207
247
  function parsedOutput(raw: string | undefined): string | undefined {
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { describeSet, diffSet, registrationKeyOf, type SchemaBundle, type SetDiff } from '@fougere/schema';
3
+ import { Bundle, lowerFirst, type SchemaBundle, type SchemaView, type SetDiff } from '@fougere/schema';
4
4
  import ProjectScan from '../services/ProjectScan.js';
5
5
  import { VERSIONS, chainOf } from '../versions.js';
6
6
  import type Freeze from '../entities/Freeze.js';
@@ -13,7 +13,7 @@ export interface FreezeInspection {
13
13
  /** What the step contains — absent when there is nothing before to step from. */
14
14
  step?: SetDiff;
15
15
  /** Per entity, the pairs the calculation refuses to decide. Empty means it was written. */
16
- ambiguous: Record<string, Array<{ removed: string; added: string }>>;
16
+ ambiguous: Record<string, { removed: string; added: string }[]>;
17
17
  /** Whether anything reached the disk — false while a question stands. */
18
18
  written: boolean;
19
19
  }
@@ -54,7 +54,9 @@ export default class FreezeHandler {
54
54
  path,
55
55
  bundle,
56
56
  previous,
57
- step: previous ? diffSet(previous.bundle, bundle, { renamed }) : undefined,
57
+ step: previous
58
+ ? Bundle.fromDescriptor(previous.bundle).diff(Bundle.fromDescriptor(bundle), { renamed })
59
+ : undefined,
58
60
  }));
59
61
 
60
62
  const ambiguous: FreezeInspection['ambiguous'] = {};
@@ -102,7 +104,9 @@ export default class FreezeHandler {
102
104
  .filter((frond) => frond.entities.length > 0)
103
105
  .map(async (frond) => ({
104
106
  path: frond.source.path,
105
- bundle: describeSet(Object.fromEntries(frond.entities.map((e) => [e.name, e.entityClass]))),
107
+ bundle: Bundle.fromSchemas(
108
+ Object.fromEntries(frond.entities.map((e) => [e.name, e.entityClass])),
109
+ ).descriptor,
106
110
  declared: declaredRenames(frond.entities),
107
111
  previous: await previousOf(frond.source.path, input.version),
108
112
  })),
@@ -114,17 +118,17 @@ type Inspected = { previous?: { name: string }; step?: SetDiff };
114
118
 
115
119
  /**
116
120
  * What the entities state about themselves — `previous` says what a field WAS, while
117
- * `diff` reads old new, so the pair is turned around here and nowhere else.
121
+ * `Bundle.diff` reads old to new, so the pair is turned around here and nowhere else.
118
122
  */
119
123
  function declaredRenames(
120
- entities: ReadonlyArray<{ name: string; entityClass: unknown }>,
124
+ entities: readonly { name: string; entityClass: unknown }[],
121
125
  ): Record<string, Record<string, string>> {
122
126
  const out: Record<string, Record<string, string>> = {};
123
127
  for (const { name, entityClass } of entities) {
124
- const previous = (entityClass as { previous?: Record<string, string> }).previous;
125
- // Keyed as `describeSet` keys `$defs`, which is what `diffSet` reads. Spelling the
128
+ const previous = (entityClass as SchemaView).previous;
129
+ // Keyed as `Bundle.fromSchemas` keys `$defs`, which is what `Bundle.diff` reads. Spelling the
126
130
  // convention a second way here is the defect this repo has already recorded twice.
127
- const key = registrationKeyOf(name);
131
+ const key = lowerFirst(name);
128
132
  if (previous) out[key] = Object.fromEntries(Object.entries(previous).map(([now, was]) => [was, now]));
129
133
  }
130
134
  return out;
@@ -132,12 +136,12 @@ function declaredRenames(
132
136
 
133
137
  /** Every source of an answer, folded per entity — later sources win field by field. */
134
138
  function settled(
135
- sources: ReadonlyArray<Record<string, Record<string, string>>>,
139
+ sources: readonly Record<string, Record<string, string>>[],
136
140
  answers: Record<string, Record<string, string>>,
137
141
  ): Record<string, Record<string, string>> {
138
142
  const out: Record<string, Record<string, string>> = {};
139
143
  for (const source of [...sources, answers]) {
140
- for (const [entity, pairs] of Object.entries(source)) out[entity] = { ...(out[entity] ?? {}), ...pairs };
144
+ for (const [entity, pairs] of Object.entries(source)) out[entity] = { ...out[entity], ...pairs };
141
145
  }
142
146
  return out;
143
147
  }
@@ -77,7 +77,7 @@ export default class MigrateHandler {
77
77
  }
78
78
 
79
79
  /** The versions read, oldest first and each named once however many fronds cut it. */
80
- function versionsOf(steps: ReadonlyArray<{ version: string }>): string[] {
80
+ function versionsOf(steps: readonly { version: string }[]): string[] {
81
81
  return [...new Set(steps.map(({ version }) => version))].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
82
82
  }
83
83
 
@@ -90,7 +90,7 @@ function onSource(step: SetDiff, source: string, sourceOf: (entity: string) => s
90
90
  }
91
91
 
92
92
  /** Every recorded step, oldest first — the chain composes, so it is replayed whole. */
93
- async function stepsOf(frondPath: string): Promise<Array<{ version: string; step: SetDiff }>> {
93
+ async function stepsOf(frondPath: string): Promise<{ version: string; step: SetDiff }[]> {
94
94
  const chain = await chainOf(frondPath);
95
95
  // The first version has a shape and no step — there was nothing before it to move from.
96
96
  return chain.flatMap(({ name, step }) => (step ? [{ version: name, step }] : []));
@@ -1,5 +1,5 @@
1
1
  import { entity, text } from '@fougere/schema';
2
2
 
3
3
  export default class BuildFrond extends entity({
4
- name: text({ description: 'Frond name to build (e.g. blog)' }),
4
+ frond: text({ description: 'Frond name to build (e.g. blog)' }),
5
5
  }) {}
@@ -2,5 +2,5 @@ import { entity, text } from "@fougere/schema";
2
2
 
3
3
  /** `fougere call <entity>.<op> [--field value …]` — invoke one operation, print the result. */
4
4
  export default class Call extends entity({
5
- target: text({ min: 1, description: "entity.op to invoke (e.g. post.create)" }),
5
+ operation: text({ min: 1, description: "entity.op to invoke (e.g. post.create)" }),
6
6
  }) {}
@@ -1,6 +1,6 @@
1
1
  import { entity, text } from '@fougere/schema';
2
2
 
3
3
  export default class Sync extends entity({
4
- name: text({ description: 'Frond name to sync (e.g. blog)' }),
4
+ frond: text({ description: 'Frond name to sync (e.g. blog)' }),
5
5
  from: text({ description: 'Remote URL (e.g. http://blog-service:3000)' }),
6
6
  }) {}
@@ -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`,
@@ -252,10 +252,10 @@ export default class SyncHandler {
252
252
  }, null, 2) + '\n');
253
253
 
254
254
  // Update .fougere/remotes.json — central registry of synced remotes
255
- this.updateRemotesRegistry(input.name, baseUrl, frondDir);
255
+ this.updateRemotesRegistry(input.frond, baseUrl, frondDir);
256
256
 
257
257
  // Update tsconfig paths if tsconfig.json exists (non-Nuxt projects)
258
- this.updateTsconfigPaths(input.name, frondDir, conventions);
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.4.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.4.0-alpha.0",
45
+ "@fougere/defaults": "0.4.0-alpha.0",
46
+ "@fougere/transport-http": "0.4.0-alpha.0",
47
+ "@fougere/core": "0.4.0-alpha.0",
48
+ "@fougere/schema": "0.4.0-alpha.0",
49
+ "@fougere/container": "0.4.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
+ }