@fougere/cli 0.2.0-alpha.2 → 0.4.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/app/commands/BuildCommand.ts +39 -0
- package/app/commands/CallCommand.ts +3 -3
- package/app/commands/CheckCommand.ts +2 -1
- package/app/commands/ExplainCommand.ts +124 -0
- package/app/commands/FreezeCommand.ts +107 -0
- package/app/commands/GrantCommand.ts +44 -0
- package/app/commands/KeysCommand.ts +56 -0
- package/app/commands/MigrateCommand.ts +54 -0
- package/app/commands/NewCommand.ts +5 -5
- package/app/commands/ServeCommand.ts +74 -7
- package/app/commands/grant-material.ts +5 -0
- package/dist/bin.js +55 -10
- package/dist/bin.js.map +1 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +15 -8
- package/dist/bridge.js.map +1 -1
- package/dist/completion.d.ts +4 -1
- package/dist/completion.d.ts.map +1 -1
- package/dist/completion.js +54 -20
- package/dist/completion.js.map +1 -1
- package/dist/loader.d.ts +11 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +23 -0
- package/dist/loader.js.map +1 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +7 -6
- package/dist/runner.js.map +1 -1
- package/fronds/analysis/entities/Build.ts +7 -0
- package/fronds/analysis/entities/Explain.ts +9 -0
- package/fronds/analysis/entities/Freeze.ts +7 -0
- package/fronds/analysis/entities/Migrate.ts +7 -0
- package/fronds/analysis/handlers/BuildHandler.ts +60 -0
- package/fronds/analysis/handlers/CheckHandler.ts +40 -41
- package/fronds/analysis/handlers/ExplainHandler.ts +254 -0
- package/fronds/analysis/handlers/FreezeHandler.ts +176 -0
- package/fronds/analysis/handlers/MigrateHandler.ts +97 -0
- package/fronds/analysis/services/ProjectScan.ts +23 -7
- package/fronds/analysis/versions.ts +58 -0
- package/fronds/scaffold/entities/BuildFrond.ts +1 -1
- package/fronds/scaffold/entities/Call.ts +1 -1
- package/fronds/scaffold/entities/Grant.ts +6 -0
- package/fronds/scaffold/entities/Keys.ts +4 -0
- package/fronds/scaffold/entities/Serve.ts +2 -1
- package/fronds/scaffold/entities/Sync.ts +1 -1
- package/fronds/scaffold/handlers/BuildFrondHandler.ts +13 -15
- package/fronds/scaffold/handlers/GrantHandler.ts +8 -0
- package/fronds/scaffold/handlers/KeysHandler.ts +8 -0
- package/fronds/scaffold/handlers/SyncHandler.ts +40 -37
- package/fronds/scaffold/services/ProjectWriter.ts +9 -8
- package/package.json +10 -8
- package/src/bin.ts +83 -0
- package/src/bridge.ts +70 -0
- package/src/completion.ts +152 -0
- package/src/index.ts +3 -0
- package/src/loader.ts +28 -0
- package/src/runner.ts +139 -0
- package/src/theme.ts +19 -0
- package/src/ui.ts +131 -0
- package/templates/admin/fronds/admin/handlers/UserHandler.ts +3 -5
- package/templates/admin/fronds/admin/package.json +1 -1
- package/templates/api/fronds/api/handlers/TaskHandler.ts +3 -5
- package/templates/api/fronds/api/package.json +1 -1
- package/templates/apps/nuxt/app/pages/index.vue +1 -1
- package/templates/blog/app/pages/posts/index.vue +1 -1
- package/templates/blog/app/pages/posts/manage.vue +1 -1
- package/templates/blog/app/pages/posts/new.vue +1 -1
- package/templates/blog/fronds/blog/handlers/PostHandler.ts +3 -5
- package/templates/blog/fronds/blog/package.json +1 -1
- package/templates/flat/AGENTS.md +14 -0
- package/templates/flat/CLAUDE.md +25 -3
- package/templates/frond/AGENTS.md +14 -0
- package/templates/frond/CLAUDE.md +25 -3
- package/templates/frond/fronds/__name__/handlers/PostHandler.ts +3 -5
- package/templates/frond/fronds/__name__/package.json +1 -1
- package/templates/frond/serve.mjs +3 -2
- package/templates/fronds/blank/package.json +1 -1
- package/templates/fronds/blog/handlers/PostHandler.ts +2 -4
- package/templates/fronds/blog/package.json +1 -1
- package/templates/workspace/AGENTS.md +14 -0
- package/templates/workspace/CLAUDE.md +25 -3
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type FougereConfig, type ScanResult } from '@fougere/core';
|
|
2
|
+
import {
|
|
3
|
+
scanProject, frondAliases, setModuleLoader, loadConfig, resolveConventions,
|
|
4
|
+
} from '@fougere/core/node';
|
|
2
5
|
import { resolve } from 'node:path';
|
|
3
6
|
|
|
4
7
|
/**
|
|
@@ -18,18 +21,31 @@ export default class ProjectScan {
|
|
|
18
21
|
private cwd = process.cwd();
|
|
19
22
|
|
|
20
23
|
/** Scan the project at `root`, relative to where the command was invoked. */
|
|
21
|
-
async at(root?: string): Promise<ScanResult & { root: string }> {
|
|
24
|
+
async at(root?: string): Promise<ScanResult & { root: string; config: FougereConfig }> {
|
|
22
25
|
const target = resolve(this.cwd, root || '.');
|
|
23
26
|
|
|
24
27
|
// The scan reads `.ts` sources; the default loader is a plain `import`, which
|
|
25
28
|
// cannot. Installed once per call because the loader is module-global — the
|
|
26
29
|
// CLI's own app was loaded with its own, and this replaces it for the target.
|
|
27
30
|
const { createJiti } = await import('jiti');
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const install = (alias?: Record<string, string>): void => {
|
|
32
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true, ...(alias ? { alias } : {}) });
|
|
33
|
+
setModuleLoader((filePath) => jiti.import(filePath) as Promise<Record<string, unknown>>);
|
|
34
|
+
};
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The config is read BEFORE the aliases, because it names the scope they are built
|
|
38
|
+
* from. Safe because nothing in `fougere.config.ts` may import `@fronds/*` — the one
|
|
39
|
+
* import that would need the name to read the file that declares it.
|
|
40
|
+
*/
|
|
41
|
+
install();
|
|
42
|
+
const config = await loadConfig(target);
|
|
43
|
+
const conventions = resolveConventions(config.conventions);
|
|
44
|
+
|
|
45
|
+
// The scope is the framework's own convention; the loader has to know it, or a frond
|
|
46
|
+
// naming its neighbour is unreadable to the very tool that checks it.
|
|
47
|
+
install(await frondAliases(target, conventions));
|
|
48
|
+
|
|
49
|
+
return { root: target, config, ...(await scanProject(target, undefined, conventions)) };
|
|
34
50
|
}
|
|
35
51
|
}
|
|
@@ -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
|
+
}
|
|
@@ -2,5 +2,5 @@ import { entity, text } from "@fougere/schema";
|
|
|
2
2
|
|
|
3
3
|
/** `fougere call <entity>.<op> [--field value …]` — invoke one operation, print the result. */
|
|
4
4
|
export default class Call extends entity({
|
|
5
|
-
|
|
5
|
+
operation: text({ min: 1, description: "entity.op to invoke (e.g. post.create)" }),
|
|
6
6
|
}) {}
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
import { entity, text } from '@fougere/schema';
|
|
2
2
|
|
|
3
3
|
export default class Sync extends entity({
|
|
4
|
-
|
|
4
|
+
frond: text({ description: 'Frond name to sync (e.g. blog)' }),
|
|
5
5
|
from: text({ description: 'Remote URL (e.g. http://blog-service:3000)' }),
|
|
6
6
|
}) {}
|
|
@@ -1,26 +1,24 @@
|
|
|
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).
|
|
11
8
|
private cwd = process.cwd();
|
|
12
9
|
|
|
13
10
|
/** Build a frond into a standalone deployable package. */
|
|
14
|
-
async execute(input: {
|
|
15
|
-
const
|
|
11
|
+
async execute(input: { frond: string }): Promise<{ path: string; entities: string[] }> {
|
|
12
|
+
const conventions = resolveConventions((await loadConfig(this.cwd)).conventions);
|
|
13
|
+
const frondDir = join(this.cwd, conventions.fronds, input.frond);
|
|
16
14
|
|
|
17
15
|
if (!existsSync(frondDir)) {
|
|
18
|
-
throw new Error(`Frond '${input.
|
|
16
|
+
throw new Error(`Frond '${input.frond}' not found at ${frondDir}`);
|
|
19
17
|
}
|
|
20
18
|
|
|
21
|
-
const entitiesDir = join(frondDir,
|
|
19
|
+
const entitiesDir = join(frondDir, conventions.dirs.entities);
|
|
22
20
|
if (!existsSync(entitiesDir)) {
|
|
23
|
-
throw new Error(`No entities/ directory in frond '${input.
|
|
21
|
+
throw new Error(`No ${conventions.dirs.entities}/ directory in frond '${input.frond}'`);
|
|
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 '
|
|
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',
|
|
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:
|
|
70
|
+
: { name: frondPackage(input.frond, 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
|
-
|
|
80
|
-
types:
|
|
81
|
-
default:
|
|
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
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { upperFirst, EntityTypeSource, FacadeTypeSource, type SchemaDescriptor } from '@fougere/schema';
|
|
4
4
|
// The card's shape is declared once, in core, and imported here. A private copy of it
|
|
5
5
|
// lived in this file and went stale the day an op stopped being a bare name: nothing
|
|
6
6
|
// compared the copy to the original, so the drift cost nothing until someone read it.
|
|
7
|
-
import type
|
|
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)) {
|
|
@@ -23,7 +26,7 @@ export function entityClassName(name: string): string {
|
|
|
23
26
|
const identifier = name
|
|
24
27
|
.split('-')
|
|
25
28
|
.filter(Boolean)
|
|
26
|
-
.map(
|
|
29
|
+
.map(upperFirst)
|
|
27
30
|
.join('');
|
|
28
31
|
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier)) {
|
|
29
32
|
throw new Error(`Entity name '${name}' cannot be represented as a TypeScript identifier`);
|
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const card = value
|
|
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.
|
|
@@ -96,8 +93,8 @@ export default class SyncHandler {
|
|
|
96
93
|
private cwd = process.cwd();
|
|
97
94
|
|
|
98
95
|
/** Mirror a remote frond's contract into local entities. */
|
|
99
|
-
async execute(input: {
|
|
100
|
-
assertSafeName('frond', input.
|
|
96
|
+
async execute(input: { frond: string; from: string }): Promise<{ path: string; entities: string[]; removed: string[] }> {
|
|
97
|
+
assertSafeName('frond', input.frond);
|
|
101
98
|
let remoteUrl: URL;
|
|
102
99
|
try {
|
|
103
100
|
remoteUrl = new URL(input.from);
|
|
@@ -124,14 +121,20 @@ export default class SyncHandler {
|
|
|
124
121
|
if (rpc.error) throw new Error(`Remote error: ${rpc.error.message}`);
|
|
125
122
|
const card = identityCardOf(rpc.result);
|
|
126
123
|
|
|
127
|
-
const target = card.fronds.find((f) => f.name === input.
|
|
124
|
+
const target = card.fronds.find((f) => f.name === input.frond);
|
|
128
125
|
if (!target) {
|
|
129
|
-
throw new Error(`Frond '${input.
|
|
126
|
+
throw new Error(`Frond '${input.frond}' not found on ${baseUrl}. Available: ${card.fronds.map((f) => f.name).join(', ')}`);
|
|
130
127
|
}
|
|
131
128
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
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
|
+
|
|
135
|
+
const frondDir = join(this.cwd, '.fougere', 'remotes', input.frond);
|
|
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
|
|
|
@@ -155,7 +158,7 @@ export default class SyncHandler {
|
|
|
155
158
|
/**
|
|
156
159
|
* One card, one class.
|
|
157
160
|
*
|
|
158
|
-
* `
|
|
161
|
+
* `Card.toSchema` gives the JUDGE — validate, from, getFields — and now takes the
|
|
159
162
|
* row shape as a type argument, so the same declaration gives the TYPE. Both
|
|
160
163
|
* come off the same card: nothing to keep in step, and the file a consumer reads
|
|
161
164
|
* has the shape of the one they would have written by hand
|
|
@@ -164,10 +167,10 @@ export default class SyncHandler {
|
|
|
164
167
|
const writeRow = (className: string, descriptor: SchemaDescriptor): void => {
|
|
165
168
|
written.add(join(entitiesDir, `${className}.ts`));
|
|
166
169
|
writeFileSync(join(entitiesDir, `${className}.ts`), [
|
|
167
|
-
`import {
|
|
170
|
+
`import { Card } from '@fougere/schema';`,
|
|
168
171
|
``,
|
|
169
172
|
`// Generated by \`fougere sync\` from ${baseUrl} — do not edit.`,
|
|
170
|
-
|
|
173
|
+
EntityTypeSource.of(descriptor).render({ name: className }),
|
|
171
174
|
``,
|
|
172
175
|
`export default ${className};`,
|
|
173
176
|
``,
|
|
@@ -200,7 +203,7 @@ export default class SyncHandler {
|
|
|
200
203
|
`// contract, and a contract that drags a runtime dependency is not one.`,
|
|
201
204
|
`type Invocation = { params?: Record<string, string>; query?: Record<string, unknown>; body?: unknown; state?: Record<string, unknown> };`,
|
|
202
205
|
``,
|
|
203
|
-
|
|
206
|
+
FacadeTypeSource.of(ops ?? []).render({
|
|
204
207
|
name: `${className}Handler`,
|
|
205
208
|
...(descriptor !== undefined ? { rowType: className } : {}),
|
|
206
209
|
}),
|
|
@@ -229,37 +232,37 @@ 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 '
|
|
233
|
-
...(door ? [`export type { ${name}Handler } from '
|
|
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:
|
|
242
|
+
name: frondPackage(input.frond, conventions),
|
|
240
243
|
version: '0.0.0-synced',
|
|
241
244
|
type: 'module',
|
|
242
|
-
fougere: { frond: input.
|
|
245
|
+
fougere: { frond: input.frond, synced: true, source: baseUrl },
|
|
243
246
|
exports: {
|
|
244
247
|
'.': './index.ts',
|
|
245
|
-
|
|
246
|
-
|
|
248
|
+
[`./${entities}/*`]: `./${entities}/*.ts`,
|
|
249
|
+
[`./${handlers}/*`]: `./${handlers}/*.ts`,
|
|
247
250
|
'./package.json': './package.json',
|
|
248
251
|
},
|
|
249
252
|
}, null, 2) + '\n');
|
|
250
253
|
|
|
251
254
|
// Update .fougere/remotes.json — central registry of synced remotes
|
|
252
|
-
this.updateRemotesRegistry(input.
|
|
255
|
+
this.updateRemotesRegistry(input.frond, baseUrl, frondDir);
|
|
253
256
|
|
|
254
257
|
// Update tsconfig paths if tsconfig.json exists (non-Nuxt projects)
|
|
255
|
-
this.updateTsconfigPaths(input.
|
|
258
|
+
this.updateTsconfigPaths(input.frond, 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 `@
|
|
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
|
|
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[
|
|
324
|
-
tsconfig.compilerOptions.paths[
|
|
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,7 @@
|
|
|
1
1
|
import { cpSync, existsSync, renameSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
2
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';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The monorepo's `packages/`, found by its workspace marker rather than counted
|
|
@@ -67,7 +68,7 @@ export default class ProjectWriter {
|
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
70
|
* Put a frond template's directories at the project root. Only the directories: at the
|
|
70
|
-
* root the app's own `package.json` is the frond's, and `@
|
|
71
|
+
* root the app's own `package.json` is the frond's, and `@fronds/<name>` comes from the
|
|
71
72
|
* directory through the Nuxt module's alias, so the template's package would only
|
|
72
73
|
* duplicate it under a second name.
|
|
73
74
|
*/
|
|
@@ -81,8 +82,8 @@ export default class ProjectWriter {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
/** Add a frond (business hexagon) under fronds/<name>. */
|
|
84
|
-
addFrond(wsDir: string, template: string, name: string): { path: string } {
|
|
85
|
-
const dest = join(wsDir,
|
|
85
|
+
addFrond(wsDir: string, template: string, name: string, conventions: Conventions = DEFAULT_CONVENTIONS): { path: string } {
|
|
86
|
+
const dest = join(wsDir, conventions.fronds, name);
|
|
86
87
|
cpSync(join(TEMPLATES, 'fronds', template), dest, { recursive: true });
|
|
87
88
|
// Only the import name. Carrying the convention is what makes a frond — the scan
|
|
88
89
|
// reads directories. `fougere.frond` IS read now (`scanner.ts`, `frondNameOf`), but
|
|
@@ -90,7 +91,7 @@ export default class ProjectWriter {
|
|
|
90
91
|
const pkgPath = join(dest, 'package.json');
|
|
91
92
|
if (existsSync(pkgPath)) {
|
|
92
93
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name: string };
|
|
93
|
-
pkg.name =
|
|
94
|
+
pkg.name = frondPackage(name, conventions);
|
|
94
95
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
95
96
|
}
|
|
96
97
|
return { path: dest };
|
|
@@ -119,11 +120,11 @@ export default class ProjectWriter {
|
|
|
119
120
|
* A template cannot carry it: the frond is named at composition time (`blog:catalog`),
|
|
120
121
|
* so a dependency written into `templates/apps/nuxt` would name the template instead
|
|
121
122
|
* and resolve to nothing. Which is what happened — the generated app imported
|
|
122
|
-
* `@
|
|
123
|
+
* `@fronds/blog` whatever you had called it, and did not start.
|
|
123
124
|
*
|
|
124
125
|
* `fronds/` and `apps/` are the registry, like `listTemplates`: nothing to declare.
|
|
125
126
|
*/
|
|
126
|
-
linkFronds(wsDir: string): void {
|
|
127
|
+
linkFronds(wsDir: string, conventions: Conventions = DEFAULT_CONVENTIONS): void {
|
|
127
128
|
const dirs = (kind: string): string[] => {
|
|
128
129
|
const dir = join(wsDir, kind);
|
|
129
130
|
if (!existsSync(dir)) return [];
|
|
@@ -131,7 +132,7 @@ export default class ProjectWriter {
|
|
|
131
132
|
return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
132
133
|
};
|
|
133
134
|
|
|
134
|
-
const fronds = dirs(
|
|
135
|
+
const fronds = dirs(conventions.fronds);
|
|
135
136
|
if (fronds.length === 0) return;
|
|
136
137
|
|
|
137
138
|
for (const app of dirs('apps')) {
|
|
@@ -140,7 +141,7 @@ export default class ProjectWriter {
|
|
|
140
141
|
|
|
141
142
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { dependencies?: Record<string, string> };
|
|
142
143
|
pkg.dependencies ??= {};
|
|
143
|
-
for (const frond of fronds) pkg.dependencies[
|
|
144
|
+
for (const frond of fronds) pkg.dependencies[frondPackage(frond, conventions)] = 'workspace:*';
|
|
144
145
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
145
146
|
}
|
|
146
147
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fougere/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-alpha.0",
|
|
4
4
|
"description": "The Fougere CLI — compose a workspace, serve a frond, call an operation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fougere",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"dist",
|
|
33
33
|
"app",
|
|
34
34
|
"fronds",
|
|
35
|
-
"templates"
|
|
35
|
+
"templates",
|
|
36
|
+
"src"
|
|
36
37
|
],
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"@clack/prompts": "^0.10.0",
|
|
@@ -40,11 +41,12 @@
|
|
|
40
41
|
"consola": "^3.4.2",
|
|
41
42
|
"jiti": "^2.4.2",
|
|
42
43
|
"picocolors": "^1.1.1",
|
|
43
|
-
"@fougere/
|
|
44
|
-
"@fougere/
|
|
45
|
-
"@fougere/
|
|
46
|
-
"@fougere/
|
|
47
|
-
"@fougere/
|
|
44
|
+
"@fougere/adapter-sql": "0.4.0-alpha.0",
|
|
45
|
+
"@fougere/defaults": "0.4.0-alpha.0",
|
|
46
|
+
"@fougere/transport-http": "0.4.0-alpha.0",
|
|
47
|
+
"@fougere/core": "0.4.0-alpha.0",
|
|
48
|
+
"@fougere/schema": "0.4.0-alpha.0",
|
|
49
|
+
"@fougere/container": "0.4.0-alpha.0"
|
|
48
50
|
},
|
|
49
51
|
"devDependencies": {
|
|
50
52
|
"vitest": "^4.1.0"
|
|
@@ -53,7 +55,7 @@
|
|
|
53
55
|
"access": "public"
|
|
54
56
|
},
|
|
55
57
|
"scripts": {
|
|
56
|
-
"build": "rm -rf dist && tsc && chmod +x dist/bin.js",
|
|
58
|
+
"build": "rm -rf dist && tsc && chmod +x dist/bin.js && node dist/bin.js build",
|
|
57
59
|
"test": "vitest run",
|
|
58
60
|
"test:watch": "vitest",
|
|
59
61
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.runtime.json && tsc -p tsconfig.templates.json"
|
package/src/bin.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fougere CLI — a Fougere app powered by citty.
|
|
4
|
+
*
|
|
5
|
+
* src/ → compiled (tsc → dist/)
|
|
6
|
+
* fronds/ → loaded at runtime by jiti (domain)
|
|
7
|
+
* app/ → loaded at runtime by jiti (presentation)
|
|
8
|
+
*/
|
|
9
|
+
import { createApp, setLogLevel, envLevel, type ScanResult } from '@fougere/core';
|
|
10
|
+
import { scanProject, getModuleLoader, frondDirsOf, DEFAULT_CONVENTIONS } from '@fougere/core/node';
|
|
11
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { createContainer } from '@fougere/container';
|
|
14
|
+
import { ui } from './ui.js';
|
|
15
|
+
import { run } from './runner.js';
|
|
16
|
+
import { installLoader } from './loader.js';
|
|
17
|
+
|
|
18
|
+
await installLoader(process.cwd());
|
|
19
|
+
|
|
20
|
+
const cliRoot = new URL('..', import.meta.url).pathname;
|
|
21
|
+
const container = createContainer();
|
|
22
|
+
const terminal = ui();
|
|
23
|
+
container.registerValue('ui', terminal);
|
|
24
|
+
container.registerValue('cwd', process.cwd());
|
|
25
|
+
|
|
26
|
+
// The CLI is a Fougere app — silence its boot chatter unless explicitly asked. The
|
|
27
|
+
// threshold is SET, not only announced: a static import evaluates the logger module,
|
|
28
|
+
// its env read included, before this line runs.
|
|
29
|
+
process.env.FOUGERE_LOG_LEVEL ??= 'warn';
|
|
30
|
+
setLogLevel(envLevel() ?? 'warn');
|
|
31
|
+
|
|
32
|
+
/** The newest declaration under `fronds/`, or 0 when there is none to compare against. */
|
|
33
|
+
async function newestDeclaration(root: string): Promise<number> {
|
|
34
|
+
const frondsDir = join(root, DEFAULT_CONVENTIONS.fronds);
|
|
35
|
+
const names = await readdir(frondsDir, { withFileTypes: true }).catch(() => []);
|
|
36
|
+
const dirs = names.filter((entry) => entry.isDirectory())
|
|
37
|
+
.flatMap((entry) => [
|
|
38
|
+
join(frondsDir, entry.name),
|
|
39
|
+
...frondDirsOf(DEFAULT_CONVENTIONS).map((dir) => join(frondsDir, entry.name, dir)),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const times = await Promise.all(dirs.map(async (dir) => {
|
|
43
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
44
|
+
const stats = await Promise.all(entries
|
|
45
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
|
|
46
|
+
.map((entry) => stat(join(dir, entry.name)).then((s) => s.mtimeMs).catch(() => 0)));
|
|
47
|
+
return Math.max(0, ...stats);
|
|
48
|
+
}));
|
|
49
|
+
return Math.max(0, ...times);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The CLI is a Fougere app, so it reads its own written-down scan like any deployment —
|
|
54
|
+
* producing the description reads the project, consuming it does not.
|
|
55
|
+
*
|
|
56
|
+
* Reading its own 44 declarations through the compiler cost 617 ms on every invocation,
|
|
57
|
+
* for a domain fixed at publish time. Staleness is decided by mtime rather than by a flag:
|
|
58
|
+
* editing a frond must not need a command, and a published package has nothing newer than
|
|
59
|
+
* its artefact.
|
|
60
|
+
*/
|
|
61
|
+
async function scanOf(root: string): Promise<ScanResult> {
|
|
62
|
+
const written = join(root, '.fougere/scan.generated.ts');
|
|
63
|
+
const writtenAt = await stat(written).then((s) => s.mtimeMs).catch(() => 0);
|
|
64
|
+
if (writtenAt > 0 && writtenAt >= await newestDeclaration(root)) {
|
|
65
|
+
return ((await getModuleLoader()(written)) as unknown as { scan: ScanResult }).scan;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The only slow phase, and it announced nothing: the boot states it at `info`, which the
|
|
69
|
+
// threshold above lowers to `warn`. The terminal says it instead, and a pipe keeps its
|
|
70
|
+
// output parsable.
|
|
71
|
+
const spin = process.stdout.isTTY ? terminal.spinner('reading fronds') : undefined;
|
|
72
|
+
const scan = await scanProject(root);
|
|
73
|
+
spin?.stop(`${scan.fronds.length} frond(s)`);
|
|
74
|
+
return scan;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const scan = await scanOf(cliRoot);
|
|
78
|
+
|
|
79
|
+
const app = await createApp({ scan, createContainer: () => container });
|
|
80
|
+
|
|
81
|
+
container.registerValue('app', app);
|
|
82
|
+
|
|
83
|
+
await run(app);
|