rman 2.0.0-beta.4 → 2.0.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/README.md +20 -7
- package/builtins/platforms/node/node-config.interface.d.ts +13 -0
- package/builtins/platforms/node/services/publish.service.d.ts +26 -0
- package/builtins/platforms/node/services/publish.service.js +78 -9
- package/builtins/publish-targets/npm/npm-publish-target.d.ts +5 -0
- package/builtins/publish-targets/npm/npm-publish-target.js +14 -1
- package/cli.js +16 -11
- package/constants.js +1 -1
- package/core/interfaces/custom-command.d.ts +8 -1
- package/core/interfaces/custom-command.js +22 -3
- package/core/plugin-loader.d.ts +1 -1
- package/core/plugin-loader.js +9 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -94,17 +94,30 @@ worked examples of every single command, see **[docs/cli-rman.md](https://github
|
|
|
94
94
|
| [`import <path>`](#rman-import-path) | Imports an external git repository as a new package, with history. |
|
|
95
95
|
|
|
96
96
|
**`ci` and `clean` come from the `node` built-in**, which ships inside this package - each is about
|
|
97
|
-
npm or TypeScript rather than about repositories
|
|
98
|
-
repository
|
|
97
|
+
npm or TypeScript rather than about repositories. It needs no declaration: rman lays its own `node`
|
|
98
|
+
preset under every repository, so a clone with no `.rmanrc` at all already has them, plus the `node`
|
|
99
|
+
technology itself and the npm publish target. It used to take a second package (`rman-node`) and a
|
|
100
|
+
`.rmanrc` before anything worked.
|
|
101
|
+
|
|
102
|
+
A preset is an ordinary rman config rather than a type of its own, so another technology arrives the
|
|
103
|
+
same way and a polyglot repository asks for both:
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
extends: ['rman:node', 'rman:cargo']
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**`plugins: ['node']` is not a way to name a technology and is refused**, with a message saying so.
|
|
110
|
+
That key takes a plugin instance or a glob naming modules that export one; a name is what `extends`
|
|
111
|
+
resolves. The keys that do exist:
|
|
99
112
|
|
|
100
113
|
```yaml
|
|
101
|
-
|
|
102
|
-
|
|
114
|
+
platform: node # which technology claims *this directory* - it does not load anything
|
|
115
|
+
platforms: [...] # contributes technologies: an instance, or a glob naming modules exporting one
|
|
103
116
|
```
|
|
104
117
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
118
|
+
Whatever a repository declares is asked about a directory **before** the default preset, so
|
|
119
|
+
`extends: 'rman:cargo'` in a root holding both a `Cargo.toml` and a tooling `package.json` resolves
|
|
120
|
+
to what it declared.
|
|
108
121
|
|
|
109
122
|
**`publish` is here, but *where* a package ships is a plugin's to say.** A **publish target** is
|
|
110
123
|
one answer to "is this version on the registry, and how do I push it" - rman ships `docker`
|
|
@@ -48,6 +48,19 @@ export declare namespace RmanNodeConfig {
|
|
|
48
48
|
* Publishing from such a directory means the manifest there is **generated by `publish`**,
|
|
49
49
|
* from the package's own - see `PublishService`. There is nothing to configure about it. */
|
|
50
50
|
directory?: ConfigValue<string>;
|
|
51
|
+
/**
|
|
52
|
+
* Publish into npm's **staging queue** (`npm stage publish`) instead of straight to the
|
|
53
|
+
* registry: the version is held pending until a maintainer approves it with `npm stage approve`,
|
|
54
|
+
* which is the step that carries the 2FA challenge.
|
|
55
|
+
*
|
|
56
|
+
* Per package like every other key here, so a repository can stage the one package that matters
|
|
57
|
+
* and publish the rest directly.
|
|
58
|
+
*
|
|
59
|
+
* Requires npm >= 11.15.0 and Node >= 22.14.0 on whatever runs the publish. rman does not check
|
|
60
|
+
* either: the version that matters is the runner's, not the one resolving the config, and npm's
|
|
61
|
+
* own "Unknown command: stage" is a better message than a guess made somewhere else.
|
|
62
|
+
*/
|
|
63
|
+
staged?: ConfigValue<boolean>;
|
|
51
64
|
}
|
|
52
65
|
}
|
|
53
66
|
/**
|
|
@@ -32,6 +32,17 @@ export declare namespace PublishService {
|
|
|
32
32
|
* a release pipeline gates on both show where a version is going.
|
|
33
33
|
*/
|
|
34
34
|
tag?: string;
|
|
35
|
+
/**
|
|
36
|
+
* `npm stage publish` instead of `npm publish` - the version goes into npm's staging queue and
|
|
37
|
+
* is not on the registry until a maintainer runs `npm stage approve`, which is where the 2FA
|
|
38
|
+
* challenge moves to. `false` forces a direct publish even where the config asks for staging;
|
|
39
|
+
* `undefined` leaves the decision to each package's `.rmanrc "publish.npm.staged"`.
|
|
40
|
+
*
|
|
41
|
+
* **A plan option, for the same reason `tag` is one**: what a run is about to do is the thing a
|
|
42
|
+
* reader confirms and a pipeline gates on, and "this release will not be live when the workflow
|
|
43
|
+
* goes green" is the most important sentence in the plan when it applies.
|
|
44
|
+
*/
|
|
45
|
+
staged?: boolean;
|
|
35
46
|
}
|
|
36
47
|
interface ApplyOptions extends Options {
|
|
37
48
|
packageManager?: CiService.PackageManager;
|
|
@@ -63,6 +74,21 @@ export declare namespace PublishService {
|
|
|
63
74
|
* says; recomputing at publish time would let the two disagree about where a package is going.
|
|
64
75
|
*/
|
|
65
76
|
distTag?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Whether this entry goes into npm's staging queue rather than straight to the registry -
|
|
79
|
+
* `--staged`, or the package's own `.rmanrc "publish.npm.staged"`.
|
|
80
|
+
*
|
|
81
|
+
* Decided in the plan and read back by `applyPlan`, exactly as `distTag` is: a plan that said
|
|
82
|
+
* "staged" while the command published directly would be the one disagreement that cannot be
|
|
83
|
+
* undone, since a live version cannot be unpublished after 72 hours.
|
|
84
|
+
*
|
|
85
|
+
* **A staged entry still reads `publish`, not a status of its own**, and that is a limitation
|
|
86
|
+
* rather than a decision: rman's question is "is this version on the registry", and a pending
|
|
87
|
+
* version is not - `npm view` does not report the queue. So a second run proposes the same
|
|
88
|
+
* package again, and whether npm accepts a duplicate stage is npm's answer to give, not rman's
|
|
89
|
+
* to guess. Watch this if a run is ever repeated before an approval.
|
|
90
|
+
*/
|
|
91
|
+
staged?: boolean;
|
|
66
92
|
/** `PublishTarget.Entry`'s own field - what the core's `publish` prints beside the package
|
|
67
93
|
* name. Carries the dist-tag, so where a version is going is visible in the plan and in
|
|
68
94
|
* `--dry-run --json` without the core knowing anything about npm. */
|
|
@@ -156,7 +156,13 @@ export var PublishService;
|
|
|
156
156
|
const packages = filterPackages(repository.getPackages({ toposort: true }), options);
|
|
157
157
|
const dirtyFiles = await git.listDirtyFiles({ absolute: true });
|
|
158
158
|
const isDirty = (pkg) => dirtyFiles.some(f => !path.relative(pkg.dirname, f).startsWith('..'));
|
|
159
|
-
|
|
159
|
+
/** Per package, not per run: `resolveRegistry` reads the package's own `publishConfig.registry`
|
|
160
|
+
* when `--registry` was not given, so the question is asked where the answer lives. The test
|
|
161
|
+
* seam keeps its `(name, cwd)` shape - a stub answers from a map and has no registry to
|
|
162
|
+
* respect. */
|
|
163
|
+
const viewPackage = (pkg) => deps.npmViewPackage
|
|
164
|
+
? deps.npmViewPackage(pkg.name, pkg.dirname)
|
|
165
|
+
: npmViewPackage(pkg.name, pkg.dirname, { ...options, registry: resolveRegistry(pkg, options.registry) });
|
|
160
166
|
const entries = new Map();
|
|
161
167
|
const toCheck = [];
|
|
162
168
|
for (const pkg of packages) {
|
|
@@ -200,21 +206,27 @@ export var PublishService;
|
|
|
200
206
|
}
|
|
201
207
|
}
|
|
202
208
|
await Promise.all(toCheck.map(async (pkg) => {
|
|
203
|
-
const view = await viewPackage(pkg
|
|
209
|
+
const view = await viewPackage(pkg);
|
|
204
210
|
const registryVersion = view?.latest;
|
|
205
211
|
/** **This version**, not `latest` - the two part company the moment a prerelease is
|
|
206
212
|
* published under its own dist-tag, and `latest` then never moves however many betas go
|
|
207
213
|
* out. Asking it kept proposing an already-published version until npm answered 403. */
|
|
208
214
|
const published = !!view?.versions.includes(pkg.version);
|
|
209
215
|
const distTag = distTagFor(pkg, options.tag).tag;
|
|
216
|
+
const staged = resolveStaged(pkg, options.staged);
|
|
210
217
|
entries.set(pkg.name, {
|
|
211
218
|
package: pkg,
|
|
212
219
|
version: pkg.version,
|
|
213
220
|
registryVersion,
|
|
214
221
|
distTag,
|
|
222
|
+
staged,
|
|
215
223
|
/** Printed beside the package in the plan, so where a version is going is something the
|
|
216
|
-
* reader confirms rather than something they have to infer from the version string.
|
|
217
|
-
|
|
224
|
+
* reader confirms rather than something they have to infer from the version string.
|
|
225
|
+
* Staging is named first because it changes what the run *does* - a reader who reads
|
|
226
|
+
* only the dist-tag would take a staged entry for one that goes live. */
|
|
227
|
+
detail: [staged ? 'staged for approval' : undefined, distTag ? `-> dist-tag "${distTag}"` : undefined]
|
|
228
|
+
.filter(Boolean)
|
|
229
|
+
.join(', ') || undefined,
|
|
218
230
|
status: published ? 'up-to-date' : 'publish',
|
|
219
231
|
reason: published
|
|
220
232
|
? `registry already has ${pkg.version}`
|
|
@@ -262,10 +274,14 @@ export var PublishService;
|
|
|
262
274
|
const publishDir = resolvePublishDir(pkg, options.contents);
|
|
263
275
|
const restore = preparePublishManifest(pkg, publishDir, packagesByName);
|
|
264
276
|
try {
|
|
265
|
-
/** The plan's own tag, not
|
|
266
|
-
* the reader confirmed that, and `applyPlan` is here to carry it out.
|
|
267
|
-
* the fallback only for a plan built by something other than `getPlan`. */
|
|
268
|
-
await exec(buildPublishCommand(packageManager, {
|
|
277
|
+
/** The plan's own tag and staging decision, not recomputed ones: `getPlan` decided where
|
|
278
|
+
* this package goes, the reader confirmed that, and `applyPlan` is here to carry it out.
|
|
279
|
+
* `options.*` is the fallback only for a plan built by something other than `getPlan`. */
|
|
280
|
+
await exec(buildPublishCommand(packageManager, {
|
|
281
|
+
...options,
|
|
282
|
+
tag: entry.distTag ?? options.tag,
|
|
283
|
+
staged: entry.staged ?? options.staged,
|
|
284
|
+
}), {
|
|
269
285
|
cwd: publishDir,
|
|
270
286
|
app: pkg.repository.app,
|
|
271
287
|
stdio: 'inherit',
|
|
@@ -294,6 +310,40 @@ function resolvePublishDir(pkg, contentsOverride) {
|
|
|
294
310
|
const rel = (typeof native === 'string' && native) || configured || contentsOverride;
|
|
295
311
|
return rel ? path.resolve(pkg.dirname, rel) : pkg.dirname;
|
|
296
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* Which registry a package's own version is asked about, and published to.
|
|
315
|
+
*
|
|
316
|
+
* npm's own precedence, measured rather than assumed: `--registry` on the command line wins over
|
|
317
|
+
* `package.json`'s `publishConfig.registry`, and with neither given npm resolves it itself from
|
|
318
|
+
* `.npmrc` - including a scoped `@owner:registry=`, which is how a GitHub Packages repository is
|
|
319
|
+
* normally set up.
|
|
320
|
+
*
|
|
321
|
+
* @returns the registry to pass, or `undefined` to let npm decide.
|
|
322
|
+
*/
|
|
323
|
+
/* **The check has to ask the registry the publish will use, and it did not.** `npm publish` honours
|
|
324
|
+
* `publishConfig.registry` - it survives into the generated manifest, since `derivePublishManifest`
|
|
325
|
+
* deletes only `publishConfig.directory` - but **`npm view` ignores it**: measured, a package whose
|
|
326
|
+
* `publishConfig.registry` pointed at a dead local address was still answered from
|
|
327
|
+
* registry.npmjs.org.
|
|
328
|
+
*
|
|
329
|
+
* So question B was asked of the wrong registry for exactly one of the three ways a registry can be
|
|
330
|
+
* stated, and `npmViewPackage` swallows a failed lookup as `undefined`, which reads as "never
|
|
331
|
+
* published". Two consequences, and the second is the one to fear:
|
|
332
|
+
*
|
|
333
|
+
* - the plan proposes a publish on **every** run, so the first succeeds and the second is rejected
|
|
334
|
+
* by the registry for republishing a version - a release job that fails for no reason of its own;
|
|
335
|
+
* - if some *other* package holds that name on npmjs.org, rman reads a stranger's version list, and
|
|
336
|
+
* can report `up-to-date` for a publish that never happened.
|
|
337
|
+
*
|
|
338
|
+
* A scoped name in an `.npmrc` was always fine - measured, `npm view @foo/bar` with
|
|
339
|
+
* `@foo:registry=http://127.0.0.1:1/` tries that address - which is why this went unnoticed.
|
|
340
|
+
*
|
|
341
|
+
* Returning `undefined` rather than a default is the half that keeps it working: passing an explicit
|
|
342
|
+
* `--registry` would override whatever `.npmrc` says and break the case that already worked. */
|
|
343
|
+
function resolveRegistry(pkg, override) {
|
|
344
|
+
const native = pkg.manifest.raw.publishConfig?.registry;
|
|
345
|
+
return override || (typeof native === 'string' && native ? native : undefined);
|
|
346
|
+
}
|
|
297
347
|
/**
|
|
298
348
|
* The retired `publish.directory` spelling, if a config still carries it.
|
|
299
349
|
*
|
|
@@ -361,8 +411,17 @@ function distTagFor(pkg, tag) {
|
|
|
361
411
|
'no --tag npm would put it on "latest" - pass --tag <name>',
|
|
362
412
|
};
|
|
363
413
|
}
|
|
414
|
+
/* **`stage publish`, not a `--staged` flag**, because that is npm's own spelling: `npm stage` is a
|
|
415
|
+
* command with `publish`/`list`/`view`/`download`/`approve`/`reject` under it, and rman only ever
|
|
416
|
+
* drives the first. The rest is a maintainer's, at a terminal with a 2FA prompt - which is the whole
|
|
417
|
+
* point of staging, and the reason rman must not grow an `approve`.
|
|
418
|
+
*
|
|
419
|
+
* The publish flags are passed through unchanged. `--otp` is the one that reads oddly beside it,
|
|
420
|
+
* since staging is what *defers* the 2FA challenge to approval time - it is passed anyway rather
|
|
421
|
+
* than refused, because whether npm accepts it there is npm's to answer and a guess here would be a
|
|
422
|
+
* rule rman invented. */
|
|
364
423
|
function buildPublishCommand(packageManager, options) {
|
|
365
|
-
const args = ['publish'];
|
|
424
|
+
const args = options.staged ? ['stage', 'publish'] : ['publish'];
|
|
366
425
|
if (options.access)
|
|
367
426
|
args.push('--access', options.access);
|
|
368
427
|
if (options.tag)
|
|
@@ -375,3 +434,13 @@ function buildPublishCommand(packageManager, options) {
|
|
|
375
434
|
args.push('--userconfig', options.userconfig);
|
|
376
435
|
return `${packageManager} ${args.join(' ')}`;
|
|
377
436
|
}
|
|
437
|
+
/** Whether this package stages: `--staged`/`--no-staged` when given, else its own
|
|
438
|
+
* `.rmanrc "publish.npm.staged"`. */
|
|
439
|
+
/* Per package rather than per run, like `directory` beside it - a repository may well want its one
|
|
440
|
+
* widely-depended-on package held for approval and the rest published directly, and the cascade
|
|
441
|
+
* already makes "all of them" a single `"[*]"` line. */
|
|
442
|
+
function resolveStaged(pkg, override) {
|
|
443
|
+
if (override !== undefined)
|
|
444
|
+
return override;
|
|
445
|
+
return !!pkg.config?.publish?.npm?.staged;
|
|
446
|
+
}
|
|
@@ -50,9 +50,17 @@ export class NpmPublishTarget {
|
|
|
50
50
|
describe: 'npm publish --otp <otp> - a 2FA one-time password, for registries that require it',
|
|
51
51
|
type: 'string',
|
|
52
52
|
},
|
|
53
|
+
staged: {
|
|
54
|
+
target: 'cli',
|
|
55
|
+
describe: "npm stage publish - hold each version in npm's staging queue instead of publishing it, " +
|
|
56
|
+
'until a maintainer runs "npm stage approve" with 2FA. --no-staged forces a direct publish ' +
|
|
57
|
+
'over .rmanrc "publish.npm.staged"',
|
|
58
|
+
type: 'boolean',
|
|
59
|
+
},
|
|
53
60
|
registry: {
|
|
54
61
|
target: 'cli',
|
|
55
|
-
describe: 'Registry to check against and publish to
|
|
62
|
+
describe: 'Registry to check against and publish to, for every package in this run. Overrides each ' +
|
|
63
|
+
'package\'s own "publishConfig.registry"; with neither, npm resolves .npmrc itself',
|
|
56
64
|
type: 'string',
|
|
57
65
|
},
|
|
58
66
|
userconfig: {
|
|
@@ -90,6 +98,11 @@ export class NpmPublishTarget {
|
|
|
90
98
|
* decides the dist-tag and refuses the two cases with nothing to derive, so `--dry-run` and
|
|
91
99
|
* the JSON a pipeline gates on have to see the flag. */
|
|
92
100
|
tag: ctx.args.tag,
|
|
101
|
+
/** Plan-time for the same reason, and a stronger one: whether a run leaves the version live
|
|
102
|
+
* or waiting for an approval is the thing a reader is confirming. Left `undefined` when the
|
|
103
|
+
* flag was not given, so each package's own `publish.npm.staged` still decides - `false`
|
|
104
|
+
* here would mean `--no-staged` and silently overrule the config. */
|
|
105
|
+
staged: ctx.args.staged,
|
|
93
106
|
};
|
|
94
107
|
}
|
|
95
108
|
}
|
package/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import { version } from './constants.js';
|
|
|
13
13
|
import { RmanApplication } from './core/application.js';
|
|
14
14
|
import { Repository } from './core/classes/repository.js';
|
|
15
15
|
import { commandName, toYargsCommand } from './core/command-builder.js';
|
|
16
|
-
import { assertNoBuiltinShadowing, defaultCommandGlobs, loadCustomCommands, } from './core/interfaces/custom-command.js';
|
|
16
|
+
import { assertNoBuiltinShadowing, defaultCommandGlobs, describeCommandSource, loadCustomCommands, } from './core/interfaces/custom-command.js';
|
|
17
17
|
import { checkCustomCommand } from './core/plugin-loader.js';
|
|
18
18
|
import { commandRegistry } from './interfaces/rman-config.interface.js';
|
|
19
19
|
import { colorYaml } from './utils/color-yaml.js';
|
|
@@ -141,7 +141,7 @@ export async function runCli(options) {
|
|
|
141
141
|
if (!c.register)
|
|
142
142
|
return { name: c.name, file: c.file, module: toCustomModule(c.custom, repository, app) };
|
|
143
143
|
const declared = c.register(app);
|
|
144
|
-
const meta = checkCustomCommand({ ...declared, command: declared.command?.trim() || c.name }, c.file);
|
|
144
|
+
const meta = checkCustomCommand({ ...declared, command: declared.command?.trim() || c.name }, describeCommandSource(c.file));
|
|
145
145
|
return { name: commandName(meta.command), file: c.file, module: toYargsCommand(meta) };
|
|
146
146
|
});
|
|
147
147
|
/**
|
|
@@ -179,7 +179,8 @@ export async function runCli(options) {
|
|
|
179
179
|
const survivors = new Set(commands);
|
|
180
180
|
for (const lost of localModules.filter(c => !survivors.has(c))) {
|
|
181
181
|
const winner = byName.get(lost.name);
|
|
182
|
-
logger.verbose(`"${lost.name}" from ${lost.file} is overridden by
|
|
182
|
+
logger.verbose(`"${lost.name}" from ${describeCommandSource(lost.file)} is overridden by ` +
|
|
183
|
+
`${describeCommandSource(winner.file)}.`);
|
|
183
184
|
}
|
|
184
185
|
}
|
|
185
186
|
assertNoBuiltinShadowing(commands, builtInNames(builtIns));
|
|
@@ -265,17 +266,21 @@ function commandEntries(repository) {
|
|
|
265
266
|
if (!entry || seen.has(entry))
|
|
266
267
|
continue;
|
|
267
268
|
seen.add(entry);
|
|
268
|
-
/** No file
|
|
269
|
-
*
|
|
269
|
+
/** **No file, and `file: ''` says so** - `describeCommandSource` turns that into
|
|
270
|
+
* `.rmanrc "commands"` wherever a message needs to name the origin. It used to be the
|
|
271
|
+
* literal string `'"commands"'`, which is not a path: `path.relative` mangled it and its
|
|
272
|
+
* own quotes doubled in every message that added a pair.
|
|
273
|
+
*
|
|
274
|
+
* The metadata therefore has to name itself, since there is no basename to fall back on,
|
|
275
|
+
* and `checkCustomCommand` is what says so when it does not. */
|
|
270
276
|
if (typeof entry === 'function') {
|
|
271
|
-
direct.push({ name: '', file: '
|
|
277
|
+
direct.push({ name: '', file: '', register: entry });
|
|
272
278
|
}
|
|
273
279
|
else {
|
|
274
|
-
/**
|
|
275
|
-
*
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
direct.push({ name: commandName(custom.command), file: '"commands"', custom });
|
|
280
|
+
/** Reached here rather than at registration, so an inline command is checked the same way
|
|
281
|
+
* one loaded from a file is. */
|
|
282
|
+
const custom = checkCustomCommand(entry, describeCommandSource(''));
|
|
283
|
+
direct.push({ name: commandName(custom.command), file: '', custom });
|
|
279
284
|
}
|
|
280
285
|
}
|
|
281
286
|
}
|
package/constants.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '2.0.0
|
|
1
|
+
export const version = '2.0.0';
|
|
@@ -157,9 +157,16 @@ export declare function loadCustomCommands(patterns: string[]): Promise<{
|
|
|
157
157
|
commands: LoadedCommand[];
|
|
158
158
|
errors: CommandLoadError[];
|
|
159
159
|
}>;
|
|
160
|
+
/**
|
|
161
|
+
* How to name a command's origin in a message: the file it came from, or the config key that
|
|
162
|
+
* declared it.
|
|
163
|
+
*
|
|
164
|
+
* @param file a `LoadedCommand.file` - `''` for a command written into `.rmanrc "commands"`.
|
|
165
|
+
*/
|
|
166
|
+
export declare function describeCommandSource(file: string): string;
|
|
160
167
|
/**
|
|
161
168
|
* Refuses a command that would take a built-in's name. Unlike a module that simply fails to load,
|
|
162
|
-
* this one is thrown: the
|
|
169
|
+
* this one is thrown: the source is fine, the *name* is the mistake, and there is no reading of
|
|
163
170
|
* `rman publish` that is safe to guess at. Silently preferring either one would leave whoever typed
|
|
164
171
|
* it unable to tell which ran.
|
|
165
172
|
*/
|
|
@@ -111,16 +111,35 @@ export async function loadCustomCommands(patterns) {
|
|
|
111
111
|
}
|
|
112
112
|
return { commands, errors };
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* How to name a command's origin in a message: the file it came from, or the config key that
|
|
116
|
+
* declared it.
|
|
117
|
+
*
|
|
118
|
+
* @param file a `LoadedCommand.file` - `''` for a command written into `.rmanrc "commands"`.
|
|
119
|
+
*/
|
|
120
|
+
/* **A command loaded from a config key has no file, and saying so beats inventing one.** `file` was
|
|
121
|
+
* `'"commands"'` for those - a string that is not a path, carrying its own quotes - and every
|
|
122
|
+
* message that formatted it got something wrong: `path.relative` mangled it, the quotes doubled
|
|
123
|
+
* (`""commands""` in the shadowing error), and `checkCustomCommand` announced it as a plugin name.
|
|
124
|
+
* One empty string and one helper is the whole fix. */
|
|
125
|
+
export function describeCommandSource(file) {
|
|
126
|
+
return file ? `"${path.relative(process.cwd(), file)}"` : '.rmanrc "commands"';
|
|
127
|
+
}
|
|
114
128
|
/**
|
|
115
129
|
* Refuses a command that would take a built-in's name. Unlike a module that simply fails to load,
|
|
116
|
-
* this one is thrown: the
|
|
130
|
+
* this one is thrown: the source is fine, the *name* is the mistake, and there is no reading of
|
|
117
131
|
* `rman publish` that is safe to guess at. Silently preferring either one would leave whoever typed
|
|
118
132
|
* it unable to tell which ran.
|
|
119
133
|
*/
|
|
134
|
+
/* The remedy differs by where the command came from, which is the other half of why the origin has
|
|
135
|
+
* to be honest: "rename the file" is the answer for a `.rman/*.mjs` and no answer at all for an
|
|
136
|
+
* entry in a config, where there is no file to rename. */
|
|
120
137
|
export function assertNoBuiltinShadowing(commands, builtins) {
|
|
121
138
|
const clash = commands.find(c => builtins.includes(c.name));
|
|
122
139
|
if (!clash)
|
|
123
140
|
return;
|
|
124
|
-
|
|
125
|
-
`
|
|
141
|
+
const remedy = clash.file
|
|
142
|
+
? `Rename the file, or give it its own name with \`command: '<name>'\`.`
|
|
143
|
+
: `Give it its own name with \`command: '<name>'\`.`;
|
|
144
|
+
throw new Error(`${describeCommandSource(clash.file)} would shadow rman's built-in "${clash.name}" command.\n ${remedy}`);
|
|
126
145
|
}
|
package/core/plugin-loader.d.ts
CHANGED
package/core/plugin-loader.js
CHANGED
|
@@ -37,17 +37,21 @@ export function registerPlugin(app, entry) {
|
|
|
37
37
|
}
|
|
38
38
|
app.plugins.add(entry);
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
/* **`source` is where the command came from, already worded for a message** - a file in quotes, or
|
|
41
|
+
* `.rmanrc "commands"` - and `describeCommandSource` is what produces it. It was called
|
|
42
|
+
* `pluginName` and the messages said `Plugin "..."`, which neither caller ever passed: both hand
|
|
43
|
+
* over a command's *origin*, so a repository's own `.rman/deploy.mjs` was announced as a plugin and
|
|
44
|
+
* a config entry came out as `Plugin """commands"""`. */
|
|
45
|
+
export function checkCustomCommand(command, source) {
|
|
41
46
|
const declared = command?.command?.trim();
|
|
42
47
|
if (!declared) {
|
|
43
|
-
throw new Error(
|
|
48
|
+
throw new Error(`${source} has a command with no "command" name - it cannot be registered.`);
|
|
44
49
|
}
|
|
45
50
|
if (typeof command.handler !== 'function') {
|
|
46
|
-
throw new Error(
|
|
51
|
+
throw new Error(`${source} command "${declared}" has no "handler" function.`);
|
|
47
52
|
}
|
|
48
53
|
if (typeof command.describe !== 'string' || !command.describe) {
|
|
49
|
-
throw new Error(
|
|
50
|
-
`nothing to list it by.`);
|
|
54
|
+
throw new Error(`${source} command "${declared}" has no "describe" - \`rman --help\` would have nothing to ` + `list it by.`);
|
|
51
55
|
}
|
|
52
56
|
return { ...command, command: declared };
|
|
53
57
|
}
|