rman 2.0.0-beta.4 → 2.0.0-beta.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/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, so nothing it contributes exists until a
98
- repository asks for it:
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
- plugins: ['node'] # by name
102
- platform: node # the same statement at a repository root, plus which technology its packages are
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
- Or say nothing: a repository that declares no technology gets the one its own files imply, announced
106
- on stderr rather than guessed silently. It used to take a second package (`rman-node`) and a
107
- `.rmanrc` before anything worked at all.
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. */
@@ -207,14 +207,20 @@ export var PublishService;
207
207
  * out. Asking it kept proposing an already-published version until npm answered 403. */
208
208
  const published = !!view?.versions.includes(pkg.version);
209
209
  const distTag = distTagFor(pkg, options.tag).tag;
210
+ const staged = resolveStaged(pkg, options.staged);
210
211
  entries.set(pkg.name, {
211
212
  package: pkg,
212
213
  version: pkg.version,
213
214
  registryVersion,
214
215
  distTag,
216
+ staged,
215
217
  /** 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
- detail: distTag ? `-> dist-tag "${distTag}"` : undefined,
218
+ * reader confirms rather than something they have to infer from the version string.
219
+ * Staging is named first because it changes what the run *does* - a reader who reads
220
+ * only the dist-tag would take a staged entry for one that goes live. */
221
+ detail: [staged ? 'staged for approval' : undefined, distTag ? `-> dist-tag "${distTag}"` : undefined]
222
+ .filter(Boolean)
223
+ .join(', ') || undefined,
218
224
  status: published ? 'up-to-date' : 'publish',
219
225
  reason: published
220
226
  ? `registry already has ${pkg.version}`
@@ -262,10 +268,14 @@ export var PublishService;
262
268
  const publishDir = resolvePublishDir(pkg, options.contents);
263
269
  const restore = preparePublishManifest(pkg, publishDir, packagesByName);
264
270
  try {
265
- /** The plan's own tag, not a recomputed one: `getPlan` decided where this package goes,
266
- * the reader confirmed that, and `applyPlan` is here to carry it out. `options.tag` is
267
- * the fallback only for a plan built by something other than `getPlan`. */
268
- await exec(buildPublishCommand(packageManager, { ...options, tag: entry.distTag ?? options.tag }), {
271
+ /** The plan's own tag and staging decision, not recomputed ones: `getPlan` decided where
272
+ * this package goes, the reader confirmed that, and `applyPlan` is here to carry it out.
273
+ * `options.*` is the fallback only for a plan built by something other than `getPlan`. */
274
+ await exec(buildPublishCommand(packageManager, {
275
+ ...options,
276
+ tag: entry.distTag ?? options.tag,
277
+ staged: entry.staged ?? options.staged,
278
+ }), {
269
279
  cwd: publishDir,
270
280
  app: pkg.repository.app,
271
281
  stdio: 'inherit',
@@ -361,8 +371,17 @@ function distTagFor(pkg, tag) {
361
371
  'no --tag npm would put it on "latest" - pass --tag <name>',
362
372
  };
363
373
  }
374
+ /* **`stage publish`, not a `--staged` flag**, because that is npm's own spelling: `npm stage` is a
375
+ * command with `publish`/`list`/`view`/`download`/`approve`/`reject` under it, and rman only ever
376
+ * drives the first. The rest is a maintainer's, at a terminal with a 2FA prompt - which is the whole
377
+ * point of staging, and the reason rman must not grow an `approve`.
378
+ *
379
+ * The publish flags are passed through unchanged. `--otp` is the one that reads oddly beside it,
380
+ * since staging is what *defers* the 2FA challenge to approval time - it is passed anyway rather
381
+ * than refused, because whether npm accepts it there is npm's to answer and a guess here would be a
382
+ * rule rman invented. */
364
383
  function buildPublishCommand(packageManager, options) {
365
- const args = ['publish'];
384
+ const args = options.staged ? ['stage', 'publish'] : ['publish'];
366
385
  if (options.access)
367
386
  args.push('--access', options.access);
368
387
  if (options.tag)
@@ -375,3 +394,13 @@ function buildPublishCommand(packageManager, options) {
375
394
  args.push('--userconfig', options.userconfig);
376
395
  return `${packageManager} ${args.join(' ')}`;
377
396
  }
397
+ /** Whether this package stages: `--staged`/`--no-staged` when given, else its own
398
+ * `.rmanrc "publish.npm.staged"`. */
399
+ /* Per package rather than per run, like `directory` beside it - a repository may well want its one
400
+ * widely-depended-on package held for approval and the rest published directly, and the cascade
401
+ * already makes "all of them" a single `"[*]"` line. */
402
+ function resolveStaged(pkg, override) {
403
+ if (override !== undefined)
404
+ return override;
405
+ return !!pkg.config?.publish?.npm?.staged;
406
+ }
@@ -49,6 +49,11 @@ export declare class NpmPublishTarget implements PublishTarget {
49
49
  describe: string;
50
50
  type: "string";
51
51
  };
52
+ staged: {
53
+ target: "cli";
54
+ describe: string;
55
+ type: "boolean";
56
+ };
52
57
  registry: {
53
58
  target: "cli";
54
59
  describe: string;
@@ -50,6 +50,13 @@ 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
62
  describe: 'Registry to check against and publish to (default: whatever .npmrc already configures)',
@@ -90,6 +97,11 @@ export class NpmPublishTarget {
90
97
  * decides the dist-tag and refuses the two cases with nothing to derive, so `--dry-run` and
91
98
  * the JSON a pipeline gates on have to see the flag. */
92
99
  tag: ctx.args.tag,
100
+ /** Plan-time for the same reason, and a stronger one: whether a run leaves the version live
101
+ * or waiting for an approval is the thing a reader is confirming. Left `undefined` when the
102
+ * flag was not given, so each package's own `publish.npm.staged` still decides - `false`
103
+ * here would mean `--no-staged` and silently overrule the config. */
104
+ staged: ctx.args.staged,
93
105
  };
94
106
  }
95
107
  }
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 ${winner.file}.`);
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 to fall back on, so the metadata has to name itself - `checkCustomCommand` says
269
- * so when it does not. `"commands"` stands in for the file in that message. */
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: '"commands"', register: entry });
277
+ direct.push({ name: '', file: '', register: entry });
272
278
  }
273
279
  else {
274
- /** `checkCustomCommand` is what refuses a nameless one, with the message that names the
275
- * omission - reached here rather than at registration so an inline command is checked
276
- * the same way a plugin's used to be. */
277
- const custom = checkCustomCommand(entry, '"commands"');
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-beta.4';
1
+ export const version = '2.0.0-beta.5';
@@ -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 file is fine, the *name* is the mistake, and there is no reading of
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 file is fine, the *name* is the mistake, and there is no reading of
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
- throw new Error(`"${path.relative(process.cwd(), clash.file)}" would shadow rman's built-in "${clash.name}" command.\n` +
125
- ` Rename the file, or give it its own name with \`command: '<name>'\`.`);
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
  }
@@ -20,6 +20,6 @@ export declare function checkCustomCommand<T extends {
20
20
  command?: string;
21
21
  describe?: unknown;
22
22
  handler?: unknown;
23
- }>(command: T, pluginName: string): T & {
23
+ }>(command: T, source: string): T & {
24
24
  command: string;
25
25
  };
@@ -37,17 +37,21 @@ export function registerPlugin(app, entry) {
37
37
  }
38
38
  app.plugins.add(entry);
39
39
  }
40
- export function checkCustomCommand(command, pluginName) {
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(`Plugin "${pluginName}" has a command with no "command" name - it cannot be registered.`);
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(`Plugin "${pluginName}" command "${declared}" has no "handler" function.`);
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(`Plugin "${pluginName}" command "${declared}" has no "describe" - \`rman --help\` would have ` +
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
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "2.0.0-beta.4",
4
+ "version": "2.0.0-beta.5",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {