rman 2.10.1 → 2.11.1

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 (37) hide show
  1. package/builtins/platforms/node/services/ci.service.d.ts +1 -1
  2. package/builtins/platforms/node/services/ci.service.js +1 -1
  3. package/builtins/platforms/node/services/clean.service.d.ts +1 -1
  4. package/builtins/platforms/node/services/clean.service.js +1 -1
  5. package/builtins/platforms/node/services/publish.service.d.ts +10 -5
  6. package/builtins/platforms/node/services/publish.service.js +46 -11
  7. package/builtins/publish-targets/npm/npm-publish-target.js +3 -1
  8. package/cli.js +81 -23
  9. package/commands/changelog.command.js +1 -1
  10. package/commands/version.command.js +42 -29
  11. package/constants.js +1 -1
  12. package/core/application.d.ts +8 -1
  13. package/core/application.js +7 -1
  14. package/core/classes/log-sink.d.ts +99 -0
  15. package/core/classes/log-sink.js +101 -0
  16. package/{utils → core/classes}/logger.d.ts +1 -1
  17. package/core/classes/run-reporters.d.ts +64 -0
  18. package/core/classes/run-reporters.js +135 -0
  19. package/core/classes/service.d.ts +1 -1
  20. package/core/command-builder.js +3 -0
  21. package/core/interfaces/custom-command.d.ts +5 -1
  22. package/core/interfaces/run-step.d.ts +5 -5
  23. package/index.d.ts +2 -2
  24. package/index.js +1 -1
  25. package/package.json +1 -1
  26. package/services/exec.service.d.ts +1 -1
  27. package/services/exec.service.js +1 -1
  28. package/services/run.service.d.ts +22 -3
  29. package/services/run.service.js +190 -125
  30. package/services/version-plan.service.js +3 -3
  31. package/utils/progress-panel.d.ts +6 -0
  32. package/utils/progress-panel.js +22 -7
  33. package/utils/run-bin.d.ts +10 -2
  34. package/utils/run-bin.js +57 -11
  35. package/utils/version-group.d.ts +9 -0
  36. package/utils/version-group.js +11 -0
  37. /package/{utils → core/classes}/logger.js +0 -0
@@ -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;
@@ -43,6 +43,11 @@ export declare namespace PublishService {
43
43
  * goes green" is the most important sentence in the plan when it applies.
44
44
  */
45
45
  staged?: boolean;
46
+ /** Subdirectory to publish from, relative to the package's own directory - only consulted when
47
+ * the package doesn't already declare its own `package.json` `publishConfig.directory` (npm's
48
+ * native mechanism for this, which always wins when present). A plan option, because the
49
+ * manifest in that directory is what the plan decides from. */
50
+ contents?: string;
46
51
  }
