rman 2.10.1 → 2.11.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.
@@ -1,5 +1,5 @@
1
+ import type { LogLevel } from '../../../../core/classes/logger.js';
1
2
  import type { Repository } from '../../../../core/classes/repository.js';
2
- import type { LogLevel } from '../../../../utils/logger.js';
3
3
  import type { PackageFilterOptions } from '../../../../utils/package-filter.js';
4
4
  export declare namespace CiService {
5
5
  /**
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import colors from 'ansi-colors';
4
+ import { Logger, resolveRootLogLevel } from '../../../../core/classes/logger.js';
4
5
  import { exec } from '../../../../utils/exec.js';
5
- import { Logger, resolveRootLogLevel } from '../../../../utils/logger.js';
6
6
  import { filterPackages } from '../../../../utils/package-filter.js';
7
7
  import { formatDuration, ProgressPanel } from '../../../../utils/progress-panel.js';
8
8
  export var CiService;
@@ -1,5 +1,5 @@
1
+ import type { LogLevel } from '../../../../core/classes/logger.js';
1
2
  import type { Repository } from '../../../../core/classes/repository.js';
2
- import type { LogLevel } from '../../../../utils/logger.js';
3
3
  import type { PackageFilterOptions } from '../../../../utils/package-filter.js';
4
4
  export declare namespace CleanService {
5
5
  interface Options extends PackageFilterOptions {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import colors from 'ansi-colors';
4
4
  import fg from 'fast-glob';
5
- import { Logger, resolveRootLogLevel } from '../../../../utils/logger.js';
5
+ import { Logger, resolveRootLogLevel } from '../../../../core/classes/logger.js';
6
6
  import { filterPackages } from '../../../../utils/package-filter.js';
7
7
  import { ProgressPanel } from '../../../../utils/progress-panel.js';
8
8
  export var CleanService;
package/cli.js CHANGED
@@ -11,13 +11,14 @@ import yargs, {} from 'yargs';
11
11
  import { hideBin } from 'yargs/helpers';
12
12
  import { version } from './constants.js';
13
13
  import { RmanApplication } from './core/application.js';
14
+ import { LogSink } from './core/classes/log-sink.js';
15
+ import { LOG_LEVELS, Logger, resolveRootLogLevel } from './core/classes/logger.js';
14
16
  import { Repository } from './core/classes/repository.js';
15
17
  import { commandName, toYargsCommand } from './core/command-builder.js';
16
18
  import { assertNoBuiltinShadowing, defaultCommandGlobs, describeCommandSource, loadCustomCommands, } from './core/interfaces/custom-command.js';
17
19
  import { checkCustomCommand } from './core/plugin-loader.js';
18
20
  import { commandRegistry } from './interfaces/rman-config.interface.js';
19
21
  import { colorYaml } from './utils/color-yaml.js';
20
- import { LOG_LEVELS, Logger, resolveRootLogLevel } from './utils/logger.js';
21
22
  import { filterPackages, readFromRootOption, readPackageFilterOptions } from './utils/package-filter.js';
22
23
  import { printableConfig, withoutContributions } from './utils/printable-config.js';
23
24
  import { runBin } from './utils/run-bin.js';
@@ -58,6 +59,20 @@ export async function runCli(options) {
58
59
  'on, and the .rmanrc keys it reads - and run nothing',
59
60
  type: 'boolean',
60
61
  default: false,
62
+ })
63
+ /** **Global, not per command** - the format of what rman writes, the way `--log-level` is its
64
+ * volume. A command that declares its own `--json` (`list`, `version`, `publish`, ...) keeps
65
+ * that declaration and its meaning: its *result* as JSON, which the shared release workflow
66
+ * parses. See `LogSink`. */
67
+ .option('json', {
68
+ describe: 'Write the log as JSON Lines - one event per line on stdout, and no panel, spinner or prose. ' +
69
+ 'A command that prints a result (list, version, publish, ...) prints that result as JSON',
70
+ type: 'boolean',
71
+ })
72
+ .option('log-file', {
73
+ describe: 'Also write the log to this file - JSON Lines under --json, plain text lines otherwise. ' +
74
+ 'Overwritten each run; relative to the current directory',
75
+ type: 'string',
61
76
  })
62
77
  .showHelpOnFail(false, 'Run with --help for available options')
63
78
  .fail((msg, err) => {
@@ -384,37 +399,80 @@ function interceptStatusLines(repository, program) {
384
399
  return register({
385
400
  ...spec,
386
401
  handler: async (args) => {
387
- /** The flag when it was given, the repository's own `logLevel` otherwise - the same
388
- * precedence every other reader of it applies. `argvLogLevel` is not used here: that one
389
- * exists for diagnostics emitted *before* parsing, and by now yargs has the answer. */
390
- const level = args.logLevel ?? repository.config?.logLevel;
391
- if (spec.printsDocument || args.json || args.config || level === 'silent')
392
- return run(args);
393
402
  const name = commandName(spec.command ?? '');
394
- /** Held on the application so `runBin` can find it through the `app` it already receives -
395
- * see `RmanApplication.statusRegion` for why that rather than ambient state. */
396
- const region = new StatusRegion(name, repository.name ?? '');
397
- repository.app.statusRegion = region;
398
- region.start();
403
+ /** Built for every command, used by whichever writes a log - see `LogSink`. A command owning
404
+ * `--json` prints its result as JSON, so the log must not be written to stdout beside it. */
405
+ const sink = new LogSink({ json: !!args.json && !spec.ownsJson, file: args.logFile, cwd: repository.cwd });
406
+ repository.app.logSink = sink;
399
407
  try {
400
- const result = await run(args);
401
- region.stop('ok');
402
- return result;
403
- }
404
- catch (e) {
405
- /** **The bracket is closed even on a failure.** A spinner that simply stops, leaving its
406
- * last frame on screen, reads as a hang - which is the thing this exists to prevent. The
407
- * error itself is left to the caller, which already prints it. */
408
- region.stop('fail');
409
- throw e;
408
+ return await runWithStatusLine(args, name);
410
409
  }
411
410
  finally {
412
- repository.app.statusRegion = undefined;
411
+ sink.close();
412
+ repository.app.logSink = undefined;
413
+ warnUnhonoured(name, args, sink, !!spec.ownsJson);
413
414
  }
414
415
  },
415
416
  });
417
+ async function runWithStatusLine(args, name) {
418
+ /** The flag when it was given, the repository's own `logLevel` otherwise - the same
419
+ * precedence every other reader of it applies. `argvLogLevel` is not used here: that one
420
+ * exists for diagnostics emitted *before* parsing, and by now yargs has the answer. */
421
+ const level = args.logLevel ?? repository.config?.logLevel;
422
+ if (spec.printsDocument || args.json || args.config || level === 'silent')
423
+ return run(args);
424
+ /** Held on the application so `runBin` can find it through the `app` it already receives -
425
+ * see `RmanApplication.statusRegion` for why that rather than ambient state.
426
+ *
427
+ * **Not drawn under `--no-progress`**, which asks for *no* live output rather than "no
428
+ * panel": the spinner is progress too, and it would redraw over the plain lines such a run
429
+ * prints. Its result line still prints, as it does without a TTY. Children are not told -
430
+ * they are run without a terminal instead, see `RunService`'s panel-off path. */
431
+ const region = new StatusRegion(name, repository.name ?? '', args.progress === false ? false : undefined);
432
+ repository.app.statusRegion = region;
433
+ region.start();
434
+ try {
435
+ const result = await run(args);
436
+ region.stop('ok');
437
+ return result;
438
+ }
439
+ catch (e) {
440
+ /** **The bracket is closed even on a failure.** A spinner that simply stops, leaving its
441
+ * last frame on screen, reads as a hang - which is the thing this exists to prevent. The
442
+ * error itself is left to the caller, which already prints it. */
443
+ region.stop('fail');
444
+ throw e;
445
+ }
446
+ finally {
447
+ repository.app.statusRegion = undefined;
448
+ }
449
+ }
416
450
  };
417
451
  }
452
+ /**
453
+ * One line on stderr for a `--json` or `--log-file` that the command did not honour - so a flag that
454
+ * does nothing does not pass for one that worked.
455
+ *
456
+ * **Detected, not listed.** Whether a command writes a log is whatever went through the sink, so a
457
+ * command that starts writing one stops being warned about with no list to update - the rule
458
+ * `builtInNames` follows. On stderr because under `--json` stdout belongs to the events.
459
+ */
460
+ /* **Why warn rather than refuse.** Both flags are global now, so yargs accepts them on every command,
461
+ * and `rman clean --json` - which `.strict()` used to reject as an unknown argument - would otherwise
462
+ * be quietly ignored. Refusing would turn a log option into a reason for a release step to fail. The
463
+ * commands that write a log so far are the ones whose steps run through `RunService` - `run`,
464
+ * `build`, `test`, and a command using `forEachPackage`. */
465
+ function warnUnhonoured(name, args, sink, ownsJson) {
466
+ /** `--config` runs nothing by design, so nothing written is the right outcome there. */
467
+ if (sink.used || args.config)
468
+ return;
469
+ if (args.logFile) {
470
+ process.stderr.write(colors.yellow(`--log-file: "rman ${name}" does not write a log yet, so ${args.logFile} was not created.\n`));
471
+ }
472
+ if (args.json && !ownsJson) {
473
+ process.stderr.write(colors.yellow(`--json: "rman ${name}" has no JSON output yet; what it printed is unchanged.\n`));
474
+ }
475
+ }
418
476
  /**
419
477
  * What a command would run with: its resolved options, the packages it would act on, and the
420
478
  * `.rmanrc` those packages carry.
@@ -1,8 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import colors from 'ansi-colors';
3
+ import { Logger, resolveRootLogLevel } from '../core/classes/logger.js';
3
4
  import { registerCommand } from '../interfaces/rman-config.interface.js';
4
5
  import { ChangeHashService } from '../services/change-hash.service.js';
5
- import { Logger, resolveRootLogLevel } from '../utils/logger.js';
6
6
  import { fromRootOption, packageFilterOptions, readPackageFilterOptions } from '../utils/package-filter.js';
7
7
  import { ProgressPanel } from '../utils/progress-panel.js';
8
8
  const COMMAND = 'changelog';
@@ -5,7 +5,7 @@ import { registerCommand } from '../interfaces/rman-config.interface.js';
5
5
  import { VersionPlanService } from '../services/version-plan.service.js';
6
6
  import { assertAllowedBranch, branchGuardOptions, readBranchGuardOptions } from '../utils/branch-guard.js';
7
7
  import { packageFilterOptions, readPackageFilterOptions } from '../utils/package-filter.js';
8
- import { isSoloGroupKey } from '../utils/version-group.js';
8
+ import { isNamedGroupKey, isSoloGroupKey, ROOT_GROUP_KEY } from '../utils/version-group.js';
9
9
  /**
10
10
  * Hoisted out of the metadata literal so the handler can be annotated against them - see
11
11
  * `RmanConfig.ArgsOf` for why an inferred `argv` and the metadata's own typo checking cannot both
@@ -290,12 +290,12 @@ function printApplied(result) {
290
290
  function printPlan(entries) {
291
291
  const table = new EasyTable();
292
292
  let first = true;
293
- for (const { members, shared } of planBlocks(entries)) {
293
+ for (const { members } of planBlocks(entries)) {
294
294
  if (!first)
295
295
  table.pushDelimeter(PLAN_COLUMNS);
296
296
  first = false;
297
297
  for (const e of members)
298
- printPlanRow(table, e, shared);
298
+ printPlanRow(table, e);
299
299
  }
300
300
  console.log(table.toString().trim());
301
301
  }
@@ -322,9 +322,9 @@ function printPlan(entries) {
322
322
  * repository that has nothing to group - noise standing in for structure. They share a block
323
323
  * because what they have in common is real: none of them is tied to anyone else.
324
324
  *
325
- * `shared` travels with each block because it is also what decides whether the `Group` column is
326
- * written at all - see `printPlanRow`. One partition, asked once, answering both questions; working
327
- * it out again at the cell would be a second rule that could disagree with the blocks beside it.
325
+ * The Group column is decided per row from the group key (`groupCell`), not from the block: both ask
326
+ * the same key the same question - is this package in a group, and did anybody name it - so they
327
+ * cannot disagree.
328
328
  */
329
329
  function planBlocks(entries) {
330
330
  /**
@@ -359,36 +359,17 @@ function planBlocks(entries) {
359
359
  if (isSoloGroupKey(key))
360
360
  solo.push(...group);
361
361
  else
362
- shared.push({ members: group, shared: true });
362
+ shared.push({ members: group });
363
363
  }
364
- return [
365
- ...(root.length ? [{ members: root, shared: false }] : []),
366
- ...shared,
367
- ...(solo.length ? [{ members: solo, shared: false }] : []),
368
- ];
364
+ return [...(root.length ? [{ members: root }] : []), ...shared, ...(solo.length ? [{ members: solo }] : [])];
369
365
  }
370
- /** `buildRootEntry`'s display label. Named here because `printPlanRow` has to recognize the one
371
- * singleton whose group is worth printing, and a bare `'root'` in a condition reads as a guess. */
372
- const ROOT_GROUP = 'root';
373
366
  /** The column names in the order `printPlanRow` writes them - `pushDelimeter` has to be handed the
374
367
  * same set, or the dashes appear under a column that does not exist yet. */
375
368
  const PLAN_COLUMNS = ['Status', 'Package', 'Group', 'From', '', 'To', 'Reason'];
376
- function printPlanRow(table, e, shared) {
369
+ function printPlanRow(table, e) {
377
370
  table.cell('Status', statusLabel(e.status));
378
371
  table.cell('Package', colors.cyan(e.package.name));
379
- /**
380
- * **Blank for a package that shares its version line with nobody.** `resolveGroupKey` gives an
381
- * ungrouped package a group of one named after the package, so the cell printed the Package
382
- * column again, one column to the right - a word that looks like information and carries none.
383
- * Worse, it made `(default)` and `(@panates/tsconfig)` read as the same *kind* of answer when one
384
- * names a line four packages move along and the other names nothing at all.
385
- *
386
- * The root keeps its label: `root` is not its package name but what the row *is*, and it is the
387
- * only thing on the line saying that the number below is the repository's identity rather than a
388
- * release. So the test is `shared`, plus that one entry - not "does this string repeat the name",
389
- * which would blank a genuine group that happened to be called after one of its members.
390
- */
391
- table.cell('Group', shared || e.group === ROOT_GROUP ? colors.gray(`(${e.group})`) : '');
372
+ table.cell('Group', groupCell(e));
392
373
  table.cell('From', e.from);
393
374
  /**
394
375
  * Gated on `to`, not on `status === 'bump'`, which is what kept the column blank on the one
@@ -403,6 +384,38 @@ function printPlanRow(table, e, shared) {
403
384
  table.cell('Reason', e.status === 'error' ? colors.red(e.reason ?? '') : colors.gray(e.reason ?? ''));
404
385
  table.newRow();
405
386
  }
387
+ /**
388
+ * What the Group column says for one row - a **name** where the repository gave the group one, a
389
+ * **description** in grey parentheses where it did not, and nothing where there is no group at all.
390
+ */
391
+ /* **A name and a description are different kinds of thing, and the cell says which.** `(default)`
392
+ * and `(root)` are not names anybody wrote - the default group is the line every package is on until
393
+ * told otherwise, and `root` is what the row *is*: the repository's release identity rather than a
394
+ * release. Both are notes about the row, so they read as notes. A named group (`group: "core"`) is a
395
+ * value the repository chose, decides its changelog file and its tag, and is printed as a value -
396
+ * bare, in the ordinary colour. Asked for directly: printed `(abisena-iomt)` in grey, a real group
397
+ * read like an annotation beside the default one.
398
+ *
399
+ * **Decided by the group key, never by the label.** A group may be *named* `default` or `root` - both
400
+ * pass `assertGroupName` - and testing the label would print it as the description it is spelling.
401
+ *
402
+ * **Blank for a package in no group** (`group: false`): its group of one is named after the package,
403
+ * so the cell would repeat the Package column - a word that looks like information and carries none.
404
+ *
405
+ * **Blank for a single-package repository's one package** too, which is the root and is in the
406
+ * default group: there is nothing there to group, and `(default)` on the only row would be noise. It
407
+ * was blank before this cell distinguished names from descriptions, and stays so. */
408
+ function groupCell(e) {
409
+ if (e.groupKey === ROOT_GROUP_KEY)
410
+ return colors.gray(`(${e.group})`);
411
+ if (e.package.isRoot || isSoloGroupKey(e.groupKey))
412
+ return '';
413
+ if (isNamedGroupKey(e.groupKey))
414
+ return e.group;
415
+ /** The default group - and any key a custom planner's `resolveGroupKey` returns, which is a
416
+ * description of its own making rather than a name the repository wrote. */
417
+ return colors.gray(`(${e.group})`);
418
+ }
406
419
  function statusLabel(status) {
407
420
  switch (status) {
408
421
  case 'bump':
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.10.1';
1
+ export const version = '2.11.0';
@@ -1,6 +1,7 @@
1
1
  import type { VersionPlanService } from '../services/version-plan.service.js';
2
- import { Logger, type LogLevel } from '../utils/logger.js';
3
2
  import type { StatusRegion } from '../utils/status-region.js';
3
+ import type { LogSink } from './classes/log-sink.js';
4
+ import { Logger, type LogLevel } from './classes/logger.js';
4
5
  import { Registry } from './classes/registry.js';
5
6
  import type { Repository } from './classes/repository.js';
6
7
  import type { ServiceFactory, ServiceMap } from './classes/service.js';
@@ -42,6 +43,12 @@ export declare class RmanApplication {
42
43
  * routes its output through `passThrough`.
43
44
  */
44
45
  statusRegion?: StatusRegion;
46
+ /**
47
+ * Where the run's log goes besides the screen - the global `--json` and `--log-file`. Set and
48
+ * closed by `cli.ts` around each command, like `statusRegion`; absent for a caller that did not
49
+ * come through the CLI, which writes no log.
50
+ */
51
+ logSink?: LogSink;
45
52
  /**
46
53
  * The **plugins** themselves, in declaration order - what a config named, before the platforms
47
54
  * were taken out of them.
@@ -7,7 +7,7 @@ import { ImportService } from '../services/import.service.js';
7
7
  import { ListService } from '../services/list.service.js';
8
8
  import { RunService } from '../services/run.service.js';
9
9
  import { VersionService } from '../services/version.service.js';
10
- import { Logger } from '../utils/logger.js';
10
+ import { Logger } from './classes/logger.js';
11
11
  import { Registry } from './classes/registry.js';
12
12
  import { basePlatform } from './interfaces/plugin.js';
13
13
  /**
@@ -50,6 +50,12 @@ export class RmanApplication {
50
50
  * state, and a spec that builds its own application cannot be affected by one that built
51
51
  * another. The root-hook era this repository removed is what that rule is about. */
52
52
  statusRegion;
53
+ /**
54
+ * Where the run's log goes besides the screen - the global `--json` and `--log-file`. Set and
55
+ * closed by `cli.ts` around each command, like `statusRegion`; absent for a caller that did not
56
+ * come through the CLI, which writes no log.
57
+ */
58
+ logSink;
53
59
  /**
54
60
  * The **plugins** themselves, in declaration order - what a config named, before the platforms
55
61
  * were taken out of them.
@@ -0,0 +1,99 @@
1
+ import type { ProgressItem } from '../../utils/progress-panel.js';
2
+ import type { LogLevel } from './logger.js';
3
+ /**
4
+ * One thing that happened during a run, in the order it happened. The JSON form is exactly this
5
+ * object with a `time` added; the text form is one line per event.
6
+ */
7
+ export type LogEvent = {
8
+ event: 'start';
9
+ package: string;
10
+ step: string;
11
+ command?: string;
12
+ } | {
13
+ event: 'output';
14
+ package: string;
15
+ stream: 'stdout' | 'stderr';
16
+ line: string;
17
+ /** Set on a line rman itself wrote to say why a step failed - never inferred from the stream. */
18
+ level?: 'error';
19
+ } | {
20
+ event: 'end';
21
+ package: string;
22
+ step: string;
23
+ status: 'success' | 'failed';
24
+ ms: number;
25
+ error?: string;
26
+ } | {
27
+ event: 'summary';
28
+ succeeded: number;
29
+ failed: number;
30
+ skipped: number;
31
+ ms: number;
32
+ } | {
33
+ event: 'message';
34
+ level: 'info' | 'error';
35
+ message: string;
36
+ };
37
+ /**
38
+ * Something a run's events are rendered by - the screen, the `--json` stream, a log file.
39
+ *
40
+ * The scheduler produces each event once and hands it to every reporter the run has; none of them
41
+ * knows the others exist. Adding a destination is a reporter, never another branch where the event
42
+ * is produced.
43
+ */
44
+ export interface Reporter {
45
+ /**
46
+ * One event. `origin` is given for every event that belongs to a package - which row it is, the
47
+ * package's own log level, and the step's label - and is absent for the run's own (`summary`,
48
+ * `message`).
49
+ */
50
+ report(event: LogEvent, origin?: ReportOrigin): void;
51
+ /**
52
+ * Environment a child should be started with for this reporter to render its lines - `FORCE_COLOR`
53
+ * where a reporter prints them to a terminal, nothing where colour would only be stripped again.
54
+ */
55
+ readonly childEnv?: Record<string, string>;
56
+ }
57
+ /** Which package an event is about, as a screen reporter needs to know it. */
58
+ export interface ReportOrigin {
59
+ /** The package's row - the run's state for it, whether or not a panel is drawing it. */
60
+ item: ProgressItem;
61
+ /** The package's own `run.<script>.logLevel`, which decides what a plain screen prints. */
62
+ logLevel: LogLevel;
63
+ /** The step's label for a one-line-per-step log; empty for an anonymous function. */
64
+ label?: string;
65
+ }
66
+ /**
67
+ * Where a run's log goes besides the screen - the global `--json` and `--log-file`.
68
+ *
69
+ * - **`--json`**: the log is written to stdout as JSON Lines, one event per line, and nothing else
70
+ * is - no panel, no spinner, no prose.
71
+ * - **`--log-file <path>`**: the log is written to that file, in the same format the console uses -
72
+ * JSON Lines under `--json`, plain text lines otherwise.
73
+ *
74
+ * One per invocation, on `RmanApplication.logSink`, beside `statusRegion` and for the same reason:
75
+ * what writes to it is handed the application already, so nothing reaches for ambient state.
76
+ */
77
+ export declare class LogSink implements Reporter {
78
+ /** The console format is JSON Lines - so nothing but events may reach stdout. */
79
+ readonly json: boolean;
80
+ /** Whether anything was written - read by the CLI to warn about a flag nothing honoured. */
81
+ used: boolean;
82
+ private fd?;
83
+ private readonly file?;
84
+ constructor(options: {
85
+ /** Write the log to stdout as JSON Lines, and use that format for the file. */
86
+ json?: boolean;
87
+ /** Where to write the log; resolved against `cwd`. */
88
+ file?: string;
89
+ cwd?: string;
90
+ });
91
+ /** Whether an event would go anywhere. A caller can skip building one when it would not. */
92
+ get active(): boolean;
93
+ /** The resolved file, for the message that reports it. */
94
+ get filePath(): string | undefined;
95
+ report(event: LogEvent): void;
96
+ /** Closes the file, if one was opened. Safe to call twice. */
97
+ close(): void;
98
+ protected append(text: string): void;
99
+ }
@@ -0,0 +1,101 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /**
4
+ * Where a run's log goes besides the screen - the global `--json` and `--log-file`.
5
+ *
6
+ * - **`--json`**: the log is written to stdout as JSON Lines, one event per line, and nothing else
7
+ * is - no panel, no spinner, no prose.
8
+ * - **`--log-file <path>`**: the log is written to that file, in the same format the console uses -
9
+ * JSON Lines under `--json`, plain text lines otherwise.
10
+ *
11
+ * One per invocation, on `RmanApplication.logSink`, beside `statusRegion` and for the same reason:
12
+ * what writes to it is handed the application already, so nothing reaches for ambient state.
13
+ */
14
+ /* **A log, not an answer.** `list`, `version`, `publish`, `config`, `info` and `github-release`
15
+ * declare their own `--json`, and what it prints is the command's *result* - a contract the shared
16
+ * release workflow reads with `jq` (`rman publish --dry-run --json`, `rman list --json`). This never
17
+ * writes to stdout for one of those: the result stays where it was, in the shape it had.
18
+ *
19
+ * **Every line is stripped of escape sequences** before it is written anywhere. A child piped with
20
+ * `FORCE_COLOR` carries colour, and a JSON string or a log file holding `\u001b[32m` is noise to every
21
+ * reader that is not a terminal - which is every reader of either.
22
+ *
23
+ * **The file is opened on the first event, not up front**, so a command that writes no log leaves
24
+ * no empty file behind - and `used` stays false, which is how the CLI can say so. Written
25
+ * synchronously, line by line: an interrupted run keeps every line written before the interrupt,
26
+ * and lines from concurrent packages stay whole and in order. */
27
+ export class LogSink {
28
+ /** The console format is JSON Lines - so nothing but events may reach stdout. */
29
+ json;
30
+ /** Whether anything was written - read by the CLI to warn about a flag nothing honoured. */
31
+ used = false;
32
+ fd;
33
+ file;
34
+ constructor(options) {
35
+ this.json = !!options.json;
36
+ this.file = options.file ? path.resolve(options.cwd ?? process.cwd(), options.file) : undefined;
37
+ }
38
+ /** Whether an event would go anywhere. A caller can skip building one when it would not. */
39
+ get active() {
40
+ return this.json || !!this.file;
41
+ }
42
+ /** The resolved file, for the message that reports it. */
43
+ get filePath() {
44
+ return this.file;
45
+ }
46
+ report(event) {
47
+ if (!this.active)
48
+ return;
49
+ this.used = true;
50
+ const time = new Date().toISOString();
51
+ const clean = stripEvent(event);
52
+ const json = JSON.stringify({ time, ...clean });
53
+ if (this.json)
54
+ process.stdout.write(json + '\n');
55
+ if (this.file)
56
+ this.append((this.json ? json : textLine(time, clean)) + '\n');
57
+ }
58
+ /** Closes the file, if one was opened. Safe to call twice. */
59
+ close() {
60
+ if (this.fd === undefined)
61
+ return;
62
+ fs.closeSync(this.fd);
63
+ this.fd = undefined;
64
+ }
65
+ append(text) {
66
+ if (this.fd === undefined) {
67
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
68
+ this.fd = fs.openSync(this.file, 'w');
69
+ }
70
+ fs.writeSync(this.fd, text);
71
+ }
72
+ }
73
+ /** One event as a line of the text log: the time, the package in brackets, and what happened. */
74
+ function textLine(time, e) {
75
+ switch (e.event) {
76
+ case 'start':
77
+ return `${time} [${e.package}] ▶ ${e.step}${e.command ? ` | ${e.command}` : ''}`;
78
+ case 'output':
79
+ return `${time} [${e.package}] ${e.line}`;
80
+ case 'end':
81
+ return (`${time} [${e.package}] ${e.status === 'success' ? '✔' : '✖'} ${e.step} ${e.status} (${e.ms} ms)` +
82
+ (e.error ? `\n${time} [${e.package}] ${e.error.split('\n').join(`\n${time} [${e.package}] `)}` : ''));
83
+ case 'summary':
84
+ return (`${time} ${e.succeeded} succeeded, ${e.failed} failed` +
85
+ (e.skipped ? `, ${e.skipped} skipped` : '') +
86
+ ` (${e.ms} ms)`);
87
+ case 'message':
88
+ return `${time} ${e.message}`;
89
+ }
90
+ }
91
+ /** Every string field of an event, with escape sequences removed. */
92
+ function stripEvent(e) {
93
+ const out = {};
94
+ for (const [k, v] of Object.entries(e))
95
+ out[k] = typeof v === 'string' ? stripAnsi(v) : v;
96
+ return out;
97
+ }
98
+ function stripAnsi(s) {
99
+ // eslint-disable-next-line no-control-regex
100
+ return s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
101
+ }
@@ -1,4 +1,4 @@
1
- import type { Repository } from '../core/classes/repository.js';
1
+ import type { Repository } from './repository.js';
2
2
  export type LogLevel = 'silent' | 'error' | 'info' | 'verbose';
3
3
  export declare const LOG_LEVELS: LogLevel[];
4
4
  /**
@@ -0,0 +1,64 @@
1
+ import type { ProgressPanel } from '../../utils/progress-panel.js';
2
+ import type { LogEvent, Reporter, ReportOrigin } from './log-sink.js';
3
+ import { type LogLevel } from './logger.js';
4
+ /**
5
+ * The screen with no live panel: each line a step prints, led by its package, and one line per step
6
+ * when it ends - the output `--no-progress`, a pipe or CI gets.
7
+ */
8
+ export declare class PlainReporter implements Reporter {
9
+ /** What the per-step lines call the run - `run`, `build`, `check`. */
10
+ protected readonly commandName: string;
11
+ /** Whose tally the recap prints; absent for a reporter that only says rman's own messages. */
12
+ protected readonly panel?: ProgressPanel | undefined;
13
+ readonly childEnv: Record<string, string> | undefined;
14
+ /** Four letters per level, as winston and log4j-style formats shorten them - `ERROR` keeps its five. */
15
+ protected readonly levelTags: Readonly<Record<Exclude<LogLevel, 'silent'>, string>>;
16
+ constructor(
17
+ /** What the per-step lines call the run - `run`, `build`, `check`. */
18
+ commandName: string,
19
+ /** Whose tally the recap prints; absent for a reporter that only says rman's own messages. */
20
+ panel?: ProgressPanel | undefined);
21
+ report(event: LogEvent, origin?: ReportOrigin): void;
22
+ /** The `executing` line, at `verbose` only - rman's old behaviour, hidden by default. */
23
+ protected started(event: Extract<LogEvent, {
24
+ event: 'start';
25
+ }>, origin: ReportOrigin): void;
26
+ /** One line of a step's output, to the stream it came from, led by its package - in red when rman
27
+ * itself wrote the line to say why the step failed (`level: 'error'`). */
28
+ protected output(event: Extract<LogEvent, {
29
+ event: 'output';
30
+ }>, origin: ReportOrigin): void;
31
+ /** `[INFO] build pkg ┆ exec success ┆ tsc -b (123 ms)` - suppressed below `info` unless it failed,
32
+ * and under `silent` even then. */
33
+ protected ended(event: Extract<LogEvent, {
34
+ event: 'end';
35
+ }>, origin: ReportOrigin): void;
36
+ /**
37
+ * A line's own level as `[INFO]`, `[VERB]`, `[ERROR]` - upper case, four letters but for `ERROR`, in
38
+ * the level's colour.
39
+ */
40
+ protected levelTag(level: Exclude<LogLevel, 'silent'>): string;
41
+ /** Whether `level` prints what `wanted` does. */
42
+ protected atLeast(level: LogLevel, wanted: LogLevel): boolean;
43
+ /** The `┆ <what it runs>` tail of a step line, **or nothing at all** when the step has no label of
44
+ * its own - an anonymous function handed to `forEachPackage`, where the slot name already said
45
+ * everything and a separator with nothing after it is noise. */
46
+ protected describing(origin: ReportOrigin): string[];
47
+ }
48
+ /**
49
+ * The screen while the live panel draws: a step's lines go to its package's row - the last one
50
+ * shown under it, all of them replayed if the package fails - and the recap lists every package.
51
+ */
52
+ export declare class PanelReporter extends PlainReporter {
53
+ /** A child's lines are only stored, and replayed into a row whose width is measured - colour
54
+ * would only be escape codes counted as text there. */
55
+ readonly childEnv: undefined;
56
+ constructor(commandName: string, panel: ProgressPanel);
57
+ /** The row names the step itself; there is no separate line to print. */
58
+ protected started(): void;
59
+ protected output(event: Extract<LogEvent, {
60
+ event: 'output';
61
+ }>, origin: ReportOrigin): void;
62
+ /** The row shows the step's outcome; the recap prints the package's. */
63
+ protected ended(): void;
64
+ }