@fougere/cli 0.2.0-alpha.1 → 0.3.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +10 -1
  2. package/app/commands/BuildCommand.ts +39 -0
  3. package/app/commands/CallCommand.ts +4 -4
  4. package/app/commands/CheckCommand.ts +3 -2
  5. package/app/commands/ExplainCommand.ts +77 -0
  6. package/app/commands/FreezeCommand.ts +107 -0
  7. package/app/commands/GrantCommand.ts +44 -0
  8. package/app/commands/GraphCommand.ts +1 -1
  9. package/app/commands/KeysCommand.ts +56 -0
  10. package/app/commands/MigrateCommand.ts +54 -0
  11. package/app/commands/NewCommand.ts +6 -6
  12. package/app/commands/ServeCommand.ts +85 -9
  13. package/app/commands/grant-material.ts +5 -0
  14. package/dist/bin.js +54 -8
  15. package/dist/bin.js.map +1 -1
  16. package/dist/bridge.d.ts.map +1 -1
  17. package/dist/bridge.js +6 -6
  18. package/dist/bridge.js.map +1 -1
  19. package/dist/runner.d.ts.map +1 -1
  20. package/dist/runner.js +7 -7
  21. package/dist/runner.js.map +1 -1
  22. package/dist/theme.d.ts +10 -0
  23. package/dist/theme.d.ts.map +1 -0
  24. package/dist/theme.js +10 -0
  25. package/dist/theme.js.map +1 -0
  26. package/dist/ui.d.ts +74 -0
  27. package/dist/ui.d.ts.map +1 -0
  28. package/dist/ui.js +111 -0
  29. package/dist/ui.js.map +1 -0
  30. package/fronds/analysis/entities/Build.ts +7 -0
  31. package/fronds/analysis/entities/Explain.ts +8 -0
  32. package/fronds/analysis/entities/Freeze.ts +7 -0
  33. package/fronds/analysis/entities/Migrate.ts +7 -0
  34. package/fronds/analysis/handlers/BuildHandler.ts +60 -0
  35. package/fronds/analysis/handlers/CheckHandler.ts +61 -35
  36. package/fronds/analysis/handlers/ExplainHandler.ts +214 -0
  37. package/fronds/analysis/handlers/FreezeHandler.ts +172 -0
  38. package/fronds/analysis/handlers/MigrateHandler.ts +97 -0
  39. package/fronds/analysis/services/ProjectScan.ts +23 -7
  40. package/fronds/analysis/versions.ts +58 -0
  41. package/fronds/scaffold/entities/Grant.ts +6 -0
  42. package/fronds/scaffold/entities/Keys.ts +4 -0
  43. package/fronds/scaffold/entities/Serve.ts +2 -1
  44. package/fronds/scaffold/handlers/BuildFrondHandler.ts +11 -13
  45. package/fronds/scaffold/handlers/GrantHandler.ts +8 -0
  46. package/fronds/scaffold/handlers/KeysHandler.ts +8 -0
  47. package/fronds/scaffold/handlers/SyncHandler.ts +27 -24
  48. package/fronds/scaffold/services/ProjectWriter.ts +29 -10
  49. package/package.json +10 -8
  50. package/templates/admin/fronds/admin/handlers/UserHandler.ts +3 -5
  51. package/templates/admin/fronds/admin/package.json +1 -1
  52. package/templates/api/fronds/api/handlers/TaskHandler.ts +3 -5
  53. package/templates/api/fronds/api/package.json +1 -1
  54. package/templates/apps/nuxt/app/pages/index.vue +1 -1
  55. package/templates/apps/nuxt/package.json +1 -1
  56. package/templates/blog/app/pages/posts/index.vue +1 -1
  57. package/templates/blog/app/pages/posts/manage.vue +1 -1
  58. package/templates/blog/app/pages/posts/new.vue +1 -1
  59. package/templates/blog/fronds/blog/handlers/PostHandler.ts +3 -5
  60. package/templates/blog/fronds/blog/package.json +1 -1
  61. package/templates/flat/AGENTS.md +14 -0
  62. package/templates/flat/CLAUDE.md +27 -5
  63. package/templates/flat/package.json +1 -1
  64. package/templates/frond/AGENTS.md +14 -0
  65. package/templates/frond/CLAUDE.md +27 -5
  66. package/templates/frond/fronds/__name__/handlers/PostHandler.ts +3 -5
  67. package/templates/frond/fronds/__name__/package.json +1 -1
  68. package/templates/frond/package.json +1 -1
  69. package/templates/frond/serve.mjs +4 -3
  70. package/templates/fronds/blank/package.json +1 -1
  71. package/templates/fronds/blog/handlers/PostHandler.ts +2 -4
  72. package/templates/fronds/blog/package.json +1 -1
  73. package/templates/workspace/AGENTS.md +14 -0
  74. package/templates/workspace/CLAUDE.md +27 -5
