rman 2.18.2 → 2.19.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.
@@ -68,11 +68,17 @@ export declare class NodeDependencyUpdater implements DependencyUpdater {
68
68
  protected resolve(decisions: readonly NodeDependencyUpdater.Decision[], ctx: DependencyUpdater.Context): void;
69
69
  /** One broken rule, mended by one step down. Whether anything moved. */
70
70
  protected settle(about: NodeDependencyUpdater.Decision, stating: NodeDependencyUpdater.Decision | undefined, range: string, rule: string): boolean;
71
+ /** `rule`, plus why `about` cannot meet it when the version that would is one its own settings
72
+ * leave out - `0.14.0: major - deps.target is "minor"`. */
73
+ protected explain(rule: string, about: NodeDependencyUpdater.Decision, range: string): string;
71
74
  /** Moves `d` down to `index` (`-1` keeps what is declared), remembering why it left its newest. */
72
75
  protected stepTo(d: NodeDependencyUpdater.Decision, index: number, rule: string): void;
73
76
  /** The version `d` resolves to: the one it moves to, or else the newest release its declarations
74
77
  * already allow, which is what an install picks. */
75
78
  protected effective(d: NodeDependencyUpdater.Decision): string | undefined;
79
+ /** The newest release `d`'s declarations already allow - what an install picks if `d` does not
80
+ * move. */
81
+ protected resolvedDeclared(d: NodeDependencyUpdater.Decision): string | undefined;
76
82
  /** What `d` declares in `field` once it has moved. */
77
83
  protected rangeFor(d: NodeDependencyUpdater.Decision, field: string): string;
78
84
  /** The in-repo packages `pkg` declares a dependency on. */
@@ -357,15 +357,36 @@ export class NodeDependencyUpdater {
357
357
  return false;
358
358
  if (about.index >= 0) {
359
359
  const fits = about.candidates.findIndex((v, i) => i > about.index && semver.satisfies(v, range));
360
- this.stepTo(about, fits, rule);
361
- return true;
360
+ if (fits >= 0) {
361
+ this.stepTo(about, fits, rule);
362
+ return true;
363
+ }
364
+ const declared = this.resolvedDeclared(about);
365
+ if (declared && semver.satisfies(declared, range, { includePrerelease: true })) {
366
+ this.stepTo(about, -1, rule);
367
+ return true;
368
+ }
369
+ /* Nothing `about` may move to keeps the rule - so stepping it down gains nothing, and since a
370
+ * decision only ever steps down it would never come back up once the side stating the rule
371
+ * had given way. Measured on `abisena/syncbridge-iomt`: `@syncbridge/builtins@0.14.13` needs
372
+ * `@syncbridge/common ^0.14.0`, which `deps.target: minor` leaves out (a 0.x minor is a
373
+ * major), and `common`'s own `0.13.9` was dropped for it - then `builtins` stepped back too,
374
+ * and both read "not updated" with a reason pointing at a move that never happened. */
362
375
  }
363
376
  if (stating && stating.index >= 0) {
364
- this.stepTo(stating, stating.index + 1 < stating.candidates.length ? stating.index + 1 : -1, rule);
377
+ this.stepTo(stating, stating.index + 1 < stating.candidates.length ? stating.index + 1 : -1, this.explain(rule, about, range));
365
378
  return true;
366
379
  }
367
380
  return false;
368
381
  }
382
+ /** `rule`, plus why `about` cannot meet it when the version that would is one its own settings
383
+ * leave out - `0.14.0: major - deps.target is "minor"`. */
384
+ explain(rule, about, range) {
385
+ const newest = about.available;
386
+ if (!newest || !about.skipReason || !semver.satisfies(newest, range, { includePrerelease: true }))
387
+ return rule;
388
+ return `${rule} (${about.name}@${newest}: ${about.skipReason})`;
389
+ }
369
390
  /** Moves `d` down to `index` (`-1` keeps what is declared), remembering why it left its newest. */
370
391
  stepTo(d, index, rule) {
371
392
  if (d.index === 0 && !d.reason)
@@ -375,8 +396,11 @@ export class NodeDependencyUpdater {
375
396
  /** The version `d` resolves to: the one it moves to, or else the newest release its declarations
376
397
  * already allow, which is what an install picks. */
377
398
  effective(d) {
378
- if (d.index >= 0)
379
- return d.candidates[d.index];
399
+ return d.index >= 0 ? d.candidates[d.index] : this.resolvedDeclared(d);
400
+ }
401
+ /** The newest release `d`'s declarations already allow - what an install picks if `d` does not
402
+ * move. */
403
+ resolvedDeclared(d) {
380
404
  if (!d.releases?.size)
381
405
  return undefined;
382
406
  const ranges = [...d.fields.values()].filter(r => semver.validRange(r));
@@ -138,6 +138,24 @@ export declare namespace PublishService {
138
138
  * place of `npm`. */
139
139
  function registryLabel(pkg: Package): string | undefined;
140
140
  function getPlan(repository: Repository, options?: Options, deps?: Deps): Promise<Entry[]>;
141
+ interface WaitOptions {
142
+ /** As in `Options` - the registry each package is asked on is resolved the same way. */
143
+ registry?: string;
144
+ userconfig?: string;
145
+ /** How long to keep asking, in milliseconds. Default five minutes. */
146
+ timeoutMs?: number;
147
+ /** How long between rounds, in milliseconds. Default five seconds. */
148
+ intervalMs?: number;
149
+ }
150
+ /**
151
+ * Asks each package's registry, every `intervalMs`, whether it serves the version just published,
152
+ * until all of them do or `timeoutMs` passes. Returns the entries still missing - none when every
153
+ * version showed up.
154
+ *
155
+ * A staged entry is not waited for: it stays in npm's queue until a maintainer approves it, which
156
+ * no wait in a release job can outlast.
157
+ */
158
+ function waitUntilAvailable(entries: Entry[], options?: WaitOptions, deps?: Deps): Promise<Entry[]>;
141
159
  /**
142
160
  * Publishes every `'publish'` entry in `plan`, topological order (already `plan`'s own order -
143
161
  * see `getPlan`), via the configured `packageManager`'s own `publish` command. Sequential, not
@@ -290,6 +290,40 @@ export var PublishService;
290
290
  return packages.map(pkg => entries.get(pkg.name));
291
291
  }
292
292
  PublishService.getPlan = getPlan;
293
+ /**
294
+ * Asks each package's registry, every `intervalMs`, whether it serves the version just published,
295
+ * until all of them do or `timeoutMs` passes. Returns the entries still missing - none when every
296
+ * version showed up.
297
+ *
298
+ * A staged entry is not waited for: it stays in npm's queue until a maintainer approves it, which
299
+ * no wait in a release job can outlast.
300
+ */
301
+ /* `--prefer-online`, or npm answers from the copy of the package document it cached before the
302
+ * publish. One round asks about every pending package at once. */
303
+ async function waitUntilAvailable(entries, options = {}, deps = {}) {
304
+ const pending = new Set(entries.filter(e => e.status === 'publish' && !e.staged));
305
+ const deadline = Date.now() + (options.timeoutMs ?? 300_000);
306
+ const interval = options.intervalMs ?? 5_000;
307
+ const view = (pkg) => deps.npmViewPackage
308
+ ? deps.npmViewPackage(pkg.name, pkg.dirname)
309
+ : npmViewPackage(pkg.name, pkg.dirname, {
310
+ userconfig: options.userconfig,
311
+ registry: resolveRegistry(pkg, options.registry),
312
+ preferOnline: true,
313
+ });
314
+ while (pending.size) {
315
+ await Promise.all([...pending].map(async (entry) => {
316
+ if ((await view(entry.package))?.versions.includes(entry.version))
317
+ pending.delete(entry);
318
+ }));
319
+ const left = deadline - Date.now();
320
+ if (!pending.size || left <= 0)
321
+ break;
322
+ await new Promise(resolve => setTimeout(resolve, Math.min(interval, left)));
323
+ }
324
+ return [...pending];
325
+ }
326
+ PublishService.waitUntilAvailable = waitUntilAvailable;
293
327
  /**
294
328
  * Publishes every `'publish'` entry in `plan`, topological order (already `plan`'s own order -
295
329
  * see `getPlan`), via the configured `packageManager`'s own `publish` command. Sequential, not
@@ -79,5 +79,6 @@ export declare class NpmPublishTarget implements PublishTarget {
79
79
  applyPlan(ctx: PublishTarget.Context, plan: PublishTarget.Entry[]): Promise<PublishService.Entry[]>;
80
80
  /** The shared filters plus the two flags both halves need - read here rather than in the
81
81
  * service, which keeps taking a plain options object and stays callable without a CLI. */
82
+ waitUntilAvailable(ctx: PublishTarget.Context, published: PublishTarget.Entry[]): Promise<PublishService.Entry[]>;
82
83
  protected planOptions(ctx: PublishTarget.Context): PublishService.Options;
83
84
  }
@@ -31,7 +31,7 @@ export class NpmPublishTarget {
31
31
  packageManager: {
32
32
  target: 'cli',
33
33
  cliName: 'package-manager',
34
- describe: 'Package manager to publish with (default: npm, or .rmanrc "packageManager")',
34
+ describe: 'Package manager to publish with (default: .rmanrc "packageManager.node", else npm)',
35
35
  choices: CiService.PACKAGE_MANAGERS,
36
36
  },
37
37
  access: {
@@ -96,6 +96,12 @@ export class NpmPublishTarget {
96
96
  }
97
97
  /** The shared filters plus the two flags both halves need - read here rather than in the
98
98
  * service, which keeps taking a plain options object and stays callable without a CLI. */
99
+ waitUntilAvailable(ctx, published) {
100
+ return PublishService.waitUntilAvailable(published, {
101
+ registry: ctx.args.registry,
102
+ userconfig: ctx.args.userconfig,
103
+ });
104
+ }
99
105
  planOptions(ctx) {
100
106
  return {
101
107
  ...ctx.options,
@@ -31,6 +31,7 @@ export interface NpmPackageView {
31
31
  export declare function npmViewPackage(name: string, cwd: string, options?: {
32
32
  registry?: string;
33
33
  userconfig?: string;
34
+ preferOnline?: boolean;
34
35
  }): Promise<NpmPackageView | undefined>;
35
36
  /**
36
37
  * `npm view <name> version` - the **`latest` dist-tag's** version, or `undefined` for anything that
@@ -27,6 +27,10 @@ export async function npmViewPackage(name, cwd, options = {}) {
27
27
  argv.push('--registry', options.registry);
28
28
  if (options.userconfig)
29
29
  argv.push('--userconfig', options.userconfig);
30
+ /** Past npm's local cache - a caller polling for a version it just published would otherwise be
31
+ * answered from the copy fetched before the publish. */
32
+ if (options.preferOnline)
33
+ argv.push('--prefer-online');
30
34
  try {
31
35
  const { stdout } = await execFileAsync('npm', argv, { cwd });
32
36
  const text = stdout.trim();
package/cli.js CHANGED
@@ -19,6 +19,7 @@ import { assertNoBuiltinShadowing, defaultCommandGlobs, describeCommandSource, l
19
19
  import { checkCustomCommand } from './core/plugin-loader.js';
20
20
  import { commandRegistry } from './interfaces/rman-config.interface.js';
21
21
  import { colorYaml } from './utils/color-yaml.js';
22
+ import { askEnterOrEsc } from './utils/key-prompt.js';
22
23
  import { filterPackages, readFromRootOption, readPackageFilterOptions } from './utils/package-filter.js';
23
24
  import { printableConfig, withoutContributions } from './utils/printable-config.js';
24
25
  import { runBin } from './utils/run-bin.js';
@@ -41,7 +42,14 @@ export async function runCli(options) {
41
42
  /** One application per run, made here so `--log-level` reaches its logger, and handed to
42
43
  * `Repository.create` rather than found through a global. */
43
44
  const app = options?.app ?? new RmanApplication();
44
- const repository = await Repository.create(options?.cwd, { app, presets: options?.presets });
45
+ const missingPresets = [];
46
+ const repository = await Repository.create(options?.cwd, {
47
+ app,
48
+ presets: options?.presets,
49
+ onMissingExtends: (target, from) => missingPresets.push({ target, from }),
50
+ });
51
+ if (missingPresets.length)
52
+ await confirmMissingPresets(missingPresets, _argv);
45
53
  const program = yargs(_argv)
46
54
  .scriptName('rman')
47
55
  .version(version)
@@ -68,6 +76,14 @@ export async function runCli(options) {
68
76
  describe: 'Write the log as JSON Lines - one event per line on stdout, and no panel, spinner or prose. ' +
69
77
  'A command that prints a result (list, version, publish, ...) prints that result as JSON',
70
78
  type: 'boolean',
79
+ })
80
+ /** **Global**, like `--json`: a command that asks for confirmation declares its own `--yes`
81
+ * with the same meaning, and this one also answers the question asked before any command
82
+ * runs - whether to go on without a preset that cannot be found. */
83
+ .option('yes', {
84
+ alias: 'y',
85
+ describe: 'Answer yes to every question - including going on without an "extends" preset that cannot be found',
86
+ type: 'boolean',
71
87
  })
72
88
  .option('log-file', {
73
89
  describe: 'Also write the log to this file - JSON Lines under --json, plain text lines otherwise. ' +
@@ -639,6 +655,50 @@ function commandContext(repository, app, args) {
639
655
  parallel: (tasks, options) => run.parallel(tasks, options ?? {}),
640
656
  };
641
657
  }
658
+ /**
659
+ * Warns about the `extends` presets that could not be found - their settings are left out - and
660
+ * asks whether to go on: **Enter** continues, **Esc** cancels. `--yes` goes on without asking; with
661
+ * no terminal to ask on, it is an error that says to pass `--yes`.
662
+ *
663
+ * `--help` is never held up: it is what someone reaches for in exactly this situation.
664
+ */
665
+ /* **It was an error outright**, and the case that made that wrong is a repository whose
666
+ * `node_modules` is not installed yet: its shared preset is nowhere to be found, so `rman ci` - the
667
+ * command that would install it - could not start. Without a terminal there is nobody to ask, and a
668
+ * release in CI quietly built without its preset is the worse outcome, so the error stays there
669
+ * unless `--yes` says otherwise. */
670
+ async function confirmMissingPresets(missing, argv) {
671
+ if (!argv.length || argv.some(arg => arg === '-h' || arg === '--help'))
672
+ return;
673
+ const byTarget = new Map();
674
+ for (const { target, from } of missing) {
675
+ const where = path.relative(process.cwd(), from) || from;
676
+ byTarget.set(target, [...new Set([...(byTarget.get(target) ?? []), where])]);
677
+ }
678
+ const width = Math.max(...[...byTarget.keys()].map(t => t.length));
679
+ console.error(colors.yellow('These presets could not be found, so their settings are left out:'));
680
+ for (const [target, files] of byTarget) {
681
+ console.error(` ${colors.cyan(target.padEnd(width))} ${colors.gray(`extends in ${files.join(', ')}`)}`);
682
+ }
683
+ console.error(colors.gray('Are they installed? "rman ci" installs the repository\'s dependencies.'));
684
+ if (argv.some(arg => arg === '--yes' || arg === '-y')) {
685
+ console.error(colors.gray('Continuing without them (--yes).'));
686
+ return;
687
+ }
688
+ if (!process.stdin.isTTY) {
689
+ throw logged('No terminal to ask on - pass --yes to continue without them.');
690
+ }
691
+ if (!(await askEnterOrEsc(colors.yellow('Press Enter to continue without them, Esc to cancel. ')))) {
692
+ throw logged('Cancelled.');
693
+ }
694
+ }
695
+ /** The `logged` convention: printed here, so the caller's catch does not print it a second time. */
696
+ function logged(message) {
697
+ console.error(colors.red(message));
698
+ const err = new Error(message);
699
+ err.logged = true;
700
+ return err;
701
+ }
642
702
  /**
643
703
  * `--log-level` read straight off argv, for a message printed **before** yargs parses anything.
644
704
  *
@@ -190,52 +190,89 @@ function jsonPlan(entries) {
190
190
  }));
191
191
  }
192
192
  /**
193
- * One block per package with something to say: what moves, grouped by size, smallest first - and
194
- * then **what does not**, each with the reason it stays.
193
+ * One table per package with something to say, under a header: what each dependency would move to
194
+ * and how far, and for one that stays, the newest release and why it is not taken.
195
195
  */
196
- /* **The second half is the one `npm-check-updates` taught people to read.** Under `--target minor`
197
- * a major that was left behind has to be on the screen, or a run reporting three patches looks like
198
- * a repository that is current. A dependency that moved but not as far as it could appears in both:
199
- * once under its size, once under "not updated" with what held it. */
196
+ /* **One row per dependency, the change a column.** It was a list per size (`patch`, `minor`) and a
197
+ * second list headed "not updated" - wrong for a command that, without `-u`, updates nothing, and a
198
+ * dependency that moved part of the way appeared in both. The `Change` column says what a row is:
199
+ * the size of the move, or `held` (another dependency's rule refuses it), `skipped` (the package's
200
+ * own settings leave the newer version out) or `error`. Moves first, smallest first, then the rest. */
200
201
  function printPlan(entries, root) {
201
202
  const updated = entries.filter(e => e.status === 'update');
202
- const behind = entries.filter(e => e.status === 'held' || e.status === 'skipped' || e.status === 'error' || (e.status === 'update' && e.reason));
203
- /** Said first when nothing moves, so a run whose only lines are majors left behind does not read
203
+ const shown = entries.filter(e => e.status !== 'up-to-date');
204
+ /** Said first when nothing moves, so a run whose only rows are majors left behind does not read
204
205
  * as a list of things to do. */
205
206
  if (!updated.length) {
206
207
  console.log(colors.green('All dependencies are up to date') +
207
- (behind.length ? colors.gray(' - newer versions their settings leave out, or another rule holds back:') : '.'));
208
- if (!behind.length)
208
+ (shown.length ? colors.gray(' - newer versions their settings leave out, or another rule holds back:') : '.'));
209
+ if (!shown.length)
209
210
  return;
210
211
  }
211
- const shown = [...updated, ...behind];
212
- const nameWidth = Math.max(...shown.map(e => e.name.length));
213
- const currentWidth = Math.max(...shown.map(e => e.current.length));
214
- const row = (e, rest) => ` ${e.name.padEnd(nameWidth)} ${e.current.padEnd(currentWidth)} ${rest}`;
212
+ const header = ['Dependency', 'Current', 'Upgrade', 'Latest', 'Change'];
213
+ const cells = (e) => [
214
+ e.name,
215
+ e.current,
216
+ e.status === 'update' ? (e.target ?? '') : '-',
217
+ e.status === 'error' ? '-' : (e.available ?? e.latest ?? ''),
218
+ changeOf(e),
219
+ ];
220
+ const widths = header.map((h, i) => Math.max(h.length, ...shown.map(e => cells(e)[i].length)));
221
+ /** Padded to the column, except a last value nothing follows - trailing spaces inside a colour
222
+ * code are past the reach of `trimEnd`. */
223
+ const line = (values, paint = text => text) => ' ' + values.map((v, i) => paint(i === values.length - 1 ? v : v.padEnd(widths[i]), i)).join(' ');
215
224
  for (const pkg of [...new Set(shown.map(e => e.package))]) {
225
+ const sizes = pkg.versionScheme.bumpNames;
226
+ const rank = (e) => e.status === 'update' ? sizes.indexOf(e.bump) : sizes.length + ['held', 'skipped', 'error'].indexOf(e.status);
227
+ const rows = shown.filter(e => e.package === pkg).sort((x, y) => rank(x) - rank(y));
216
228
  const where = path.relative(root, pkg.dirname);
217
229
  console.log(colors.cyan(pkg.name) + (where ? colors.gray(` (${where})`) : ''));
218
- const sizes = pkg.versionScheme.bumpNames;
219
- for (const size of [...sizes, undefined]) {
220
- const group = updated.filter(e => e.package === pkg && (size ? e.bump === size : !sizes.includes(e.bump)));
221
- if (!group.length)
230
+ console.log(colors.gray(line([...header, 'Note']).trimEnd()));
231
+ for (const e of rows) {
232
+ const paint = e.status === 'update' ? sizeColor(e.bump, sizes) : changeColor(e.status);
233
+ const values = cells(e);
234
+ if (e.reason)
235
+ values[4] = values[4].padEnd(widths[4]);
236
+ const text = line(values, (v, column) => (column === 2 || column === 4 ? paint(v) : v));
237
+ if (!e.reason) {
238
+ console.log(text);
222
239
  continue;
223
- const paint = sizeColor(size, sizes);
224
- console.log(` ${paint(size ?? 'other')}`);
225
- for (const e of group)
226
- console.log(row(e, `→ ${paint(e.target ?? '')}`));
240
+ }
241
+ const indent = 2 + widths.reduce((sum, w) => sum + w + 2, 0);
242
+ const note = wrapNote(e.reason, process.stdout.isTTY ? process.stdout.columns - indent : 0);
243
+ console.log(`${text} ${note.map(l => colors.gray(l)).join('\n' + ' '.repeat(indent))}`);
227
244
  }
228
- const left = behind.filter(e => e.package === pkg);
229
- if (!left.length)
230
- continue;
231
- console.log(` ${colors.yellow('not updated')}`);
232
- const newest = (e) => (e.status === 'error' ? 'error' : (e.available ?? e.latest ?? ''));
233
- const width = Math.max(...left.map(e => newest(e).length));
234
- for (const e of left) {
235
- const paint = e.status === 'error' ? colors.red : (text) => text;
236
- console.log(row(e, `${paint(newest(e).padEnd(width))} ${colors.gray(e.reason ?? '')}`));
245
+ }
246
+ }
247
+ /**
248
+ * A note cut into lines of at most `width` characters, at spaces, so a long one continues under its
249
+ * own column instead of wrapping to the terminal's left edge. One line when `width` is too narrow to
250
+ * be worth it, or 0 - output that is not a terminal has no edge to wrap at.
251
+ */
252
+ function wrapNote(note, width) {
253
+ if (width < 20 || note.length <= width)
254
+ return [note];
255
+ const lines = [];
256
+ let current = '';
257
+ for (const word of note.split(' ')) {
258
+ if (current && current.length + 1 + word.length > width) {
259
+ lines.push(current);
260
+ current = word;
237
261
  }
262
+ else
263
+ current = current ? `${current} ${word}` : word;
238
264
  }
265
+ if (current)
266
+ lines.push(current);
267
+ return lines;
268
+ }
269
+ /** What the `Change` column says: the size of the move, or why there is none. */
270
+ function changeOf(e) {
271
+ return e.status === 'update' ? (e.bump ?? 'other') : e.status;
272
+ }
273
+ /** `held` and `skipped` in yellow, an error in red - a row with no move to colour by size. */
274
+ function changeColor(status) {
275
+ return status === 'error' ? colors.red : colors.yellow;
239
276
  }
240
277
  /** The colours `npm-check-updates` uses - the largest size red, the next cyan, the rest green - read
241
278
  * off the scheme's own order so a scheme with other names still gets them. */
@@ -165,9 +165,19 @@ const publishCommand = registerCommand(app => {
165
165
  if (!proceed)
166
166
  return;
167
167
  const outcomes = [];
168
+ const publishedBy = new Map();
169
+ let waited = false;
168
170
  for (const [target, plan] of plans) {
171
+ /** Once, before the first target built from what the others published - see
172
+ * `PublishTarget.waitUntilAvailable`. */
173
+ if (target.publishesLast && !waited && plan.some(e => e.status === 'publish')) {
174
+ waited = true;
175
+ await waitForPublished(ctx, publishedBy);
176
+ }
169
177
  const applied = await target.applyPlan(ctx, plan);
170
- outcomes.push(...printApplied(applied, plan, target.name));
178
+ const done = printApplied(applied, plan, target.name);
179
+ outcomes.push(...done);
180
+ publishedBy.set(target, done.filter(o => o.entry.status === 'publish').map(o => o.entry));
171
181
  }
172
182
  if (!args.json)
173
183
  printRecap(outcomes);
@@ -274,6 +284,28 @@ function printPlan(entries, label) {
274
284
  }
275
285
  }
276
286
  }
287
+ /**
288
+ * Waits for every target that can say so to serve what it just published, so a target building from
289
+ * those packages finds them. On stderr, as progress rather than part of the answer; a version still
290
+ * missing at the end is reported and the build goes ahead - it may have arrived since, and if not,
291
+ * the build's own error now says which version it could not find.
292
+ */
293
+ async function waitForPublished(ctx, publishedBy) {
294
+ for (const [target, published] of publishedBy) {
295
+ if (!target.waitUntilAvailable || !published.length)
296
+ continue;
297
+ const started = Date.now();
298
+ console.error(colors.gray(`waiting for ${published.length} package(s) to be served by [${target.name}]...`));
299
+ const missing = await target.waitUntilAvailable(ctx, published);
300
+ const took = ((Date.now() - started) / 1000).toFixed(1);
301
+ if (!missing.length) {
302
+ console.error(colors.gray(`[${target.name}] serves all ${published.length} (${took}s)`));
303
+ continue;
304
+ }
305
+ console.error(colors.yellow(`[${target.name}] still does not serve ${missing.map(e => `${e.package.name}@${e.version}`).join(', ')} ` +
306
+ `after ${took}s - building anyway`));
307
+ }
308
+ }
277
309
  /**
278
310
  * What one target actually did, printed as it is known. Returns the outcome of every package the
279
311
  * *plan* said to publish.
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.18.2';
1
+ export const version = '2.19.0';
@@ -236,6 +236,9 @@ export declare class Repository extends Package {
236
236
  deep?: number;
237
237
  app?: RmanApplication;
238
238
  presets?: readonly string[];
239
+ /** See `ConfigReader`'s constructor: an `extends` package that cannot be found is reported
240
+ * here and left out, rather than failing the read. */
241
+ onMissingExtends?: (target: string, from: string) => void;
239
242
  }): Promise<Repository>;
240
243
  /**
241
244
  * Takes the workspace's packages as this repository's own - the back-references a `Package` needs
@@ -7,6 +7,7 @@ import { RmanApplication } from '../application.js';
7
7
  import { ConfigFileScope } from '../config/config-file-scope.js';
8
8
  import { ConfigInterpolator } from '../config/config-interpolator.js';
9
9
  import { DEFERRED_PATHS } from '../config/config-paths.js';
10
+ import { ConfigReader } from '../config/config-reader.js';
10
11
  import { Manifest } from '../interfaces/manifest.js';
11
12
  import { Package } from './package.js';
12
13
  import { Workspace } from './workspace.js';
@@ -596,6 +597,7 @@ export class Repository extends Package {
596
597
  deep: options?.deep,
597
598
  platforms: [...app.platforms],
598
599
  presets: options?.presets,
600
+ reader: options?.onMissingExtends ? new ConfigReader({ onMissingExtends: options.onMissingExtends }) : undefined,
599
601
  });
600
602
  /** What the configs named, onto the application the commands read from. */
601
603
  for (const platform of workspace.platforms)
@@ -8,6 +8,18 @@ export declare class ConfigReader {
8
8
  * @protected
9
9
  */
10
10
  protected readonly configFiles: readonly string[];
11
+ /** Told about an `extends` package that cannot be found, which is then left out - see the
12
+ * constructor. */
13
+ protected readonly onMissingExtends?: (target: string, from: string) => void;
14
+ /**
15
+ * `onMissingExtends`, when given, turns an `extends` naming a **package** that cannot be found -
16
+ * `@scope/preset` or `rman:<name>` - into a call to it, and the config is read without that base.
17
+ * Without it such a target is an error, as it always was. A relative path that does not exist is
18
+ * an error either way: it is the repository's own file, and missing it is a typo.
19
+ */
20
+ constructor(options?: {
21
+ onMissingExtends?: (target: string, from: string) => void;
22
+ });
11
23
  /**
12
24
  * Whether a config key names **packages** rather than a setting: `"[*]"`, `"[/]"`, `"[pkg-a]"`.
13
25
  */
@@ -39,6 +39,21 @@ export class ConfigReader {
39
39
  '.rmanrc.mjs',
40
40
  '.rmanrc.js',
41
41
  ];
42
+ /** Told about an `extends` package that cannot be found, which is then left out - see the
43
+ * constructor. */
44
+ onMissingExtends;
45
+ /**
46
+ * `onMissingExtends`, when given, turns an `extends` naming a **package** that cannot be found -
47
+ * `@scope/preset` or `rman:<name>` - into a call to it, and the config is read without that base.
48
+ * Without it such a target is an error, as it always was. A relative path that does not exist is
49
+ * an error either way: it is the repository's own file, and missing it is a typo.
50
+ */
51
+ /* For the CLI, which asks before going on: a repository whose `node_modules` is not installed yet
52
+ * has its shared preset nowhere to be found, and refusing to start left `rman ci` - the command
53
+ * that installs it - unable to run. A library caller keeps the error unless it asks otherwise. */
54
+ constructor(options) {
55
+ this.onMissingExtends = options?.onMissingExtends;
56
+ }
42
57
  /**
43
58
  * Whether a config key names **packages** rather than a setting: `"[*]"`, `"[/]"`, `"[pkg-a]"`.
44
59
  */
@@ -460,7 +475,17 @@ export class ConfigReader {
460
475
  }
461
476
  const base = {};
462
477
  for (const target of targets) {
463
- const file = this._resolveExtendTarget(target, from, EXTENDS_KEY);
478
+ let file;
479
+ try {
480
+ file = this._resolveExtendTarget(target, from, EXTENDS_KEY);
481
+ }
482
+ catch (e) {
483
+ const isPath = target.startsWith('.') || path.isAbsolute(target);
484
+ if (!this.onMissingExtends || isPath)
485
+ throw e;
486
+ this.onMissingExtends(target, from);
487
+ continue;
488
+ }
464
489
  if (seen.includes(file)) {
465
490
  throw new Error(`"extends" forms a cycle: ${[...seen, file].map(f => path.basename(f)).join(' -> ')}`);
466
491
  }
@@ -79,6 +79,14 @@ export interface PublishTarget {
79
79
  * registry's host, `npm.pkg.github.com` for GitHub Packages. `undefined` shows `name`.
80
80
  */
81
81
  labelFor?(pkg: Package): string | undefined;
82
+ /**
83
+ * Waits until what `applyPlan` just published is served by this target's registry, and returns
84
+ * the entries that still are not when it gives up - none when everything showed up.
85
+ *
86
+ * Asked by `publish` before a `publishesLast` target runs, so an image built from these packages
87
+ * finds them. Absent, nothing waits for this target.
88
+ */
89
+ waitUntilAvailable?(ctx: PublishTarget.Context, published: PublishTarget.Entry[]): Promise<PublishTarget.Entry[]>;
82
90
  /** What this target *would* do - never publishes. Called even under `--dry-run`, which is the
83
91
  * whole point of the split. */
84
92
  getPlan(ctx: PublishTarget.Context): Promise<PublishTarget.Entry[]>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "2.18.2",
4
+ "version": "2.19.0",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Writes `question` to stderr and waits for one key: **Enter** answers `true`, **Esc** or
3
+ * **Ctrl+C** answers `false`. Any other key is ignored.
4
+ *
5
+ * Only for a terminal - the caller checks `process.stdin.isTTY` first, since there is no key to
6
+ * wait for in a pipe.
7
+ */
8
+ export declare function askEnterOrEsc(question: string,
9
+ /** Where the key comes from - the terminal, unless a caller (a spec) brings its own. */
10
+ stdin?: Pick<NodeJS.ReadStream, 'isRaw' | 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'>): Promise<boolean>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Writes `question` to stderr and waits for one key: **Enter** answers `true`, **Esc** or
3
+ * **Ctrl+C** answers `false`. Any other key is ignored.
4
+ *
5
+ * Only for a terminal - the caller checks `process.stdin.isTTY` first, since there is no key to
6
+ * wait for in a pipe.
7
+ */
8
+ /* One key rather than a typed answer, because the question is a gate in front of the whole run and
9
+ * the two answers are "go on" and "stop". An escape sequence (an arrow key arrives as `\x1b[A`) is
10
+ * not Esc: only a lone `\x1b` cancels. */
11
+ export function askEnterOrEsc(question,
12
+ /** Where the key comes from - the terminal, unless a caller (a spec) brings its own. */
13
+ stdin = process.stdin) {
14
+ process.stderr.write(question);
15
+ return new Promise(resolve => {
16
+ const wasRaw = stdin.isRaw;
17
+ stdin.setRawMode(true);
18
+ stdin.resume();
19
+ const finish = (answer) => {
20
+ stdin.off('data', onKey);
21
+ stdin.setRawMode(wasRaw);
22
+ stdin.pause();
23
+ process.stderr.write('\n');
24
+ resolve(answer);
25
+ };
26
+ const onKey = (data) => {
27
+ const key = data.toString();
28
+ if (key === '\r' || key === '\n')
29
+ finish(true);
30
+ else if (key === '\x1b' || key === '\x03')
31
+ finish(false);
32
+ };
33
+ stdin.on('data', onKey);
34
+ });
35
+ }