rman 1.2.4 → 1.2.5

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
@@ -27,9 +27,21 @@ import { filterPackages, readPackageFilterOptions, readRootOption } from './util
27
27
  import { printableConfig } from './utils/printable-config.js';
28
28
  import { runBin } from './utils/run-bin.js';
29
29
  export async function runCli(options) {
30
+ const _argv = options?.argv || hideBin(process.argv);
31
+ /**
32
+ * **Answered before the repository is touched**, because neither question is about a repository.
33
+ *
34
+ * `rman -v` is what you reach for when something is wrong - to find out which rman is even
35
+ * installed - and it was the one thing a broken repository took away: resolution happens during
36
+ * `Repository.create`, long before yargs sees the flag, so `rman -v` in a repository whose
37
+ * `.rmanrc` named a plugin it could not resolve answered with that error and exit 1. Measured.
38
+ */
39
+ if (_argv.some(arg => arg === '-v' || arg === '--version')) {
40
+ console.log(version);
41
+ return;
42
+ }
30
43
  try {
31
44
  const repository = await Repository.create(options?.cwd);
32
- const _argv = options?.argv || hideBin(process.argv);
33
45
  const program = yargs(_argv)
34
46
  .scriptName('rman')
35
47
  .version(version)
@@ -156,6 +168,27 @@ export async function runCli(options) {
156
168
  await program.parseAsync();
157
169
  }
158
170
  catch (e) {
171
+ /**
172
+ * **`--help` still answers**, because a broken repository is the moment you most want it. The
173
+ * command list is the part that genuinely needs the repository - every built-in's `initCli`
174
+ * closes over it, and a plugin's commands *are* the repository's - so help degrades to the
175
+ * global options and says plainly why the rest is missing, rather than failing outright.
176
+ *
177
+ * The reason goes to stderr, so `rman --help | less` is still just help.
178
+ */
179
+ if (_argv.some(arg => arg === '-h' || arg === '--help')) {
180
+ console.error(colors.yellow(`Repository could not be read: ${e.message}`));
181
+ console.error(colors.yellow('Commands are not listed - they come from this repository and its plugins.\n'));
182
+ await yargs(_argv)
183
+ .scriptName('rman')
184
+ .version(version)
185
+ .alias('version', 'v')
186
+ .usage('$0 <cmd> [options...]')
187
+ .help('help')
188
+ .alias('help', 'h')
189
+ .showHelp(text => console.log(text));
190
+ return;
191
+ }
159
192
  /** Setup failures - no `package.json` to be found, a `.rman` command shadowing a built-in -
160
193
  * used to be printed and then swallowed, so the shell saw success: `rman info` in the wrong
161
194
  * directory reported failure on stdout and 0 to whatever called it. Printed once (unless the
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '1.2.4';
1
+ export const version = '1.2.5';
package/core/config.js CHANGED
@@ -8,7 +8,7 @@ import semver from 'semver';
8
8
  import { pathToFileURL } from 'url';
9
9
  import vm from 'vm';
10
10
  import { assertNoSelectorExtends, EXTENDS_KEY, resolveExtends } from './extends-config.js';
11
- import { finalizeConfig, mergeConfig, PREVIOUS_VALUES } from './merge-config.js';
11
+ import { finalizeConfig, mergeConfig, ORIGINS, PREVIOUS_VALUES } from './merge-config.js';
12
12
  /**
13
13
  * Identity helper for authoring a `.rmanrc.cjs`/`.mjs`/`.js` config with full type-checking and
14
14
  * autocomplete - the same `defineConfig` pattern Vite/Vitest use. Returns `config` completely
@@ -71,7 +71,7 @@ export async function readDirConfig(dirname) {
71
71
  assertNoSelectorExtends(pkgJson.rman, pkgJsonFile);
72
72
  if (EXTENDS_KEY in pkgJson.rman)
73
73
  extendsFrom = pkgJsonFile;
74
- mergeConfig(result, pkgJson.rman);
74
+ mergeConfig(result, pkgJson.rman, pkgJsonFile);
75
75
  }
76
76
  }
77
77
  const ymlFile = path.join(dirname, '.rmanrc.yml');
@@ -81,7 +81,7 @@ export async function readDirConfig(dirname) {
81
81
  assertNoSelectorExtends(obj, ymlFile);
82
82
  if (EXTENDS_KEY in obj)
83
83
  extendsFrom = ymlFile;
84
- mergeConfig(result, obj);
84
+ mergeConfig(result, obj, ymlFile);
85
85
  }
86
86
  }
87
87
  const rcFile = path.join(dirname, '.rmanrc');
@@ -91,7 +91,7 @@ export async function readDirConfig(dirname) {
91
91
  assertNoSelectorExtends(obj, rcFile);
92
92
  if (EXTENDS_KEY in obj)
93
93
  extendsFrom = rcFile;
94
- mergeConfig(result, obj);
94
+ mergeConfig(result, obj, rcFile);
95
95
  }
96
96
  }
97
97
  for (const jsFileName of JS_CONFIG_FILES) {
@@ -102,7 +102,7 @@ export async function readDirConfig(dirname) {
102
102
  assertNoSelectorExtends(obj, jsFile);
103
103
  if (EXTENDS_KEY in obj)
104
104
  extendsFrom = jsFile;
105
- mergeConfig(result, obj);
105
+ mergeConfig(result, obj, jsFile);
106
106
  }
107
107
  }
108
108
  }
@@ -350,7 +350,8 @@ export function interpolateConfig(config, scope, options) {
350
350
  resolving.push(key);
351
351
  try {
352
352
  const chain = config[PREVIOUS_VALUES];
353
- const value = walkWithPrevious(config[key], chain?.[key], scope, context, [...base, key], skip);
353
+ const origins = config[ORIGINS];
354
+ const value = withOrigin(origins?.[key], () => walkWithPrevious(config[key], chain?.[key], scope, context, [...base, key], skip));
354
355
  resolved.set(key, value);
355
356
  return value;
356
357
  }
@@ -526,8 +527,9 @@ function walk(value, scope, context, at, skip) {
526
527
  const chain = value[PREVIOUS_VALUES];
527
528
  return withScopedVars(value, scope, context, at, skip, () => {
528
529
  const result = {};
530
+ const origins = value[ORIGINS];
529
531
  for (const [key, item] of Object.entries(value)) {
530
- result[key] = walkWithPrevious(item, chain?.[key], scope, context, [...at, key], skip);
532
+ result[key] = withOrigin(origins?.[key], () => walkWithPrevious(item, chain?.[key], scope, context, [...at, key], skip));
531
533
  }
532
534
  return result;
533
535
  });
@@ -612,6 +614,42 @@ function isPlainObject(value) {
612
614
  * level, which costs a script that would have been called `vars`: `run.vars` is a scope, not a
613
615
  * script. Nothing enumerates `run`'s keys as a list of script names, so the cost stops there. */
614
616
  const VARS_KEY = 'vars';
617
+ /**
618
+ * The file the key being walked was written in, for the errors below to name.
619
+ *
620
+ * A config is merged from several files before anything reads it - a directory's own forms, an
621
+ * `extends` base, every `"[selector]"` block, one layer per directory - so `version.commitMessage`
622
+ * alone does not say where to go and look. `mergeConfig` records the file per key (`ORIGINS`); this
623
+ * is the depth-first cursor over that, kept in a module variable rather than threaded through
624
+ * `walk`'s signature because every error site would otherwise have to carry a parameter it only
625
+ * passes on.
626
+ *
627
+ * Nested keys inherit the enclosing file when the merge recorded none of their own, which is what a
628
+ * nested object in one file means.
629
+ */
630
+ let currentOrigin;
631
+ function withOrigin(origin, body) {
632
+ const outer = currentOrigin;
633
+ if (origin !== undefined)
634
+ currentOrigin = origin;
635
+ try {
636
+ return body();
637
+ }
638
+ finally {
639
+ currentOrigin = outer;
640
+ }
641
+ }
642
+ /** `"version.commitMessage" (.rmanrc.yml)`, or just the path when nothing recorded a file - a
643
+ * caller interpolating a fragment it built itself, say. Relative to the repository when it sits
644
+ * inside one, since an absolute path is noise in a message about the repository you are in. */
645
+ function describeAt(at) {
646
+ const where = at.length ? formatPath(at) : 'the config root';
647
+ return currentOrigin ? `${where}" (${shortenOrigin(currentOrigin)})` : `${where}"`;
648
+ }
649
+ function shortenOrigin(file) {
650
+ const relative = path.relative(process.cwd(), file);
651
+ return !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
652
+ }
615
653
  /**
616
654
  * Walks one key of an object with **`value` bound** to whatever the layers below it resolved to.
617
655
  *
@@ -660,10 +698,9 @@ function walkWithPrevious(item, previous, scope, context, at, skip) {
660
698
  * and wrong for every key that is not a list.
661
699
  */
662
700
  if (wasRead && resolved === undefined && !e?.rmanValueHint) {
663
- const where = at.length ? formatPath(at) : 'the config root';
664
701
  e.rmanValueHint = true;
665
702
  e.message =
666
- `${e.message}\n \`value\` is undefined here - nothing below this layer sets "${where}".` +
703
+ `${e.message}\n \`value\` is undefined here - nothing below this layer sets "${describeAt(at)}.` +
667
704
  `\n Write \`value ?? []\` (or \`?? ''\`) if it has to work as the first layer too.`;
668
705
  }
669
706
  throw e;
@@ -733,10 +770,9 @@ function callValueFn(fn, context, at) {
733
770
  return fn(arg);
734
771
  }
735
772
  catch (e) {
736
- const where = at.length ? formatPath(at) : 'the config root';
737
773
  /** The `value` hint comes from `walkWithPrevious`, which wraps this call and is the one place
738
774
  * that knows whether `value` was read - so the expression spelling gets the same sentence. */
739
- throw new Error(`Config function in "${where}" failed: ${e?.message}`, { cause: e });
775
+ throw new Error(`Config function in "${describeAt(at)} failed: ${e?.message}`, { cause: e });
740
776
  }
741
777
  }
742
778
  function interpolateString(value, context, at) {
@@ -758,8 +794,7 @@ function interpolateString(value, context, at) {
758
794
  * tag like `app:undefined` that looks plausible and is wrong - the exact silent-mistake shape
759
795
  * this evaluator exists to avoid. `?? 'fallback'` says what was meant. */
760
796
  if (result === undefined || result === null) {
761
- const where = at.length ? formatPath(at) : 'the config root';
762
- throw new Error(`Expression in "${where}" is ${result} inside a string: ${value.trim()}\n` +
797
+ throw new Error(`Expression in "${describeAt(at)} is ${result} inside a string: ${value.trim()}\n` +
763
798
  ` \${{${expr}}} has no value here - give it a fallback (\${{${expr.trim()} ?? '...'}}).`);
764
799
  }
765
800
  return String(result);
@@ -772,8 +807,7 @@ function evaluate(expr, source, context, at) {
772
807
  return vm.runInContext(expr, context, { timeout: EXPRESSION_TIMEOUT });
773
808
  }
774
809
  catch (e) {
775
- const where = at.length ? formatPath(at) : 'the config root';
776
- throw new Error(`Invalid expression in "${where}": ${source.trim()}\n ${e?.message ?? e}`, { cause: e });
810
+ throw new Error(`Invalid expression in "${describeAt(at)}: ${source.trim()}\n ${e?.message ?? e}`, { cause: e });
777
811
  }
778
812
  }
779
813
  function formatPath(at) {
@@ -42,7 +42,7 @@ export async function resolveExtends(config, from, seen = []) {
42
42
  const loaded = await loadConfigFile(file);
43
43
  assertNoSelectorExtends(loaded, file);
44
44
  // Recursive: a shared config may itself be built on another.
45
- mergeConfig(base, await resolveExtends(loaded, file, [...seen, file]));
45
+ mergeConfig(base, await resolveExtends(loaded, file, [...seen, file]), file);
46
46
  }
47
47
  const own = { ...config };
48
48
  delete own[EXTENDS_KEY];
@@ -31,6 +31,19 @@ export declare const ALWAYS_APPEND: readonly string[];
31
31
  * `A <- expr2 <- expr3`, and one slot would have lost `A` the moment `expr3` arrived.
32
32
  */
33
33
  export declare const PREVIOUS_VALUES: unique symbol;
34
+ /**
35
+ * Which file each key came from, so an error can name it.
36
+ *
37
+ * A config is merged from several files before anything reads it - a directory's own four forms, an
38
+ * `extends` base, every `"[selector]"` block, and one layer per directory from the root down - so by
39
+ * the time an expression fails, `version.commitMessage` could have been written in any of them.
40
+ * Saying only the key sends the reader looking through all of them.
41
+ *
42
+ * Recorded the same way `PREVIOUS_VALUES` is, for the same reason: a symbol on the containing
43
+ * object, invisible to `Object.entries`, `JSON.stringify` and js-yaml, so it travels with the config
44
+ * without any reader having to know it is there.
45
+ */
46
+ export declare const ORIGINS: unique symbol;
34
47
  /** One link: the raw value this key held, and whatever *it* was derived from. */
35
48
  export interface PreviousValue {
36
49
  value: unknown;
@@ -69,7 +82,10 @@ export declare function appendTarget(key: string): string | undefined;
69
82
  * `key` and `+key` in the same object are both honored, in that order: the replacement happens
70
83
  * first, then the append lands on top of it.
71
84
  */
72
- export declare function mergeConfig(target: Record<string, any>, source: Record<string, any>): Record<string, any>;
85
+ export declare function mergeConfig(target: Record<string, any>, source: Record<string, any>,
86
+ /** The file `source` was read from, recorded per key - see `ORIGINS`. A caller merging a value it
87
+ * built rather than read (a selector block already carrying its own origins) passes nothing. */
88
+ origin?: string): Record<string, any>;
73
89
  /**
74
90
  * Turns any `+key` still outstanding into its plain key, as a list - the "nothing was inherited"
75
91
  * case, where an append simply is the whole value.
@@ -31,6 +31,19 @@ export const ALWAYS_APPEND = ['plugins'];
31
31
  * `A <- expr2 <- expr3`, and one slot would have lost `A` the moment `expr3` arrived.
32
32
  */
33
33
  export const PREVIOUS_VALUES = Symbol('rman.previousValues');
34
+ /**
35
+ * Which file each key came from, so an error can name it.
36
+ *
37
+ * A config is merged from several files before anything reads it - a directory's own four forms, an
38
+ * `extends` base, every `"[selector]"` block, and one layer per directory from the root down - so by
39
+ * the time an expression fails, `version.commitMessage` could have been written in any of them.
40
+ * Saying only the key sends the reader looking through all of them.
41
+ *
42
+ * Recorded the same way `PREVIOUS_VALUES` is, for the same reason: a symbol on the containing
43
+ * object, invisible to `Object.entries`, `JSON.stringify` and js-yaml, so it travels with the config
44
+ * without any reader having to know it is there.
45
+ */
46
+ export const ORIGINS = Symbol('rman.origins');
34
47
  /** Only these two can ask for `value`, so only these two are worth remembering a previous for. */
35
48
  export function carriesPreviousValue(value) {
36
49
  return typeof value === 'function' || (typeof value === 'string' && value.includes('${{'));
@@ -70,7 +83,10 @@ export function appendTarget(key) {
70
83
  * `key` and `+key` in the same object are both honored, in that order: the replacement happens
71
84
  * first, then the append lands on top of it.
72
85
  */
73
- export function mergeConfig(target, source) {
86
+ export function mergeConfig(target, source,
87
+ /** The file `source` was read from, recorded per key - see `ORIGINS`. A caller merging a value it
88
+ * built rather than read (a selector block already carrying its own origins) passes nothing. */
89
+ origin) {
74
90
  // Plain keys first, so a `+key` alongside its own `key` appends to that replacement rather than
75
91
  // to whatever the previous layer had.
76
92
  for (const [key, value] of Object.entries(source)) {
@@ -81,7 +97,7 @@ export function mergeConfig(target, source) {
81
97
  appendList(target, key, value);
82
98
  continue;
83
99
  }
84
- assignMerged(target, key, value);
100
+ assignMerged(target, key, value, source, origin);
85
101
  }
86
102
  for (const [key, value] of Object.entries(source)) {
87
103
  const plain = appendTarget(key);
@@ -90,7 +106,7 @@ export function mergeConfig(target, source) {
90
106
  // An object merges either way, so the prefix asks for nothing extra - resolve it now and let
91
107
  // the two spellings coincide.
92
108
  if (isPlainObject(value) || isPlainObject(target[plain])) {
93
- assignMerged(target, plain, value);
109
+ assignMerged(target, plain, value, source, origin);
94
110
  continue;
95
111
  }
96
112
  if (plain in target) {
@@ -131,16 +147,30 @@ export function finalizeConfig(config) {
131
147
  }
132
148
  /** Carried across by hand: this rebuilds the object from `Object.entries`, which does not see a
133
149
  * symbol - and dropping it here would lose every `value` chain the merge just recorded. */
134
- const chain = config[PREVIOUS_VALUES];
135
- if (chain)
136
- Object.defineProperty(result, PREVIOUS_VALUES, { value: chain });
150
+ for (const carried of [PREVIOUS_VALUES, ORIGINS]) {
151
+ const value = config[carried];
152
+ if (value)
153
+ Object.defineProperty(result, carried, { value });
154
+ }
137
155
  return result;
138
156
  }
139
- function assignMerged(target, key, value) {
157
+ function assignMerged(target, key, value, source, origin) {
158
+ /** A source that already carries origins wins over the caller's: an `extends` base keeps the file
159
+ * its own keys were written in, rather than being attributed to the file that named it. */
160
+ const from = source[ORIGINS]?.[key] ?? origin;
161
+ if (from !== undefined) {
162
+ const carrier = target;
163
+ /** **Non-enumerable**, like `PREVIOUS_VALUES`: `expect`'s `toEqual` compares symbol properties,
164
+ * so a plain assignment turned every config-shape assertion in the suite into a diff about
165
+ * bookkeeping (measured, five specs at once). Nothing should see this but the error messages. */
166
+ if (!carrier[ORIGINS])
167
+ Object.defineProperty(target, ORIGINS, { value: {}, writable: true });
168
+ carrier[ORIGINS][key] = from;
169
+ }
140
170
  if (isPlainObject(value)) {
141
171
  if (!isPlainObject(target[key]))
142
172
  target[key] = {};
143
- mergeConfig(target[key], value);
173
+ mergeConfig(target[key], value, from);
144
174
  return;
145
175
  }
146
176
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "1.2.4",
4
+ "version": "1.2.5",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {