@fougere/cli 0.2.0-alpha.1 → 0.3.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 (74) hide show
  1. package/README.md +10 -1
  2. package/app/commands/BuildCommand.ts +39 -0
  3. package/app/commands/CallCommand.ts +4 -4
  4. package/app/commands/CheckCommand.ts +3 -2
  5. package/app/commands/ExplainCommand.ts +77 -0
  6. package/app/commands/FreezeCommand.ts +107 -0
  7. package/app/commands/GrantCommand.ts +44 -0
  8. package/app/commands/GraphCommand.ts +1 -1
  9. package/app/commands/KeysCommand.ts +56 -0
  10. package/app/commands/MigrateCommand.ts +54 -0
  11. package/app/commands/NewCommand.ts +6 -6
  12. package/app/commands/ServeCommand.ts +85 -9
  13. package/app/commands/grant-material.ts +5 -0
  14. package/dist/bin.js +54 -8
  15. package/dist/bin.js.map +1 -1
  16. package/dist/bridge.d.ts.map +1 -1
  17. package/dist/bridge.js +6 -6
  18. package/dist/bridge.js.map +1 -1
  19. package/dist/runner.d.ts.map +1 -1
  20. package/dist/runner.js +7 -7
  21. package/dist/runner.js.map +1 -1
  22. package/dist/theme.d.ts +10 -0
  23. package/dist/theme.d.ts.map +1 -0
  24. package/dist/theme.js +10 -0
  25. package/dist/theme.js.map +1 -0
  26. package/dist/ui.d.ts +74 -0
  27. package/dist/ui.d.ts.map +1 -0
  28. package/dist/ui.js +111 -0
  29. package/dist/ui.js.map +1 -0
  30. package/fronds/analysis/entities/Build.ts +7 -0
  31. package/fronds/analysis/entities/Explain.ts +8 -0
  32. package/fronds/analysis/entities/Freeze.ts +7 -0
  33. package/fronds/analysis/entities/Migrate.ts +7 -0
  34. package/fronds/analysis/handlers/BuildHandler.ts +60 -0
  35. package/fronds/analysis/handlers/CheckHandler.ts +61 -35
  36. package/fronds/analysis/handlers/ExplainHandler.ts +214 -0
  37. package/fronds/analysis/handlers/FreezeHandler.ts +172 -0
  38. package/fronds/analysis/handlers/MigrateHandler.ts +97 -0
  39. package/fronds/analysis/services/ProjectScan.ts +23 -7
  40. package/fronds/analysis/versions.ts +58 -0
  41. package/fronds/scaffold/entities/Grant.ts +6 -0
  42. package/fronds/scaffold/entities/Keys.ts +4 -0
  43. package/fronds/scaffold/entities/Serve.ts +2 -1
  44. package/fronds/scaffold/handlers/BuildFrondHandler.ts +11 -13
  45. package/fronds/scaffold/handlers/GrantHandler.ts +8 -0
  46. package/fronds/scaffold/handlers/KeysHandler.ts +8 -0
  47. package/fronds/scaffold/handlers/SyncHandler.ts +27 -24
  48. package/fronds/scaffold/services/ProjectWriter.ts +29 -10
  49. package/package.json +10 -8
  50. package/templates/admin/fronds/admin/handlers/UserHandler.ts +3 -5
  51. package/templates/admin/fronds/admin/package.json +1 -1
  52. package/templates/api/fronds/api/handlers/TaskHandler.ts +3 -5
  53. package/templates/api/fronds/api/package.json +1 -1
  54. package/templates/apps/nuxt/app/pages/index.vue +1 -1
  55. package/templates/apps/nuxt/package.json +1 -1
  56. package/templates/blog/app/pages/posts/index.vue +1 -1
  57. package/templates/blog/app/pages/posts/manage.vue +1 -1
  58. package/templates/blog/app/pages/posts/new.vue +1 -1
  59. package/templates/blog/fronds/blog/handlers/PostHandler.ts +3 -5
  60. package/templates/blog/fronds/blog/package.json +1 -1
  61. package/templates/flat/AGENTS.md +14 -0
  62. package/templates/flat/CLAUDE.md +27 -5
  63. package/templates/flat/package.json +1 -1
  64. package/templates/frond/AGENTS.md +14 -0
  65. package/templates/frond/CLAUDE.md +27 -5
  66. package/templates/frond/fronds/__name__/handlers/PostHandler.ts +3 -5
  67. package/templates/frond/fronds/__name__/package.json +1 -1
  68. package/templates/frond/package.json +1 -1
  69. package/templates/frond/serve.mjs +4 -3
  70. package/templates/fronds/blank/package.json +1 -1
  71. package/templates/fronds/blog/handlers/PostHandler.ts +2 -4
  72. package/templates/fronds/blog/package.json +1 -1
  73. package/templates/workspace/AGENTS.md +14 -0
  74. package/templates/workspace/CLAUDE.md +27 -5