package/README.md CHANGED
@@ -1,10 +1,19 @@
1
1
  # @fougere/cli
2
2
  > The Fougere CLI
3
3
  Compose a workspace (`new`), serve a frond on its own (`serve`), call an operation
4
- (`call`), read the application graph (`graph`).
4
+ (`call`), inspect its effective contract (`explain`), read the application graph (`graph`).
5
5
 
6
6
  The CLI is itself a Fougere app: its commands ride on the call contract.
7
7
 
8
+ ```bash
9
+ fougere explain Post.publish
10
+ fougere explain Post.publish --json
11
+ ```
12
+
13
+ `explain` reads the scanner's resolved operation contract: kind, input/output, parameter
14
+ bindings and collectors, surfaces/adapters, Frond and local/remote placement. It does not
15
+ boot the target application, so introspection does not run migrations or seeds.
16
+
8
17
  ## Installation
9
18
  ```bash
10
19
  pnpm add @fougere/cli
@@ -0,0 +1,39 @@
1
+ import { createAppRunner } from '@fougere/core';
2
+ import type { App } from '@fougere/core';
3
+ import type { ui as createUi } from '../../src/ui.js';
4
+ import type { BuildReport } from '../../fronds/analysis/handlers/BuildHandler.js';
5
+ import pc from 'picocolors';
6
+
7
+ type Ui = ReturnType<typeof createUi>;
8
+
9
+ /**
10
+ * `fougere build` — the scan, written down.
11
+ *
12
+ * It reports what the module HOLDS rather than that it was written: a build that found
13
+ * one frond where the project has three has succeeded at the wrong thing, and the path
14
+ * alone does not say so.
15
+ */
16
+ export default class BuildCommand {
17
+ constructor(private app: App, private ui: Ui) {}
18
+
19
+ async run(raw: Record<string, unknown>) {
20
+ const built = (await createAppRunner(this.app)(
21
+ { entity: 'build', op: 'execute' },
22
+ { params: {}, query: {}, body: raw, state: {} },
23
+ )) as BuildReport;
24
+
25
+ if (built.fronds.length === 0) {
26
+ this.ui.warn('No fronds found. Run this from a Fougere project root.');
27
+ return;
28
+ }
29
+
30
+ this.ui.success(`${built.path} — ${built.fronds.length} frond(s), ${built.entities} entities, ${built.handlers} handlers`);
31
+ this.ui.step(built.fronds.map((name) => pc.bold(name)).join(', '));
32
+
33
+ // A diagnostic travels INTO the module, so a boot from it says the same thing. Saying
34
+ // it here too is not a duplicate: this is the moment someone can still fix the source.
35
+ for (const diagnostic of built.diagnostics) this.ui.warn(diagnostic);
36
+
37
+ this.ui.step(pc.dim('hand it to createApp as `scan:` — nothing reads a disk after this'));
38
+ }
39
+ }
@@ -1,8 +1,8 @@
1
1
  import { createAppRunner } from '@fougere/core';
2
- import { toRegistrationName } from '@fougere/core/contract';
3
- import { bootAppFromConfig } from '@fougere/runtime';
2
+ import { registrationKeyOf } from '@fougere/core/contract';
3
+ import { bootAppFromConfig } from '@fougere/defaults';
4
4
  import type { App } from '@fougere/core';
5
- import type { ui as createUi } from '@fougere/cli-ui';
5
+ import type { ui as createUi } from '../../src/ui.js';
6
6
 
7
7
  type Ui = ReturnType<typeof createUi>;
8
8
 
@@ -57,7 +57,7 @@ export default class CallCommand {
57
57
  const app = await bootAppFromConfig(process.cwd(), {});
58
58
  try {
59
59
  const result = await createAppRunner(app)(
60
- { entity: toRegistrationName(entityName), op },
60
+ { entity: registrationKeyOf(entityName), op },
61
61
  { params, query: {}, body, state: {} },
62
62
  );
63
63
  this.ui.note(JSON.stringify(result, null, 2), target);
@@ -1,7 +1,7 @@
1
1
  import type { CheckResult, Finding } from '../../fronds/analysis/handlers/CheckHandler.js';
2
2
  import type { App } from '@fougere/core';
3
3
  import { createAppRunner } from '@fougere/core';
4
- import type { ui as createUi } from '@fougere/cli-ui';
4
+ import type { ui as createUi } from '../../src/ui.js';
5
5
  import pc from 'picocolors';
6
6
  import { relative } from 'node:path';
7
7
 
@@ -45,7 +45,8 @@ export default class CheckCommand {
45
45
  function render(f: Finding): string {
46
46
  const mark = f.severity === 'blocking' ? pc.red('✗') : pc.yellow('⚠');
47
47
  const where = relative(process.cwd(), f.filePath) || f.filePath;
48
- return ` ${mark} ${pc.bold(`[${f.code}]`)}\n${wrap(f.message, 76, ' ')}\n ${pc.dim(where)}`;
48
+ const what = f.subject ? ` ${f.subject}` : '';
49
+ return ` ${mark} ${pc.bold(`[${f.code}]`)}${what}\n${wrap(f.message, 76, ' ')}\n ${pc.dim(where)}`;
49
50
  }
50
51
 
51
52
  /** A box grows to its longest line, so a one-line sentence makes an unreadable box. */
@@ -0,0 +1,77 @@
1
+ import type { App } from '@fougere/core';
2
+ import { createAppRunner } from '@fougere/core';
3
+ import type { ui as createUi } from '../../src/ui.js';
4
+ import type {
5
+ ExplainResult,
6
+ ExplainedBinding,
7
+ } from '../../fronds/analysis/handlers/ExplainHandler.js';
8
+ import pc from 'picocolors';
9
+
10
+ type Ui = ReturnType<typeof createUi>;
11
+
12
+ export default class ExplainCommand {
13
+ constructor(private app: App, private ui: Ui) {}
14
+
15
+ async run(raw: Record<string, unknown>) {
16
+ const result = await createAppRunner(this.app)(
17
+ { entity: 'explain', op: 'execute' },
18
+ { params: {}, query: {}, body: raw, state: {} },
19
+ ) as ExplainResult;
20
+
21
+ if (raw.json === true) {
22
+ process.stdout.write(renderExplainJson(result) + '\n');
23
+ return;
24
+ }
25
+
26
+ this.ui.note(renderExplain(result), result.operation);
27
+ }
28
+ }
29
+
30
+ export function renderExplainJson(result: ExplainResult): string {
31
+ return JSON.stringify(result, null, 2);
32
+ }
33
+
34
+ export function renderExplain(result: ExplainResult): string {
35
+ const lines = [
36
+ `${pc.dim('Handler:')} ${result.handler.class}.${result.handler.method}`,
37
+ `${pc.dim('Address:')} ${result.handler.address}.${result.operation.split('.').at(-1)}`,
38
+ `${pc.dim('Kind:')} ${result.kind}`,
39
+ `${pc.dim('Input:')} ${result.input ?? '—'}`,
40
+ `${pc.dim('Output:')} ${result.output ? `${result.output.type}${result.output.cardinality ? ` (${result.output.cardinality})` : ''}` : '—'}`,
41
+ ];
42
+
43
+ if (result.description) lines.push(`${pc.dim('Purpose:')} ${result.description}`);
44
+
45
+ lines.push('', pc.bold('Parameters'));
46
+ if (result.parameters.length === 0) lines.push(' —');
47
+ for (const parameter of result.parameters) {
48
+ const optional = parameter.optional ? '?' : '';
49
+ lines.push(` ${parameter.name}${optional}: ${parameter.type ?? 'unknown'} ${pc.dim('→')} ${bindingName(parameter.binding)}`);
50
+ }
51
+
52
+ lines.push('', pc.bold('Collectors'));
53
+ if (result.collectors.length === 0) lines.push(' —');
54
+ for (const collector of result.collectors) {
55
+ lines.push(` ${collector.typeName} ${pc.dim('→')} ${collector.class}`);
56
+ }
57
+
58
+ lines.push(
59
+ '',
60
+ `${pc.dim('Surfaces:')} ${result.exposure.surfaces.join(', ') || '—'}`,
61
+ `${pc.dim('Adapters:')} ${result.exposure.adapters.join(', ') || '—'}`,
62
+ `${pc.dim('Placement:')} ${result.placement.frond} / ${result.placement.runtime}${result.placement.remote ? ` (${result.placement.remote})` : ''}`,
63
+ );
64
+
65
+ if (result.handler.file) lines.push(`${pc.dim('Source:')} ${result.handler.file}`);
66
+ return lines.join('\n');
67
+ }
68
+
69
+ function bindingName(binding: ExplainedBinding | null): string {
70
+ if (!binding) return 'unbound';
71
+ switch (binding.kind) {
72
+ case 'collector': return `collector:${binding.typeName}`;
73
+ case 'fact': return `fact:${binding.factName}`;
74
+ case 'param': return `param:${binding.name}${binding.coerce ? ` (${binding.coerce})` : ''}`;
75
+ default: return binding.kind;
76
+ }
77
+ }
@@ -0,0 +1,107 @@
1
+ import { createAppRunner } from '@fougere/core';
2
+ import type { Change } from '@fougere/schema';
3
+ import type { App } from '@fougere/core';
4
+ import type { ui as createUi } from '../../src/ui.js';
5
+ import type { FreezeInspection } from '../../fronds/analysis/handlers/FreezeHandler.js';
6
+ import pc from 'picocolors';
7
+
8
+ type Ui = ReturnType<typeof createUi>;
9
+
10
+ /**
11
+ * Cutting a version — inspect, ask what only a human knows, then record.
12
+ *
13
+ * The question is asked between the two ops and nowhere else: a field gone plus a field
14
+ * appeared is either a rename or a drop-and-add, the two produce opposite DDL, and the
15
+ * intent left with the person who made the change. Nothing is written until it is settled.
16
+ */
17
+ export default class FreezeCommand {
18
+ constructor(private app: App, private ui: Ui) {}
19
+
20
+ async run(raw: Record<string, unknown>) {
21
+ if (!raw.version) {
22
+ this.ui.error('Usage: fougere freeze <version>');
23
+ return;
24
+ }
25
+
26
+ const freeze = (body: Record<string, unknown>) =>
27
+ createAppRunner(this.app)({ entity: 'freeze', op: 'execute' }, { params: {}, query: {}, body, state: {} }) as Promise<FreezeInspection>;
28
+
29
+ // Idempotent while it refuses: this writes when nothing is ambiguous, and reports
30
+ // otherwise — so the first call is both the inspection and the happy path.
31
+ const seen = await freeze(raw);
32
+
33
+ if (seen.entities.length === 0) {
34
+ this.ui.warn('No entities found. Run this from a Fougere project root.');
35
+ return;
36
+ }
37
+
38
+ if (seen.written) {
39
+ this.report(seen);
40
+ return;
41
+ }
42
+
43
+ const renamed = await this.settle(seen);
44
+ if (renamed === undefined) return;
45
+
46
+ this.report(await freeze({ ...raw, renamed }));
47
+ }
48
+
49
+ /** Turn every ambiguity into a declaration, or `undefined` when the answer is a refusal. */
50
+ private async settle(seen: FreezeInspection): Promise<Record<string, Record<string, string>> | undefined> {
51
+ const renamed: Record<string, Record<string, string>> = {};
52
+ const pending = Object.entries(seen.ambiguous);
53
+ if (pending.length === 0) return renamed;
54
+
55
+ this.ui.warn('A field left and a field of the same shape appeared. Only you know which.');
56
+
57
+ for (const [entity, pairs] of pending) {
58
+ // Grouped by what LEFT: one departed field is one question, however many
59
+ // candidates carry its shape.
60
+ const byRemoved = new Map<string, string[]>();
61
+ for (const { removed, added } of pairs) byRemoved.set(removed, [...(byRemoved.get(removed) ?? []), added]);
62
+
63
+ for (const [removed, candidates] of byRemoved) {
64
+ const answer = await this.ui.select({
65
+ message: `${entity}.${pc.bold(removed)} — what happened to it?`,
66
+ options: [
67
+ ...candidates.map((added) => ({ value: added, label: `renamed to ${added}`, hint: 'the data moves with it' })),
68
+ { value: '', label: 'dropped', hint: 'the column goes, and what it held goes with it' },
69
+ ],
70
+ });
71
+ // An empty answer is a cancel as much as a "dropped" — writing on either would
72
+ // record a decision nobody made.
73
+ if (!answer) {
74
+ this.ui.warn(`${entity}.${removed} treated as dropped — say so explicitly if that is right.`);
75
+ return undefined;
76
+ }
77
+ renamed[entity] = { ...(renamed[entity] ?? {}), [removed]: answer };
78
+ }
79
+ }
80
+ return renamed;
81
+ }
82
+
83
+ private report(written: FreezeInspection) {
84
+ const { step } = written;
85
+ if (!step) {
86
+ this.ui.success(`${written.version} recorded — ${written.entities.length} entities, and nothing before it`);
87
+ return;
88
+ }
89
+
90
+ const moved = Object.entries(step.entities);
91
+ const count = moved.reduce((total, [, answer]) => total + answer.changes.length, 0);
92
+
93
+ this.ui.success(`${written.version} recorded — ${count} change(s) since ${written.previous}`);
94
+ for (const [entity, answer] of moved) {
95
+ this.ui.step(`${pc.bold(entity)} — ${answer.changes.map(describeChange).join(', ')}`);
96
+ }
97
+ if (step.entitiesAdded.length > 0) this.ui.step(`new: ${step.entitiesAdded.join(', ')}`);
98
+ if (step.entitiesRemoved.length > 0) this.ui.step(`gone: ${step.entitiesRemoved.join(', ')}`);
99
+ }
100
+ }
101
+
102
+ function describeChange(change: Change): string {
103
+ if (change.kind === 'renamed') return `${change.from} → ${change.to}`;
104
+ // An axis that moved names WHICH: "restated title" says nothing a reader can act on.
105
+ if (change.kind === 'restated') return `${change.field}: ${change.axis} moved`;
106
+ return `${change.kind} ${change.field}`;
107
+ }
@@ -0,0 +1,44 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { generateKeyPair, issueGrant } from '@fougere/core/node';
4
+ import type { App } from '@fougere/core';
5
+ import type { ui as createUi } from '../../src/ui.js';
6
+ import { ROOT_KEY, packed } from './grant-material.js';
7
+
8
+ type Ui = ReturnType<typeof createUi>;
9
+
10
+ /**
11
+ * Vouch for one frond: bind its name to a fresh key, signed by the root.
12
+ *
13
+ * The key is printed and never stored — it belongs to the deployment, not to the
14
+ * repository. Re-running issues a NEW key rather than showing the old one, which is
15
+ * what rotation is: run it again, redeploy that frond, and nobody else's config moves.
16
+ */
17
+ export default class GrantCommand {
18
+ constructor(private app: App, private ui: Ui) {}
19
+
20
+ async run(raw: Record<string, unknown>) {
21
+ const frond = raw.frond as string | undefined;
22
+ if (!frond) {
23
+ this.ui.error('Usage: fougere grant <frond>');
24
+ return;
25
+ }
26
+
27
+ let rootPrivateKey: string;
28
+ try {
29
+ rootPrivateKey = await readFile(join(process.cwd(), ROOT_KEY), 'utf8');
30
+ } catch {
31
+ this.ui.error(`No ${ROOT_KEY} — run \`fougere keys\` first.`);
32
+ return;
33
+ }
34
+
35
+ const { privateKey, publicKey } = generateKeyPair();
36
+ const grant = issueGrant(rootPrivateKey, frond, publicKey);
37
+
38
+ this.ui.success(`granted — '${frond}' will be admitted by any receiver trusting this root`);
39
+ this.ui.note(
40
+ `FOUGERE_KEY=${packed(privateKey)}\nFOUGERE_GRANT=${grant}`,
41
+ `Inject into '${frond}' at launch — secret, shown once`,
42
+ );
43
+ }
44
+ }
@@ -1,7 +1,7 @@
1
1
  import type { GraphResult } from '../../fronds/analysis/handlers/GraphHandler.js';