47
52
  interface ApplyOptions extends Options {
48
53
  packageManager?: CiService.PackageManager;
@@ -50,10 +55,6 @@ export declare namespace PublishService {
50
55
  access?: 'public' | 'restricted';
51
56
  /** `npm publish --otp <otp>` - a 2FA one-time password, for registries that require it. */
52
57
  otp?: string;
53
- /** Subdirectory to publish from, relative to the package's own directory - only consulted when
54
- * the package doesn't already declare its own `package.json` `publishConfig.directory` (npm's
55
- * native mechanism for this, which always wins when present). */
56
- contents?: string;
57
58
  }
58
59
  /** One package's outcome in a publish plan - see `getPlan`. */
59
60
  interface Entry {
@@ -101,7 +102,11 @@ export declare namespace PublishService {
101
102
  * queries it to decide, so it's safe to call any time, including as the plan a bare `rman
102
103
  * publish` shows before asking for confirmation.
103
104
  *
104
- * A `private: true` package is always `'skip'`ped outright. Each remaining package's **dist-tag**
105
+ * **Whether a package is private is read from the manifest that would be published** - the one in
106
+ * its build directory when it publishes from one, its own otherwise. A build directory with no
107
+ * `package.json` is an `'error'`: nothing has been built to publish. A package `private` in its
108
+ * source with no `publishConfig` is skipped before that is asked, since it stays private in any
109
+ * manifest derived from it. Each remaining package's **dist-tag**
105
110
  * is settled next (`distTagFor`): usually derived from the version itself, `'error'` in the two
106
111
  * cases with nothing to derive - that one is about the invocation rather than the package, so it
107
112
  * is answered before anything touches the network. A package with uncommitted local changes is
@@ -72,21 +72,19 @@ function preparePublishManifest(pkg, publishDir, packagesByName) {
72
72
  * builds a native module on install. The rest (`build`, `test`, `prepare`, ...) never reach the
73
73
  * consumer - `prepare` runs for a git dependency, which builds from the repository, not from this
74
74
  * tarball.
75
- * - `private` - it is a statement about the **source tree**, not about the artifact derived from it,
76
- * and a manifest being published is by definition not private. rman's own gate normally stops a
77
- * private package before this runs, so the line looks unreachable; it is not, because `private`
78
- * is also how a repository keeps a stray `npm publish` out of the package directory while
79
- * publishing `build/` on purpose. That is a real configuration - this repository ran it until
80
- * `rman publish` became its own release path and the gate started refusing the thing it ships.
81
- * A narrower guard (`prepublishOnly`, which never reaches a consumer either) says the same thing
82
- * without claiming the artifact is unpublishable.
75
+ * - `private` - **only when the package declares a `publishConfig`**. A package configured to be
76
+ * published that is also `private` is guarding its source tree against a stray `npm publish`,
77
+ * not refusing to ship; one with no `publishConfig` means it, and keeps the flag. `getPlan`
78
+ * decides from the manifest this produces, so the rule here *is* the publish rule - a preset
79
+ * writing the build directory's manifest must apply the same one.
83
80
  * - `publishConfig.directory` - it pointed *here*; kept, it would point one level deeper again.
84
81
  */
85
82
  function derivePublishManifest(pkg, packagesByName) {
86
83
  const json = structuredClone(pkg.manifest.raw);
87
84
  resolveWorkspaceRanges(json, packagesByName);
88
85
  delete json.devDependencies;
89
- delete json.private;
86
+ if (json.publishConfig)
87
+ delete json.private;
90
88
  if (json.scripts) {
91
89
  const kept = Object.fromEntries(Object.entries(json.scripts).filter(([name]) => CONSUMER_SCRIPTS.has(name)));
92
90
  if (Object.keys(kept).length)
@@ -129,7 +127,11 @@ export var PublishService;
129
127
  * queries it to decide, so it's safe to call any time, including as the plan a bare `rman
130
128
  * publish` shows before asking for confirmation.
131
129
  *
132
- * A `private: true` package is always `'skip'`ped outright. Each remaining package's **dist-tag**
130
+ * **Whether a package is private is read from the manifest that would be published** - the one in
131
+ * its build directory when it publishes from one, its own otherwise. A build directory with no
132
+ * `package.json` is an `'error'`: nothing has been built to publish. A package `private` in its
133
+ * source with no `publishConfig` is skipped before that is asked, since it stays private in any
134
+ * manifest derived from it. Each remaining package's **dist-tag**
133
135
  * is settled next (`distTagFor`): usually derived from the version itself, `'error'` in the two
134
136
  * cases with nothing to derive - that one is about the invocation rather than the package, so it
135
137
  * is answered before anything touches the network. A package with uncommitted local changes is
@@ -187,7 +189,19 @@ export var PublishService;
187
189
  reason: 'excluded via .rmanrc "publish.skip"',
188
190
  });
189
191
  }
190
- else if (pkg.isPrivate) {
192
+ else if (privateBySource(pkg)) {
193
+ entries.set(pkg.name, { package: pkg, version: pkg.version, status: 'skip', reason: 'private package' });
194
+ }
195
+ else if (!fs.existsSync(path.join(resolvePublishDir(pkg, options.contents), 'package.json'))) {
196
+ const dir = path.relative(pkg.dirname, resolvePublishDir(pkg, options.contents)) || '.';
197
+ entries.set(pkg.name, {
198
+ package: pkg,
199
+ version: pkg.version,
200
+ status: 'error',
201
+ reason: `nothing to publish in "${dir}" - it has no package.json; build the package first`,
202
+ });
203
+ }
204
+ else if (publishedManifestOf(pkg, options.contents).private) {
191
205
  entries.set(pkg.name, { package: pkg, version: pkg.version, status: 'skip', reason: 'private package' });
192
206
  }
193
207
  else if (distTag.error) {
@@ -310,6 +324,27 @@ function resolvePublishDir(pkg, contentsOverride) {
310
324
  const rel = (typeof native === 'string' && native) || configured || contentsOverride;
311
325
  return rel ? path.resolve(pkg.dirname, rel) : pkg.dirname;
312
326
  }
327
+ /** The manifest `npm publish` will read for `pkg` - its build directory's, or its own when it
328
+ * publishes in place. */
329
+ /* **Decided from the artifact, not the source**, because the two disagree about `private` and only
330
+ * one of them is published. Read before any build, the plan answered `skip - private package` for a
331
+ * single-package repository whose source carries `private: true` as a guard against a stray
332
+ * `npm publish` - while `derivePublishManifest` was removing that very flag from what it ships.
333
+ * Reported from `postgrejs-kysely`: version, tag and GitHub release all went through, and the
334
+ * registry got nothing. */
335
+ function publishedManifestOf(pkg, contentsOverride) {
336
+ const dir = resolvePublishDir(pkg, contentsOverride);
337
+ if (path.resolve(dir) === path.resolve(pkg.dirname))
338
+ return pkg.manifest.raw;
339
+ return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
340
+ }
341
+ /** `private` in the source with no `publishConfig` - private in every manifest derived from it too,
342
+ * so it is known without a build. */
343
+ /* Asked first so an unbuilt private package is skipped rather than reported as unbuilt: opra's
344
+ * examples inherit `publish.npm.directory` from the shared preset and skip their build. */
345
+ function privateBySource(pkg) {
346
+ return !!pkg.manifest.raw.private && !pkg.manifest.raw.publishConfig;
347
+ }
313
348
  /**
314
349
  * Which registry a package's own version is asked about, and published to.
315
350
  *
@@ -84,7 +84,6 @@ export class NpmPublishTarget {
84
84
  packageManager: ctx.args.packageManager,
85
85
  access: ctx.args.access,
86
86
  otp: ctx.args.otp,
87
- contents: ctx.args.contents,
88
87
  });
89
88
  }
90
89
  /** The shared filters plus the two flags both halves need - read here rather than in the
@@ -103,6 +102,9 @@ export class NpmPublishTarget {
103
102
  * flag was not given, so each package's own `publish.npm.staged` still decides - `false`
104
103
  * here would mean `--no-staged` and silently overrule the config. */
105
104
  staged: ctx.args.staged,
105
+ /** The plan decides from the manifest in the directory being published, so it has to know
106
+ * which directory that is. */
107
+ contents: ctx.args.contents,
106
108
  };
107
109
  }
108
110
  }
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.1';
@@ -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
+ }