@fougere/cli 0.1.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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/app/commands/CallCommand.ts +68 -0
  4. package/app/commands/CheckCommand.ts +61 -0
  5. package/app/commands/CompletionCommand.ts +23 -0
  6. package/app/commands/GraphCommand.ts +61 -0
  7. package/app/commands/NewCommand.ts +122 -0
  8. package/app/commands/ServeCommand.ts +34 -0
  9. package/dist/bin.d.ts +3 -0
  10. package/dist/bin.d.ts.map +1 -0
  11. package/dist/bin.js +28 -0
  12. package/dist/bin.js.map +1 -0
  13. package/dist/bridge.d.ts +11 -0
  14. package/dist/bridge.d.ts.map +1 -0
  15. package/dist/bridge.js +54 -0
  16. package/dist/bridge.js.map +1 -0
  17. package/dist/completion.d.ts +9 -0
  18. package/dist/completion.d.ts.map +1 -0
  19. package/dist/completion.js +93 -0
  20. package/dist/completion.js.map +1 -0
  21. package/dist/index.d.ts +4 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +4 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/runner.d.ts +12 -0
  26. package/dist/runner.d.ts.map +1 -0
  27. package/dist/runner.js +113 -0
  28. package/dist/runner.js.map +1 -0
  29. package/fronds/analysis/entities/Check.ts +5 -0
  30. package/fronds/analysis/entities/Graph.ts +6 -0
  31. package/fronds/analysis/handlers/CheckHandler.ts +90 -0
  32. package/fronds/analysis/handlers/GraphHandler.ts +32 -0
  33. package/fronds/analysis/services/ProjectScan.ts +35 -0
  34. package/fronds/scaffold/entities/BuildFrond.ts +5 -0
  35. package/fronds/scaffold/entities/Call.ts +6 -0
  36. package/fronds/scaffold/entities/Completion.ts +5 -0
  37. package/fronds/scaffold/entities/New.ts +19 -0
  38. package/fronds/scaffold/entities/Serve.ts +7 -0
  39. package/fronds/scaffold/entities/Sync.ts +6 -0
  40. package/fronds/scaffold/handlers/BuildFrondHandler.ts +97 -0
  41. package/fronds/scaffold/handlers/CallHandler.ts +9 -0
  42. package/fronds/scaffold/handlers/CompletionHandler.ts +8 -0
  43. package/fronds/scaffold/handlers/NewHandler.ts +8 -0
  44. package/fronds/scaffold/handlers/ServeHandler.ts +9 -0
  45. package/fronds/scaffold/handlers/SyncHandler.ts +329 -0
  46. package/fronds/scaffold/services/ProjectWriter.ts +176 -0
  47. package/package.json +52 -0
  48. package/templates/admin/fronds/admin/entities/User.ts +10 -0
  49. package/templates/admin/fronds/admin/handlers/UserHandler.ts +29 -0
  50. package/templates/admin/fronds/admin/package.json +10 -0
  51. package/templates/api/fronds/api/entities/Task.ts +9 -0
  52. package/templates/api/fronds/api/handlers/TaskHandler.ts +29 -0
  53. package/templates/api/fronds/api/package.json +10 -0
  54. package/templates/apps/nuxt/app/app.vue +25 -0
  55. package/templates/apps/nuxt/app/pages/index.vue +33 -0
  56. package/templates/apps/nuxt/nuxt.config.ts +6 -0
  57. package/templates/apps/nuxt/package.json +19 -0
  58. package/templates/apps/nuxt/tsconfig.json +3 -0
  59. package/templates/blog/app/pages/index.vue +12 -0
  60. package/templates/blog/app/pages/posts/index.vue +21 -0
  61. package/templates/blog/app/pages/posts/manage.vue +47 -0
  62. package/templates/blog/app/pages/posts/new.vue +34 -0
  63. package/templates/blog/fronds/blog/entities/Post.ts +12 -0
  64. package/templates/blog/fronds/blog/handlers/PostHandler.ts +35 -0
  65. package/templates/blog/fronds/blog/package.json +10 -0
  66. package/templates/flat/CLAUDE.md +65 -0
  67. package/templates/flat/_gitignore +6 -0
  68. package/templates/flat/app/app.vue +25 -0
  69. package/templates/flat/app/pages/index.vue +7 -0
  70. package/templates/flat/fougere.config.ts +7 -0
  71. package/templates/flat/nuxt.config.ts +4 -0
  72. package/templates/flat/package.json +24 -0
  73. package/templates/flat/pnpm-workspace.yaml +6 -0
  74. package/templates/flat/tsconfig.frond.json +25 -0
  75. package/templates/flat/tsconfig.json +3 -0
  76. package/templates/frond/CLAUDE.md +65 -0
  77. package/templates/frond/_gitignore +4 -0
  78. package/templates/frond/fougere.config.ts +5 -0
  79. package/templates/frond/fronds/__name__/entities/Post.ts +10 -0
  80. package/templates/frond/fronds/__name__/handlers/PostHandler.ts +26 -0
  81. package/templates/frond/fronds/__name__/package.json +10 -0
  82. package/templates/frond/package.json +18 -0
  83. package/templates/frond/pnpm-workspace.yaml +2 -0
  84. package/templates/frond/serve.mjs +25 -0
  85. package/templates/fronds/blank/entities/Item.ts +8 -0
  86. package/templates/fronds/blank/handlers/ItemHandler.ts +5 -0
  87. package/templates/fronds/blank/package.json +14 -0
  88. package/templates/fronds/blog/entities/Post.ts +10 -0
  89. package/templates/fronds/blog/handlers/PostHandler.ts +38 -0
  90. package/templates/fronds/blog/package.json +14 -0
  91. package/templates/workspace/CLAUDE.md +65 -0
  92. package/templates/workspace/_gitignore +6 -0
  93. package/templates/workspace/fougere.config.ts +5 -0
  94. package/templates/workspace/fronds/tsconfig.json +18 -0
  95. package/templates/workspace/package.json +19 -0
  96. package/templates/workspace/pnpm-workspace.yaml +10 -0
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { run } from './runner.js';
2
+ export { entityToArgs } from './bridge.js';
3
+ export { generateZshCompletion, generateBashCompletion } from './completion.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAClC,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC"}
@@ -0,0 +1,12 @@
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
+ export declare function run(app: App): Promise<void>;
12
+ //# sourceMappingURL=runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,eAAe,CAAC;AA2CzC,wBAAsB,GAAG,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAsFjD"}
package/dist/runner.js ADDED
@@ -0,0 +1,113 @@
1
+ import { createAppRunner } from '@fougere/core';
2
+ import { toRegistrationName } from '@fougere/core/contract';
3
+ import { defineCommand, runMain } from 'citty';
4
+ import { ui } from '@fougere/cli-ui';
5
+ import { entityToArgs } from './bridge.js';
6
+ import { readdir } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ function toKebab(name) {
9
+ return name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()).replace(/^-/, '');
10
+ }
11
+ function capitalize(name) {
12
+ return name.charAt(0).toUpperCase() + name.slice(1);
13
+ }
14
+ function toCamel(kebab) {
15
+ return kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
16
+ }
17
+ /** Scan app/commands/ for command classes. */
18
+ async function loadAppCommands(cliRoot, loader) {
19
+ const map = new Map();
20
+ const dir = join(cliRoot, 'app', 'commands');
21
+ const files = await readdir(dir, { withFileTypes: true }).catch(() => []);
22
+ for (const f of files) {
23
+ if (!f.isFile() || !(f.name.endsWith('.ts') || f.name.endsWith('.js')))
24
+ continue;
25
+ const name = f.name.replace(/Command\.(ts|js)$/, '').replace(/\.(ts|js)$/, '');
26
+ const kebab = toKebab(name);
27
+ const mod = await loader(join(dir, f.name));
28
+ if (mod.default && typeof mod.default === 'function') {
29
+ map.set(kebab, mod.default);
30
+ }
31
+ }
32
+ return map;
33
+ }
34
+ export async function run(app) {
35
+ const terminal = ui();
36
+ const cliRoot = new URL('..', import.meta.url).pathname;
37
+ // Load app commands (presentation layer)
38
+ const { createJiti } = await import('jiti');
39
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
40
+ const loader = (path) => jiti.import(path);
41
+ const appCommands = await loadAppCommands(cliRoot, loader);
42
+ const subCommands = {};
43
+ for (const frond of app.fronds) {
44
+ const handlerMap = new Map(frond.handlers.map((h) => [h.address, h]));
45
+ for (const entity of frond.entities) {
46
+ const handlerEntry = handlerMap.get(entity.name);
47
+ if (!handlerEntry)
48
+ continue;
49
+ const handlerName = `${entity.name}Handler`;
50
+ let handler;
51
+ try {
52
+ handler = app.resolve(handlerName);
53
+ }
54
+ catch {
55
+ continue;
56
+ }
57
+ if (typeof handler.execute !== 'function')
58
+ continue;
59
+ const cmdName = toKebab(entity.name);
60
+ const fields = entity.entityClass.getFields();
61
+ const args = entityToArgs(fields);
62
+ // Check for an app command (presentation layer)
63
+ const AppCommand = appCommands.get(cmdName);
64
+ // App commands handle their own prompting — don't let citty reject missing args
65
+ if (AppCommand) {
66
+ for (const def of Object.values(args)) {
67
+ if (typeof def === 'object' && def)
68
+ def.required = false;
69
+ }
70
+ }
71
+ subCommands[cmdName] = defineCommand({
72
+ meta: {
73
+ name: cmdName,
74
+ // `--help` reads the operation's own doc sentence, which the scan already
75
+ // carries for every door (`OperationContract.description`). A table here
76
+ // would be the same fact written twice, and it drifted: it described `add`
77
+ // and `doctor`, which do not exist, and had nothing for `call` or `serve`.
78
+ description: handlerEntry.operations.get('execute')?.description,
79
+ },
80
+ args,
81
+ run: async ({ args: parsed }) => {
82
+ if (cmdName !== 'completion')
83
+ terminal.intro();
84
+ // citty adds `_` (raw positionals) and `--` (passthrough); strip them
85
+ // so only the entity's own fields reach the handler.
86
+ const input = { ...parsed };
87
+ delete input._;
88
+ delete input['--'];
89
+ try {
90
+ if (AppCommand) {
91
+ const cmd = new AppCommand(app, terminal);
92
+ await cmd.run(input);
93
+ }
94
+ else {
95
+ // Ride the call contract — the same envelope every consumer uses.
96
+ await createAppRunner(app)({ entity: toRegistrationName(entity.name), op: 'execute' }, { params: {}, query: {}, body: input, state: {} });
97
+ }
98
+ }
99
+ catch (err) {
100
+ terminal.error(err instanceof Error ? err.message : String(err));
101
+ process.exit(1);
102
+ }
103
+ },
104
+ });
105
+ }
106
+ }
107
+ const main = defineCommand({
108
+ meta: { name: 'fougere', description: 'Fougere CLI' },
109
+ subCommands,
110
+ });
111
+ await runMain(main);
112
+ }
113
+ //# sourceMappingURL=runner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runner.js","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ import { entity, text } from '@fougere/schema';
2
+
3
+ export default class Check extends entity({
4
+ root: text({ description: 'Project root directory (default: cwd)' }),
5
+ }) {}
@@ -0,0 +1,6 @@
1
+ import { entity, text, number } from '@fougere/schema';
2
+
3
+ export default class Graph extends entity({
4
+ root: text({ description: 'Project root directory (default: cwd)' }),
5
+ minEntities: number({ default: 6, description: 'Minimum entities before suggesting split' }),
6
+ }) {}
@@ -0,0 +1,90 @@
1
+ import { resolveContracts, crossFrondImports, type ScanDiagnostic } from '@fougere/core';
2
+ import ProjectScan from '../services/ProjectScan.js';
3
+
4
+ /** One thing that does not hold, in the terms of whoever has to fix it. */
5
+ export interface Finding {
6
+ severity: 'blocking' | 'warning';
7
+ /** Stable rule name — the same vocabulary a scan diagnostic uses. */
8
+ code: string;
9
+ /** Where to go and look. */
10
+ filePath: string;
11
+ /** What is wrong, and what it costs. One sentence. */
12
+ message: string;
13
+ }
14
+
15
+ export interface CheckResult {
16
+ fronds: number;
17
+ handlers: number;
18
+ findings: Finding[];
19
+ }
20
+
21
+ /**
22
+ * What does not hold in this app — derived from its declarations, not from tests.
23
+ *
24
+ * It scans rather than boots — see `ProjectScan`, which states what that costs.
25
+ *
26
+ * Two rules today, and the shape for the rest:
27
+ *
28
+ * - what the scan could not do (`ScanResult.diagnostics`) — an unreadable
29
+ * directory, a handler that would not parse, an `extends` it could not follow;
30
+ * - an operation whose parameters have no binding plan — it is served, and it
31
+ * receives nothing;
32
+ * - a relative import that resolves into another frond — a colocation constraint
33
+ * nothing declares, which holds until the day the other frond is not on this disk.
34
+ *
35
+ * A rule about an ABSENCE is only sound if the analysis attests it looked, which
36
+ * is why the first bullet had to exist before this command could.
37
+ */
38
+ export default class CheckHandler {
39
+ constructor(private projectScan: ProjectScan) {}
40
+
41
+ /** Report what does not hold in a Fougere app, without booting it. */
42
+ async execute(input: { root?: string }): Promise<CheckResult> {
43
+ const { fronds, diagnostics } = await this.projectScan.at(input.root);
44
+ const findings: Finding[] = diagnostics.map(asFinding);
45
+ let handlers = 0;
46
+
47
+ for (const frond of fronds) {
48
+ const collectorEntityNames = new Set(frond.collectors.map((c) => c.entityName));
49
+ for (const handler of frond.handlers) {
50
+ handlers++;
51
+ // The same merge the façade performs — asked for, not redone. A second
52
+ // opinion here would report a contract the runtime does not serve.
53
+ const contracts = resolveContracts(handler, frond.operationsOverrides, collectorEntityNames);
54
+
55
+ for (const [op, contract] of contracts) {
56
+ const params = contract.signature?.params.length ?? 0;
57
+ if (params > 0 && !contract.binding) {
58
+ findings.push({
59
+ severity: 'blocking',
60
+ code: 'operation-unbound',
61
+ filePath: handler.filePath,
62
+ message: `${handler.ctor.name}.${op} declares ${params} parameter(s) and has no `
63
+ + `binding plan — it is served, and it receives none of them.`,
64
+ });
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * A warning, not a refusal: it resolves today and the app runs. What it costs is
72
+ * paid once, late — the day the frond it reaches into is deployed on its own.
73
+ */
74
+ for (const reach of await crossFrondImports(fronds)) {
75
+ findings.push({
76
+ severity: 'warning',
77
+ code: reach.rule,
78
+ filePath: reach.filePath,
79
+ message: reach.message,
80
+ });
81
+ }
82
+
83
+ return { fronds: fronds.length, handlers, findings };
84
+ }
85
+ }
86
+
87
+ /** A scan diagnostic IS a finding — same vocabulary, so the renderer has one shape. */
88
+ function asFinding(d: ScanDiagnostic): Finding {
89
+ return { severity: d.severity, code: d.code, filePath: d.filePath, message: d.message };
90
+ }
@@ -0,0 +1,32 @@
1
+ import {
2
+ buildGraph, suggestSplit,
3
+ type EntityNode, type DomainCluster, type FrondDescriptor,
4
+ } from '@fougere/core';
5
+ import ProjectScan from '../services/ProjectScan.js';
6
+
7
+ export interface GraphResult {
8
+ fronds: FrondDescriptor[];
9
+ nodes: Map<string, EntityNode>;
10
+ clusters: DomainCluster[];
11
+ totalEntities: number;
12
+ totalRefs: number;
13
+ }
14
+
15
+ export default class GraphHandler {
16
+ constructor(private projectScan: ProjectScan) {}
17
+
18
+ /** Report how a workspace's fronds and entities reference each other. */
19
+ async execute(input: { root?: string; minEntities?: number }): Promise<GraphResult> {
20
+ const { fronds } = await this.projectScan.at(input.root);
21
+ const nodes = buildGraph(fronds);
22
+ const clusters = suggestSplit(nodes);
23
+
24
+ return {
25
+ fronds,
26
+ nodes,
27
+ clusters,
28
+ totalEntities: nodes.size,
29
+ totalRefs: [...nodes.values()].reduce((sum, n) => sum + n.refs.length, 0),
30
+ };
31
+ }
32
+ }
@@ -0,0 +1,35 @@
1
+ import { scanProject, frondAliases, setModuleLoader, type ScanResult } from '@fougere/core';
2
+ import { resolve } from 'node:path';
3
+
4
+ /**
5
+ * Read a target project without booting it — what every analysis command needs first.
6
+ *
7
+ * `graph` and `check` had the same five lines each: resolve the root against the
8
+ * CLI's cwd, install a jiti loader so the scan can import TypeScript sources, scan.
9
+ * Two copies of one gesture, and the second was written by copying the first.
10
+ *
11
+ * Not a boot: `bootAppFromConfig` runs migrations and seeds, so an analysis command
12
+ * that booted would write to the target's database. The cost is stated rather than
13
+ * hidden, though — the scan IMPORTS the modules it reads, so a frond's top-level
14
+ * code runs. "No application cycle, no database", not "no side effect".
15
+ */
16
+ export default class ProjectScan {
17
+ // cwd is ambient in a CLI — not a DI service (the container resolves by type).
18
+ private cwd = process.cwd();
19
+
20
+ /** Scan the project at `root`, relative to where the command was invoked. */
21
+ async at(root?: string): Promise<ScanResult & { root: string }> {
22
+ const target = resolve(this.cwd, root || '.');
23
+
24
+ // The scan reads `.ts` sources; the default loader is a plain `import`, which
25
+ // cannot. Installed once per call because the loader is module-global — the
26
+ // CLI's own app was loaded with its own, and this replaces it for the target.
27
+ const { createJiti } = await import('jiti');
28
+ // `@frond/<name>` is the framework's own convention; the loader has to know it,
29
+ // or a frond naming its neighbour is unreadable to the very tool that checks it.
30
+ const jiti = createJiti(import.meta.url, { interopDefault: true, alias: await frondAliases(target) });
31
+ setModuleLoader((filePath) => jiti.import(filePath) as Promise<Record<string, unknown>>);
32
+
33
+ return { root: target, ...(await scanProject(target)) };
34
+ }
35
+ }
@@ -0,0 +1,5 @@
1
+ import { entity, text } from '@fougere/schema';
2
+
3
+ export default class BuildFrond extends entity({
4
+ name: text({ description: 'Frond name to build (e.g. blog)' }),
5
+ }) {}
@@ -0,0 +1,6 @@
1
+ import { entity, text } from "@fougere/schema";
2
+
3
+ /** `fougere call <entity>.<op> [--field value …]` — invoke one operation, print the result. */
4
+ export default class Call extends entity({
5
+ target: text({ min: 1, description: "entity.op to invoke (e.g. post.create)" }),
6
+ }) {}
@@ -0,0 +1,5 @@
1
+ import { entity, oneOf } from '@fougere/schema';
2
+
3
+ export default class Completion extends entity({
4
+ shell: oneOf('zsh', 'bash', { description: 'Shell type (default: auto-detect)' }),
5
+ }) {}
@@ -0,0 +1,19 @@
1
+ import { entity, text, bool } from "@fougere/schema";
2
+
3
+ /**
4
+ * `fougere new [name]` — the workspace composer.
5
+ *
6
+ * Guided when it has a TTY and nothing to compose from; stated when `--frond` or
7
+ * `--app` is given, which is the only form a script, a CI job or an agent can drive.
8
+ * `--bare` is the third: the empty shell, nothing composed. `--flat` is the fourth, and
9
+ * it selects a SHAPE rather than a composition: one app that is its own domain.
10
+ */
11
+ export default class New extends entity({
12
+ name: text({ description: "Workspace name" }),
13
+ force: bool({ description: "Overwrite existing directory", default: false }),
14
+ bare: bool({ description: "Skip the guided flow — just the empty shell", default: false }),
15
+ flat: bool({ description: "One domain: no fronds/ — the app root is the frond", default: false }),
16
+ local: bool({ description: "Link @fougere/* to this monorepo (dev — installs offline)", default: false }),
17
+ frond: text({ description: "Fronds to add, no prompt — 'blog' or 'blog:shop', comma-separated", default: "" }),
18
+ app: text({ description: "Apps to add, no prompt — 'nuxt' or 'nuxt:web', comma-separated", default: "" }),
19
+ }) {}
@@ -0,0 +1,7 @@
1
+ import { entity, text, number, optional } from "@fougere/schema";
2
+
3
+ /** `fougere serve <frond>` — run one frond alone in its own process (JSON-RPC over HTTP). */
4
+ export default class Serve extends entity({
5
+ frond: text({ min: 1, description: "Frond to host in its own process" }),
6
+ port: optional(number({ description: "Port to listen on (default 4100)" })),
7
+ }) {}
@@ -0,0 +1,6 @@
1
+ import { entity, text } from '@fougere/schema';
2
+
3
+ export default class Sync extends entity({
4
+ name: text({ description: 'Frond name to sync (e.g. blog)' }),
5
+ from: text({ description: 'Remote URL (e.g. http://blog-service:3000)' }),
6
+ }) {}
@@ -0,0 +1,97 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
3
+ import { join, basename } from 'node:path';
4
+
5
+ function capitalize(s: string): string {
6
+ return s[0].toUpperCase() + s.slice(1);
7
+ }
8
+
9
+ export default class BuildFrondHandler {
10
+ // cwd is ambient in a CLI — not a DI service (the container resolves by type).
11
+ private cwd = process.cwd();
12
+
13
+ /** Build a frond into a standalone deployable package. */
14
+ async execute(input: { name: string }): Promise<{ path: string; entities: string[] }> {
15
+ const frondDir = join(this.cwd, 'fronds', input.name);
16
+
17
+ if (!existsSync(frondDir)) {
18
+ throw new Error(`Frond '${input.name}' not found at ${frondDir}`);
19
+ }
20
+
21
+ const entitiesDir = join(frondDir, 'entities');
22
+ if (!existsSync(entitiesDir)) {
23
+ throw new Error(`No entities/ directory in frond '${input.name}'`);
24
+ }
25
+
26
+ // Discover entity files
27
+ const entityFiles = readdirSync(entitiesDir)
28
+ .filter((f) => f.endsWith('.ts') && !f.endsWith('.d.ts'))
29
+ .sort();
30
+
31
+ if (entityFiles.length === 0) {
32
+ throw new Error(`No .ts files in ${entitiesDir}`);
33
+ }
34
+
35
+ const entityNames = entityFiles.map((f) => basename(f, '.ts'));
36
+
37
+ // Generate barrel index.ts
38
+ const indexLines = entityNames.map(
39
+ (name) => `export { default as ${name} } from './entities/${name}.js';`,
40
+ );
41
+ writeFileSync(join(frondDir, 'index.ts'), indexLines.join('\n') + '\n');
42
+
43
+ // Generate tsconfig.build.json
44
+ const tsconfig = {
45
+ compilerOptions: {
46
+ target: 'ES2022',
47
+ module: 'Node16',
48
+ moduleResolution: 'Node16',
49
+ declaration: true,
50
+ outDir: './dist',
51
+ rootDir: '.',
52
+ strict: true,
53
+ esModuleInterop: true,
54
+ skipLibCheck: true,
55
+ },
56
+ include: ['index.ts', 'entities/**/*.ts'],
57
+ };
58
+
59
+ const tsconfigPath = join(frondDir, 'tsconfig.build.json');
60
+ writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2));
61
+
62
+ try {
63
+ execSync('npx tsc -p tsconfig.build.json', { cwd: frondDir, stdio: 'inherit' });
64
+ } finally {
65
+ try { unlinkSync(tsconfigPath); } catch { /* ignore */ }
66
+ }
67
+
68
+ // Update package.json
69
+ const pkgPath = join(frondDir, 'package.json');
70
+ const pkg = existsSync(pkgPath)
71
+ ? JSON.parse(readFileSync(pkgPath, 'utf-8'))
72
+ : { name: `@frond/${input.name}`, version: '0.0.1', type: 'module' };
73
+
74
+ pkg.exports = {
75
+ '.': {
76
+ types: './dist/index.d.ts',
77
+ default: './dist/index.js',
78
+ },
79
+ './entities/*': {
80
+ types: './dist/entities/*.d.ts',
81
+ default: './dist/entities/*.js',
82
+ },
83
+ './package.json': './package.json',
84
+ };
85
+
86
+ // Only publish dist + package.json
87
+ pkg.files = ['dist', 'package.json'];
88
+
89
+ // Ensure peerDependencies on @fougere/schema
90
+ pkg.peerDependencies ??= {};
91
+ pkg.peerDependencies['@fougere/schema'] ??= '*';
92
+
93
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
94
+
95
+ return { path: join(frondDir, 'dist'), entities: entityNames };
96
+ }
97
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The client end lives in app/commands/CallCommand (it boots the project app
3
+ * and drives one operation). This handler exists only so the runner registers
4
+ * the `call` subcommand.
5
+ */
6
+ export default class CallHandler {
7
+ /** Invoke one operation of a frond from the terminal. */
8
+ async execute(): Promise<void> {}
9
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The completion script is produced by CompletionCommand (it holds the app and
3
+ * prints it). This handler exists only so the runner registers the subcommand.
4
+ */
5
+ export default class CompletionHandler {
6
+ /** Print the shell completion script for this CLI. */
7
+ async execute(): Promise<void> {}
8
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The guided flow lives in app/commands/NewCommand (it prompts and composes).
3
+ * This handler exists only so the runner registers the `new` subcommand.
4
+ */
5
+ export default class NewHandler {
6
+ /** Create a Fougere project, workspace or flat. */
7
+ async execute(): Promise<void> {}
8
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The host lives in app/commands/ServeCommand (it boots the frond alone and
3
+ * exposes it over HTTP). This handler exists only so the runner registers the
4
+ * `serve` subcommand.
5
+ */
6
+ export default class ServeHandler {
7
+ /** Host a frond in its own process, behind JSON-RPC. */
8
+ async execute(): Promise<void> {}
9
+ }