2
2
  import type { EntityNode, DomainCluster, App } from '@fougere/core';
3
3
  import { createAppRunner } from '@fougere/core';
4
- import type { ui as createUi } from '@fougere/cli-ui';
4
+ import type { ui as createUi } from '../../src/ui.js';
5
5
  import pc from 'picocolors';
6
6
 
7
7
  type Ui = ReturnType<typeof createUi>;
@@ -0,0 +1,56 @@
1
+ import { mkdir, readFile, writeFile, appendFile, access } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { generateKeyPair } from '@fougere/core/node';
4
+ import type { App } from '@fougere/core';
5
+ import type { ui as createUi } from '../../src/ui.js';
6
+ import { ROOT_KEY, packed } from './grant-material.js';
7
+
8
+ type Ui = ReturnType<typeof createUi>;
9
+
10
+ /**
11
+ * Create the authority — a command, not a service.
12
+ *
13
+ * This is the whole of it: it runs at deployment time and exits. Nothing stays alive,
14
+ * nothing is joined at boot, and no frond reaches anything to prove who it is. What a
15
+ * receiver ends up holding is ONE public key, so a frond granted tomorrow is admitted
16
+ * by a receiver deployed today without its config being touched.
17
+ *
18
+ * The private key is written and never printed: it signs grants, and a grant is the
19
+ * only thing that has to travel.
20
+ */
21
+ export default class KeysCommand {
22
+ constructor(private app: App, private ui: Ui) {}
23
+
24
+ async run() {
25
+ const path = join(process.cwd(), ROOT_KEY);
26
+ if (await exists(path)) {
27
+ this.ui.error(`${ROOT_KEY} already exists — a second root would split the system in two.`);
28
+ this.ui.info('Delete it deliberately to start over; every grant issued so far stops being recognized.');
29
+ return;
30
+ }
31
+
32
+ const { privateKey, publicKey } = generateKeyPair();
33
+ await mkdir(join(process.cwd(), '.fougere'), { recursive: true });
34
+ await writeFile(path, privateKey, { mode: 0o600 });
35
+ await ignore(ROOT_KEY);
36
+
37
+ this.ui.success(`root created — ${ROOT_KEY} (never commit it; added to .gitignore)`);
38
+ this.ui.note(
39
+ `FOUGERE_ROOT=${packed(publicKey)}`,
40
+ 'Every frond that ANSWERS — public, safe in an image or a manifest',
41
+ );
42
+ this.ui.info('Then `fougere grant <frond>` for each frond that CALLS.');
43
+ }
44
+ }
45
+
46
+ async function exists(path: string): Promise<boolean> {
47
+ return access(path).then(() => true, () => false);
48
+ }
49
+
50
+ /** Keep the root out of a commit — the one mistake that cannot be walked back. */
51
+ async function ignore(entry: string): Promise<void> {
52
+ const path = join(process.cwd(), '.gitignore');
53
+ const current = await readFile(path, 'utf8').catch(() => '');
54
+ if (current.split('\n').some((line) => line.trim() === entry)) return;
55
+ await appendFile(path, `${current.endsWith('\n') || current === '' ? '' : '\n'}${entry}\n`);
56
+ }
@@ -0,0 +1,54 @@
1
+ import { createAppRunner } from '@fougere/core';
2
+ import type { App } from '@fougere/core';
3
+ import type { ui as createUi } from '../../src/ui.js';
4
+ import type { MigrationPlan } from '../../fronds/analysis/handlers/MigrateHandler.js';
5
+ import pc from 'picocolors';
6
+
7
+ type Ui = ReturnType<typeof createUi>;
8
+
9
+ /**
10
+ * Catching a database up with the frozen chain.
11
+ *
12
+ * Prints by default and moves nothing: what this runs drops and renames columns, so the
13
+ * plan is read before it is agreed to. `--apply` is that agreement.
14
+ */
15
+ export default class MigrateCommand {
16
+ constructor(private app: App, private ui: Ui) {}
17
+
18
+ async run(raw: Record<string, unknown>) {
19
+ const result = (await createAppRunner(this.app)(
20
+ { entity: 'migrate', op: 'execute' },
21
+ { params: {}, query: {}, body: raw, state: {} },
22
+ )) as MigrationPlan;
23
+
24
+ if (result.chain.length === 0) {
25
+ this.ui.warn('No frozen step to apply. `fougere freeze <version>` records one.');
26
+ return;
27
+ }
28
+
29
+ if (result.refusals.length > 0) {
30
+ this.ui.error(`This chain cannot be realised as it stands (${result.chain.join(' → ')}):`);
31
+ for (const one of result.refusals) this.ui.step(`${pc.bold(`${one.entity}.${one.field}`)} — ${one.reason}`);
32
+ return;
33
+ }
34
+
35
+ if (result.changes.length === 0) {
36
+ this.ui.success(`Up to date — ${result.chain.join(' → ')} already realised.`);
37
+ return;
38
+ }
39
+
40
+ for (const change of result.changes) {
41
+ this.ui.step(
42
+ change.kind === 'renameColumn'
43
+ ? `${change.table}: ${pc.bold(change.from)} → ${pc.bold(change.to)}`
44
+ : `${change.table}: drop ${pc.bold(change.column)}`,
45
+ );
46
+ }
47
+
48
+ if (result.ran.length === 0) {
49
+ this.ui.info(`${result.changes.length} statement(s) — run again with ${pc.bold('--apply')} to make it so.`);
50
+ return;
51
+ }
52
+ this.ui.success(`${result.ran.length} statement(s) run.`);
53
+ }
54
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import ProjectWriter from '../../fronds/scaffold/services/ProjectWriter.js';
4
- import type { ui as createUi } from '@fougere/cli-ui';
4
+ import type { ui as createUi } from '../../src/ui.js';
5
5
  import type { App } from '@fougere/core';
6
6
 
7
7
  type Ui = ReturnType<typeof createUi>;
@@ -47,7 +47,7 @@ export default class NewCommand {
47
47
  if (raw.local) pw.linkLocal(dir);
48
48
  this.ui.info(`${template} at the root`);
49
49
  this.ui.note([`cd ${name}`, INSTALL, `pnpm dev`].join('\n'), `${name} — one frond, at the root`);
50
- this.ui.outro('Prêt.');
50
+ this.ui.outro('Ready.');
51
51
  return;
52
52
  }
53
53
 
@@ -56,7 +56,7 @@ export default class NewCommand {
56
56
  if (raw.bare) {
57
57
  if (raw.local) pw.linkLocal(dir);
58
58
  this.ui.note([`cd ${name}`, `fougere new # compose it (guided)`].join('\n'), `${name} — empty workspace`);
59
- this.ui.outro('Prêt.');
59
+ this.ui.outro('Ready.');
60
60
  return;
61
61
  }
62
62
 
@@ -65,16 +65,16 @@ export default class NewCommand {
65
65
  const stated = (raw.frond as string) || (raw.app as string);
66
66
  const fronds = stated
67
67
  ? this.state(dir, pw, 'fronds', raw.frond as string)
68
- : await this.compose(dir, pw, 'fronds', 'Fronds — tes domaines', pw.listTemplates('fronds'));
68
+ : await this.compose(dir, pw, 'fronds', 'Fronds — your domains', pw.listTemplates('fronds'));
69
69
  const apps = stated
70
70
  ? this.state(dir, pw, 'apps', raw.app as string)
71
- : await this.compose(dir, pw, 'apps', 'Apps — ce qui les consomme', pw.listTemplates('apps'));
71
+ : await this.compose(dir, pw, 'apps', 'Apps — what consumes them', pw.listTemplates('apps'));
72
72
 
73
73
  // Every app depends on every frond — stated here, where both names are known.
74
74
  pw.linkFronds(dir);
75
75
  if (raw.local) pw.linkLocal(dir);
76
76
  this.ui.note([`cd ${name}`, INSTALL, `pnpm dev`].join('\n'), `${name} — ${fronds} frond(s), ${apps} app(s)`);
77
- this.ui.outro('Prêt.');
77
+ this.ui.outro('Ready.');
78
78
  }
79
79
 
80
80
  /**
@@ -1,11 +1,29 @@
1
- import { createLocalRunner } from '@fougere/core';
2
- import { bootAppFromConfig } from '@fougere/runtime';
1
+ import { createLocalRunner, identityFromEnv } from '@fougere/core';
2
+ import { setModuleLoader, loadConfig, resolveConventions, watchPathsOf } from '@fougere/core/node';
3
+ import { bootAppFromConfig } from '@fougere/defaults';
3
4
  import { serve } from '@fougere/transport-http';
4
- import type { App } from '@fougere/core';
5
- import type { ui as createUi } from '@fougere/cli-ui';
5
+ import { watch } from 'node:fs';
6
+ import type { App, Transport } from '@fougere/core';
7
+ import type { ui as createUi } from '../../src/ui.js';
6
8
 
7
9
  type Ui = ReturnType<typeof createUi>;
8
10
 
11
+ /** What a reload gives the calls already running before it releases the app they hold. */
12
+ const DRAIN_MS = 5_000;
13
+
14
+ /** One save fires several events; the boot must not start once per event. */
15
+ const SETTLE_MS = 60;
16
+
17
+ /**
18
+ * A loader that re-reads. Every loader caches by specifier, so a second boot in this
19
+ * process would be handed the modules the first one read — a reload that changes nothing.
20
+ */
21
+ async function rereadingLoader(): Promise<void> {
22
+ const { createJiti } = await import('jiti');
23
+ const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
24
+ setModuleLoader((filePath) => jiti.import(filePath) as Promise<Record<string, unknown>>);
25
+ }
26
+
9
27
  /**
10
28
  * The host end of the gradient: one frond, alone in this process, reachable
11
29
  * over HTTP. `topology: false` — a served frond IS the host, it never routes
@@ -19,16 +37,74 @@ export default class ServeCommand {
19
37
  const frond = raw.frond as string | undefined;
20
38
  if (!frond) { this.ui.error('Usage: fougere serve <frond>'); return; }
21
39
 
22
- const app = await bootAppFromConfig(process.cwd(), { fronds: [frond], topology: false });
23
- if (!app.fronds.some((f) => f.name === frond)) {
40
+ const root = process.cwd();
41
+ const watching = raw.watch === true;
42
+
43
+ // Cache-free from the FIRST boot, so every boot in this process reads the same way.
44
+ if (watching) await rereadingLoader();
45
+
46
+ let hosted = await bootAppFromConfig(root, { fronds: [frond], topology: false });
47
+ if (!hosted.fronds.some((f) => f.name === frond)) {
24
48
  this.ui.error(`Frond '${frond}' introuvable dans ce projet.`);
25
49
  return;
26
50
  }
27
51
 
52
+ // A handle, not a binding: `serve` holds this closure for the process's life while
53
+ // `current` moves under it, so nothing on the wire learns the app was replaced.
54
+ let current: Transport = createLocalRunner(hosted);
55
+
28
56
  const port = raw.port != null ? Number(raw.port) : 4100;
29
- const { port: bound } = await serve(createLocalRunner(app), { port });
57
+ // A served frond admits what it can establish. With no root injected it takes the
58
+ // state it is handed, which is why the loopback default is the other half.
59
+ const { verify, requireIdentity } = await identityFromEnv();
60
+ const { port: bound } = await serve((call, inv) => current(call, inv), { port, verify, requireIdentity });
30
61
  this.ui.step(`frond ${frond} servie — POST http://127.0.0.1:${bound}/_fougere/call`);
31
- this.ui.info('Ctrl-C pour arrêter.');
32
- // The listening server keeps the event loop alive; the command returns and stays up.
62
+ this.ui.info(requireIdentity ? 'signed calls only (FOUGERE_ROOT is set)' : 'unsigned calls accepted — no FOUGERE_ROOT');
63
+
64
+ if (!watching) {
65
+ this.ui.info('Ctrl-C pour arrêter.');
66
+ // The listening server keeps the event loop alive; the command returns and stays up.
67
+ return;
68
+ }
69
+
70
+ const reload = async (): Promise<void> => {
71
+ const started = Date.now();
72
+ let next: App;
73
+ try {
74
+ next = await bootAppFromConfig(root, { fronds: [frond], topology: false });
75
+ } catch (error) {
76
+ // The previous app keeps serving: a dev loop that dies on a typo is worse than
77
+ // one that holds the last state which booted.
78
+ this.ui.error(`reload refused — ${(error as Error).message}`);
79
+ return;
80
+ }
81
+ const previous = hosted;
82
+ hosted = next;
83
+ current = createLocalRunner(next);
84
+ this.ui.step(`reloaded in ${Date.now() - started} ms`);
85
+ // Drain then release, on the OLD app and in that order: a call that started before
86
+ // the swap finishes on the app it started on.
87
+ await previous.drain(DRAIN_MS).catch((e) => this.ui.info(`drain: ${(e as Error).message}`));
88
+ await previous.dispose();
89
+ };
90
+
91
+ const config = await loadConfig(root);
92
+ const conventions = resolveConventions(config.conventions);
93
+ let settling: NodeJS.Timeout | undefined;
94
+ let watched = 0;
95
+
96
+ for (const path of hosted.fronds.flatMap((f) => watchPathsOf(f, root, conventions))) {
97
+ try {
98
+ watch(path, { recursive: true }, () => {
99
+ clearTimeout(settling);
100
+ settling = setTimeout(() => { void reload(); }, SETTLE_MS);
101
+ });
102
+ watched++;
103
+ } catch {
104
+ // An absent convention directory IS the convention — the same silence the scan keeps.
105
+ }
106
+ }
107
+
108
+ this.ui.info(`watching ${watched} path(s) — Ctrl-C pour arrêter.`);
33
109
  }
34
110
  }
@@ -0,0 +1,5 @@
1
+ /** Where the root lives. `.fougere/` already holds generated local state. */
2
+ export const ROOT_KEY = '.fougere/root.key';
3
+
4
+ /** A PEM spans lines and an env var carrying one survives compose, systemd and CI poorly. */
5
+ export const packed = (pem: string) => Buffer.from(pem, 'utf8').toString('base64');