@@ -0,0 +1,58 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { SetDiff } from '@fougere/schema';
4
+
5
+ /**
6
+ * Where a FROND keeps what its shapes used to be — beside `entities/`, not under a dot.
7
+ * Written by `fougere freeze`, replayed by `fougere migrate`.
8
+ */
9
+ export const VERSIONS = 'versions';
10
+
11
+ /** One cut version: its name, and the step that reached it — absent on the first. */
12
+ export interface Version {
13
+ name: string;
14
+ step?: SetDiff & { previous?: string };
15
+ }
16
+
17
+ /**
18
+ * The versions a frond has cut, oldest first — read by FOLLOWING each step's `previous`.
19
+ *
20
+ * That link is the fact recorded the day the version was cut. Sorting directory names is
21
+ * a guess about the same fact, and a hotfix cut after a later version orders it wrong.
22
+ */
23
+ export async function chainOf(frondPath: string): Promise<Version[]> {
24
+ const directory = join(frondPath, VERSIONS);
25
+ const found = await readdir(directory, { withFileTypes: true }).catch(() => []);
26
+ const names = found.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
27
+ if (names.length === 0) return [];
28
+
29
+ const steps = new Map<string, Version['step']>();
30
+ for (const name of names) {
31
+ const raw = await readFile(join(directory, name, 'from.json'), 'utf8').catch(() => undefined);
32
+ steps.set(name, raw ? (JSON.parse(raw) as Version['step']) : undefined);
33
+ }
34
+
35
+ const roots = names.filter((name) => steps.get(name)?.previous === undefined);
36
+ if (roots.length !== 1) {
37
+ const said = roots.length === 0 ? 'none starts it' : `${roots.join(', ')} each start one`;
38
+ throw new Error(`${directory}: a frond's versions are ONE line and ${said}.`);
39
+ }
40
+
41
+ const next = new Map<string, string>();
42
+ for (const [name, step] of steps) if (step?.previous !== undefined) next.set(step.previous, name);
43
+
44
+ const chain: Version[] = [];
45
+ const seen = new Set<string>();
46
+ for (let at: string | undefined = roots[0]; at !== undefined && !seen.has(at); at = next.get(at)) {
47
+ seen.add(at);
48
+ chain.push({ name: at, step: steps.get(at) });
49
+ }
50
+
51
+ // One check for every way the links fail to be a line: a fork (two versions claiming
52
+ // the same `previous`), a cycle, or a step naming a version that is not there.
53
+ const adrift = names.filter((name) => !seen.has(name));
54
+ if (adrift.length > 0) {
55
+ throw new Error(`${directory}: ${adrift.join(', ')} follow no version in the chain that starts at ${roots[0]}.`);
56
+ }
57
+ return chain;
58
+ }
@@ -0,0 +1,6 @@
1
+ import { entity, text } from "@fougere/schema";
2
+
3
+ /** `fougere grant <frond>` — vouch for one frond, so any receiver admits it. */
4
+ export default class Grant extends entity({
5
+ frond: text({ min: 1, description: "Frond to vouch for" }),
6
+ }) {}
@@ -0,0 +1,4 @@
1
+ import { entity } from "@fougere/schema";
2
+
3
+ /** `fougere keys` — create the root this system's grants are signed by. Once. */
4
+ export default class Keys extends entity({}) {}
@@ -1,7 +1,8 @@
1
- import { entity, text, number, optional } from "@fougere/schema";
1
+ import { entity, text, number, bool, optional } from "@fougere/schema";
2
2
 
