rman 2.16.0 → 2.18.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 CHANGED
@@ -169,7 +169,7 @@ orders by dependencies instead, and `--json` keeps the inventory's own order.
169
169
 
170
170
  Prints local environment (OS/CPU/memory, Node, git) and repository information. A platform adds its
171
171
  own ecosystem's part - the `node` built-in reports whichever package manager
172
- `.rmanrc "packageManager"` names (`npm` by default), plus the installed `rman` and `typescript`.
172
+ `.rmanrc "packageManager.node"` names (`npm` by default), plus the installed `rman` and `typescript`.
173
173
 
174
174
  ```bash
175
175
  rman info
@@ -430,7 +430,8 @@ such as `"[*]"` the packages below.
430
430
 
431
431
  ```yaml
432
432
  # .rmanrc.yml, at the repository root
433
- packageManager: pnpm
433
+ packageManager:
434
+ node: pnpm
434
435
  logLevel: info
435
436
  allowBranch: [main, release/*]
436
437
 
@@ -1,5 +1,5 @@
1
1
  import type { cleanCommand, CleanExtraKeys } from '../commands/clean.command.js';
2
- import type { NodeConfigKeys, RmanNodeConfig } from '../node-config.interface.js';
2
+ import type { NodeConfigKeys, NodePackageManagers, RmanNodeConfig } from '../node-config.interface.js';
3
3
  import type { CiService } from '../services/ci.service.js';
4
4
  /**
5
5
  * **Every type this plugin adds to rman, in one `declare module` block.**
@@ -25,6 +25,9 @@ declare module '../../../../interfaces/rman-config.interface.js' {
25
25
  */
26
26
  interface RmanConfigKeys extends NodeConfigKeys {
27
27
  }
28
+ /** Node's entry in `.rmanrc "packageManager"` - `{ node: 'pnpm' }`. */
29
+ interface PackageManagers extends NodePackageManagers {
30
+ }
28
31
  /**
29
32
  * **`clean.*`, contributed by the command that reads it**, the way every built-in contributes its
30
33
  * own key - `skip` derived from the command's `config` block, `include`/`exclude` hand-written in
@@ -74,7 +77,7 @@ declare module '../../../../services/system-info.js' {
74
77
  /**
75
78
  * Report this package manager's version under `Binaries`, plus the `npmPackages` sections.
76
79
  *
77
- * Defaults to `.rmanrc "packageManager"` (read off `Options.repository`), then to `npm` -
80
+ * Defaults to `.rmanrc "packageManager.node"` (read off `Options.repository`), then to `npm` -
78
81
  * this package being installed is itself the statement that the repository is a Node one.
79
82
  */
80
83
  packageManager?: PackageManager;
@@ -1,4 +1,5 @@
1
1
  import { SystemInfo as OrgSystemInfo } from '../../../../services/system-info.js';
2
+ import { CiService } from '../services/ci.service.js';
2
3
  /**
3
4
  * Adds the npm half of `SystemInfo` to rman's own types.
4
5
  *
@@ -41,7 +42,12 @@ export function augmentSystemInfo() {
41
42
  /** An explicit argument first, then the repository's own config, then npm. The config step is
42
43
  * why the core declares `Options.repository`: `rman info` passes only that, so without it a
43
44
  * pnpm repository would silently be reported as an npm one. */
44
- const packageManager = options?.packageManager ?? asPackageManager(options?.repository?.config?.packageManager) ?? 'npm';
45
+ /** The old bare `packageManager: pnpm` is refused here as `ci` and `publish` refuse it - read
46
+ * as a map it has no `node` entry, and `info` would report npm for a pnpm repository. */
47
+ const configured = options?.repository?.config?.packageManager;
48
+ if (typeof configured === 'string')
49
+ CiService.resolvePackageManager(options.repository);
50
+ const packageManager = options?.packageManager ?? asPackageManager(configured?.node) ?? 'npm';
45
51
  return base({
46
52
  ...options,
47
53
  envinfo: {
@@ -1,31 +1,18 @@
1
1
  import type { ConfigValue, RmanConfig, ScopedVars } from '../../../interfaces/rman-config.interface.js';
2
2
  import type { CiService } from './services/ci.service.js';
3
3
  /**
4
- * The `.rmanrc` keys that only mean something because the repository is a Node one.
5
- *
6
- * **One key, and it is here because no single command owns it**: `packageManager` is read by `ci`
7
- * *and* by the `npm` publish target, so neither can contribute it. Everything else this package
8
- * adds to `.rmanrc` is declared by whoever reads it - `clean.*` by `clean.command.ts` (through
9
- * `CommandContribution`), `publish.npm.*` by the target, below.
10
- *
11
- * **Two surfaces, and they are not alternatives:**
12
- *
13
- * - the `declare module 'rman'` block in [`../augmentation/rman.augmentation.ts`] merges this into
14
- * `RmanConfigKeys`, so `repository.config.packageManager` stays typed wherever it is read with no
15
- * cast. It lives there because one such block per package is the limit;
16
- * - `RmanNodeConfig` is the name a *config author* annotates with, which is what makes the import
17
- * carrying that augmentation explicit instead of a side effect someone has to remember.
4
+ * The `.rmanrc` keys that only mean something because the repository is a Node one, merged into
5
+ * `RmanConfigKeys` by `rmanrc.augmentation.ts`. Everything else this built-in adds is declared by
6
+ * whoever reads it - `clean.*` by `clean.command.ts`, `publish.npm.*` by the target, below.
18
7
  */
19
8
  export interface NodeConfigKeys {
20
- /**
21
- * Which package manager `ci`/`publish` shell out to, and whose version `info` reports under
22
- * `Binaries`. Root level only. Default `npm`.
23
- *
24
- * It used to be a core key, on the grounds that `info` read it - and that stopped being true the
25
- * moment `SystemInfo`'s npm half moved here: measured, **nothing in rman's core reads it at
26
- * all**, only the declaration was left behind. The value set was npm's tooling the whole time.
27
- */
28
- packageManager?: CiService.PackageManager;
9
+ }
10
+ /**
11
+ * Node's entry in `.rmanrc "packageManager"`: which package manager `ci` installs with, `publish`
12
+ * publishes with and `info` reports - `packageManager: { node: pnpm }`. Default `npm`.
13
+ */
14
+ export interface NodePackageManagers {
15
+ node?: CiService.PackageManager;
29
16
  }
30
17
  export declare namespace RmanNodeConfig {
31
18
  /**
@@ -1,4 +1,5 @@
1
1
  import type { LogLevel } from '../../../../core/classes/logger.js';
2
+ import type { Package } from '../../../../core/classes/package.js';
2
3
  import type { Repository } from '../../../../core/classes/repository.js';
3
4
  import type { PackageFilterOptions } from '../../../../utils/package-filter.js';
4
5
  export declare namespace CiService {
@@ -21,9 +22,11 @@ export declare namespace CiService {
21
22
  * the root's `.rmanrc logLevel`, then 'info' - see `resolveRootLogLevel`. */
22
23
  logLevel?: LogLevel;
23
24
  }
24
- /** `.rmanrc packageManager` (root only) picks the package manager used for the final install;
25
- * explicit CLI value wins over it. Defaults to 'npm'. */
26
- function resolvePackageManager(repository: Repository, cliValue?: PackageManager): PackageManager;
25
+ /**
26
+ * The package manager `pkg` uses: `cliValue` when given, else its `.rmanrc "packageManager.node"`,
27
+ * else `npm`. Pass the repository for the one the repository installs with.
28
+ */
29
+ function resolvePackageManager(pkg: Package, cliValue?: PackageManager): PackageManager;
27
30
  /** Deletes `node_modules` and any known lockfile directly under `dirname`. Returns the names
28
31
  * that actually existed (and were removed), so the caller can log only those. */
29
32
  function wipe(dirname: string): Promise<string[]>;
@@ -16,17 +16,28 @@ export var CiService;
16
16
  * act on. One list, one place it is written down.
17
17
  */
18
18
  CiService.PACKAGE_MANAGERS = ['npm', 'yarn', 'pnpm', 'bun'];
19
- /** `.rmanrc packageManager` (root only) picks the package manager used for the final install;
20
- * explicit CLI value wins over it. Defaults to 'npm'. */
21
- function resolvePackageManager(repository, cliValue) {
19
+ /**
20
+ * The package manager `pkg` uses: `cliValue` when given, else its `.rmanrc "packageManager.node"`,
21
+ * else `npm`. Pass the repository for the one the repository installs with.
22
+ */
23
+ /* **The bare string is refused rather than read.** `packageManager: pnpm` was the old spelling,
24
+ * and rman validates no config keys, so read as an object it would simply have no `node` entry -
25
+ * a pnpm repository installing with npm, saying nothing. No repository of this organization
26
+ * wrote it, so a clear refusal costs nobody anything. */
27
+ function resolvePackageManager(pkg, cliValue) {
22
28
  if (cliValue)
23
29
  return cliValue;
24
- const configured = repository.config?.packageManager;
25
- if (configured === undefined)
30
+ const configured = pkg.config?.packageManager;
31
+ if (typeof configured === 'string') {
32
+ throw new Error(`.rmanrc "packageManager" takes one entry per technology - write packageManager: { node: ${configured} } ` +
33
+ `instead of packageManager: ${configured}.`);
34
+ }
35
+ const node = configured?.node;
36
+ if (node === undefined)
26
37
  return 'npm';
27
- if (CiService.PACKAGE_MANAGERS.includes(configured))
28
- return configured;
29
- throw new Error(`Invalid "packageManager" in .rmanrc: "${configured}" (expected one of: ${CiService.PACKAGE_MANAGERS.join(', ')})`);
38
+ if (typeof node === 'string' && CiService.PACKAGE_MANAGERS.includes(node))
39
+ return node;
40
+ throw new Error(`Invalid "packageManager.node" in .rmanrc: "${String(node)}" (expected one of: ${CiService.PACKAGE_MANAGERS.join(', ')})`);
30
41
  }
31
42
  CiService.resolvePackageManager = resolvePackageManager;
32
43
  /** Deletes `node_modules` and any known lockfile directly under `dirname`. Returns the names
@@ -305,7 +305,6 @@ export var PublishService;
305
305
  * `consumerNeeds`).
306
306
  */
307
307
  async function applyPlan(repository, plan, options = {}) {
308
- const packageManager = CiService.resolvePackageManager(repository, options.packageManager);
309
308
  const failed = new Set();
310
309
  const result = [];
311
310
  /** The tuple is explicit: `[p.name, p]` widens to `(string | Package)[]`, and whether `new Map`
@@ -331,7 +330,7 @@ export var PublishService;
331
330
  /** The plan's own tag and staging decision, not recomputed ones: `getPlan` decided where
332
331
  * this package goes, the reader confirmed that, and `applyPlan` is here to carry it out.
333
332
  * `options.*` is the fallback only for a plan built by something other than `getPlan`. */
334
- await exec(buildPublishCommand(packageManager, {
333
+ await exec(buildPublishCommand(CiService.resolvePackageManager(pkg, options.packageManager), {
335
334
  ...options,
336
335
  tag: entry.distTag ?? options.tag,
337
336
  staged: entry.staged ?? options.staged,
@@ -0,0 +1,30 @@
1
+ import type { RunBinOptions, RunBinResult } from '../../../../utils/run-bin.js';
2
+ /**
3
+ * The files `copyAssets` copies when it is given no patterns: the data formats a package keeps
4
+ * beside its sources - translations, fixtures, schemas - that `tsc` does not emit.
5
+ */
6
+ export declare const DEFAULT_ASSET_PATTERNS: readonly string[];
7
+ export interface CopyAssetsOptions {
8
+ /** The tsconfig the build compiles with - its `rootDir` and `outDir` say where the files are and
9
+ * where they go. */
10
+ tsconfig: string;
11
+ /** Globs over paths relative to `rootDir`. Default `DEFAULT_ASSET_PATTERNS`. */
12
+ patterns?: readonly string[];
13
+ /** Runs `tsc` - a function step's own `ctx.runBin`, which already resolves the repository's
14
+ * installed copy. */
15
+ runBin: (bin: string, argv: string[], options?: RunBinOptions) => Promise<RunBinResult>;
16
+ }
17
+ /**
18
+ * Copies the files under a tsconfig's `rootDir` that match `patterns` into its `outDir`, keeping
19
+ * their place in the tree - `src/i18n/tr.json` lands at `build/i18n/tr.json`, where the compiled
20
+ * code that reads it expects to find it. Returns the files it wrote, absolute.
21
+ *
22
+ * `tsc` emits none of them: it compiles sources, and a `.json` reaches the output only when code
23
+ * imports it under `resolveJsonModule`. A file read at run time - a translation, an XML template, a
24
+ * fixture - is left behind, and the built package fails on a path that exists only in `src`.
25
+ *
26
+ * A tsconfig with no `outDir` compiles beside its sources, so there is nothing to copy and nothing
27
+ * is. `node_modules`, the output directory itself, `tsconfig*.json`, `package.json` and
28
+ * `package-lock.json` are never copied.
29
+ */
30
+ export declare function copyAssets(options: CopyAssetsOptions): Promise<string[]>;
@@ -0,0 +1,104 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import glob from 'fast-glob';
4
+ /**
5
+ * The files `copyAssets` copies when it is given no patterns: the data formats a package keeps
6
+ * beside its sources - translations, fixtures, schemas - that `tsc` does not emit.
7
+ */
8
+ /* **A list, not "everything that is not TypeScript"** - the user's call, and the reason is what
9
+ * ships: a published package is what lands in a consumer's `node_modules`, and "every other file"
10
+ * puts a stray `.md`, a `.DS_Store` or a test's data file there with nothing saying so. A list
11
+ * leaves out what nobody named, and a repository that needs more names it. */
12
+ export const DEFAULT_ASSET_PATTERNS = ['**/*.json', '**/*.xml', '**/*.yaml', '**/*.yml'];
13
+ /**
14
+ * Copies the files under a tsconfig's `rootDir` that match `patterns` into its `outDir`, keeping
15
+ * their place in the tree - `src/i18n/tr.json` lands at `build/i18n/tr.json`, where the compiled
16
+ * code that reads it expects to find it. Returns the files it wrote, absolute.
17
+ *
18
+ * `tsc` emits none of them: it compiles sources, and a `.json` reaches the output only when code
19
+ * imports it under `resolveJsonModule`. A file read at run time - a translation, an XML template, a
20
+ * fixture - is left behind, and the built package fails on a path that exists only in `src`.
21
+ *
22
+ * A tsconfig with no `outDir` compiles beside its sources, so there is nothing to copy and nothing
23
+ * is. `node_modules`, the output directory itself, `tsconfig*.json`, `package.json` and
24
+ * `package-lock.json` are never copied.
25
+ */
26
+ /* **`rootDir`/`outDir` are asked of `tsc --showConfig`, not read out of the file.** A package's
27
+ * tsconfig usually inherits both through `extends` - a relative path or a package such as
28
+ * `@panates/tsconfig` - and resolving that chain is `tsc`'s own logic: JSON with comments, a list
29
+ * of bases, package `exports`. `--showConfig` prints the resolved result with every path relative
30
+ * to the config it was given, in about 0.4s (measured on `panates/opra`'s `common`), which is small
31
+ * beside the compile it follows.
32
+ *
33
+ * **No `rootDir` means `tsc` computed one** - the common directory of its inputs - and `--showConfig`
34
+ * lists those inputs under `files`, so the same answer is taken from there. Declaration files are
35
+ * left out of it, as `tsc` leaves them out. */
36
+ export async function copyAssets(options) {
37
+ const configDir = path.dirname(path.resolve(options.tsconfig));
38
+ /** **`onLine` swallows the lines**: a step's `runBin` hands every line to the step's log, and the
39
+ * whole resolved configuration printed under the package is noise. `output` still collects them. */
40
+ let output;
41
+ try {
42
+ ({ output } = await options.runBin('tsc', ['--showConfig', '-p', options.tsconfig], {
43
+ stdio: 'pipe',
44
+ onLine: () => undefined,
45
+ }));
46
+ }
47
+ catch (e) {
48
+ throw new Error(`"tsc --showConfig -p ${options.tsconfig}" failed:\n${String(e?.output ?? e?.message).trim()}`, {
49
+ cause: e,
50
+ });
51
+ }
52
+ const config = parseShowConfig(output, options.tsconfig);
53
+ const outDir = config.compilerOptions?.outDir;
54
+ if (!outDir)
55
+ return [];
56
+ const out = path.resolve(configDir, outDir);
57
+ const root = config.compilerOptions?.rootDir
58
+ ? path.resolve(configDir, config.compilerOptions.rootDir)
59
+ : (commonDir((config.files ?? []).filter(f => !/\.d\.[cm]?ts$/.test(f)).map(f => path.resolve(configDir, f))) ??
60
+ configDir);
61
+ /** `package.json` and its lockfile because a `rootDir` of the package itself would otherwise send
62
+ * them along - and the build directory's manifest is `publish`'s (or the preset's) to write. */
63
+ const ignore = ['**/node_modules/**', '**/tsconfig*.json', '**/package.json', '**/package-lock.json'];
64
+ const outInsideRoot = path.relative(root, out);
65
+ if (outInsideRoot && !outInsideRoot.startsWith('..') && !path.isAbsolute(outInsideRoot)) {
66
+ ignore.push(`${outInsideRoot.split(path.sep).join('/')}/**`);
67
+ }
68
+ const files = await glob([...(options.patterns ?? DEFAULT_ASSET_PATTERNS)], {
69
+ cwd: root,
70
+ ignore,
71
+ onlyFiles: true,
72
+ dot: false,
73
+ });
74
+ const written = [];
75
+ for (const rel of files.sort()) {
76
+ const target = path.join(out, rel);
77
+ fs.mkdirSync(path.dirname(target), { recursive: true });
78
+ fs.copyFileSync(path.join(root, rel), target);
79
+ written.push(target);
80
+ }
81
+ return written;
82
+ }
83
+ /** `tsc --showConfig`'s answer. It prints the JSON alone on success; anything else is a tsconfig
84
+ * `tsc` could not read, which is reported with what it said. */
85
+ function parseShowConfig(output, tsconfig) {
86
+ const start = output.indexOf('{');
87
+ try {
88
+ return JSON.parse(output.slice(start));
89
+ }
90
+ catch {
91
+ throw new Error(`"tsc --showConfig -p ${tsconfig}" did not print a configuration:\n${output.trim()}`);
92
+ }
93
+ }
94
+ /** The deepest directory holding every one of `files`, or `undefined` for none. */
95
+ function commonDir(files) {
96
+ if (!files.length)
97
+ return undefined;
98
+ let common = path.dirname(files[0]);
99
+ for (const file of files.slice(1)) {
100
+ while (path.relative(common, file).startsWith('..'))
101
+ common = path.dirname(common);
102
+ }
103
+ return common;
104
+ }
@@ -183,7 +183,42 @@ async function buildAndPush(repository, entry) {
183
183
  args.push('--build-arg', `${name}="${expandEnvValue(value)}"`);
184
184
  }
185
185
  args.push('-f', `"${dockerfile}"`, '-t', `"${entry.image}:${entry.version}"`, '-t', `"${entry.image}:latest"`, '--push', '.');
186
- await exec(`docker ${args.join(' ')}`, { cwd, app: repository.app, stdio: 'inherit' });
186
+ /** **Shown as it runs and kept**, so a failure can say why. It ran with `stdio: 'inherit'`, and the
187
+ * one thing left to report was the exit code - measured on `panates/syncbridge`, the line that
188
+ * explained the failure sat forty lines above the plan's recap and the recap said
189
+ * `Command failed (1)`. On stderr, so a `--json` stdout stays one document. */
190
+ const lines = [];
191
+ const result = await exec(`docker ${args.join(' ')}`, {
192
+ cwd,
193
+ app: repository.app,
194
+ throwOnError: false,
195
+ onLine: line => {
196
+ lines.push(line);
197
+ process.stderr.write(line + '\n');
198
+ },
199
+ });
200
+ if (result.code)
201
+ throw new Error(`docker build exited with code ${result.code}${failureLines(lines)}`);
202
+ }
203
+ /**
204
+ * The lines of a failed build worth putting in a one-paragraph reason: those saying "error", with
205
+ * BuildKit's `#42 31.01 ` step prefix removed, each once - BuildKit prints a failing step's output
206
+ * twice, once as it runs and again in its summary - and without npm's pointer to its own log file.
207
+ * At most four, as an indented block under the first line.
208
+ */
209
+ function failureLines(lines) {
210
+ const seen = new Set();
211
+ for (const raw of lines) {
212
+ const line = raw
213
+ .replace(/^#\d+\s+[\d.]+\s+/, '')
214
+ .replace(/^[\d.]+\s+/, '')
215
+ .trim();
216
+ if (!/\berror\b/i.test(line) || /complete log of this run/i.test(line))
217
+ continue;
218
+ seen.add(line);
219
+ }
220
+ const picked = [...seen].slice(0, 4);
221
+ return picked.length ? ':\n' + picked.map(l => ` ${l}`).join('\n') : '';
187
222
  }
188
223
  async function updateDescription(entry) {
189
224
  const pkg = entry.package;
@@ -14,6 +14,8 @@ import { DOCKER_TARGET } from './docker-publish.service.js';
14
14
  export const dockerPublishTarget = {
15
15
  name: DOCKER_TARGET,
16
16
  describe: 'Build and push a container image (docker buildx build --push)',
17
+ /** An image installs its dependencies from the registries the other targets push to. */
18
+ publishesLast: true,
17
19
  options: {
18
20
  dockerNamespace: {
19
21
  target: 'cli',
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import readline from 'node:readline/promises';
2
3
  import colors from 'ansi-colors';
3
4
  import { unknownTargets } from '../core/interfaces/publish-target.js';
@@ -109,7 +110,9 @@ const publishCommand = registerCommand(app => {
109
110
  handler: async (args) => {
110
111
  await assertAllowedBranch(repository, readBranchGuardOptions(args));
111
112
  const requested = args.target;
112
- const selected = selectTargets(targets, requested);
113
+ /** A target built from what the others publish goes after them - see `publishesLast`. A
114
+ * stable sort, so registration order still decides among the rest. */
115
+ const selected = selectTargets(targets, requested).sort((a, b) => Number(!!a.publishesLast) - Number(!!b.publishesLast));
113
116
  const ctx = {
114
117
  app,
115
118
  repository,
@@ -161,14 +164,21 @@ const publishCommand = registerCommand(app => {
161
164
  }
162
165
  if (!proceed)
163
166
  return;
164
- let failed = false;
167
+ const outcomes = [];
165
168
  for (const [target, plan] of plans) {
166
169
  const applied = await target.applyPlan(ctx, plan);
167
- if (printApplied(applied, plan, target.name))
168
- failed = true;
170
+ outcomes.push(...printApplied(applied, plan, target.name));
171
+ }
172
+ if (!args.json)
173
+ printRecap(outcomes);
174
+ writeStepSummary(outcomes);
175
+ const failed = outcomes.filter(o => o.entry.status === 'error');
176
+ /** **The names are in the line itself**, not only in the recap above it: in CI stdout and
177
+ * stderr are separate pipes, and measured on `panates/syncbridge` this line landed in the
178
+ * middle of the `published` lines rather than after them. */
179
+ if (failed.length) {
180
+ throw logged(`"publish" failed for ${failed.map(o => `[${o.target}] ${o.entry.package.name}`).join(', ')}`);
169
181
  }
170
- if (failed)
171
- throw logged('"publish" failed');
172
182
  },
173
183
  };
174
184
  });
@@ -265,25 +275,86 @@ function printPlan(entries, label) {
265
275
  }
266
276
  }
267
277
  /**
268
- * What one target actually did. Returns whether anything it was asked to publish failed.
278
+ * What one target actually did, printed as it is known. Returns the outcome of every package the
279
+ * *plan* said to publish.
269
280
  *
270
- * An `'error'` is only reported when the *plan* said `'publish'` for that package: an entry that
281
+ * An `'error'` is only an outcome when the plan said `'publish'` for that package: an entry that
271
282
  * was already an error before anything ran has been printed once by `printPlan`, and printing it
272
283
  * again under "failed" would read as a push that was attempted and did not work.
273
284
  */
274
285
  function printApplied(applied, plan, label) {
275
286
  const prefix = colors.gray(`[${label}] `);
276
- let failed = false;
287
+ const outcomes = [];
277
288
  for (const entry of applied) {
289
+ if (plan.find(e => e.package === entry.package)?.status !== 'publish')
290
+ continue;
278
291
  if (entry.status === 'publish') {
279
292
  console.log(colors.green('published'), prefix + colors.cyan(entry.package.name), entry.detail ?? entry.version);
280
293
  }
281
- else if (entry.status === 'error' && plan.find(e => e.package === entry.package)?.status === 'publish') {
282
- failed = true;
294
+ else if (entry.status === 'error') {
283
295
  console.log(colors.red('failed'), prefix + colors.cyan(entry.package.name), colors.red(entry.reason ?? ''));
284
296
  }
297
+ else
298
+ continue;
299
+ outcomes.push({ target: label, entry });
300
+ }
301
+ return outcomes;
302
+ }
303
+ /**
304
+ * **One block at the end saying how it went**: how many went up, and every failure again with its
305
+ * reason. A target prints as it goes, so on a long release the failed line is hundreds of lines up,
306
+ * among the output of the build that failed - measured on `panates/syncbridge`, where the last
307
+ * thing in the log was `"publish" failed` and nothing said what.
308
+ */
309
+ function printRecap(outcomes) {
310
+ if (!outcomes.length)
311
+ return;
312
+ const failed = outcomes.filter(o => o.entry.status === 'error');
313
+ const published = outcomes.length - failed.length;
314
+ console.log('\n' +
315
+ colors.bold('publish') +
316
+ ' ' +
317
+ colors.green(`${published} published`) +
318
+ colors.gray(', ') +
319
+ (failed.length ? colors.red(`${failed.length} failed`) : colors.gray('0 failed')));
320
+ for (const { target, entry } of failed) {
321
+ const [first, ...rest] = (entry.reason ?? '').split('\n');
322
+ console.log(` ${colors.red('failed')} ${colors.gray(`[${target}]`)} ${colors.cyan(entry.package.name)} ${entry.version}`);
323
+ console.log(` ${colors.red(first ?? '')}`);
324
+ for (const line of rest)
325
+ console.log(colors.gray(` ${line}`));
326
+ }
327
+ }
328
+ /**
329
+ * The same outcome as a table on the job's summary page, when running in GitHub Actions - where a
330
+ * reader looks first, and where a failed release otherwise shows only "exit code 1".
331
+ *
332
+ * A no-op outside it (`GITHUB_STEP_SUMMARY` unset), and a summary that cannot be written is not a
333
+ * reason to fail a publish that worked.
334
+ */
335
+ function writeStepSummary(outcomes) {
336
+ const file = process.env.GITHUB_STEP_SUMMARY;
337
+ if (!file || !outcomes.length)
338
+ return;
339
+ const cell = (text) => text.replace(/\|/g, '\\|').replace(/\n+/g, '<br>');
340
+ const rows = outcomes.map(({ target, entry }) => entry.status === 'error'
341
+ ? `| ❌ failed | ${target} | \`${entry.package.name}\` | ${entry.version} | ${cell(entry.reason ?? '')} |`
342
+ : `| ✅ published | ${target} | \`${entry.package.name}\` | ${entry.version} | ${cell(entry.detail ?? '')} |`);
343
+ const failed = outcomes.filter(o => o.entry.status === 'error').length;
344
+ const markdown = [
345
+ `### rman publish - ${outcomes.length - failed} published, ${failed} failed`,
346
+ '',
347
+ '| Result | Target | Package | Version | Detail |',
348
+ '| --- | --- | --- | --- | --- |',
349
+ ...rows,
350
+ '',
351
+ ].join('\n');
352
+ try {
353
+ fs.appendFileSync(file, markdown + '\n');
354
+ }
355
+ catch {
356
+ /* The summary is a courtesy; the release itself is what the exit code reports. */
285
357
  }
286
- return failed;
287
358
  }
288
359
  /** The `logged` convention: printed here, so `runCli`'s catch does not print it a second time. */
289
360
  /* On stderr, because under `--json` stdout is one JSON document: measured on opra's release, the
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.16.0';
1
+ export const version = '2.18.0';
@@ -41,6 +41,11 @@ export interface PublishTarget {
41
41
  * Name a flag for the target when it genuinely belongs to one (`--docker-namespace`).
42
42
  */
43
43
  readonly options?: Record<string, RmanConfig.CommandOption>;
44
+ /**
45
+ * Applied after every target without it. For a target whose artifact is built **from** what the
46
+ * others publish - an image whose `Dockerfile` runs `npm install` - and so has to wait for them.
47
+ */
48
+ readonly publishesLast?: boolean;
44
49
  /**
45
50
  * The technologies this target is for, by `Platform.name` - `['node']` for `npm`. Absent means
46
51
  * every platform, which is what a technology-agnostic target says: any package can carry a
package/index.d.ts CHANGED
@@ -169,6 +169,7 @@ export { CiService } from './builtins/platforms/node/services/ci.service.js';
169
169
  export { CleanService } from './builtins/platforms/node/services/clean.service.js';
170
170
  export { PublishService } from './builtins/platforms/node/services/publish.service.js';
171
171
  export { NodeVersionPlanService } from './builtins/platforms/node/services/version-plan.service.js';
172
+ export { copyAssets, type CopyAssetsOptions, DEFAULT_ASSET_PATTERNS, } from './builtins/platforms/node/utils/copy-assets.js';
172
173
  export type { ParsedWorkspaceRange } from './builtins/platforms/node/utils/workspace-range.js';
173
174
  export { NPM_TARGET, NpmPublishTarget } from './builtins/publish-targets/npm/npm-publish-target.js';
174
175
  export { isCalendarVersion } from './utils/release-version.js';
package/index.js CHANGED
@@ -135,6 +135,7 @@ export { CiService } from './builtins/platforms/node/services/ci.service.js';
135
135
  export { CleanService } from './builtins/platforms/node/services/clean.service.js';
136
136
  export { PublishService } from './builtins/platforms/node/services/publish.service.js';
137
137
  export { NodeVersionPlanService } from './builtins/platforms/node/services/version-plan.service.js';
138
+ export { copyAssets, DEFAULT_ASSET_PATTERNS, } from './builtins/platforms/node/utils/copy-assets.js';
138
139
  export { NPM_TARGET, NpmPublishTarget } from './builtins/publish-targets/npm/npm-publish-target.js';
139
140
  export { isCalendarVersion } from './utils/release-version.js';
140
141
  export { runBin } from './utils/run-bin.js';
@@ -72,6 +72,13 @@ export type CommandEntry = string | CustomCommand | RmanConfig.CommandRegisterFu
72
72
  /** One entry of `.rmanrc "publishTargets"` - a target, or a **glob** naming `.js` modules that
73
73
  * `export default` one. */
74
74
  export type PublishTargetEntry = string | PublishTarget;
75
+ /**
76
+ * `.rmanrc "packageManager"`: one entry per technology, by `Platform.name`. Empty here - each
77
+ * platform that has a package manager to choose adds its own entry by declaration merging, the way
78
+ * `PublishTargetConfigs` collects the publish targets' blocks.
79
+ */
80
+ export interface PackageManagers {
81
+ }
75
82
  /**
76
83
  * **The `.rmanrc` keys no command owns**, without `extends` - kept
77
84
  * separate from `RmanConfig` only so the two halves stay legible. A key a *command* owns is
@@ -308,6 +315,11 @@ export interface RmanConfigKeys {
308
315
  */
309
316
  vars?: Record<string, unknown>;
310
317
  logLevel?: 'silent' | 'error' | 'info' | 'verbose';
318
+ /**
319
+ * Which package manager each technology uses, keyed by `Platform.name` - `{ node: 'pnpm' }`.
320
+ * Cascades per package. A technology with no choice to make (Cargo has `cargo`) adds no entry.
321
+ */
322
+ packageManager?: PackageManagers;
311
323
  allowBranch?: string | string[];
312
324
  ignoreBranch?: string | string[];
313
325
  /**
package/package.json CHANGED
@@ -1,33 +1,25 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "2.16.0",
4
+ "version": "2.18.0",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
8
+ "@jsopen/objects": "^2.3.4",
8
9
  "@netlify/parse-npm-script": "^0.1.2",
9
10
  "@xmldom/xmldom": "^0.9.12",
10
- "@jsopen/objects": "^2.3.4",
11
11
  "ansi-colors": "^4.1.3",
12
- "cross-dirname": "^0.1.0",
13
12
  "easy-table": "^1.2.0",
14
13
  "envinfo": "^7.21.0",
15
14
  "fast-glob": "^3.3.3",
16
- "fast-tokenizer": "^1.10.0",
15
+ "fast-tokenizer": "^1.10.1",
17
16
  "ini": "^7.0.0",
18
- "is-ci": "^4.1.0",
19
17
  "js-yaml": "^5.4.1",
20
18
  "micromatch": "^4.0.8",
21
- "npmlog": "^7.0.1",
22
19
  "power-tasks": "^1.14.2",
23
- "putil-merge": "^3.13.0",
24
- "putil-varhelpers": "^1.7.0",
25
20
  "semver": "^7.8.5",
26
21
  "signal-exit": "^4.1.0",
27
- "strict-typed-events": "^2.8.0",
28
- "strip-color": "^0.1.0",
29
- "tslib": "^2.8.1",
30
- "yargs": "^18.1.0"
22
+ "yargs": "^18.2.0"
31
23
  },
32
24
  "type": "module",
33
25
  "module": "./index.js",
package/utils/git.js CHANGED
@@ -228,7 +228,7 @@ export class GitHelper {
228
228
  async moveHistory(file) {
229
229
  let stdout;
230
230
  try {
231
- ({ stdout } = await execFileAsync('git', ['log', '--follow', '--format=%x00%H', '--name-status', '--', path.relative(this.cwd, file)], { cwd: this.cwd, maxBuffer: 64 * 1024 * 1024 }));
231
+ ({ stdout } = await execFileAsync('git', ['log', '--follow', '--format=%x00%H', '--name-status', '--', path.relative(this.cwd, file)], { cwd: this.cwd }));
232
232
  }
233
233
  catch {
234
234
  return [];
@@ -263,7 +263,6 @@ export class GitHelper {
263
263
  if (!renames) {
264
264
  renames = execFileAsync('git', ['show', '-M', '--name-status', '--format=', sha], {
265
265
  cwd: this.cwd,
266
- maxBuffer: 64 * 1024 * 1024,
267
266
  }).then(({ stdout }) => stdout
268
267
  .split(/\r?\n/)
269
268
  .map(line => line.split('\t'))
@@ -275,7 +274,6 @@ export class GitHelper {
275
274
  try {
276
275
  const { stdout } = await execFileAsync('git', ['ls-tree', '-r', '--name-only', `${sha}^`, '--', `${fromDir}/`], {
277
276
  cwd: this.cwd,
278
- maxBuffer: 64 * 1024 * 1024,
279
277
  });
280
278
  held = stdout.split(/\r?\n/).filter(Boolean);
281
279
  }
@@ -436,7 +434,7 @@ export class GitHelper {
436
434
  if (pathspec)
437
435
  args.push('--', pathspec);
438
436
  try {
439
- const { stdout } = await execFileAsync('git', args, { cwd: this.cwd, maxBuffer: 64 * 1024 * 1024 });
437
+ const { stdout } = await execFileAsync('git', args, { cwd: this.cwd });
440
438
  return stdout;
441
439
  }
442
440
  catch (e) {
@@ -450,7 +448,6 @@ export class GitHelper {
450
448
  try {
451
449
  const { stdout } = await execFileAsync('git', ['format-patch', '--root', '-o', outputDir, 'HEAD'], {
452
450
  cwd: this.cwd,
453
- maxBuffer: 64 * 1024 * 1024,
454
451
  });
455
452
  return stdout.trim() ? stdout.trim().split(/\r?\n/) : [];
456
453
  }
@@ -465,11 +462,20 @@ export class GitHelper {
465
462
  if (!patchFiles.length)
466
463
  return;
467
464
  try {
468
- await execFileAsync('git', ['am', '--3way', ...patchFiles], { cwd: this.cwd, maxBuffer: 64 * 1024 * 1024 });
465
+ await execFileAsync('git', ['am', '--3way', ...patchFiles], { cwd: this.cwd });
469
466
  }
470
467
  catch (e) {
471
468
  throw new Error(`Unable to apply patches: ${e.message}`, { cause: e });
472
469
  }
473
470
  }
474
471
  }
475
- const execFileAsync = promisify(execFile);
472
+ /* **One buffer for every git call, not one per call site.** Node's `execFile` defaults `maxBuffer`
473
+ * to 1 MiB and fails the call past it, and how much git prints depends on the repository, not on
474
+ * the command. Opting in call by call left the `git show --name-only` behind `listCommittedFiles`
475
+ * and `listCommits` on the default, so one vendored-library commit in `panates/formwave` - 28k
476
+ * files, 2.9 MB of names - failed `rman list` with "stdout maxBuffer length exceeded". */
477
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
478
+ const execFileP = promisify(execFile);
479
+ function execFileAsync(file, args, options) {
480
+ return execFileP(file, args, { maxBuffer: GIT_MAX_BUFFER, ...options });
481
+ }