@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.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/app/commands/CallCommand.ts +68 -0
- package/app/commands/CheckCommand.ts +61 -0
- package/app/commands/CompletionCommand.ts +23 -0
- package/app/commands/GraphCommand.ts +61 -0
- package/app/commands/NewCommand.ts +122 -0
- package/app/commands/ServeCommand.ts +34 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +28 -0
- package/dist/bin.js.map +1 -0
- package/dist/bridge.d.ts +11 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +54 -0
- package/dist/bridge.js.map +1 -0
- package/dist/completion.d.ts +9 -0
- package/dist/completion.d.ts.map +1 -0
- package/dist/completion.js +93 -0
- package/dist/completion.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/runner.d.ts +12 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +113 -0
- package/dist/runner.js.map +1 -0
- package/fronds/analysis/entities/Check.ts +5 -0
- package/fronds/analysis/entities/Graph.ts +6 -0
- package/fronds/analysis/handlers/CheckHandler.ts +90 -0
- package/fronds/analysis/handlers/GraphHandler.ts +32 -0
- package/fronds/analysis/services/ProjectScan.ts +35 -0
- package/fronds/scaffold/entities/BuildFrond.ts +5 -0
- package/fronds/scaffold/entities/Call.ts +6 -0
- package/fronds/scaffold/entities/Completion.ts +5 -0
- package/fronds/scaffold/entities/New.ts +19 -0
- package/fronds/scaffold/entities/Serve.ts +7 -0
- package/fronds/scaffold/entities/Sync.ts +6 -0
- package/fronds/scaffold/handlers/BuildFrondHandler.ts +97 -0
- package/fronds/scaffold/handlers/CallHandler.ts +9 -0
- package/fronds/scaffold/handlers/CompletionHandler.ts +8 -0
- package/fronds/scaffold/handlers/NewHandler.ts +8 -0
- package/fronds/scaffold/handlers/ServeHandler.ts +9 -0
- package/fronds/scaffold/handlers/SyncHandler.ts +329 -0
- package/fronds/scaffold/services/ProjectWriter.ts +176 -0
- package/package.json +52 -0
- package/templates/admin/fronds/admin/entities/User.ts +10 -0
- package/templates/admin/fronds/admin/handlers/UserHandler.ts +29 -0
- package/templates/admin/fronds/admin/package.json +10 -0
- package/templates/api/fronds/api/entities/Task.ts +9 -0
- package/templates/api/fronds/api/handlers/TaskHandler.ts +29 -0
- package/templates/api/fronds/api/package.json +10 -0
- package/templates/apps/nuxt/app/app.vue +25 -0
- package/templates/apps/nuxt/app/pages/index.vue +33 -0
- package/templates/apps/nuxt/nuxt.config.ts +6 -0
- package/templates/apps/nuxt/package.json +19 -0
- package/templates/apps/nuxt/tsconfig.json +3 -0
- package/templates/blog/app/pages/index.vue +12 -0
- package/templates/blog/app/pages/posts/index.vue +21 -0
- package/templates/blog/app/pages/posts/manage.vue +47 -0
- package/templates/blog/app/pages/posts/new.vue +34 -0
- package/templates/blog/fronds/blog/entities/Post.ts +12 -0
- package/templates/blog/fronds/blog/handlers/PostHandler.ts +35 -0
- package/templates/blog/fronds/blog/package.json +10 -0
- package/templates/flat/CLAUDE.md +65 -0
- package/templates/flat/_gitignore +6 -0
- package/templates/flat/app/app.vue +25 -0
- package/templates/flat/app/pages/index.vue +7 -0
- package/templates/flat/fougere.config.ts +7 -0
- package/templates/flat/nuxt.config.ts +4 -0
- package/templates/flat/package.json +24 -0
- package/templates/flat/pnpm-workspace.yaml +6 -0
- package/templates/flat/tsconfig.frond.json +25 -0
- package/templates/flat/tsconfig.json +3 -0
- package/templates/frond/CLAUDE.md +65 -0
- package/templates/frond/_gitignore +4 -0
- package/templates/frond/fougere.config.ts +5 -0
- package/templates/frond/fronds/__name__/entities/Post.ts +10 -0
- package/templates/frond/fronds/__name__/handlers/PostHandler.ts +26 -0
- package/templates/frond/fronds/__name__/package.json +10 -0
- package/templates/frond/package.json +18 -0
- package/templates/frond/pnpm-workspace.yaml +2 -0
- package/templates/frond/serve.mjs +25 -0
- package/templates/fronds/blank/entities/Item.ts +8 -0
- package/templates/fronds/blank/handlers/ItemHandler.ts +5 -0
- package/templates/fronds/blank/package.json +14 -0
- package/templates/fronds/blog/entities/Post.ts +10 -0
- package/templates/fronds/blog/handlers/PostHandler.ts +38 -0
- package/templates/fronds/blog/package.json +14 -0
- package/templates/workspace/CLAUDE.md +65 -0
- package/templates/workspace/_gitignore +6 -0
- package/templates/workspace/fougere.config.ts +5 -0
- package/templates/workspace/fronds/tsconfig.json +18 -0
- package/templates/workspace/package.json +19 -0
- package/templates/workspace/pnpm-workspace.yaml +10 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { entitySourceOf, facadeTypeSourceOf, type SchemaDescriptor } from '@fougere/schema';
|
|
4
|
+
// The card's shape is declared once, in core, and imported here. A private copy of it
|
|
5
|
+
// lived in this file and went stale the day an op stopped being a bare name: nothing
|
|
6
|
+
// compared the copy to the original, so the drift cost nothing until someone read it.
|
|
7
|
+
import type { IdentityCard } from '@fougere/core';
|
|
8
|
+
|
|
9
|
+
function assertSafeName(kind: string, name: string): void {
|
|
10
|
+
if (typeof name !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$-]*$/.test(name)) {
|
|
11
|
+
throw new Error(`Invalid ${kind} name '${name}' received from remote`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The line every generated file carries. It is what makes pruning safe: sync removes what
|
|
17
|
+
* IT wrote and nothing else, so a file a human dropped into the folder survives.
|
|
18
|
+
*/
|
|
19
|
+
const GENERATED = 'Generated by `fougere sync`';
|
|
20
|
+
|
|
21
|
+
export function entityClassName(name: string): string {
|
|
22
|
+
assertSafeName('entity', name);
|
|
23
|
+
const identifier = name
|
|
24
|
+
.split('-')
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
27
|
+
.join('');
|
|
28
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier)) {
|
|
29
|
+
throw new Error(`Entity name '${name}' cannot be represented as a TypeScript identifier`);
|
|
30
|
+
}
|
|
31
|
+
return identifier;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One entry — a door or a fact — judged the same way, because sync consumes the same two
|
|
36
|
+
* values from both: a name it can turn into a class, and a descriptor it can rebuild.
|
|
37
|
+
*
|
|
38
|
+
* A missing descriptor is legal on either side and means different things: a door that
|
|
39
|
+
* stores nothing (a health check, a search across shapes), or a fact whose announced type
|
|
40
|
+
* is not a declared entity. Neither produces a row class, and demanding one here refused
|
|
41
|
+
* the WHOLE card over a single entry.
|
|
42
|
+
*/
|
|
43
|
+
function assertEntry(kind: string, frondName: string, entry: { name: string; schema?: SchemaDescriptor } | undefined): void {
|
|
44
|
+
if (!entry || typeof entry !== 'object') {
|
|
45
|
+
throw new Error(`Remote frond '${frondName}' contains an invalid ${kind} entry`);
|
|
46
|
+
}
|
|
47
|
+
assertSafeName(kind, entry.name);
|
|
48
|
+
if (entry.schema === undefined) return;
|
|
49
|
+
// `ops` is not checked because it is not used: sync writes entities, and the name
|
|
50
|
+
// and the descriptor below are the only two values that reach a file. The clause
|
|
51
|
+
// that stood here demanded strings — the shape ops had before they carried their
|
|
52
|
+
// kind and their views — and so refused every real host. Judge what you consume.
|
|
53
|
+
const descriptor = entry.schema as unknown;
|
|
54
|
+
if (
|
|
55
|
+
!descriptor
|
|
56
|
+
|| typeof descriptor !== 'object'
|
|
57
|
+
|| Array.isArray(descriptor)
|
|
58
|
+
|| (descriptor as SchemaDescriptor).type !== 'object'
|
|
59
|
+
|| !(descriptor as SchemaDescriptor).properties
|
|
60
|
+
|| typeof (descriptor as SchemaDescriptor).properties !== 'object'
|
|
61
|
+
|| Array.isArray((descriptor as SchemaDescriptor).properties)
|
|
62
|
+
|| (descriptor as SchemaDescriptor)['x-fougere-version'] !== 1
|
|
63
|
+
|| (descriptor as SchemaDescriptor)['x-fougere-vendor'] !== 'fougere'
|
|
64
|
+
) {
|
|
65
|
+
throw new Error(`Remote ${kind} '${entry.name}' has no valid schema descriptor`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
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;
|
|
74
|
+
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
|
+
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
|
+
// Absent rather than empty is tolerated: a host older than the fact list says nothing
|
|
83
|
+
// about facts, and refusing it would break sync against every previous version for a
|
|
84
|
+
// feature the consumer may not use.
|
|
85
|
+
if (frond.facts !== undefined && !Array.isArray(frond.facts)) {
|
|
86
|
+
throw new Error(`Remote frond '${frond.name}' has no valid facts array`);
|
|
87
|
+
}
|
|
88
|
+
for (const door of frond.doors) assertEntry('door', frond.name, door);
|
|
89
|
+
for (const fact of frond.facts ?? []) assertEntry('fact', frond.name, fact);
|
|
90
|
+
}
|
|
91
|
+
return card;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default class SyncHandler {
|
|
95
|
+
// cwd is ambient in a CLI — not a DI service (the container resolves by type).
|
|
96
|
+
private cwd = process.cwd();
|
|
97
|
+
|
|
98
|
+
/** Mirror a remote frond's contract into local entities. */
|
|
99
|
+
async execute(input: { name: string; from: string }): Promise<{ path: string; entities: string[]; removed: string[] }> {
|
|
100
|
+
assertSafeName('frond', input.name);
|
|
101
|
+
let remoteUrl: URL;
|
|
102
|
+
try {
|
|
103
|
+
remoteUrl = new URL(input.from);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error(`Invalid remote URL '${input.from}'`);
|
|
106
|
+
}
|
|
107
|
+
if (remoteUrl.protocol !== 'http:' && remoteUrl.protocol !== 'https:') {
|
|
108
|
+
throw new Error(`Remote URL must use http or https, got '${remoteUrl.protocol}'`);
|
|
109
|
+
}
|
|
110
|
+
const baseUrl = remoteUrl.toString().replace(/\/$/, '');
|
|
111
|
+
|
|
112
|
+
// The served frond answers `rpc.discover` on its call endpoint with its
|
|
113
|
+
// identity card — the same surface every consumer reads, no side endpoint.
|
|
114
|
+
const res = await fetch(`${baseUrl}/_fougere/call`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'content-type': 'application/json' },
|
|
117
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'rpc.discover', params: { params: {}, query: {}, state: {} } }),
|
|
118
|
+
signal: AbortSignal.timeout(10_000),
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
throw new Error(`Failed to reach ${baseUrl}/_fougere/call: ${res.status} ${res.statusText}`);
|
|
122
|
+
}
|
|
123
|
+
const rpc = (await res.json()) as { result?: IdentityCard; error?: { message: string } };
|
|
124
|
+
if (rpc.error) throw new Error(`Remote error: ${rpc.error.message}`);
|
|
125
|
+
const card = identityCardOf(rpc.result);
|
|
126
|
+
|
|
127
|
+
const target = card.fronds.find((f) => f.name === input.name);
|
|
128
|
+
if (!target) {
|
|
129
|
+
throw new Error(`Frond '${input.name}' not found on ${baseUrl}. Available: ${card.fronds.map((f) => f.name).join(', ')}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const frondDir = join(this.cwd, '.fougere', 'remotes', input.name);
|
|
133
|
+
const entitiesDir = join(frondDir, 'entities');
|
|
134
|
+
const handlersDir = join(frondDir, 'handlers');
|
|
135
|
+
mkdirSync(entitiesDir, { recursive: true });
|
|
136
|
+
mkdirSync(handlersDir, { recursive: true });
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What was written under each name — the barrel below is a projection of exactly this.
|
|
140
|
+
*
|
|
141
|
+
* Three combinations, and all three occur: a door with rows behind it (both files), a
|
|
142
|
+
* door with none (the façade type alone), and a fact (the row class alone, because
|
|
143
|
+
* nothing calls a fact).
|
|
144
|
+
*/
|
|
145
|
+
const generated = new Map<string, { row: boolean; door: boolean }>();
|
|
146
|
+
/** Absolute paths written by THIS run — anything else generated here is now stale. */
|
|
147
|
+
const written = new Set<string>();
|
|
148
|
+
const claim = (name: string): string => {
|
|
149
|
+
const className = entityClassName(name);
|
|
150
|
+
if (generated.has(className)) throw new Error(`Remote declares duplicate entity '${className}'`);
|
|
151
|
+
generated.set(className, { row: false, door: false });
|
|
152
|
+
return className;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One card, one class.
|
|
157
|
+
*
|
|
158
|
+
* `reconstruct` gives the JUDGE — validate, from, getFields — and now takes the
|
|
159
|
+
* row shape as a type argument, so the same declaration gives the TYPE. Both
|
|
160
|
+
* come off the same card: nothing to keep in step, and the file a consumer reads
|
|
161
|
+
* has the shape of the one they would have written by hand
|
|
162
|
+
* (`class Post extends entity({…}) {}`).
|
|
163
|
+
*/
|
|
164
|
+
const writeRow = (className: string, descriptor: SchemaDescriptor): void => {
|
|
165
|
+
written.add(join(entitiesDir, `${className}.ts`));
|
|
166
|
+
writeFileSync(join(entitiesDir, `${className}.ts`), [
|
|
167
|
+
`import { reconstruct } from '@fougere/schema';`,
|
|
168
|
+
``,
|
|
169
|
+
`// Generated by \`fougere sync\` from ${baseUrl} — do not edit.`,
|
|
170
|
+
entitySourceOf(descriptor, { name: className }),
|
|
171
|
+
``,
|
|
172
|
+
`export default ${className};`,
|
|
173
|
+
``,
|
|
174
|
+
].join('\n'));
|
|
175
|
+
generated.get(className)!.row = true;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
for (const { name, schema: descriptor, ops } of target.doors) {
|
|
179
|
+
const className = claim(name);
|
|
180
|
+
generated.get(className)!.door = true;
|
|
181
|
+
|
|
182
|
+
// No shape behind this door: its operations still travel, its rows do not exist.
|
|
183
|
+
// `rowType` falls back to `unknown`, which is the truth rather than an empty class.
|
|
184
|
+
if (descriptor !== undefined) writeRow(className, descriptor as SchemaDescriptor);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The door's type, next to the row's — what `Facade<T>` needs and what nothing
|
|
188
|
+
* carried across a repository boundary.
|
|
189
|
+
*
|
|
190
|
+
* Writing `Facade<ArticleHandler>` used to require importing the handler's class.
|
|
191
|
+
* `sync` does not ship handlers and should not: a handler is behaviour with state,
|
|
192
|
+
* and behaviour does not travel. Its OPERATIONS do — the card names them, says how
|
|
193
|
+
* much each returns, and that is exactly a signature.
|
|
194
|
+
*/
|
|
195
|
+
written.add(join(handlersDir, `${className}Handler.ts`));
|
|
196
|
+
writeFileSync(join(handlersDir, `${className}Handler.ts`), [
|
|
197
|
+
...(descriptor !== undefined ? [`import type { ${className} } from '../entities/${className}.js';`, ``] : []),
|
|
198
|
+
`// Generated by \`fougere sync\` from ${baseUrl} — do not edit.`,
|
|
199
|
+
`// The shape of an invocation, restated rather than imported: this file is a`,
|
|
200
|
+
`// contract, and a contract that drags a runtime dependency is not one.`,
|
|
201
|
+
`type Invocation = { params?: Record<string, string>; query?: Record<string, unknown>; body?: unknown; state?: Record<string, unknown> };`,
|
|
202
|
+
``,
|
|
203
|
+
facadeTypeSourceOf(ops ?? [], {
|
|
204
|
+
name: `${className}Handler`,
|
|
205
|
+
...(descriptor !== undefined ? { rowType: className } : {}),
|
|
206
|
+
}),
|
|
207
|
+
``,
|
|
208
|
+
].join('\n'));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The facts, which is the only reason a subscriber needs this command at all.
|
|
213
|
+
*
|
|
214
|
+
* A fact has no operation, so no façade type is written: a listener spells
|
|
215
|
+
* `reindex(fact: Fact<PostPublished>)` and the class is the whole of what it needs.
|
|
216
|
+
* Announced without a declared shape, there is nothing to write — a class built from
|
|
217
|
+
* a bare name would validate everything, which is worse than the absence.
|
|
218
|
+
*/
|
|
219
|
+
for (const { name, schema: descriptor } of target.facts ?? []) {
|
|
220
|
+
const className = claim(name);
|
|
221
|
+
if (descriptor === undefined) {
|
|
222
|
+
generated.delete(className);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
writeRow(className, descriptor as SchemaDescriptor);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Barrel index
|
|
229
|
+
// One binding carries the value AND the type, because a class is both — the pair of
|
|
230
|
+
// re-exports that stood here was the price of declaring them separately.
|
|
231
|
+
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';`] : []),
|
|
234
|
+
]);
|
|
235
|
+
writeFileSync(join(frondDir, 'index.ts'), indexLines.join('\n') + '\n');
|
|
236
|
+
|
|
237
|
+
// Package.json
|
|
238
|
+
writeFileSync(join(frondDir, 'package.json'), JSON.stringify({
|
|
239
|
+
name: `@frond/${input.name}`,
|
|
240
|
+
version: '0.0.0-synced',
|
|
241
|
+
type: 'module',
|
|
242
|
+
fougere: { frond: input.name, synced: true, source: baseUrl },
|
|
243
|
+
exports: {
|
|
244
|
+
'.': './index.ts',
|
|
245
|
+
'./entities/*': './entities/*.ts',
|
|
246
|
+
'./handlers/*': './handlers/*.ts',
|
|
247
|
+
'./package.json': './package.json',
|
|
248
|
+
},
|
|
249
|
+
}, null, 2) + '\n');
|
|
250
|
+
|
|
251
|
+
// Update .fougere/remotes.json — central registry of synced remotes
|
|
252
|
+
this.updateRemotesRegistry(input.name, baseUrl, frondDir);
|
|
253
|
+
|
|
254
|
+
// Update tsconfig paths if tsconfig.json exists (non-Nuxt projects)
|
|
255
|
+
this.updateTsconfigPaths(input.name, frondDir);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* What the host no longer serves stops being importable here.
|
|
259
|
+
*
|
|
260
|
+
* The barrel is rewritten every run, so a dropped entity loses its export on its own
|
|
261
|
+
* — 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
|
|
263
|
+
* behind it answers for. The consumer compiles, its local judge accepts, and the call
|
|
264
|
+
* comes back NOT_FOUND at the door — or never leaves, because the page dropped the
|
|
265
|
+
* call and kept the type.
|
|
266
|
+
*/
|
|
267
|
+
const removed = [...this.prune(entitiesDir, written), ...this.prune(handlersDir, written)];
|
|
268
|
+
|
|
269
|
+
return { path: entitiesDir, entities: [...generated.keys()], removed };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Delete the files this run did not write — and only the ones sync itself produced. */
|
|
273
|
+
private prune(dir: string, written: Set<string>): string[] {
|
|
274
|
+
const dropped: string[] = [];
|
|
275
|
+
for (const name of readdirSync(dir)) {
|
|
276
|
+
const path = join(dir, name);
|
|
277
|
+
if (written.has(path)) continue;
|
|
278
|
+
// Never remove what sync did not write. The folder is sync's, but a human who put
|
|
279
|
+
// something there meant it, and a delete is not the place to be clever.
|
|
280
|
+
if (!readFileSync(path, 'utf-8').includes(GENERATED)) continue;
|
|
281
|
+
rmSync(path);
|
|
282
|
+
dropped.push(name);
|
|
283
|
+
}
|
|
284
|
+
return dropped;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Write/update .fougere/remotes.json — read by @fougere/nuxt for auto-aliasing. */
|
|
288
|
+
private updateRemotesRegistry(name: string, baseUrl: string, localPath: string): void {
|
|
289
|
+
const registryPath = join(this.cwd, '.fougere', 'remotes.json');
|
|
290
|
+
let registry: Record<string, { url: string; path: string }> = {};
|
|
291
|
+
|
|
292
|
+
if (existsSync(registryPath)) {
|
|
293
|
+
try {
|
|
294
|
+
const parsed: unknown = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
|
295
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('expected an object');
|
|
296
|
+
registry = parsed as Record<string, { url: string; path: string }>;
|
|
297
|
+
} catch (cause) {
|
|
298
|
+
throw new Error(`Cannot update corrupt remote registry at ${registryPath}`, { cause });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
registry[name] = { url: baseUrl, path: localPath };
|
|
303
|
+
mkdirSync(join(this.cwd, '.fougere'), { recursive: true });
|
|
304
|
+
writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Add @frond/{name} to tsconfig paths if tsconfig.json exists. */
|
|
308
|
+
private updateTsconfigPaths(name: string, localPath: string): void {
|
|
309
|
+
const tsconfigPath = join(this.cwd, 'tsconfig.json');
|
|
310
|
+
if (!existsSync(tsconfigPath)) return;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const raw = readFileSync(tsconfigPath, 'utf-8');
|
|
314
|
+
const tsconfig = JSON.parse(raw);
|
|
315
|
+
|
|
316
|
+
// Don't touch Nuxt-managed tsconfigs (extends .nuxt/tsconfig.json)
|
|
317
|
+
if (tsconfig.extends?.includes('.nuxt/')) return;
|
|
318
|
+
|
|
319
|
+
tsconfig.compilerOptions ??= {};
|
|
320
|
+
tsconfig.compilerOptions.paths ??= {};
|
|
321
|
+
|
|
322
|
+
const relative = localPath.replace(this.cwd, '.').replace(/\\/g, '/');
|
|
323
|
+
tsconfig.compilerOptions.paths[`@frond/${name}`] = [`${relative}/index.ts`];
|
|
324
|
+
tsconfig.compilerOptions.paths[`@frond/${name}/*`] = [`${relative}/*`];
|
|
325
|
+
|
|
326
|
+
writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n');
|
|
327
|
+
} catch { /* tsconfig parse error — skip */ }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { cpSync, existsSync, renameSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Scaffolds from real template files (create-vite pattern: stdlib copy, no
|
|
7
|
+
* token engine). The workspace model composes at every scale:
|
|
8
|
+
* - a workspace shell (`workspace/`),
|
|
9
|
+
* - fronds added from `fronds/<template>` (business hexagons),
|
|
10
|
+
* - apps added from `apps/<template>` (consumers: nuxt, cli).
|
|
11
|
+
* The only per-piece edits are the package names.
|
|
12
|
+
*/
|
|
13
|
+
const TEMPLATES = fileURLToPath(new URL('../../../templates/', import.meta.url));
|
|
14
|
+
|
|
15
|
+
// npm strips a literal .gitignore from published packages — it ships as
|
|
16
|
+
// _gitignore and the name is restored on copy.
|
|
17
|
+
function restoreGitignore(dir: string): void {
|
|
18
|
+
const gi = join(dir, '_gitignore');
|
|
19
|
+
if (existsSync(gi)) renameSync(gi, join(dir, '.gitignore'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function setPackageName(dir: string, name: string): void {
|
|
23
|
+
const pkgPath = join(dir, 'package.json');
|
|
24
|
+
if (!existsSync(pkgPath)) return;
|
|
25
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name: string };
|
|
26
|
+
pkg.name = name;
|
|
27
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default class ProjectWriter {
|
|
31
|
+
/** The workspace shell — fougere.config, pnpm-workspace (fronds/* apps/*), package.json. */
|
|
32
|
+
createWorkspace(dir: string, name: string): { path: string } {
|
|
33
|
+
cpSync(join(TEMPLATES, 'workspace'), dir, { recursive: true });
|
|
34
|
+
restoreGitignore(dir);
|
|
35
|
+
setPackageName(dir, name);
|
|
36
|
+
return { path: dir };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The flat shell — one Nuxt app whose root carries the convention, so no `fronds/`
|
|
41
|
+
* and no workspace. `templates/flat/` is that shell; what separates the two shapes is
|
|
42
|
+
* only where the domain lands.
|
|
43
|
+
*/
|
|
44
|
+
createFlat(dir: string, name: string): { path: string } {
|
|
45
|
+
cpSync(join(TEMPLATES, 'flat'), dir, { recursive: true });
|
|
46
|
+
restoreGitignore(dir);
|
|
47
|
+
setPackageName(dir, name);
|
|
48
|
+
return { path: dir };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 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
|
|
54
|
+
* directory through the Nuxt module's alias, so the template's package would only
|
|
55
|
+
* duplicate it under a second name.
|
|
56
|
+
*/
|
|
57
|
+
addRootFrond(dir: string, template: string): { path: string } {
|
|
58
|
+
const src = join(TEMPLATES, 'fronds', template);
|
|
59
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
60
|
+
if (!entry.isDirectory()) continue;
|
|
61
|
+
cpSync(join(src, entry.name), join(dir, entry.name), { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
return { path: dir };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 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);
|
|
69
|
+
cpSync(join(TEMPLATES, 'fronds', template), dest, { recursive: true });
|
|
70
|
+
// Only the import name. Carrying the convention is what makes a frond — the scan
|
|
71
|
+
// reads directories. `fougere.frond` IS read now (`scanner.ts`, `frondNameOf`), but
|
|
72
|
+
// it renames the contract, which a freshly scaffolded frond has no reason to do.
|
|
73
|
+
const pkgPath = join(dest, 'package.json');
|
|
74
|
+
if (existsSync(pkgPath)) {
|
|
75
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name: string };
|
|
76
|
+
pkg.name = `@frond/${name}`;
|
|
77
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
78
|
+
}
|
|
79
|
+
return { path: dest };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Add an app (consumer) under apps/<name>. */
|
|
83
|
+
addApp(wsDir: string, template: string, name: string): { path: string } {
|
|
84
|
+
const dest = join(wsDir, 'apps', name);
|
|
85
|
+
cpSync(join(TEMPLATES, 'apps', template), dest, { recursive: true });
|
|
86
|
+
restoreGitignore(dest);
|
|
87
|
+
setPackageName(dest, name);
|
|
88
|
+
return { path: dest };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Available templates of a kind ('fronds' | 'apps') — the directory is the registry. */
|
|
92
|
+
listTemplates(kind: 'fronds' | 'apps'): string[] {
|
|
93
|
+
const dir = join(TEMPLATES, kind);
|
|
94
|
+
if (!existsSync(dir)) return [];
|
|
95
|
+
return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Every app depends on every frond of the workspace — stated once composition is done,
|
|
100
|
+
* because that is when the names exist.
|
|
101
|
+
*
|
|
102
|
+
* A template cannot carry it: the frond is named at composition time (`blog:catalog`),
|
|
103
|
+
* so a dependency written into `templates/apps/nuxt` would name the template instead
|
|
104
|
+
* and resolve to nothing. Which is what happened — the generated app imported
|
|
105
|
+
* `@frond/blog` whatever you had called it, and did not start.
|
|
106
|
+
*
|
|
107
|
+
* `fronds/` and `apps/` are the registry, like `listTemplates`: nothing to declare.
|
|
108
|
+
*/
|
|
109
|
+
linkFronds(wsDir: string): void {
|
|
110
|
+
const dirs = (kind: string): string[] => {
|
|
111
|
+
const dir = join(wsDir, kind);
|
|
112
|
+
if (!existsSync(dir)) return [];
|
|
113
|
+
|
|
114
|
+
return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const fronds = dirs('fronds');
|
|
118
|
+
if (fronds.length === 0) return;
|
|
119
|
+
|
|
120
|
+
for (const app of dirs('apps')) {
|
|
121
|
+
const pkgPath = join(wsDir, 'apps', app, 'package.json');
|
|
122
|
+
if (!existsSync(pkgPath)) continue;
|
|
123
|
+
|
|
124
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { dependencies?: Record<string, string> };
|
|
125
|
+
pkg.dependencies ??= {};
|
|
126
|
+
for (const frond of fronds) pkg.dependencies[`@frond/${frond}`] = 'workspace:*';
|
|
127
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Dev mode: rewrite every `@fougere/*` dependency in the workspace to a
|
|
133
|
+
* `link:` into this monorepo, so `pnpm install` resolves offline (the
|
|
134
|
+
* packages aren't on npm yet). No-op once the packages are published.
|
|
135
|
+
*/
|
|
136
|
+
linkLocal(wsDir: string): void {
|
|
137
|
+
const packages = fileURLToPath(new URL('../../../../', import.meta.url));
|
|
138
|
+
// Read off the monorepo rather than listed here: a hand-kept map knew the seven
|
|
139
|
+
// packages the default templates use, so the first step beyond the default — a
|
|
140
|
+
// GraphQL surface, auth — added a dependency it had never heard of, which stayed
|
|
141
|
+
// on `latest` and broke the install. `@fougere/nuxt` lives in `app/nuxt/` and
|
|
142
|
+
// `@fougere/transport-http` in `transport/http/`, which is exactly why the name is
|
|
143
|
+
// read from each package.json instead of guessed from its directory.
|
|
144
|
+
const dirOf = new Map<string, string>();
|
|
145
|
+
const scan = (dir: string, depth = 0): void => {
|
|
146
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
147
|
+
if (!e.isDirectory() || e.name === 'node_modules') continue;
|
|
148
|
+
const sub = join(dir, e.name);
|
|
149
|
+
const pkgPath = join(sub, 'package.json');
|
|
150
|
+
if (existsSync(pkgPath)) {
|
|
151
|
+
const { name } = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string };
|
|
152
|
+
if (name?.startsWith('@fougere/')) dirOf.set(name, sub);
|
|
153
|
+
} else if (depth === 0) {
|
|
154
|
+
scan(sub, depth + 1);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
scan(packages);
|
|
159
|
+
const walk = (d: string): void => {
|
|
160
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
161
|
+
if (e.name === 'node_modules') continue;
|
|
162
|
+
const f = join(d, e.name);
|
|
163
|
+
if (e.isDirectory()) { walk(f); continue; }
|
|
164
|
+
if (e.name !== 'package.json') continue;
|
|
165
|
+
const pkg = JSON.parse(readFileSync(f, 'utf8')) as { dependencies?: Record<string, string> };
|
|
166
|
+
let changed = false;
|
|
167
|
+
for (const dep of Object.keys(pkg.dependencies ?? {})) {
|
|
168
|
+
const local = dirOf.get(dep);
|
|
169
|
+
if (local) { pkg.dependencies![dep] = `link:${local}`; changed = true; }
|
|
170
|
+
}
|
|
171
|
+
if (changed) writeFileSync(f, JSON.stringify(pkg, null, 2) + '\n');
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
walk(wsDir);
|
|
175
|
+
}
|
|
176
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fougere/cli",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "The Fougere CLI — compose a workspace, serve a frond, call an operation.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/chok/fougere.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"bin": {
|
|
15
|
+
"fougere": "dist/bin.js"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"app",
|
|
26
|
+
"fronds",
|
|
27
|
+
"templates"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"citty": "^0.2.1",
|
|
31
|
+
"jiti": "^2.4.2",
|
|
32
|
+
"picocolors": "^1.1.1",
|
|
33
|
+
"@fougere/cli-ui": "0.1.0-alpha.0",
|
|
34
|
+
"@fougere/container-fougere": "0.1.0-alpha.0",
|
|
35
|
+
"@fougere/transport-http": "0.1.0-alpha.0",
|
|
36
|
+
"@fougere/runtime": "0.1.0-alpha.0",
|
|
37
|
+
"@fougere/core": "0.1.0-alpha.0",
|
|
38
|
+
"@fougere/schema": "0.1.0-alpha.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"vitest": "^4.1.0"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "rm -rf dist && tsc && chmod +x dist/bin.js",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"test:watch": "vitest",
|
|
50
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.runtime.json && tsc -p tsconfig.templates.json"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { entity, primary, text, email, auto, oneOf, readOnly } from '@fougere/schema';
|
|
2
|
+
|
|
3
|
+
export default class User extends entity({
|
|
4
|
+
id: primary(),
|
|
5
|
+
name: text({ min: 1, max: 100 }),
|
|
6
|
+
email: email(),
|
|
7
|
+
createdAt: auto(),
|
|
8
|
+
// Server-owned: a user is born active and flipped by the deactivate operation.
|
|
9
|
+
status: readOnly(oneOf('active', 'inactive', { default: 'active' })),
|
|
10
|
+
}) {}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Crud, FougereError, ErrorCode } from '@fougere/core';
|
|
2
|
+
import User from '../entities/User.js';
|
|
3
|
+
|
|
4
|
+
// An Output contract — a read projection of the entity, declared once.
|
|
5
|
+
export class UserCard extends User.pick('id', 'name', 'status') {}
|
|
6
|
+
|
|
7
|
+
// Crud(User) gives list/create/update/delete for free — the accelerator.
|
|
8
|
+
// 'deactivate' is the business contract: a state transition, not a field write.
|
|
9
|
+
export default class UserHandler extends Crud(User) {
|
|
10
|
+
/** active→inactive — an operation, not a field write. Judge: active only. */
|
|
11
|
+
async deactivate(id: string): Promise<User> {
|
|
12
|
+
const user = await this.orm.findById(id);
|
|
13
|
+
if (!user) {
|
|
14
|
+
throw new FougereError({ code: ErrorCode.NOT_FOUND, message: `User '${id}' not found`, entity: 'user', operation: 'deactivate' });
|
|
15
|
+
}
|
|
16
|
+
if ((user as { status?: string }).status === 'inactive') {
|
|
17
|
+
throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already inactive', entity: 'user', operation: 'deactivate' });
|
|
18
|
+
}
|
|
19
|
+
return this.orm.update(id, { status: 'inactive' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Active users, projected to the card contract. */
|
|
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[];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { entity, primary, text, auto, oneOf, readOnly } from '@fougere/schema';
|
|
2
|
+
|
|
3
|
+
export default class Task extends entity({
|
|
4
|
+
id: primary(),
|
|
5
|
+
title: text({ min: 1, max: 200 }),
|
|
6
|
+
createdAt: auto(),
|
|
7
|
+
// Server-owned: a task is born open and flipped by the complete operation.
|
|
8
|
+
status: readOnly(oneOf('open', 'done', { default: 'open' })),
|
|
9
|
+
}) {}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Crud, FougereError, ErrorCode } from '@fougere/core';
|
|
2
|
+
import Task from '../entities/Task.js';
|
|
3
|
+
|
|
4
|
+
// An Output contract — a read projection of the entity, declared once.
|
|
5
|
+
export class TaskCard extends Task.pick('id', 'title', 'status') {}
|
|
6
|
+
|
|
7
|
+
// Crud(Task) gives list/create/update/delete for free — the accelerator.
|
|
8
|
+
// 'complete' is the business contract: a state transition, not a field write.
|
|
9
|
+
export default class TaskHandler extends Crud(Task) {
|
|
10
|
+
/** open→done — an operation, not a field write. Judge: open only. */
|
|
11
|
+
async complete(id: string): Promise<Task> {
|
|
12
|
+
const task = await this.orm.findById(id);
|
|
13
|
+
if (!task) {
|
|
14
|
+
throw new FougereError({ code: ErrorCode.NOT_FOUND, message: `Task '${id}' not found`, entity: 'task', operation: 'complete' });
|
|
15
|
+
}
|
|
16
|
+
if ((task as { status?: string }).status === 'done') {
|
|
17
|
+
throw new FougereError({ code: ErrorCode.CONFLICT, message: 'Already done', entity: 'task', operation: 'complete' });
|
|
18
|
+
}
|
|
19
|
+
return this.orm.update(id, { status: 'done' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Still-open tasks, projected to the card contract. */
|
|
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[];
|
|
28
|
+
}
|
|
29
|
+
}
|