3
3
  /** `fougere serve <frond>` — run one frond alone in its own process (JSON-RPC over HTTP). */
4
4
  export default class Serve extends entity({
5
5
  frond: text({ min: 1, description: "Frond to host in its own process" }),
6
6
  port: optional(number({ description: "Port to listen on (default 4100)" })),
7
+ watch: optional(bool({ description: "Rebuild the app when the frond changes" })),
7
8
  }) {}
@@ -1,10 +1,7 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
3
3
  import { join, basename } from 'node:path';
4
-
5
- function capitalize(s: string): string {
6
- return s[0].toUpperCase() + s.slice(1);
7
- }
4
+ import { loadConfig, resolveConventions, frondPackage } from '@fougere/core/node';
8
5
 
9
6
  export default class BuildFrondHandler {
10
7
  // cwd is ambient in a CLI — not a DI service (the container resolves by type).
@@ -12,15 +9,16 @@ export default class BuildFrondHandler {
12
9
 
13
10
  /** Build a frond into a standalone deployable package. */
14
11
  async execute(input: { name: string }): Promise<{ path: string; entities: string[] }> {
15
- const frondDir = join(this.cwd, 'fronds', input.name);
12
+ const conventions = resolveConventions((await loadConfig(this.cwd)).conventions);
13
+ const frondDir = join(this.cwd, conventions.fronds, input.name);
16
14
 
17
15
  if (!existsSync(frondDir)) {
18
16
  throw new Error(`Frond '${input.name}' not found at ${frondDir}`);
19
17
  }
20
18
 
21
- const entitiesDir = join(frondDir, 'entities');
19
+ const entitiesDir = join(frondDir, conventions.dirs.entities);
22
20
  if (!existsSync(entitiesDir)) {
23
- throw new Error(`No entities/ directory in frond '${input.name}'`);
21
+ throw new Error(`No ${conventions.dirs.entities}/ directory in frond '${input.name}'`);
24
22
  }
25
23
 
26
24
  // Discover entity files
@@ -36,7 +34,7 @@ export default class BuildFrondHandler {
36
34
 
37
35
  // Generate barrel index.ts
38
36
  const indexLines = entityNames.map(
39
- (name) => `export { default as ${name} } from './entities/${name}.js';`,
37
+ (name) => `export { default as ${name} } from './${conventions.dirs.entities}/${name}.js';`,
40
38
  );
41
39
  writeFileSync(join(frondDir, 'index.ts'), indexLines.join('\n') + '\n');
42
40
 
@@ -53,7 +51,7 @@ export default class BuildFrondHandler {
53
51
  esModuleInterop: true,
54
52
  skipLibCheck: true,
55
53
  },
56
- include: ['index.ts', 'entities/**/*.ts'],
54
+ include: ['index.ts', `${conventions.dirs.entities}/**/*.ts`],
57
55
  };
58
56
 
59
57
  const tsconfigPath = join(frondDir, 'tsconfig.build.json');
@@ -69,16 +67,16 @@ export default class BuildFrondHandler {
69
67
  const pkgPath = join(frondDir, 'package.json');
70
68
  const pkg = existsSync(pkgPath)
71
69
  ? JSON.parse(readFileSync(pkgPath, 'utf-8'))
72
- : { name: `@frond/${input.name}`, version: '0.0.1', type: 'module' };
70
+ : { name: frondPackage(input.name, conventions), version: '0.0.1', type: 'module' };
73
71
 
74
72
  pkg.exports = {
75
73
  '.': {
76
74
  types: './dist/index.d.ts',
77
75
  default: './dist/index.js',
78
76
  },
79
- './entities/*': {
80
- types: './dist/entities/*.d.ts',
81
- default: './dist/entities/*.js',
77
+ [`./${conventions.dirs.entities}/*`]: {
78
+ types: `./dist/${conventions.dirs.entities}/*.d.ts`,
79
+ default: `./dist/${conventions.dirs.entities}/*.js`,
82
80
  },
83
81
  './package.json': './package.json',
84
82
  };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The work lives in app/commands/GrantCommand (it prints secrets). This handler
3
+ * exists only so the runner registers the `grant` subcommand.
4
+ */
5
+ export default class GrantHandler {
6
+ /** Bind a frond's name to a fresh key, signed by the root. */
7
+ async execute(): Promise<void> {}
8
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The work lives in app/commands/KeysCommand (it writes the root key). This
3
+ * handler exists only so the runner registers the `keys` subcommand.
4
+ */
5
+ export default class KeysHandler {
6
+ /** Create the root key a split deployment's grants are signed by. */
7
+ async execute(): Promise<void> {}
8
+ }
@@ -4,7 +4,10 @@ import { entitySourceOf, facadeTypeSourceOf, type SchemaDescriptor } from '@foug
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.
7
- import type { IdentityCard } from '@fougere/core';
7
+ import { assertIdentityCard, type IdentityCard } from '@fougere/core';
8
+ import {
9
+ type Conventions, loadConfig, resolveConventions, frondPackage,
10
+ } from '@fougere/core/node';
8
11
 
9
12
  function assertSafeName(kind: string, name: string): void {
10
13
  if (typeof name !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$-]*$/.test(name)) {
@@ -67,18 +70,12 @@ function assertEntry(kind: string, frondName: string, entry: { name: string; sch
67
70
  }
68
71
 
69
72
  function identityCardOf(value: unknown): IdentityCard {
70
- if (!value || typeof value !== 'object' || !Array.isArray((value as IdentityCard).fronds)) {
71
- throw new Error('Remote rpc.discover returned an invalid identity card');
72
- }
73
- const card = value as IdentityCard;
73
+ // The card's own shape is judged by the package that declares it — `fronds`, and each
74
+ // frond's `doors`. What stays here is what only a writer of files needs: a name safe to
75
+ // become one, and the descriptor a class is generated from.
76
+ const card = assertIdentityCard(value, 'Remote rpc.discover');
74
77
  for (const frond of card.fronds) {
75
- if (!frond || typeof frond !== 'object') {
76
- throw new Error('Remote rpc.discover returned an invalid frond entry');
77
- }
78
78
  assertSafeName('frond', frond.name);
79
- if (!Array.isArray(frond.doors)) {
80
- throw new Error(`Remote frond '${frond.name}' has no valid doors array`);
81
- }
82
79
  // Absent rather than empty is tolerated: a host older than the fact list says nothing
83
80
  // about facts, and refusing it would break sync against every previous version for a
84
81
  // feature the consumer may not use.
@@ -129,9 +126,15 @@ export default class SyncHandler {
129
126
  throw new Error(`Frond '${input.name}' not found on ${baseUrl}. Available: ${card.fronds.map((f) => f.name).join(', ')}`);
130
127
  }
131
128
 
129
+ // The consumer's own convention: a synced frond is laid out like the ones they wrote,
130
+ // and the export map below is what makes `@fronds/<name>/<dir>/X.js` resolve.
131
+ const conventions = resolveConventions((await loadConfig(this.cwd)).conventions);
132
+ const entities = conventions.dirs.entities;
133
+ const handlers = conventions.dirs.handlers;
134
+
132
135
  const frondDir = join(this.cwd, '.fougere', 'remotes', input.name);
133
- const entitiesDir = join(frondDir, 'entities');
134
- const handlersDir = join(frondDir, 'handlers');
136
+ const entitiesDir = join(frondDir, entities);
137
+ const handlersDir = join(frondDir, handlers);
135
138
  mkdirSync(entitiesDir, { recursive: true });
136
139
  mkdirSync(handlersDir, { recursive: true });
137
140
 
@@ -229,21 +232,21 @@ export default class SyncHandler {
229
232
  // One binding carries the value AND the type, because a class is both — the pair of
230
233
  // re-exports that stood here was the price of declaring them separately.
231
234
  const indexLines = [...generated].flatMap(([name, { row, door }]) => [
232
- ...(row ? [`export { default as ${name} } from './entities/${name}.js';`] : []),
233
- ...(door ? [`export type { ${name}Handler } from './handlers/${name}Handler.js';`] : []),
235
+ ...(row ? [`export { default as ${name} } from './${entities}/${name}.js';`] : []),
236
+ ...(door ? [`export type { ${name}Handler } from './${handlers}/${name}Handler.js';`] : []),
234
237
  ]);
235
238
  writeFileSync(join(frondDir, 'index.ts'), indexLines.join('\n') + '\n');
236
239
 
237
240
  // Package.json
238
241
  writeFileSync(join(frondDir, 'package.json'), JSON.stringify({
239
- name: `@frond/${input.name}`,
242
+ name: frondPackage(input.name, conventions),
240
243
  version: '0.0.0-synced',
241
244
  type: 'module',
242
245
  fougere: { frond: input.name, synced: true, source: baseUrl },
243
246
  exports: {
244
247
  '.': './index.ts',
245
- './entities/*': './entities/*.ts',
246
- './handlers/*': './handlers/*.ts',
248
+ [`./${entities}/*`]: `./${entities}/*.ts`,
249
+ [`./${handlers}/*`]: `./${handlers}/*.ts`,
247
250
  './package.json': './package.json',
248
251
  },
249
252
  }, null, 2) + '\n');
@@ -252,14 +255,14 @@ export default class SyncHandler {
252
255
  this.updateRemotesRegistry(input.name, baseUrl, frondDir);
253
256
 
254
257
  // Update tsconfig paths if tsconfig.json exists (non-Nuxt projects)
255
- this.updateTsconfigPaths(input.name, frondDir);
258
+ this.updateTsconfigPaths(input.name, frondDir, conventions);
256
259
 
257
260
  /**
258
261
  * What the host no longer serves stops being importable here.
259
262
  *
260
263
  * The barrel is rewritten every run, so a dropped entity loses its export on its own
261
264
  * — but the FILE stayed, and the generated `package.json` exports `'./entities/*'` as
262
- * a wildcard, so `@frond/blog/entities/Ticket.js` kept resolving to a class nothing
265
+ * a wildcard, so `@fronds/blog/entities/Ticket.js` kept resolving to a class nothing
263
266
  * behind it answers for. The consumer compiles, its local judge accepts, and the call
264
267
  * comes back NOT_FOUND at the door — or never leaves, because the page dropped the
265
268
  * call and kept the type.
@@ -304,8 +307,8 @@ export default class SyncHandler {
304
307
  writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
305
308
  }
306
309
 
307
- /** Add @frond/{name} to tsconfig paths if tsconfig.json exists. */
308
- private updateTsconfigPaths(name: string, localPath: string): void {
310
+ /** Add the frond's scoped name to tsconfig paths if tsconfig.json exists. */
311
+ private updateTsconfigPaths(name: string, localPath: string, conventions: Conventions): void {
309
312
  const tsconfigPath = join(this.cwd, 'tsconfig.json');
310
313
  if (!existsSync(tsconfigPath)) return;
311
314
 
@@ -320,8 +323,8 @@ export default class SyncHandler {
320
323
  tsconfig.compilerOptions.paths ??= {};
321
324
 
322
325
  const relative = localPath.replace(this.cwd, '.').replace(/\\/g, '/');
323
- tsconfig.compilerOptions.paths[`@frond/${name}`] = [`${relative}/index.ts`];
324
- tsconfig.compilerOptions.paths[`@frond/${name}/*`] = [`${relative}/*`];
326
+ tsconfig.compilerOptions.paths[frondPackage(name, conventions)] = [`${relative}/index.ts`];
327
+ tsconfig.compilerOptions.paths[`${frondPackage(name, conventions)}/*`] = [`${relative}/*`];
325
328
 
326
329
  writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n');
327
330
  } catch { /* tsconfig parse error — skip */ }
@@ -1,6 +1,24 @@
1
1
  import { cpSync, existsSync, renameSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
2
- import { join } from 'node:path';
2
+ import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { type Conventions, DEFAULT_CONVENTIONS, frondPackage } from '@fougere/core/node';
5
+
6
+ /**
7
+ * The monorepo's `packages/`, found by its workspace marker rather than counted
8
+ * in `..` from this file. Counting encoded how deep the CLI itself sat, so the
9
+ * day `cli/` moved into a family, `--local` linked four packages instead of
10
+ * twenty-one — silently, because the scan below simply found less.
11
+ *
12
+ * Returns undefined outside the monorepo, which is every installed copy.
13
+ */
14
+ function monorepoPackages(): string | undefined {
15
+ let d = fileURLToPath(new URL('.', import.meta.url));
16
+ while (d !== dirname(d)) {
17
+ if (existsSync(join(d, 'pnpm-workspace.yaml'))) return join(d, 'packages');
18
+ d = dirname(d);
19
+ }
20
+ return undefined;
21
+ }
4
22
 
5
23
  /**
6
24
  * Scaffolds from real template files (create-vite pattern: stdlib copy, no
@@ -50,7 +68,7 @@ export default class ProjectWriter {
50
68
 
51
69
  /**
52
70
  * Put a frond template's directories at the project root. Only the directories: at the
53
- * root the app's own `package.json` is the frond's, and `@frond/<name>` comes from the
71
+ * root the app's own `package.json` is the frond's, and `@fronds/<name>` comes from the
54
72
  * directory through the Nuxt module's alias, so the template's package would only
55
73
  * duplicate it under a second name.
56
74
  */
@@ -64,8 +82,8 @@ export default class ProjectWriter {
64
82
  }
65
83
 
66
84
  /** Add a frond (business hexagon) under fronds/<name>. */
67
- addFrond(wsDir: string, template: string, name: string): { path: string } {
68
- const dest = join(wsDir, 'fronds', name);
85
+ addFrond(wsDir: string, template: string, name: string, conventions: Conventions = DEFAULT_CONVENTIONS): { path: string } {
86
+ const dest = join(wsDir, conventions.fronds, name);
69
87
  cpSync(join(TEMPLATES, 'fronds', template), dest, { recursive: true });
70
88
  // Only the import name. Carrying the convention is what makes a frond — the scan
71
89
  // reads directories. `fougere.frond` IS read now (`scanner.ts`, `frondNameOf`), but
@@ -73,7 +91,7 @@ export default class ProjectWriter {
73
91
  const pkgPath = join(dest, 'package.json');
74
92
  if (existsSync(pkgPath)) {
75
93
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name: string };
76
- pkg.name = `@frond/${name}`;
94
+ pkg.name = frondPackage(name, conventions);
77
95
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
78
96
  }
79
97
  return { path: dest };
@@ -102,11 +120,11 @@ export default class ProjectWriter {
102
120
  * A template cannot carry it: the frond is named at composition time (`blog:catalog`),
103
121
  * so a dependency written into `templates/apps/nuxt` would name the template instead
104
122
  * and resolve to nothing. Which is what happened — the generated app imported
105
- * `@frond/blog` whatever you had called it, and did not start.
123
+ * `@fronds/blog` whatever you had called it, and did not start.
106
124
  *
107
125
  * `fronds/` and `apps/` are the registry, like `listTemplates`: nothing to declare.
108
126
  */
109
- linkFronds(wsDir: string): void {
127
+ linkFronds(wsDir: string, conventions: Conventions = DEFAULT_CONVENTIONS): void {
110
128
  const dirs = (kind: string): string[] => {
111
129
  const dir = join(wsDir, kind);
112
130
  if (!existsSync(dir)) return [];
@@ -114,7 +132,7 @@ export default class ProjectWriter {
114
132
  return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
115
133
  };
116
134
 
117
- const fronds = dirs('fronds');
135
+ const fronds = dirs(conventions.fronds);
118
136
  if (fronds.length === 0) return;
119
137
 
120
138
  for (const app of dirs('apps')) {
@@ -123,7 +141,7 @@ export default class ProjectWriter {
123
141
 
124
142
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { dependencies?: Record<string, string> };
125
143
  pkg.dependencies ??= {};
126
- for (const frond of fronds) pkg.dependencies[`@frond/${frond}`] = 'workspace:*';
144
+ for (const frond of fronds) pkg.dependencies[frondPackage(frond, conventions)] = 'workspace:*';
127
145
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
128
146
  }
129
147
  }
@@ -134,7 +152,8 @@ export default class ProjectWriter {
134
152
  * packages aren't on npm yet). No-op once the packages are published.
135
153
  */
136
154
  linkLocal(wsDir: string): void {
137
- const packages = fileURLToPath(new URL('../../../../', import.meta.url));
155
+ const packages = monorepoPackages();
156
+ if (!packages) return;
138
157
  // Read off the monorepo rather than listed here: a hand-kept map knew the seven
139
158
  // packages the default templates use, so the first step beyond the default — a
140
159
  // GraphQL surface, auth — added a dependency it had never heard of, which stayed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fougere/cli",
3
- "version": "0.2.0-alpha.1",
3
+ "version": "0.3.0-alpha.0",
4
4
  "description": "The Fougere CLI — compose a workspace, serve a frond, call an operation.",
5
5
  "keywords": [
6
6
  "fougere",
@@ -35,15 +35,17 @@
35
35
  "templates"
36
36
  ],
37
37
  "dependencies": {
38
+ "@clack/prompts": "^0.10.0",
38
39
  "citty": "^0.2.1",
40
+ "consola": "^3.4.2",
39
41
  "jiti": "^2.4.2",
40
42
  "picocolors": "^1.1.1",
41
- "@fougere/cli-ui": "0.2.0-alpha.1",
42
- "@fougere/runtime": "0.2.0-alpha.1",
43
- "@fougere/core": "0.2.0-alpha.1",
44
- "@fougere/schema": "0.2.0-alpha.1",
45
- "@fougere/container": "0.2.0-alpha.1",
46
- "@fougere/transport-http": "0.2.0-alpha.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"
47
49
  },
48
50
  "devDependencies": {
49
51
  "vitest": "^4.1.0"
@@ -52,7 +54,7 @@
52
54
  "access": "public"
53
55
  },
54
56
  "scripts": {
55
- "build": "rm -rf dist && tsc && chmod +x dist/bin.js",
57
+ "build": "rm -rf dist && tsc && chmod +x dist/bin.js && node dist/bin.js build",
56
58
  "test": "vitest run",
57
59
  "test:watch": "vitest",
58
60
  "typecheck": "tsc --noEmit && tsc -p tsconfig.runtime.json && tsc -p tsconfig.templates.json"
@@ -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')
@@ -10,7 +10,7 @@
10
10
  "@fougere/core": "latest",
11
11
  "@fougere/nuxt": "latest",
12
12
  "@fougere/schema": "latest",
13
- "@fougere/runtime": "latest",
13
+ "@fougere/defaults": "latest",
14
14
  "better-sqlite3": "^13.0.3",
15
15
  "kysely": "^0.28.17",
16
16
  "nuxt": "^4.5.1",
@@ -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": {
@@ -0,0 +1,14 @@
1
+ # Working in this application
2
+
3
+ This project is built with **Fougere**. Read `CLAUDE.md` for the model and architecture guidance
4
+ that applies to every coding agent.
5
+
6
+ ## Required verification workflow
7
+
8
+ After every change to handlers, entities, Fronds, configuration, or topology:
9
+
10
+ 1. Run `fougere check`.
11
+ 2. Fix every deterministic error it reports before continuing.
12
+ 3. Run the relevant tests, then run `pnpm typecheck`.
13
+
14
+ `fougere check` is the Fougere model barrier; tests and TypeScript come after it passes.
@@ -35,8 +35,8 @@ Before adding a surface, reach for its **projection**:
35
35
 
36
36
  | surface | the call |
37
37
  |---|---|
38
- | REST | `generateRoutes(app)` then `registerRoutes(router, routes)` — `@fougere/schema-rest` |
39
- | GraphQL | `registerAll(builder, app)` then `registerGraphQL(router, builder.toSchema())` — `@fougere/schema-graphql` |
38
+ | REST | `generateRoutes(app)` then `registerRoutes(router, routes)` — `@fougere/adapter-rest` |
39
+ | GraphQL | `registerAll(builder, app)` then `registerGraphQL(router, builder.toSchema())` — `@fougere/adapter-graphql` |
40
40
 
41
41
  Hand-writing the types (`buildSchema`, raw SDL, one Pothos resolver per field) rebuilds what the
42
42
  projection already derives, and drops the judge on the way. `registerType` / `registerOperations`
@@ -44,7 +44,18 @@ exist to add what a projection cannot derive — never to replace it.
44
44
 
45
45
  ## Reading data
46
46
 
47
- `EntityOrm`, injected by type, is the only data access:
47
+ Storage is reached through a repository. Never inject `EntityOrm` directly into a handler,
48
+ presenter or collector — the boot refuses it. With no repository file, ask for the default shape:
49
+
50
+ ```ts
51
+ import type { RepositoryOf } from '@fougere/core';
52
+ import Product from '../entities/Product.js';
53
+
54
+ constructor(private products: RepositoryOf<Product>) {}
55
+ ```
56
+
57
+ If the handler extends `Crud(Product)`, its inherited `this.orm` is already backed by that
58
+ repository; do not add a constructor. The repository forwards the guarded storage gestures:
48
59
 
49
60
  ```
50
61
  list(options?) every row — `options.where` filters, plus paging and sorting
@@ -56,10 +67,21 @@ create / update / delete
56
67
 
57
68
  Read a relation with `findAllBy`. Never read a whole table to filter it in memory.
58
69
 
70
+ When a query deserves a domain name, add `repositories/ProductRepository.ts` with
71
+ `class ProductRepository extends Repository(Product)`, put the query there, and inject
72
+ `ProductRepository`. A repository is registered as a provider and remains the only route to storage.
73
+
59
74
  ## Checking your work
60
75
 
76
+ After every change to handlers, entities, Fronds, configuration, or topology:
77
+
78
+ 1. Run `fougere check`.
79
+ 2. Fix every deterministic error it reports before continuing.
80
+ 3. Run the relevant tests, then run the project typecheck.
81
+
61
82
  ```bash
62
- npx tsc -p tsconfig.frond.json # the compiler — free, immediate, and it catches most of it
83
+ fougere check
84
+ pnpm typecheck
63
85
  ```
64
86
 
65
- Run it. It is the first judge, and the cheapest.
87
+ `fougere check` is the Fougere model barrier; tests and TypeScript come after it passes.
@@ -12,7 +12,7 @@
12
12
  "@fougere/core": "latest",
13
13
  "@fougere/nuxt": "latest",
14
14
  "@fougere/schema": "latest",
15
- "@fougere/runtime": "latest",
15
+ "@fougere/defaults": "latest",
16
16
  "better-sqlite3": "^13.0.3",
17
17
  "kysely": "^0.28.17",
18
18
  "nuxt": "^4.5.1",