@fougere/cli 0.4.0-alpha.0 → 0.5.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.
@@ -3,6 +3,7 @@ import type { App } from '@fougere/core';
3
3
  import type { ui as createUi } from '../../src/ui.js';
4
4
  import type { BuildReport } from '../../fronds/analysis/handlers/BuildHandler.js';
5
5
  import pc from 'picocolors';
6
+ import { machineWanted, printMachine } from '../../src/machine.js';
6
7
 
7
8
  type Ui = ReturnType<typeof createUi>;
8
9
 
@@ -22,6 +23,8 @@ export default class BuildCommand {
22
23
  { params: {}, query: {}, body: raw, state: {} },
23
24
  )) as BuildReport;
24
25
 
26
+ if (machineWanted(raw)) return printMachine(built);
27
+
25
28
  if (built.fronds.length === 0) {
26
29
  this.ui.warn('No fronds found. Run this from a Fougere project root.');
27
30
  return;
@@ -4,6 +4,7 @@ import { createAppRunner } from '@fougere/core';
4
4
  import type { ui as createUi } from '../../src/ui.js';
5
5
  import pc from 'picocolors';
6
6
  import { relative } from 'node:path';
7
+ import { machineWanted, printMachine } from '../../src/machine.js';
7
8
 
8
9
  type Ui = ReturnType<typeof createUi>;
9
10
 
@@ -17,6 +18,13 @@ export default class CheckCommand {
17
18
  { params: {}, query: {}, body: raw, state: {} },
18
19
  ) as CheckResult;
19
20
 
21
+ // A non-zero exit is what makes this usable in CI, so it is decided before the two
22
+ // renderings rather than inside the terminal one.
23
+ const blocking = result.findings.filter((f) => f.severity === 'blocking').length;
24
+ if (blocking > 0) process.exitCode = 1;
25
+
26
+ if (machineWanted(raw)) return printMachine(result);
27
+
20
28
  if (result.fronds === 0) {
21
29
  this.ui.warn('No fronds found. Run this from a Fougere project root.');
22
30
  return;
@@ -31,14 +39,9 @@ export default class CheckCommand {
31
39
 
32
40
  this.ui.note(result.findings.map(render).join('\n\n'), 'Findings');
33
41
 
34
- const blocking = result.findings.filter((f) => f.severity === 'blocking').length;
35
- const warnings = result.findings.length - blocking;
36
- this.ui.info(`${blocking} blocking, ${warnings} warning(s)`);
37
-
38
- // A non-zero exit is what makes this usable in CI. Warnings do not fail:
39
- // an unresolvable base class with no operation is ordinary, and a check that
40
- // cries wolf stops being read.
41
- if (blocking > 0) process.exitCode = 1;
42
+ // Warnings do not fail: an unresolvable base class with no operation is ordinary,
43
+ // and a check that cries wolf stops being read.
44
+ this.ui.info(`${blocking} blocking, ${result.findings.length - blocking} warning(s)`);
42
45
  }
43
46
  }
44
47
 
@@ -0,0 +1,92 @@
1
+ import { createAppRunner } from '@fougere/core';
2
+ import type { App, CallRecord } from '@fougere/core';
3
+ import type { CallsView } from '../../fronds/analysis/handlers/DevtoolsHandler.js';
4
+ import type { ui as createUi } from '../../src/ui.js';
5
+ import { machineWanted, printMachine } from '../../src/machine.js';
6
+ import pc from 'picocolors';
7
+
8
+ type Ui = ReturnType<typeof createUi>;
9
+
10
+ /** How long between two reads. Fast enough to read as a stream, cheap enough to ignore. */
11
+ const EVERY_MS = 500;
12
+
13
+ const ROUTE = { local: pc.green, remote: pc.cyan, system: pc.magenta } as const;
14
+
15
+ /**
16
+ * `fougere devtools` — the call log of every running app of this project, followed.
17
+ *
18
+ * A cursor per address, because each app numbers its own ring: asking for what this reader
19
+ * has not seen is the whole protocol, and it is per source or nothing.
20
+ */
21
+ export default class DevtoolsCommand {
22
+ constructor(private app: App, private ui: Ui) {}
23
+
24
+ async run(raw: Record<string, unknown>) {
25
+ const since: Record<string, number> = {};
26
+
27
+ if (machineWanted(raw)) return printMachine(await this.view(raw, since));
28
+
29
+ const first = await this.view(raw, since);
30
+ this.announce(first);
31
+
32
+ const told: Record<string, number> = {};
33
+ for (let view = first; ; view = await this.view(raw, since)) {
34
+ for (const source of view.sources) {
35
+ since[source.url] = source.cursor;
36
+
37
+ // Said once per address, and only when it grows: a ring that dropped is not a
38
+ // quiet period, and repeating it every half second would bury the calls.
39
+ if (source.dropped > (told[source.url] ?? 0)) {
40
+ this.ui.warn(`${source.url} dropped ${source.dropped - (told[source.url] ?? 0)} call(s) — its ring is smaller than this traffic`);
41
+ told[source.url] = source.dropped;
42
+ }
43
+ }
44
+
45
+ for (const call of view.calls) process.stdout.write(render(call, view.sources.length > 1) + '\n');
46
+ await new Promise((wake) => setTimeout(wake, EVERY_MS));
47
+ }
48
+ }
49
+
50
+ /** What is being watched, and what refused — stated before the first line scrolls. */
51
+ private announce(view: CallsView): void {
52
+ for (const source of view.sources) {
53
+ const name = source.frond ? `${pc.bold(source.frond)} ${pc.dim(source.url)}` : pc.bold(source.url);
54
+ if (source.refusal) this.ui.warn(`${name} — ${source.refusal}`);
55
+ else this.ui.step(`following ${name}`);
56
+ }
57
+ this.ui.info(pc.dim('ctrl-c to stop'));
58
+ }
59
+
60
+ private view(raw: Record<string, unknown>, since: Record<string, number>): Promise<CallsView> {
61
+ return createAppRunner(this.app)(
62
+ { entity: 'devtools', op: 'execute' },
63
+ { params: {}, query: {}, body: { ...raw, since }, state: {} },
64
+ ) as Promise<CallsView>;
65
+ }
66
+ }
67
+
68
+ function render(call: CallRecord & { source: string }, many: boolean): string {
69
+ // Padded BEFORE colouring: an escape sequence counts in a string's length, so padding a
70
+ // coloured value moves the column by however many bytes the colour took.
71
+ const at = many ? pc.dim(pad(new URL(call.source).port || call.source, 5)) : '';
72
+ const frond = pc.dim(pad(call.frond ?? '—', 9));
73
+ const address = pc.bold(pad(`${call.entity}.${call.operation}${call.surface ? `/${call.surface}` : ''}`, 22));
74
+ const route = call.route
75
+ ? (ROUTE[call.route] ?? pc.dim)(pad(call.route, 9))
76
+ : pc.red(pad('unrouted', 9));
77
+ const took = pc.dim(lead(call.ms === undefined ? '' : `${call.ms}ms`, 7));
78
+ const verdict = call.verdict === 'failed'
79
+ ? pc.red(call.refusal?.code ?? 'failed')
80
+ : call.verdict === 'ok' ? pc.dim('ok') : pc.yellow('running');
81
+
82
+ return [at, frond, address, route, took, verdict, call.trace ? pc.dim(call.trace.slice(3, 11)) : '']
83
+ .filter(Boolean).join(' ');
84
+ }
85
+
86
+ function pad(value: string, width: number): string {
87
+ return value.length >= width ? value : value + ' '.repeat(width - value.length);
88
+ }
89
+
90
+ function lead(value: string, width: number): string {
91
+ return value.length >= width ? value : ' '.repeat(width - value.length) + value;
92
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  ExplainedBinding,
8
8
  } from '../../fronds/analysis/handlers/ExplainHandler.js';
9
9
  import pc from 'picocolors';
10
+ import { machineText, printMachine } from '../../src/machine.js';
10
11
 
11
12
  type Ui = ReturnType<typeof createUi>;
12
13
 
@@ -23,10 +24,7 @@ export default class ExplainCommand {
23
24
 
24
25
  const result = await this.ask('execute', raw) as ExplainResult;
25
26
 
26
- if (raw.json === true) {
27
- process.stdout.write(renderExplainJson(result) + '\n');
28
- return;
29
- }
27
+ if (raw.json === true) return printMachine(result);
30
28
 
31
29
  this.ui.note(renderExplain(result), result.operation);
32
30
  }
@@ -43,10 +41,7 @@ export default class ExplainCommand {
43
41
  return;
44
42
  }
45
43
 
46
- if (raw.json === true) {
47
- process.stdout.write(JSON.stringify(listing, null, 2) + '\n');
48
- return;
49
- }
44
+ if (raw.json === true) return printMachine(listing);
50
45
 
51
46
  this.ui.note(renderListing(listing), 'what this project serves');
52
47
  }
@@ -59,8 +54,9 @@ export default class ExplainCommand {
59
54
  }
60
55
  }
61
56
 
57
+ /** Kept as the name `tests/explain.test.ts` pins the key order through. */
62
58
  export function renderExplainJson(result: ExplainResult): string {
63
- return JSON.stringify(result, null, 2);
59
+ return machineText(result);
64
60
  }
65
61
 
66
62
  export function renderExplain(result: ExplainResult): string {
@@ -4,6 +4,7 @@ import type { App } from '@fougere/core';
4
4
  import type { ui as createUi } from '../../src/ui.js';
5
5
  import type { FreezeInspection } from '../../fronds/analysis/handlers/FreezeHandler.js';
6
6
  import pc from 'picocolors';
7
+ import { machineWanted, printMachine } from '../../src/machine.js';
7
8
 
8
9
  type Ui = ReturnType<typeof createUi>;
9
10
 
@@ -30,6 +31,10 @@ export default class FreezeCommand {
30
31
  // otherwise — so the first call is both the inspection and the happy path.
31
32
  const seen = await freeze(raw);
32
33
 
34
+ // The question below cannot be put to a machine, so the inspection is the answer:
35
+ // `ambiguous` says what a human still has to settle, and nothing was written.
36
+ if (machineWanted(raw)) return printMachine(seen);
37
+
33
38
  if (seen.entities.length === 0) {
34
39
  this.ui.warn('No entities found. Run this from a Fougere project root.');
35
40
  return;
@@ -3,6 +3,7 @@ import type { EntityNode, DomainCluster, App } from '@fougere/core';
3
3
  import { createAppRunner } from '@fougere/core';
4
4
  import type { ui as createUi } from '../../src/ui.js';
5
5
  import pc from 'picocolors';
6
+ import { machineWanted, printMachine } from '../../src/machine.js';
6
7
 
7
8
  type Ui = ReturnType<typeof createUi>;
8
9
 
@@ -16,6 +17,8 @@ export default class GraphCommand {
16
17
  { params: {}, query: {}, body: raw, state: {} },
17
18
  ) as GraphResult;
18
19
 
20
+ if (machineWanted(raw)) return printMachine(result);
21
+
19
22
  if (result.fronds.length === 0) {
20
23
  this.ui.warn('No fronds found. Run this from a Fougere project root.');
21
24
  return;
@@ -3,6 +3,7 @@ import type { App } from '@fougere/core';
3
3
  import type { ui as createUi } from '../../src/ui.js';
4
4
  import type { MigrationPlan } from '../../fronds/analysis/handlers/MigrateHandler.js';
5
5
  import pc from 'picocolors';
6
+ import { machineWanted, printMachine } from '../../src/machine.js';
6
7
 
7
8
  type Ui = ReturnType<typeof createUi>;
8
9
 
@@ -21,6 +22,8 @@ export default class MigrateCommand {
21
22
  { params: {}, query: {}, body: raw, state: {} },
22
23
  )) as MigrationPlan;
23
24
 
25
+ if (machineWanted(raw)) return printMachine(result);
26
+
24
27
  if (result.chain.length === 0) {
25
28
  this.ui.warn('No frozen step to apply. `fougere freeze <version>` records one.');
26
29
  return;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * A command's machine output — the one shape a pipe reads.
3
+ *
4
+ * There is no list of commands here: `json` is declared by an entity like any other
5
+ * field, so a command that declares it gets the door. The runner used to name `explain`
6
+ * in an `if`, and `graph --json` announced a flag it then ignored.
7
+ */
8
+ export declare function machineWanted(raw: Record<string, unknown>): boolean;
9
+ /**
10
+ * A `Map` serializes to `{}`, so the door converts it rather than each command flattening
11
+ * its own result: `GraphResult.nodes` is a Map, and `graph --json` would have printed a
12
+ * report with an empty graph in it.
13
+ */
14
+ export declare function machineText(value: unknown): string;
15
+ export declare function printMachine(value: unknown): void;
16
+ //# sourceMappingURL=machine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"machine.d.ts","sourceRoot":"","sources":["../src/machine.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAEnE;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAElD;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAEjD"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A command's machine output — the one shape a pipe reads.
3
+ *
4
+ * There is no list of commands here: `json` is declared by an entity like any other
5
+ * field, so a command that declares it gets the door. The runner used to name `explain`
6
+ * in an `if`, and `graph --json` announced a flag it then ignored.
7
+ */
8
+ export function machineWanted(raw) {
9
+ return raw.json === true || typeof raw.names === 'string';
10
+ }
11
+ /**
12
+ * A `Map` serializes to `{}`, so the door converts it rather than each command flattening
13
+ * its own result: `GraphResult.nodes` is a Map, and `graph --json` would have printed a
14
+ * report with an empty graph in it.
15
+ */
16
+ export function machineText(value) {
17
+ return JSON.stringify(value, (_key, held) => (held instanceof Map ? Object.fromEntries(held) : held), 2);
18
+ }
19
+ export function printMachine(value) {
20
+ process.stdout.write(machineText(value) + '\n');
21
+ }
22
+ //# sourceMappingURL=machine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"machine.js","sourceRoot":"","sources":["../src/machine.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,GAA4B;IACxD,OAAO,GAAG,CAAC,IAAI,KAAK,IAAI,IAAI,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ,CAAC;AAC5D,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,KAAc;IACxC,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,YAAY,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;AAC3G,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC;AAClD,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,eAAe,CAAC;AAuCzC,wBAAsB,GAAG,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CA0FjD"}
1
+ {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,eAAe,CAAC;AAwCzC,wBAAsB,GAAG,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAgGjD"}
package/dist/runner.js CHANGED
@@ -2,6 +2,7 @@ import { createAppRunner } from '@fougere/core';
2
2
  import { lowerFirst } from '@fougere/core/contract';
3
3
  import { defineCommand, runMain } from 'citty';
4
4
  import { ui } from './ui.js';
5
+ import { machineWanted } from './machine.js';
5
6
  import { entityToArgs } from './bridge.js';
6
7
  import { readdir } from 'node:fs/promises';
7
8
  import { join } from 'node:path';
@@ -31,7 +32,6 @@ async function loadAppCommands(cliRoot, loader) {
31
32
  export async function run(app) {
32
33
  const terminal = ui();
33
34
  const cliRoot = new URL('..', import.meta.url).pathname;
34
- // Load app commands (presentation layer)
35
35
  const { createJiti } = await import('jiti');
36
36
  const jiti = createJiti(import.meta.url, { interopDefault: true });
37
37
  const loader = (path) => jiti.import(path);
@@ -76,10 +76,12 @@ export async function run(app) {
76
76
  },
77
77
  args,
78
78
  run: async ({ args: parsed }) => {
79
- // JSON is a protocol: a branded intro before `{` makes it unparsable. The
80
- // explain command owns its machine output and therefore gets a clean stdout.
81
- const machineOutput = cmdName === 'explain'
82
- && (parsed.json === true || typeof parsed.names === 'string');
79
+ // JSON is a protocol: a branded intro before `{` makes it unparsable. Read from
80
+ // the invocation's own args, so any command declaring `json` gets a clean stdout
81
+ // naming `explain` here is what let `graph --json` print its decorated box.
82
+ const machineOutput = machineWanted(parsed);
83
+ // `completion` is exempt by nature, not by flag: its output IS a shell script,
84
+ // there is no invocation of it that wants decoration.
83
85
  if (cmdName !== 'completion' && !machineOutput)
84
86
  terminal.intro();
85
87
  // citty adds `_` (raw positionals) and `--` (passthrough); strip them
@@ -98,7 +100,13 @@ export async function run(app) {
98
100
  }
99
101
  }
100
102
  catch (err) {
101
- terminal.error(err instanceof Error ? err.message : String(err));
103
+ const said = err instanceof Error ? err.message : String(err);
104
+ // A machine reader parses stdout: a refusal printed there is a refusal that
105
+ // breaks the parse instead of being read. stderr is where it belongs.
106
+ if (machineOutput)
107
+ process.stderr.write(said + '\n');
108
+ else
109
+ terminal.error(said);
102
110
  process.exit(1);
103
111
  }
104
112
  },
@@ -1 +1 @@
1
- {"version":3,"file":"runner.js","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"runner.js","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC;AAC/C,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAC3C,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,SAAS,OAAO,CAAC,IAAY;IAC3B,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;AAChF,CAAC;AAED,SAAS,OAAO,CAAC,KAAa;IAC5B,OAAO,KAAK,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;AAC/D,CAAC;AAED,8CAA8C;AAC9C,KAAK,UAAU,eAAe,CAC5B,OAAe,EACf,MAA0D;IAE1D,MAAM,GAAG,GAAG,IAAI,GAAG,EAAE,CAAC;IACtB,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,EAAE,KAAK,EAAE,UAAU,CAAC,CAAC;IAC7C,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;IAE1E,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;QACtB,IAAI,CAAC,CAAC,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;YAAE,SAAS;QACjF,MAAM,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,mBAAmB,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;QAC/E,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;QAC5C,IAAI,GAAG,CAAC,OAAO,IAAI,OAAO,GAAG,CAAC,OAAO,KAAK,UAAU,EAAE,CAAC;YACrD,GAAG,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;QAC9B,CAAC;IACH,CAAC;IAED,OAAO,GAAG,CAAC;AACb,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,GAAG,CAAC,GAAQ;IAChC,MAAM,QAAQ,GAAG,EAAE,EAAE,CAAC;IACtB,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC;IAExD,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,CAAC;IAC5C,MAAM,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,IAAI,EAAE,CAAC,CAAC;IACnE,MAAM,MAAM,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAqC,CAAC;IACvF,MAAM,WAAW,GAAG,MAAM,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAE3D,MAAM,WAAW,GAAqD,EAAE,CAAC;IAEzE,KAAK,MAAM,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC;QAC/B,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAEtE,KAAK,MAAM,MAAM,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;YACpC,MAAM,YAAY,GAAG,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YACjD,IAAI,CAAC,YAAY;gBAAE,SAAS;YAE5B,MAAM,WAAW,GAAG,GAAG,MAAM,CAAC,IAAI,SAAS,CAAC;YAC5C,IAAI,OAAiC,CAAC;YACtC,IAAI,CAAC;gBACH,OAAO,GAAG,GAAG,CAAC,OAAO,CAA2B,WAAW,CAAC,CAAC;YAC/D,CAAC;YAAC,MAAM,CAAC;gBAAC,SAAS;YAAC,CAAC;YAErB,IAAI,OAAO,OAAO,CAAC,OAAO,KAAK,UAAU;gBAAE,SAAS;YAEpD,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YACrC,MAAM,MAAM,GAAG,MAAM,CAAC,WAAW,CAAC,SAAS,EAAE,CAAC;YAC9C,MAAM,IAAI,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;YAElC,gDAAgD;YAChD,MAAM,UAAU,GAAG,WAAW,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;YAE5C,gFAAgF;YAChF,IAAI,UAAU,EAAE,CAAC;gBACf,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;oBACtC,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG;wBAAG,GAA+B,CAAC,QAAQ,GAAG,KAAK,CAAC;gBACxF,CAAC;YACH,CAAC;YAED,WAAW,CAAC,OAAO,CAAC,GAAG,aAAa,CAAC;gBACnC,IAAI,EAAE;oBACJ,IAAI,EAAE,OAAO;oBACb,0EAA0E;oBAC1E,yEAAyE;oBACzE,2EAA2E;oBAC3E,2EAA2E;oBAC3E,WAAW,EAAE,YAAY,CAAC,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,WAAW;iBACjE;gBACD,IAAI;gBACJ,GAAG,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE;oBAC9B,gFAAgF;oBAChF,iFAAiF;oBACjF,8EAA8E;oBAC9E,MAAM,aAAa,GAAG,aAAa,CAAC,MAAiC,CAAC,CAAC;oBAEvE,+EAA+E;oBAC/E,sDAAsD;oBACtD,IAAI,OAAO,KAAK,YAAY,IAAI,CAAC,aAAa;wBAAE,QAAQ,CAAC,KAAK,EAAE,CAAC;oBAEjE,sEAAsE;oBACtE,qDAAqD;oBACrD,MAAM,KAAK,GAAG,EAAE,GAAI,MAAkC,EAAE,CAAC;oBACzD,OAAO,KAAK,CAAC,CAAC,CAAC;oBACf,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC;oBAEnB,IAAI,CAAC;wBACH,IAAI,UAAU,EAAE,CAAC;4BACf,MAAM,GAAG,GAAG,IAAK,UAAgG,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;4BACjI,MAAM,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;wBACvB,CAAC;6BAAM,CAAC;4BACN,kEAAkE;4BAClE,MAAM,eAAe,CAAC,GAAG,CAAC,CACxB,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,SAAS,EAAE,EAClD,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,CAClD,CAAC;wBACJ,CAAC;oBACH,CAAC;oBAAC,OAAO,GAAG,EAAE,CAAC;wBACb,MAAM,IAAI,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;wBAC9D,4EAA4E;wBAC5E,sEAAsE;wBACtE,IAAI,aAAa;4BAAE,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC;;4BAChD,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;wBAC1B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;oBAClB,CAAC;gBACH,CAAC;aACF,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,MAAM,IAAI,GAAG,aAAa,CAAC;QACzB,IAAI,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,aAAa,EAAE;QACrD,WAAW;KACZ,CAAC,CAAC;IAEH,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;AACtB,CAAC"}
@@ -1,7 +1,8 @@
1
- import { entity, text, optional } from "@fougere/schema";
1
+ import { entity, bool, text, optional } from "@fougere/schema";
2
2
 
3
3
  /** `fougere build` — write down what a scan found, so a deployment reads no disk. */
4
4
  export default class Build extends entity({
5
5
  root: optional(text({ description: "Project to read. Default: the current directory" })),
6
6
  out: optional(text({ description: "Where to write it. Default: .fougere/scan.generated.ts" })),
7
+ json: bool({ default: false, description: "Print the report as JSON, with nothing else on stdout" }),
7
8
  }) {}
@@ -1,5 +1,6 @@
1
- import { entity, text } from '@fougere/schema';
1
+ import { entity, bool, text } from '@fougere/schema';
2
2
 
3
3
  export default class Check extends entity({
4
4
  root: text({ description: 'Project root directory (default: cwd)' }),
5
+ json: bool({ default: false, description: 'Print the report as JSON, with nothing else on stdout' }),
5
6
  }) {}
@@ -0,0 +1,8 @@
1
+ import { entity, bool, text, optional } from '@fougere/schema';
2
+
3
+ /** `fougere devtools` — what the running apps of this project are dispatching. */
4
+ export default class Devtools extends entity({
5
+ url: optional(text({ description: 'One app to read. Absent: every address this project declares' })),
6
+ root: optional(text({ description: 'Project to read the addresses from. Default: the current directory' })),
7
+ json: bool({ default: false, description: 'Print one page as JSON and stop, instead of following' }),
8
+ }) {}
@@ -1,7 +1,8 @@
1
- import { entity, text, optional } from "@fougere/schema";
1
+ import { entity, bool, text, optional } from "@fougere/schema";
2
2
 
3
3
  /** `fougere freeze <version>` — record today's shapes under a name, for good. */
4
4
  export default class Freeze extends entity({
5
5
  version: text({ min: 1, description: "Name to record these shapes under (e.g. v2)" }),
6
6
  root: optional(text({ description: "Project to read. Default: the current directory" })),
7
+ json: bool({ default: false, description: "Print the report as JSON, with nothing else on stdout" }),
7
8
  }) {}
@@ -1,6 +1,7 @@
1
- import { entity, text, number } from '@fougere/schema';
1
+ import { entity, bool, text, number } from '@fougere/schema';
2
2
 
3
3
  export default class Graph extends entity({
4
4
  root: text({ description: 'Project root directory (default: cwd)' }),
5
5
  minEntities: number({ default: 6, description: 'Minimum entities before suggesting split' }),
6
+ json: bool({ default: false, description: 'Print the report as JSON, with nothing else on stdout' }),
6
7
  }) {}
@@ -4,4 +4,5 @@ import { entity, bool, optional, text } from "@fougere/schema";
4
4
  export default class Migrate extends entity({
5
5
  apply: bool({ default: false, description: "Run it. Without this the plan is printed and nothing moves" }),
6
6
  root: optional(text({ description: "Project to read. Default: the current directory" })),
7
+ json: bool({ default: false, description: "Print the report as JSON, with nothing else on stdout" }),
7
8
  }) {}
@@ -39,7 +39,8 @@ export default class BuildHandler {
39
39
  constructor(private projectScan: ProjectScan) {}
40
40
 
41
41
  /** Write down what a scan found, so a deployment reads no disk. */
42
- async execute(input: Build): Promise<BuildReport> {
42
+ /** `json` is the presentation's, so it is not part of what this operation receives. */
43
+ async execute(input: Omit<Build, 'json'>): Promise<BuildReport> {
43
44
  const scan = await this.projectScan.at(input.root ?? undefined);
44
45
  const out = resolve(scan.root, input.out || DEFAULT_OUT);
45
46
 
@@ -0,0 +1,86 @@
1
+ import { createHttpTransport } from '@fougere/transport-http/client';
2
+ import type { CallPage, CallRecord } from '@fougere/core';
3
+ import ProjectScan from '../services/ProjectScan.js';
4
+
5
+ /** The default address of an app in development — Nuxt, Next and the site all sit there. */
6
+ const LOCAL = 'http://127.0.0.1:3000';
7
+
8
+ /** One address that was asked, and what came back from it. */
9
+ export interface CallSource {
10
+ url: string;
11
+ /** The frond `remotes:` names at this address, when that is where it came from. */
12
+ frond?: string;
13
+ cursor: number;
14
+ inFlight: number;
15
+ dropped: number;
16
+ /** Present instead of the numbers when the address did not answer, or serves no log. */
17
+ refusal?: string;
18
+ }
19
+
20
+ /**
21
+ * Every app of this project, read at once.
22
+ *
23
+ * A reader pulls: no app registers anywhere, and an app started alone depends on nothing.
24
+ * The price is knowing the addresses, which the project already states — `remotes:` says
25
+ * where a call goes, so it also says where the other half of a call can be watched.
26
+ */
27
+ export interface CallsView {
28
+ sources: CallSource[];
29
+ /** Every call, oldest first, each carrying the address it was read from. */
30
+ calls: (CallRecord & { source: string })[];
31
+ }
32
+
33
+ export default class DevtoolsHandler {
34
+ constructor(private projectScan: ProjectScan) {}
35
+
36
+ /** Read what the running apps have dispatched since a cursor per address. */
37
+ async execute(input: { url?: string; root?: string; since?: Record<string, number> }): Promise<CallsView> {
38
+ const asked = input.url ? [{ url: trimmed(input.url) }] : await this.addresses(input.root);
39
+ const since = input.since ?? {};
40
+
41
+ const read = await Promise.all(asked.map(async (one): Promise<CallSource & { calls: CallRecord[] }> => {
42
+ try {
43
+ const page = await createHttpTransport(one.url)(
44
+ { entity: 'rpc', op: 'calls' },
45
+ { params: {}, query: {}, body: { since: since[one.url] ?? 0 }, state: {} },
46
+ ) as CallPage;
47
+
48
+ return { ...one, cursor: page.cursor, inFlight: page.inFlight, dropped: page.dropped, calls: page.calls };
49
+ } catch (err) {
50
+ // An address that does not answer is a source with a reason, not a missing source:
51
+ // an app not started and an app without the package are different facts, and both
52
+ // are worth seeing beside the ones that answered.
53
+ return { ...one, cursor: since[one.url] ?? 0, inFlight: 0, dropped: 0, calls: [], refusal: said(err) };
54
+ }
55
+ }));
56
+
57
+ return {
58
+ sources: read.map(({ calls: _calls, ...source }) => source),
59
+ calls: read
60
+ .flatMap(({ url, calls }) => calls.map((call) => ({ ...call, source: url })))
61
+ .sort((a, b) => a.startedAt - b.startedAt || a.seq - b.seq),
62
+ };
63
+ }
64
+
65
+ /**
66
+ * The addresses this project declares, plus the local one.
67
+ *
68
+ * `remotes:` is the operator's statement of where a call goes; reading it here is not a
69
+ * second source of truth, it is the same one read for the other direction.
70
+ */
71
+ private async addresses(root?: string): Promise<{ url: string; frond?: string }[]> {
72
+ const { config } = await this.projectScan.at(root);
73
+ const remotes = Object.entries(config.remotes ?? {})
74
+ .map(([frond, url]) => ({ url: trimmed(url), frond }));
75
+
76
+ return [{ url: LOCAL }, ...remotes.filter((one) => one.url !== LOCAL)];
77
+ }
78
+ }
79
+
80
+ function trimmed(url: string): string {
81
+ return url.replace(/\/$/, '');
82
+ }
83
+
84
+ function said(err: unknown): string {
85
+ return err instanceof Error ? err.message : String(err);
86
+ }
@@ -40,7 +40,8 @@ export default class FreezeHandler {
40
40
  * ambiguities and calls again with `renamed`. Splitting it would let a caller write
41
41
  * a version it never inspected.
42
42
  */
43
- async execute(input: Freeze & { renamed?: Record<string, Record<string, string>> }): Promise<FreezeInspection> {
43
+ /** `json` is the presentation's, so it is not part of what this operation receives. */
44
+ async execute(input: Omit<Freeze, 'json'> & { renamed?: Record<string, Record<string, string>> }): Promise<FreezeInspection> {
44
45
  const fronds = await this.read(input);
45
46
  const version = input.version;
46
47
  const entities = fronds.flatMap(({ bundle }) => Object.keys(bundle.$defs ?? {}));
@@ -97,7 +98,7 @@ export default class FreezeHandler {
97
98
  * `Fronds.schemas()` is deliberately flat — a fact heard in one frond is declared in
98
99
  * another — so the per-frond map is built here, where the question IS per frond.
99
100
  */
100
- private async read(input: Freeze) {
101
+ private async read(input: Omit<Freeze, 'json'>) {
101
102
  const scan = await this.projectScan.at(input.root ?? undefined);
102
103
  return Promise.all(
103
104
  scan.fronds
@@ -31,7 +31,8 @@ export default class MigrateHandler {
31
31
  constructor(private projectScan: ProjectScan) {}
32
32
 
33
33
  /** Realise the frozen steps this database has not caught up with. */
34
- async execute(input: Migrate): Promise<MigrationPlan> {
34
+ /** `json` is the presentation's, so it is not part of what this operation receives. */
35
+ async execute(input: Omit<Migrate, 'json'>): Promise<MigrationPlan> {
35
36
  const scan = await this.projectScan.at(input.root ?? undefined);
36
37
  const perFrond = await Promise.all(scan.fronds.map((frond) => stepsOf(frond.source.path)));
37
38
  const steps = perFrond.flat();
@@ -251,10 +251,10 @@ export default class SyncHandler {
251
251
  },
252
252
  }, null, 2) + '\n');
253
253
 
254
- // Update .fougere/remotes.json central registry of synced remotes
254
+ // .fougere/remotes.json is the central registry of synced remotes.
255
255
  this.updateRemotesRegistry(input.frond, baseUrl, frondDir);
256
256
 
257
- // Update tsconfig paths if tsconfig.json exists (non-Nuxt projects)
257
+ // Non-Nuxt projects only: Nuxt writes its own paths.
258
258
  this.updateTsconfigPaths(input.frond, frondDir, conventions);
259
259
 
260
260
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fougere/cli",
3
- "version": "0.4.0-alpha.0",
3
+ "version": "0.5.0-alpha.0",
4
4
  "description": "The Fougere CLI — compose a workspace, serve a frond, call an operation.",
5
5
  "keywords": [
6
6
  "fougere",
@@ -41,12 +41,12 @@
41
41
  "consola": "^3.4.2",
42
42
  "jiti": "^2.4.2",
43
43
  "picocolors": "^1.1.1",
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"
44
+ "@fougere/adapter-sql": "0.5.0-alpha.0",
45
+ "@fougere/core": "0.5.0-alpha.0",
46
+ "@fougere/container": "0.5.0-alpha.0",
47
+ "@fougere/schema": "0.5.0-alpha.0",
48
+ "@fougere/defaults": "0.5.0-alpha.0",
49
+ "@fougere/transport-http": "0.5.0-alpha.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "vitest": "^4.1.0"
package/src/machine.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A command's machine output — the one shape a pipe reads.
3
+ *
4
+ * There is no list of commands here: `json` is declared by an entity like any other
5
+ * field, so a command that declares it gets the door. The runner used to name `explain`
6
+ * in an `if`, and `graph --json` announced a flag it then ignored.
7
+ */
8
+ export function machineWanted(raw: Record<string, unknown>): boolean {
9
+ return raw.json === true || typeof raw.names === 'string';
10
+ }
11
+
12
+ /**
13
+ * A `Map` serializes to `{}`, so the door converts it rather than each command flattening
14
+ * its own result: `GraphResult.nodes` is a Map, and `graph --json` would have printed a
15
+ * report with an empty graph in it.
16
+ */
17
+ export function machineText(value: unknown): string {
18
+ return JSON.stringify(value, (_key, held) => (held instanceof Map ? Object.fromEntries(held) : held), 2);
19
+ }
20
+
21
+ export function printMachine(value: unknown): void {
22
+ process.stdout.write(machineText(value) + '\n');
23
+ }
package/src/runner.ts CHANGED
@@ -12,6 +12,7 @@ import { createAppRunner } from '@fougere/core';
12
12
  import { lowerFirst } from '@fougere/core/contract';
13
13
  import { defineCommand, runMain } from 'citty';
14
14
  import { ui } from './ui.js';
15
+ import { machineWanted } from './machine.js';
15
16
  import { entityToArgs } from './bridge.js';
16
17
  import { readdir } from 'node:fs/promises';
17
18
  import { join } from 'node:path';
@@ -50,7 +51,6 @@ export async function run(app: App): Promise<void> {
50
51
  const terminal = ui();
51
52
  const cliRoot = new URL('..', import.meta.url).pathname;
52
53
 
53
- // Load app commands (presentation layer)
54
54
  const { createJiti } = await import('jiti');
55
55
  const jiti = createJiti(import.meta.url, { interopDefault: true });
56
56
  const loader = (path: string) => jiti.import(path) as Promise<Record<string, unknown>>;
@@ -98,10 +98,13 @@ export async function run(app: App): Promise<void> {
98
98
  },
99
99
  args,
100
100
  run: async ({ args: parsed }) => {
101
- // JSON is a protocol: a branded intro before `{` makes it unparsable. The
102
- // explain command owns its machine output and therefore gets a clean stdout.
103
- const machineOutput = cmdName === 'explain'
104
- && (parsed.json === true || typeof parsed.names === 'string');
101
+ // JSON is a protocol: a branded intro before `{` makes it unparsable. Read from
102
+ // the invocation's own args, so any command declaring `json` gets a clean stdout
103
+ // naming `explain` here is what let `graph --json` print its decorated box.
104
+ const machineOutput = machineWanted(parsed as Record<string, unknown>);
105
+
106
+ // `completion` is exempt by nature, not by flag: its output IS a shell script,
107
+ // there is no invocation of it that wants decoration.
105
108
  if (cmdName !== 'completion' && !machineOutput) terminal.intro();
106
109
 
107
110
  // citty adds `_` (raw positionals) and `--` (passthrough); strip them
@@ -122,7 +125,11 @@ export async function run(app: App): Promise<void> {
122
125
  );
123
126
  }
124
127
  } catch (err) {
125
- terminal.error(err instanceof Error ? err.message : String(err));
128
+ const said = err instanceof Error ? err.message : String(err);
129
+ // A machine reader parses stdout: a refusal printed there is a refusal that
130
+ // breaks the parse instead of being read. stderr is where it belongs.
131
+ if (machineOutput) process.stderr.write(said + '\n');
132
+ else terminal.error(said);
126
133
  process.exit(1);
127
134
  }
128
135
  },
@@ -26,7 +26,7 @@ export default class PostHandler extends Crud(Post) {
26
26
  }
27
27
 
28
28
  /** Only published posts exist for the outside world, projected to the card. */
29
- async published(): Promise<PostCard[]> {
29
+ async listPublished(): Promise<PostCard[]> {
30
30
  const posts = await this.orm.list({ where: { status: 'published' } });
31
31
  return posts.map(({ id, title, status }) => ({ id, title, status }));
32
32
  }
@@ -17,7 +17,7 @@ export default class PostHandler extends Crud(Post) {
17
17
  }
18
18
 
19
19
  /** Only published posts, projected to the card. */
20
- async published(): Promise<PostCard[]> {
20
+ async listPublished(): Promise<PostCard[]> {
21
21
  const posts = await this.orm.list({ where: { status: 'published' } });
22
22
  return posts.map(({ id, title, status }) => ({ id, title, status }));
23
23
  }
@@ -29,7 +29,7 @@ export default class PostHandler extends Crud(Post) {
29
29
  }
30
30
 
31
31
  /** Only published posts, projected to the card. */
32
- async published(): Promise<PostCard[]> {
32
+ async listPublished(): Promise<PostCard[]> {
33
33
  const posts = await this.orm.list({ where: { status: 'published' } });
34
34
  return posts.map(({ id, title, status }) => ({ id, title, status }));
35
35
  }