rman 2.19.1 → 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.js CHANGED
@@ -99,6 +99,12 @@ export async function runCli(options) {
99
99
  ? err.message
100
100
  : '';
101
101
  console.log('\n' + colors.red(text));
102
+ /** The handler's own error is marked too, because it is the one that comes back: yargs
103
+ * rejects `parseAsync` with what the handler threw, not with what this throws, so
104
+ * `runCli`'s catch saw it unmarked and printed the same line again on stderr - every
105
+ * failing command said its reason twice. */
106
+ if (err && typeof err === 'object')
107
+ err.logged = true;
102
108
  /** A real `Error`, marked `logged` since the text above just went out: yargs hands the
103
109
  * reason over as a bare string, and rethrowing that raw made bad argv indistinguishable
104
110
  * from success to anything holding the promise - or the shell. */
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.19.1';
1
+ export const version = '2.21.0';
@@ -24,6 +24,20 @@ export interface ProgressItem {
24
24
  finishedAt?: number;
25
25
  /** Captured output, printed back out if the item ends up failing. */
26
26
  log: string[];
27
+ /**
28
+ * The same output split by the step that printed it, when the driver reports steps (`run`'s
29
+ * scheduler does). A failed item's recap then names the steps that passed in a line each and
30
+ * prints the output of the one that failed; without it, `log` is printed whole.
31
+ */
32
+ steps?: StepLog[];
33
+ }
34
+ /** One step of an item, as the recap shows it - see `ProgressItem.steps`. */
35
+ export interface StepLog {
36
+ /** `before: rman check` - the slot and what ran in it. */
37
+ label: string;
38
+ /** Unset while it runs, and for a step that never got to end. */
39
+ status?: 'success' | 'failed';
40
+ lines: string[];
27
41
  }
28
42
  export interface ProgressSummary {
29
43
  successCount: number;
@@ -81,6 +95,13 @@ export declare class ProgressPanel implements TerminalRegion {
81
95
  * `TerminalRegion` half of the takeover, so a `runBin` child's output goes somewhere sane while
82
96
  * a panel is up. */
83
97
  passThrough(text: string): void;
98
+ /**
99
+ * A failed item's steps: one line for each that passed, and the one that failed - or never got to
100
+ * end - with its whole output under it.
101
+ */
102
+ protected stepRecap(steps: readonly StepLog[]): string;
103
+ /** Every line of a replayed log moved two columns right - an empty line left empty. */
104
+ protected indentLog(text: string): string;
84
105
  private render;
85
106
  /** Stops the redraw loop and erases the panel, leaving the cursor where it started. */
86
107
  stop(): void;
@@ -79,6 +79,35 @@ export class ProgressPanel {
79
79
  process.stdout.write(text);
80
80
  this.render();
81
81
  }
82
+ /**
83
+ * A failed item's steps: one line for each that passed, and the one that failed - or never got to
84
+ * end - with its whole output under it.
85
+ */
86
+ /* **Only the failing step's output, and the user's call.** The whole log used to be replayed, on
87
+ * the reasoning that an earlier step's output can explain a later failure. In a real build that
88
+ * put `rman check`'s file count and its own recap above every lint error, four lines of success
89
+ * the reader had to skip to reach the one that mattered. The passing steps keep a line each, so
90
+ * the order is still there; `--log-file` keeps every line. */
91
+ stepRecap(steps) {
92
+ const out = [];
93
+ for (const step of steps) {
94
+ if (step.status === 'success') {
95
+ out.push(`${colors.green('✔')} ${colors.gray(step.label)}`);
96
+ continue;
97
+ }
98
+ out.push(`${colors.red('✖')} ${step.label}`);
99
+ if (step.lines.length)
100
+ out.push(this.indentLog(step.lines.join('\n')));
101
+ }
102
+ return out.join('\n');
103
+ }
104
+ /** Every line of a replayed log moved two columns right - an empty line left empty. */
105
+ indentLog(text) {
106
+ return text
107
+ .split('\n')
108
+ .map(line => (line ? ` ${line}` : line))
109
+ .join('\n');
110
+ }
82
111
  render() {
83
112
  this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
84
113
  const spinner = colors.cyan(SPINNER_FRAMES[this.spinnerFrame]);
@@ -273,9 +302,14 @@ export class ProgressPanel {
273
302
  * the comma after it red.
274
303
  *
275
304
  * The `X` above already says the item failed, and the step that failed printed its own
276
- * error. */
277
- if (item.log.length)
278
- console.log(item.log.join('\n'));
305
+ * error.
306
+ *
307
+ * **Indented two columns**, so the log reads as belonging to the `X` line above it - level
308
+ * with the recap's own lines it ran together with them, a nested rman's recap most of all. */
309
+ if (item.steps?.length)
310
+ console.log(this.indentLog(this.stepRecap(item.steps)));
311
+ else if (item.log.length)
312
+ console.log(this.indentLog(item.log.join('\n')));
279
313
  }
280
314
  }
281
315
  else if (this.live.enabled) {
@@ -339,6 +373,7 @@ class PanelItem {
339
373
  startedAt;
340
374
  finishedAt;
341
375
  log = [];
376
+ steps = [];
342
377
  _currentStep;
343
378
  _currentCommand;
344
379
  constructor(name, stepsTotal) {
@@ -54,11 +54,16 @@ export declare class PanelReporter extends PlainReporter {
54
54
  * would only be escape codes counted as text there. */
55
55
  readonly childEnv: undefined;
56
56
  constructor(commandName: string, panel: ProgressPanel);
57
- /** The row names the step itself; there is no separate line to print. */
58
- protected started(): void;
57
+ /** The row names the step itself; there is no separate line to print - but the recap needs to
58
+ * know which lines were whose, so a step is opened here. */
59
+ protected started(event: Extract<LogEvent, {
60
+ event: 'start';
61
+ }>, origin: ReportOrigin): void;
59
62
  protected output(event: Extract<LogEvent, {
60
63
  event: 'output';
61
64
  }>, origin: ReportOrigin): void;
62
- /** The row shows the step's outcome; the recap prints the package's. */
63
- protected ended(): void;
65
+ /** The row shows the step's outcome; the recap prints the package's - and reads it from here. */
66
+ protected ended(event: Extract<LogEvent, {
67
+ event: 'end';
68
+ }>, origin: ReportOrigin): void;
64
69
  }
@@ -124,12 +124,20 @@ export class PanelReporter extends PlainReporter {
124
124
  constructor(commandName, panel) {
125
125
  super(commandName, panel);
126
126
  }
127
- /** The row names the step itself; there is no separate line to print. */
128
- started() { }
127
+ /** The row names the step itself; there is no separate line to print - but the recap needs to
128
+ * know which lines were whose, so a step is opened here. */
129
+ started(event, origin) {
130
+ origin.item.steps?.push({ label: event.command ? `${event.step}: ${event.command}` : event.step, lines: [] });
131
+ }
129
132
  output(event, origin) {
130
133
  origin.item.log.push(event.line);
134
+ origin.item.steps?.at(-1)?.lines.push(event.line);
131
135
  origin.item.lastLine = event.line;
132
136
  }
133
- /** The row shows the step's outcome; the recap prints the package's. */
134
- ended() { }
137
+ /** The row shows the step's outcome; the recap prints the package's - and reads it from here. */
138
+ ended(event, origin) {
139
+ const step = origin.item.steps?.at(-1);
140
+ if (step)
141
+ step.status = event.status;
142
+ }
135
143
  }
@@ -89,6 +89,15 @@ export const STEP_PATHS = [
89
89
  'version.before.command',
90
90
  'version.exec.command',
91
91
  'version.after.command',
92
+ /** A step object's own `if`, for the reason `run.*.if` is here: called while the config loads it
93
+ * would collapse to a boolean and stop being a condition. `run.*.if` already covers the
94
+ * shorthand's (`run: { build: [{ if, command }] }`), which arrives at the same path. */
95
+ 'run.*.before.if',
96
+ 'run.*.exec.if',
97
+ 'run.*.after.if',
98
+ 'version.before.if',
99
+ 'version.exec.if',
100
+ 'version.after.if',
92
101
  ];
93
102
  /**
94
103
  * Config paths left untouched when a repository's config is first resolved, and evaluated only by
@@ -47,6 +47,16 @@ export interface RunStepContext {
47
47
  * `ctx.runBin` is already bound to this directory, so a binary run through it needs no such care.
48
48
  */
49
49
  cwd: string;
50
+ /**
51
+ * The package the run was narrowed to because rman was started inside it, or `undefined` when it
52
+ * covers the repository - started at the root, or with `--from-root`.
53
+ *
54
+ * What a step asks to tell "the whole repository is building" from "just this package is":
55
+ * `repository.currentPackage` answers where rman was started and cannot see `--from-root`. Set by
56
+ * `run` and the commands built on it (`build`, `test`); `undefined` in a `version` hook, which
57
+ * always covers the repository.
58
+ */
59
+ scopedTo?: Package;
50
60
  /**
51
61
  * The repository's locally installed binaries, already carrying this run's `cwd` and log level -
52
62
  * handed over rather than imported, for the reason `CommandContext.runBin` is.
@@ -107,6 +117,17 @@ export interface RunStepObject {
107
117
  * with no package graph to join, and the key is refused there rather than quietly doing nothing.
108
118
  */
109
119
  topo?: boolean;
120
+ /**
121
+ * Whether this one step runs, asked when its turn comes - the same two forms
122
+ * `run.<script>.if` takes: a condition (`changed`, `dirty and not committed`) or a function
123
+ * handed the step's context. A step that says no is passed over, and the ones after it still
124
+ * run.
125
+ *
126
+ * @example
127
+ * // lint the package only when the build was started inside it
128
+ * { if: ({ scopedTo }) => !!scopedTo, command: 'rman lint' }
129
+ */
130
+ if?: string | RunConditionFn;
110
131
  }
111
132
  /** One entry of a `before`/`exec`/`after` slot: a shell command, a function, or a
112
133
  * [`RunStepObject`](#RunStepObject) saying something about the step. A list of them runs in
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "2.19.1",
4
+ "version": "2.21.0",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -4,7 +4,7 @@ import type { Package } from '../core/classes/package.js';
4
4
  import { ProgressPanel } from '../core/classes/progress-panel.js';
5
5
  import type { Repository } from '../core/classes/repository.js';
6
6
  import { Service } from '../core/classes/service.js';
7
- import type { RunStepContext, RunStepFn, RunStepValue } from '../core/interfaces/run-step.js';
7
+ import type { RunConditionFn, RunStepContext, RunStepFn, RunStepValue } from '../core/interfaces/run-step.js';
8
8
  import { type PackageFilterOptions } from '../utils/package-filter.js';
9
9
  /**
10
10
  * A service class - see `ListService` for the shape and `Service` for the three measured
@@ -160,6 +160,8 @@ export declare namespace RunService {
160
160
  steps: ScriptStep[];
161
161
  /** The directory the steps run in - the package's own, or the repository root for a bookend. */
162
162
  cwd: string;
163
+ /** The package the run was narrowed to by the directory it started in - `RunStepContext.scopedTo`. */
164
+ scopedTo?: Package;
163
165
  topo: boolean;
164
166
  bail: boolean;
165
167
  logLevel: LogLevel;
@@ -210,6 +212,8 @@ export declare namespace RunService {
210
212
  * and the three are three different answers, which is why this is not a plain boolean. See
211
213
  * `RunStepObject.topo`. */
212
214
  topo?: boolean;
215
+ /** The step's own condition, from its object form - see `RunStepObject.if`. */
216
+ if?: string | RunConditionFn;
213
217
  }
214
218
  export interface CommandStep extends StepBase {
215
219
  command: string;
@@ -306,7 +310,7 @@ export declare namespace RunService {
306
310
  * `onCommand` is told what the step is spawning, for as long as it runs - the argv while a child
307
311
  * is up, `undefined` when it returns.
308
312
  */
309
- export function createStepContext(pkg: Package, cwd: string, onCommand?: (command: string | undefined) => void, onLine?: (line: string, stream?: 'stdout' | 'stderr') => void): RunStepContext;
313
+ export function createStepContext(pkg: Package, cwd: string, onCommand?: (command: string | undefined) => void, onLine?: (line: string, stream?: 'stdout' | 'stderr') => void, scopedTo?: Package): RunStepContext;
310
314
  /**
311
315
  * `.rmanrc` conditional execution, GitHub Actions-`if`-flavored but a small closed grammar
312
316
  * instead of a full expression language (less to get wrong, still covers what's asked for) -
@@ -60,7 +60,7 @@ export class RunService extends Service {
60
60
  const rootSkipped = !!cwdScope ||
61
61
  !repository.monorepo ||
62
62
  rootCfg.skip === true ||
63
- !(await passesIf(repository, repository.rootPackage, rootCfg.if, repository.dirname, ifStatusCache));
63
+ !(await passesIf(repository, repository.rootPackage, rootCfg.if, repository.dirname, ifStatusCache, cwdScope));
64
64
  const rootSteps = rootSkipped ? [] : getScriptSteps(repository.rootPackage, script);
65
65
  /** Filtered on the slot, not on `'pre' + script`: the step labels are `before`/`exec`/`after`
66
66
  * now - the same words the config uses - rather than npm's `pre<script>` naming, which moved
@@ -75,6 +75,7 @@ export class RunService extends Service {
75
75
  label: 'root',
76
76
  steps,
77
77
  cwd: repository.dirname,
78
+ scopedTo: cwdScope,
78
79
  topo: false,
79
80
  bail: resolveBail(options.bail, repository.rootPackage, script, true),
80
81
  logLevel: resolveLogLevel(options.logLevel, repository.rootPackage, script, logLevelDefault),
@@ -87,7 +88,7 @@ export class RunService extends Service {
87
88
  const pkgCfg = RunService.getConfig(pkg, script);
88
89
  if (pkgCfg.skip === true)
89
90
  continue;
90
- if (!(await passesIf(repository, pkg, pkgCfg.if, pkg.dirname, ifStatusCache)))
91
+ if (!(await passesIf(repository, pkg, pkgCfg.if, pkg.dirname, ifStatusCache, cwdScope)))
91
92
  continue;
92
93
  const steps = getScriptSteps(pkg, script);
93
94
  if (!steps.length)
@@ -98,6 +99,7 @@ export class RunService extends Service {
98
99
  label: pkg.name,
99
100
  steps,
100
101
  cwd: pkg.dirname,
102
+ scopedTo: cwdScope,
101
103
  topo: resolveBool(options.topo, pkg, script, 'topo', topo),
102
104
  bail: resolveBail(options.bail, pkg, script, true),
103
105
  logLevel: resolveLogLevel(options.logLevel, pkg, script, logLevelDefault),
@@ -162,6 +164,8 @@ export class RunService extends Service {
162
164
  * abort using *its own* resolved bail setting (see `runSteps` below) - power-tasks' own
163
165
  * `bail` is a single blanket policy for the whole batch, it can't vary per package. */
164
166
  let rootTask;
167
+ /** One `git` query per reference for every step's `if: changed`, as the scripts' share one. */
168
+ const stepIfCache = new Map();
165
169
  const runSteps = async (ctx, plan, steps,
166
170
  /**
167
171
  * Which slice of the package's steps this call runs, when a `topo` step split it in two.
@@ -170,7 +174,7 @@ export class RunService extends Service {
170
174
  * one must not mark the package finished.
171
175
  */
172
176
  part) => {
173
- const { pkg, cwd, bail: pkgBail, logLevel: pkgLogLevel, label: pkgLabel } = plan;
177
+ const { pkg, cwd, bail: pkgBail, logLevel: pkgLogLevel, label: pkgLabel, scopedTo } = plan;
174
178
  const from = part?.from ?? 0;
175
179
  /** Only the first part starts the clock: the row's elapsed time is the package's, and a
176
180
  * package waiting at a `topo` barrier is waiting as part of its own run. */
@@ -181,6 +185,12 @@ export class RunService extends Service {
181
185
  try {
182
186
  for (let i = from; i < steps.length; i++) {
183
187
  const step = steps[i];
188
+ /** Asked when the step's turn comes, not when the plan is made - a condition may read
189
+ * what the steps before it left behind. A step that says no is passed over in silence:
190
+ * it is not a failure, and announcing it on every package would bury the steps that ran. */
191
+ if (step.if !== undefined && !(await passesIf(pkg.repository, pkg, step.if, cwd, stepIfCache, scopedTo))) {
192
+ continue;
193
+ }
184
194
  ctx.currentStep = step.name;
185
195
  /** The command for a shell step, the function's own name for a JS one - and a bare
186
196
  * marker for an anonymous function, which is still worth saying: the slot label alone
@@ -217,7 +227,7 @@ export class RunService extends Service {
217
227
  * run step should.
218
228
  */
219
229
  if (step.run)
220
- await runFunctionStep(step.run, pkg, cwd, onLine, onCommand);
230
+ await runFunctionStep(step.run, pkg, cwd, onLine, onCommand, scopedTo);
221
231
  else {
222
232
  await exec(step.command, {
223
233
  cwd,
@@ -583,6 +593,8 @@ export class RunService extends Service {
583
593
  * (`{ command }` / `{ run }`) reaches here too and only one place knows the three spellings.
584
594
  * `topo` cannot be set on this path - `normalizeScriptValue` refuses it for a version hook. */
585
595
  const step = toStep(slot, value);
596
+ if (step.if !== undefined && !(await passesIf(pkg.repository, pkg, step.if, pkg.dirname, new Map())))
597
+ continue;
586
598
  if (step.run) {
587
599
  await step.run(createStepContext(pkg, pkg.dirname));
588
600
  continue;
@@ -720,12 +732,13 @@ export class RunService extends Service {
720
732
  * whole of a build, because that is all the slot knows; what a reader wants is the `tsc -b
721
733
  * <tsconfig>` it is sitting in. A function step is the shape every shared config uses, so this is
722
734
  * the common case rather than an edge one. */
723
- function createStepContext(pkg, cwd, onCommand, onLine) {
735
+ function createStepContext(pkg, cwd, onCommand, onLine, scopedTo) {
724
736
  const logLevel = resolveRootLogLevel(pkg.repository);
725
737
  return {
726
738
  pkg,
727
739
  repository: pkg.repository,
728
740
  cwd,
741
+ scopedTo,
729
742
  runBin: async (bin, argv, opts) => {
730
743
  onCommand?.([bin, ...argv].join(' '));
731
744
  const result = await runBin(bin, argv, { cwd, logLevel, app: pkg.repository.app, onLine, ...opts });
@@ -830,10 +843,12 @@ function describeValue(value) {
830
843
  * the slot's own word for one passed inline, which has no name at all. */
831
844
  function toStep(slot, value) {
832
845
  const topo = typeof value === 'object' ? value.topo : undefined;
846
+ const condition = typeof value === 'object' ? value.if : undefined;
833
847
  const step = typeof value === 'object' ? value.command : value;
834
- if (typeof step === 'function')
835
- return { name: slot, label: step.name || `${slot} (js)`, run: step, topo };
836
- return { name: slot, label: step, command: step, topo };
848
+ if (typeof step === 'function') {
849
+ return { name: slot, label: step.name || `${slot} (js)`, run: step, topo, if: condition };
850
+ }
851
+ return { name: slot, label: step, command: step, topo, if: condition };
837
852
  }
838
853
  /**
839
854
  * Checks a step written as an object and hands it back - a `command` that is a shell command or a
@@ -873,10 +888,20 @@ function assertStepObject(item, where, allowTopo) {
873
888
  throw new Error(`"${where}" must set "topo" to true or false, but it is ${describeValue(item.topo)}.`);
874
889
  }
875
890
  }
876
- return { command: item.command, topo: item.topo };
891
+ /** The same two forms `run.<script>.if` takes. An empty string is refused rather than read as
892
+ * "no condition": a key written and left blank is more likely a mistake than a decision. */
893
+ if (item.if !== undefined && typeof item.if !== 'function' && (typeof item.if !== 'string' || !item.if.trim())) {
894
+ throw new Error(`"${where}" must set "if" to a condition (\`changed\`, \`dirty and not committed\`) or a function, ` +
895
+ `but it is ${describeValue(item.if)}.`);
896
+ }
897
+ return {
898
+ command: item.command,
899
+ topo: item.topo,
900
+ ...(item.if !== undefined && { if: item.if }),
901
+ };
877
902
  }
878
- /** What a step object may hold - the step itself, and the marker beside it. */
879
- const STEP_OBJECT_KEYS = ['command', 'topo'];
903
+ /** What a step object may hold - the step itself, and the markers beside it. */
904
+ const STEP_OBJECT_KEYS = ['command', 'topo', 'if'];
880
905
  /**
881
906
  * Runs a function step, with its `console` captured into `onLine` - the step's own lines, routed to
882
907
  * whichever reporters the run has, beside those of the children it spawns.
@@ -894,8 +919,8 @@ const STEP_OBJECT_KEYS = ['command', 'topo'];
894
919
  * nowhere at all. Written to `onLine` on stderr, ahead of the step's `end` - the order a shell step
895
920
  * already produces.
896
921
  */
897
- async function runFunctionStep(run, pkg, cwd, onLine, onCommand) {
898
- const context = RunService.createStepContext(pkg, cwd, onCommand, onLine);
922
+ async function runFunctionStep(run, pkg, cwd, onLine, onCommand, scopedTo) {
923
+ const context = RunService.createStepContext(pkg, cwd, onCommand, onLine, scopedTo);
899
924
  try {
900
925
  await RunService.withCapturedConsole(onLine, () => run(context));
901
926
  }
@@ -920,15 +945,16 @@ function format(args) {
920
945
  return args.map(arg => (typeof arg === 'string' ? arg : inspect(arg))).join(' ');
921
946
  }
922
947
  /**
923
- * Whether a script runs for `pkg` at all - `run.<script>.if`, in either of its two forms.
948
+ * Whether a script runs for `pkg` at all - `run.<script>.if` - or a single step does (a step
949
+ * object's own `if`), in either of their two forms.
924
950
  *
925
951
  * The function form is checked **first**: `parseIfExpr` answers `undefined` for anything that is
926
952
  * not a string, which the caller reads as "no condition given", so a function reaching it would be
927
953
  * a condition that silently always passed.
928
954
  */
929
- async function passesIf(repository, pkg, raw, cwd, statusCache) {
955
+ async function passesIf(repository, pkg, raw, cwd, statusCache, scopedTo) {
930
956
  if (typeof raw === 'function') {
931
- return !!(await raw(RunService.createStepContext(pkg, cwd)));
957
+ return !!(await raw(RunService.createStepContext(pkg, cwd, undefined, undefined, scopedTo)));
932
958
  }
933
959
  const node = RunService.parseIfExpr(raw);
934
960
  return node ? RunService.evaluateIf(repository, pkg, node, statusCache) : true;