rman 1.2.2 → 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.2';
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
  /**
@@ -10,3 +10,31 @@
10
10
  * `label` names the config key in the error, so a failure says which setting to go and look at.
11
11
  */
12
12
  export declare function resolveConfigTarget(target: string, from: string, label: string): string;
13
+ /**
14
+ * The fallback: a plugin or shared config installed **beside rman itself**, tried when the
15
+ * repository cannot resolve it.
16
+ *
17
+ * A globally installed rman's siblings *are* the globally installed packages, so this is what makes
18
+ * `rman ci` work on a fresh clone - the command comes from `rman-node`, and `ci` exists to create
19
+ * the very `node_modules` the plugin would otherwise have to be found in. Measured: with both
20
+ * installed globally, a clone answered `"plugins" target "rman-node" could not be resolved ... is
21
+ * it installed in this repository?`, which was true and useless.
22
+ *
23
+ * **The repository is always tried first**, so a repository carrying its own copy is unaffected and
24
+ * its version always wins. This is a fallback, never a search order: `createRequire` is based on the
25
+ * config file precisely so a repository's config resolves against the repository.
26
+ *
27
+ * It is deliberately *not* gated on whether the repository looks installed. That was tried - fall
28
+ * back only when there is no `node_modules` anywhere above the config file - on the reasoning that
29
+ * an installed repository merely *missing* a dependency should keep the honest error. It reads well
30
+ * and behaves unpredictably: the walk reaches the filesystem root, so a checkout under any directory
31
+ * that happens to have a `node_modules` (a home directory, a nested clone) silently lost the
32
+ * fallback. A rule whose answer depends on where the repository was cloned is worse than the
33
+ * looser one.
34
+ *
35
+ * `from` exists for the specs, and is the whole test seam: the answer depends on where **this
36
+ * module** sits, so a spec running inside this repository could otherwise only ever prove that this
37
+ * repository can see its own `node_modules`. Given a throwaway layout's URL it exercises the real
38
+ * resolution - no subprocess, and nothing to keep in step with the source.
39
+ */
40
+ export declare function resolveBesideRman(target: string, from?: string): string | undefined;
@@ -28,6 +28,44 @@ export function resolveConfigTarget(target, from, label) {
28
28
  return createRequire(pathToFileURL(path.join(dir, 'noop.js'))).resolve(target);
29
29
  }
30
30
  catch {
31
+ const beside = resolveBesideRman(target);
32
+ if (beside)
33
+ return beside;
31
34
  throw new Error(`"${label}" target "${target}" could not be resolved from "${from}" - is it installed in this repository?`);
32
35
  }
33
36
  }
37
+ /**
38
+ * The fallback: a plugin or shared config installed **beside rman itself**, tried when the
39
+ * repository cannot resolve it.
40
+ *
41
+ * A globally installed rman's siblings *are* the globally installed packages, so this is what makes
42
+ * `rman ci` work on a fresh clone - the command comes from `rman-node`, and `ci` exists to create
43
+ * the very `node_modules` the plugin would otherwise have to be found in. Measured: with both
44
+ * installed globally, a clone answered `"plugins" target "rman-node" could not be resolved ... is
45
+ * it installed in this repository?`, which was true and useless.
46
+ *
47
+ * **The repository is always tried first**, so a repository carrying its own copy is unaffected and
48
+ * its version always wins. This is a fallback, never a search order: `createRequire` is based on the
49
+ * config file precisely so a repository's config resolves against the repository.
50
+ *
51
+ * It is deliberately *not* gated on whether the repository looks installed. That was tried - fall
52
+ * back only when there is no `node_modules` anywhere above the config file - on the reasoning that
53
+ * an installed repository merely *missing* a dependency should keep the honest error. It reads well
54
+ * and behaves unpredictably: the walk reaches the filesystem root, so a checkout under any directory
55
+ * that happens to have a `node_modules` (a home directory, a nested clone) silently lost the
56
+ * fallback. A rule whose answer depends on where the repository was cloned is worse than the
57
+ * looser one.
58
+ *
59
+ * `from` exists for the specs, and is the whole test seam: the answer depends on where **this
60
+ * module** sits, so a spec running inside this repository could otherwise only ever prove that this
61
+ * repository can see its own `node_modules`. Given a throwaway layout's URL it exercises the real
62
+ * resolution - no subprocess, and nothing to keep in step with the source.
63
+ */
64
+ export function resolveBesideRman(target, from = import.meta.url) {
65
+ try {
66
+ return createRequire(from).resolve(target);
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "1.2.2",
4
+ "version": "1.2.5",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {