rman 1.0.10 → 1.0.12

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.
@@ -17,10 +17,44 @@ export interface RmanConfig {
17
17
  clean?: RmanConfig.CleanOptions;
18
18
  publish?: RmanConfig.PublishOptions;
19
19
  githubRelease?: RmanConfig.GithubReleaseOptions;
20
- /** Keyed by npm script name (e.g. `"build"`, `"lint"`, `"test"`). */
21
- run?: Record<string, RmanConfig.RunScriptOptions>;
22
- /** Keyed by the in-repo package's own name. */
23
- packages?: Record<string, RmanConfig.PackageOptions>;
20
+ /** Keyed by npm script name (e.g. `"build"`, `"lint"`, `"test"`). A bare string (or array of
21
+ * them) is shorthand for `{ exec: ... }` - `test: "mocha"` and `test: { exec: "mocha" }` mean
22
+ * exactly the same thing. */
23
+ run?: Record<string, string | string[] | RmanConfig.RunScriptOptions>;
24
+ /** In-repo packages this one depends on beyond what its real `package.json` declares - purely
25
+ * for rman's own dependency graph (topo-sort, `--deps`/`--dependents`, `run`'s scheduling). An
26
+ * array defaults each entry's range to `"*"`; an object gives an explicit name -> range map.
27
+ * Declared from the root via a selector (`"[pkg-a]": { dependencies: [...] }`) or in the
28
+ * package's own `.rmanrc`. */
29
+ dependencies?: string[] | Record<string, string>;
30
+ /**
31
+ * Config for **other** packages, keyed by a `"[selector]"` naming them - `"[*]"` for every
32
+ * package in the repository, `"[*-dialect]"` for a glob over package names, `"[pkg-a]"` for one.
33
+ * Everything else in this object configures the package of the directory declaring it, so this
34
+ * is the only way a `.rmanrc` speaks about anything but its own package - most usefully the
35
+ * repository root's, which otherwise configures the root package alone.
36
+ *
37
+ * ```yaml
38
+ * # the repository root's own .rmanrc.yml
39
+ * run:
40
+ * build:
41
+ * before: node support/generate.cjs # a repo-wide bookend, run once at the root
42
+ * "[*]":
43
+ * run:
44
+ * build:
45
+ * after: node ../../support/postbuild.cjs # run in each package's own directory
46
+ * ```
47
+ *
48
+ * In YAML the quotes are **required**: a bare `[*]` parses as a flow sequence, and `*` as an
49
+ * alias indicator. Precedence, lowest first: `"[*]"`, then other selectors in declaration order,
50
+ * then the package's own unmarked config.
51
+ *
52
+ * Recursive, mirroring the schema's own `"$ref": "#"`: whatever a `.rmanrc` may say about its own
53
+ * package it may say here about the ones it names - nested selectors included. Typed as
54
+ * `RmanConfig` rather than `unknown` so the contents are actually checked; `unknown` let any
55
+ * shape through, which is the opposite of the point.
56
+ */
57
+ [selector: `[${string}]`]: RmanConfig;
24
58
  }
25
59
  export declare namespace RmanConfig {
26
60
  interface VersionOptions {
@@ -36,9 +70,29 @@ export declare namespace RmanConfig {
36
70
  * any package's own `changelog.tagPattern`, or that package's changelog boundary will resolve
37
71
  * to the repository release instead of its own last release. */
38
72
  releaseTagPattern?: string;
39
- script?: string | string[];
40
- preScript?: string | string[];
41
- postScript?: string | string[];
73
+ /** Keep this package's Dockerfile `org.opencontainers.image.version` label in step with the
74
+ * version being written. Per-package cascaded. Default `true` - the label's value is, by
75
+ * specification, the version of the packaged software, so there is only ever one correct
76
+ * value for it, and `version` is what knows it. Only ever *rewrites* a label the Dockerfile
77
+ * already declares (never inserts one), and reads the same path `publish --target docker`
78
+ * builds from (`publish.docker.dockerfile`), so a package without one is a no-op. */
79
+ stampDockerfile?: boolean;
80
+ /** Source files whose `version` constant is rewritten to the version being written, in the same
81
+ * commit as the bump - paths relative to the package's own directory (e.g.
82
+ * `["src/constants.ts"]`). Per-package cascaded; a listed file a package doesn't have is a
83
+ * silent no-op, so one `"[*]"` declaration covers a repo where only some packages carry one.
84
+ *
85
+ * Stamping the source, not the build output: a build-time rewrite leaves the checked-in file
86
+ * claiming a placeholder, so anything running from source reports that placeholder, git never
87
+ * records the released version, and the rewrite has to be redone on every build. */
88
+ stamp?: string | string[];
89
+ /** Command(s) run as this package's own `version` npm-lifecycle step, when its `package.json`
90
+ * doesn't define one itself. An array runs them in sequence. */
91
+ exec?: string | string[];
92
+ /** Same, for `preversion`. */
93
+ before?: string | string[];
94
+ /** Same, for `postversion`. */
95
+ after?: string | string[];
42
96
  }
43
97
  interface ChangelogOptions {
44
98
  ignoreTypes?: string[];
@@ -60,14 +114,15 @@ export declare namespace RmanConfig {
60
114
  changedSince?: string;
61
115
  skip?: boolean;
62
116
  if?: string;
63
- script?: string | string[];
64
- preScript?: string | string[];
65
- postScript?: string | string[];
117
+ /** Command(s) to run as this script itself, when the package's `package.json` doesn't define
118
+ * it. An array runs them in sequence. */
119
+ exec?: string | string[];
120
+ /** Same, for this script's `pre<script>` hook. */
121
+ before?: string | string[];
122
+ /** Same, for its `post<script>` hook. */
123
+ after?: string | string[];
66
124
  override?: boolean;
67
125
  }
68
- interface PackageOptions {
69
- dependencies?: string[] | Record<string, string>;
70
- }
71
126
  interface PublishOptions {
72
127
  /** Which **registry** `publish` ships this package to - default `['npm']` (every existing repo
73
128
  * keeps working unchanged). A package that only ever wants Docker images (typically also
@@ -80,6 +135,14 @@ export declare namespace RmanConfig {
80
135
  * a release happened, and it is never opted into: see `githubRelease` and the
81
136
  * `github-release` command. */
82
137
  target?: PublishTarget | PublishTarget[];
138
+ /** Where this package's publishable output lives, relative to its own directory (e.g.
139
+ * `"build"`). Per-package cascaded, so a root `"[*]"` block can say it once for the whole
140
+ * repository instead of repeating `publishConfig.directory` in every `package.json` - which
141
+ * still wins when a package declares it, being the more specific statement.
142
+ *
143
+ * Publishing from such a directory means the manifest there is **generated by `publish`**,
144
+ * from the package's own - see `PublishService`. There is nothing to configure about it. */
145
+ directory?: string;
83
146
  docker?: DockerPublishOptions;
84
147
  /** Excludes this package from `publish` entirely (every target), regardless of
85
148
  * `target`/`"private"` - a single, explicit "never published" statement, e.g. for a package
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "1.0.10",
4
+ "version": "1.0.12",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -2,8 +2,15 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "https://raw.githubusercontent.com/panates/rman/main/schemas/rmanrc.schema.json",
4
4
  "title": "rman configuration",
5
- "description": "Schema for rman's .rmanrc (JSON) and .rmanrc.yml (YAML) config files, and the \"rman\" key in package.json. See docs/api.md#configuration-rmanrc-rmanrcyml for the full reference.",
5
+ "description": "Schema for rman's .rmanrc (JSON) and .rmanrc.yml (YAML) config files, and the \"rman\" key in package.json. See docs/api.md#configuration-rmanrc-rmanrcyml for the full reference. Any string value may embed ${{ ... }} expressions - real JavaScript, evaluated per package against pkg, repository, env and semver - so one declaration at the root can still say something package-specific (\"../../coverage/${{ pkg.basename }}\", \"panates/${{ pkg.basename }}:${{ semver.major(pkg.version) }}\"). A string that is nothing but one expression keeps that value's own type. A bare {{ ... }} is left alone, so a command carrying someone else's template survives untouched.",
6
6
  "type": "object",
7
+ "additionalProperties": false,
8
+ "patternProperties": {
9
+ "^\\[.+\\]$": {
10
+ "$ref": "#",
11
+ "description": "Config for the packages this selector names, rather than for the package of the directory declaring it - \"[*]\" for every package in the repository, \"[*-dialect]\" for a glob over package names, \"[pkg-a]\" for one. In YAML the quotes are REQUIRED: a bare [*] parses as a flow sequence and * as an alias indicator. Precedence, lowest first: \"[*]\", then other selectors in declaration order, then the package's own unmarked config."
12
+ }
13
+ },
7
14
  "properties": {
8
15
  "$schema": {
9
16
  "type": "string",
@@ -66,15 +73,24 @@
66
73
  "default": "release-*",
67
74
  "description": "Root-level. Tag naming the repository's own release, as opposed to the per-package/group tags \"changelog.tagPattern\" names - only created when the root is on a calendar version (a repo with more than one version line). Must NOT match any package's \"changelog.tagPattern\", or that package's changelog boundary resolves to the repository release instead of its own."
68
75
  },
69
- "script": {
76
+ "stampDockerfile": {
77
+ "type": "boolean",
78
+ "default": true,
79
+ "description": "Per-package cascaded. Keep this package's Dockerfile \"org.opencontainers.image.version\" label in step with the version being written, in the same commit as the bump. The label's value is by specification the version of the packaged software, so there is only one correct value for it. Only ever rewrites a label the Dockerfile already declares (never inserts one), and reads the same path \"publish --target docker\" builds from (\"publish.docker.dockerfile\")."
80
+ },
81
+ "stamp": {
70
82
  "$ref": "#/definitions/stringOrStringArray",
71
- "description": "Command(s) to run as this package's own \"version\" npm-lifecycle step, when its package.json does not define one itself."
83
+ "description": "Source files whose \"version\" constant is rewritten to the version being written, in the same commit as the bump - paths relative to the package's own directory (e.g. [\"src/constants.ts\"]). Per-package cascaded; a listed file a package doesn't have is a silent no-op. Stamps the source rather than the build output, so anything running from source reports the real version and git records it."
72
84
  },
73
- "preScript": {
85
+ "before": {
74
86
  "$ref": "#/definitions/stringOrStringArray",
75
87
  "description": "Command(s) to run as this package's own \"preversion\" step, when its package.json does not define one itself."
76
88
  },
77
- "postScript": {
89
+ "exec": {
90
+ "$ref": "#/definitions/stringOrStringArray",
91
+ "description": "Command(s) to run as this package's own \"version\" npm-lifecycle step, when its package.json does not define one itself."
92
+ },
93
+ "after": {
78
94
  "$ref": "#/definitions/stringOrStringArray",
79
95
  "description": "Command(s) to run as this package's own \"postversion\" step, when its package.json does not define one itself."
80
96
  }
@@ -151,6 +167,10 @@
151
167
  }
152
168
  ]
153
169
  },
170
+ "directory": {
171
+ "type": "string",
172
+ "description": "Where this package's publishable output lives, relative to its own directory (e.g. \"build\"). Per-package cascaded, so a root \"[*]\" block can say it once instead of repeating publishConfig.directory in every package.json - which still wins when a package declares it. Publishing from such a directory means the manifest there is GENERATED by publish, from the package's own, with devDependencies, non-install scripts, \"private\" and publishConfig.directory removed and \"workspace:\" ranges resolved. There is nothing to configure about it."
173
+ },
154
174
  "docker": {
155
175
  "$ref": "#/definitions/dockerPublishConfig",
156
176
  "description": "Required once \"docker\" is one of this package's \"publish.target\"s."
@@ -168,37 +188,34 @@
168
188
  },
169
189
  "run": {
170
190
  "type": "object",
171
- "description": "Per-script options for \"run\"/\"build\"/\"test\"/RunService, keyed by npm script name (e.g. \"build\", \"lint\", \"test\").",
191
+ "description": "Per-script options for \"run\"/\"build\"/\"test\"/RunService, keyed by npm script name (e.g. \"build\", \"lint\", \"test\"). A bare string (or array of them) is shorthand for { \"exec\": ... } - \"test\": \"mocha\" and \"test\": { \"exec\": \"mocha\" } mean exactly the same thing.",
172
192
  "additionalProperties": {
173
- "$ref": "#/definitions/runScriptConfig"
193
+ "oneOf": [
194
+ {
195
+ "$ref": "#/definitions/stringOrStringArray"
196
+ },
197
+ {
198
+ "$ref": "#/definitions/runScriptConfig"
199
+ }
200
+ ]
174
201
  }
175
202
  },
176
- "packages": {
177
- "type": "object",
178
- "description": "Per-package overrides, keyed by the in-repo package's own name.",
179
- "additionalProperties": {
180
- "type": "object",
181
- "additionalProperties": false,
182
- "properties": {
183
- "dependencies": {
184
- "description": "Extra in-repo \"dependencies\" not present in this package's real package.json, purely for rman's own dependency graph (topo-sort, --deps/--dependents, run's task scheduling). An array defaults each entry's range to \"*\"; an object gives an explicit name -> range map.",
185
- "oneOf": [
186
- {
187
- "type": "array",
188
- "items": {
189
- "type": "string"
190
- }
191
- },
192
- {
193
- "type": "object",
194
- "additionalProperties": {
195
- "type": "string"
196
- }
197
- }
198
- ]
203
+ "dependencies": {
204
+ "description": "In-repo packages this one depends on beyond what its real package.json declares, purely for rman's own dependency graph (topo-sort, --deps/--dependents, run's task scheduling). An array defaults each entry's range to \"*\"; an object gives an explicit name -> range map. Declare it from the root through a selector (\"[pkg-a]\": { \"dependencies\": [...] }) or in the package's own .rmanrc.",
205
+ "oneOf": [
206
+ {
207
+ "type": "array",
208
+ "items": {
209
+ "type": "string"
210
+ }
211
+ },
212
+ {
213
+ "type": "object",
214
+ "additionalProperties": {
215
+ "type": "string"
199
216
  }
200
217
  }
201
- }
218
+ ]
202
219
  }
203
220
  },
204
221
  "definitions": {
@@ -352,22 +369,22 @@
352
369
  "type": "string",
353
370
  "description": "Conditional-execution expression: atoms \"changed\"/\"dirty\"/\"committed\" (optionally \"= <hash>\" or \"= {ENV_VAR}\"), combined with and/or/not/(...). E.g. \"changed\", \"(changed or dirty) and not committed\"."
354
371
  },
355
- "script": {
372
+ "before": {
356
373
  "$ref": "#/definitions/stringOrStringArray",
357
- "description": "Command(s) to run as this package's own script, when its package.json does not define one for this script name."
374
+ "description": "Command(s) to run as this package's own \"pre<script>\" hook, when its package.json does not define one."
358
375
  },
359
- "preScript": {
376
+ "exec": {
360
377
  "$ref": "#/definitions/stringOrStringArray",
361
- "description": "Command(s) to run as this package's own \"pre<script>\" hook, when its package.json does not define one."
378
+ "description": "Command(s) to run as this package's own script, when its package.json does not define one for this script name."
362
379
  },
363
- "postScript": {
380
+ "after": {
364
381
  "$ref": "#/definitions/stringOrStringArray",
365
382
  "description": "Command(s) to run as this package's own \"post<script>\" hook, when its package.json does not define one."
366
383
  },
367
384
  "override": {
368
385
  "type": "boolean",
369
386
  "default": false,
370
- "description": "When true, \"script\"/\"preScript\"/\"postScript\" above replace the package's own package.json definition even when it already has one."
387
+ "description": "When true, \"before\"/\"exec\"/\"after\" above replace the package's own package.json definition even when it already has one."
371
388
  }
372
389
  }
373
390
  }
@@ -1,3 +1,19 @@
1
+ /**
2
+ * Prepares the `package.json` that `npm publish` will actually read, and returns a function that
3
+ * undoes it - always call that from a `finally`, so a failed publish leaves nothing behind.
4
+ *
5
+ * Which file that is depends on where the output lives:
6
+ *
7
+ * - **Publishing the package directory itself** - its own `package.json` is the manifest, rewritten
8
+ * in place: every `"workspace:"` range becomes a real, registry-consumable one (the same
9
+ * substitution pnpm/yarn's own publish performs - see `resolveWorkspaceRange`), since `npm
10
+ * publish` reads what is on disk rather than packing from a staging tarball.
11
+ * - **Publishing a build directory** - there is no manifest there until something writes one, and
12
+ * that something is this: see `derivePublishManifest`.
13
+ *
14
+ * Returns `undefined` when there is nothing to do at all (the package directory, with no
15
+ * `"workspace:"` range in it) - no disk write, nothing to restore.
16
+ */
1
17
  import { execFile } from 'node:child_process';
2
18
  import fs from 'node:fs';
3
19
  import path from 'node:path';
@@ -8,6 +24,94 @@ import { filterPackages } from '../utils/package-filter.js';
8
24
  import { parseWorkspaceRange, resolveWorkspaceRange } from '../utils/workspace-range.js';
9
25
  import { CiService } from './ci.service.js';
10
26
  import { DEPENDENCY_KEYS } from './version.service.js';
27
+ function preparePublishManifest(pkg, publishDir, packagesByName) {
28
+ if (path.resolve(publishDir) !== path.resolve(pkg.dirname)) {
29
+ const file = path.join(publishDir, 'package.json');
30
+ const previous = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : undefined;
31
+ fs.mkdirSync(publishDir, { recursive: true });
32
+ fs.writeFileSync(file, JSON.stringify(derivePublishManifest(pkg, packagesByName), undefined, 2) + '\n', 'utf-8');
33
+ return () => {
34
+ if (previous === undefined)
35
+ fs.rmSync(file, { force: true });
36
+ else
37
+ fs.writeFileSync(file, previous, 'utf-8');
38
+ };
39
+ }
40
+ const hasWorkspaceRange = DEPENDENCY_KEYS.some(depKey => {
41
+ const deps = pkg.json[depKey];
42
+ return deps && Object.values(deps).some(v => parseWorkspaceRange(v));
43
+ });
44
+ if (!hasWorkspaceRange)
45
+ return undefined;
46
+ const original = fs.readFileSync(pkg.jsonFileName, 'utf-8');
47
+ resolveWorkspaceRanges(pkg.json, packagesByName);
48
+ pkg.writeJson();
49
+ return () => {
50
+ fs.writeFileSync(pkg.jsonFileName, original, 'utf-8');
51
+ pkg.reloadJson();
52
+ };
53
+ }
54
+ /**
55
+ * The manifest to publish from a build directory, derived from the package's own - generated here
56
+ * rather than by a build script, and deliberately with nothing to configure.
57
+ *
58
+ * Generated at *publish* time, which is the whole point: a build script writes it when the build
59
+ * runs, so bumping the version afterwards (or building before a bump) publishes a manifest that
60
+ * disagrees with the package - and the `"workspace:"` rewrite above, which only ever touched the
61
+ * package's own file, never reached the copy at all.
62
+ *
63
+ * What comes out is the package's `package.json` minus what a consumer of the tarball can neither
64
+ * see nor use:
65
+ *
66
+ * - `devDependencies` - npm never installs a dependency's own, so they are pure noise.
67
+ * - `scripts`, **except** `preinstall`/`install`/`postinstall`. Those three are the only ones a
68
+ * consumer's install actually runs, and dropping them would silently break every package that
69
+ * builds a native module on install. The rest (`build`, `test`, `prepare`, ...) never reach the
70
+ * consumer - `prepare` runs for a git dependency, which builds from the repository, not from this
71
+ * tarball.
72
+ * - `private` - rman refuses to publish a private package in the first place, so carrying the flag
73
+ * into a manifest that is being published can only be wrong.
74
+ * - `publishConfig.directory` - it pointed *here*; kept, it would point one level deeper again.
75
+ */
76
+ function derivePublishManifest(pkg, packagesByName) {
77
+ const json = structuredClone(pkg.json);
78
+ resolveWorkspaceRanges(json, packagesByName);
79
+ delete json.devDependencies;
80
+ delete json.private;
81
+ if (json.scripts) {
82
+ const kept = Object.fromEntries(Object.entries(json.scripts).filter(([name]) => CONSUMER_SCRIPTS.has(name)));
83
+ if (Object.keys(kept).length)
84
+ json.scripts = kept;
85
+ else
86
+ delete json.scripts;
87
+ }
88
+ if (json.publishConfig) {
89
+ delete json.publishConfig.directory;
90
+ if (!Object.keys(json.publishConfig).length)
91
+ delete json.publishConfig;
92
+ }
93
+ return json;
94
+ }
95
+ /** The only lifecycle scripts a consumer's `npm install` of this package runs - see
96
+ * https://docs.npmjs.com/cli/using-npm/scripts. */
97
+ const CONSUMER_SCRIPTS = new Set(['preinstall', 'install', 'postinstall']);
98
+ /** Rewrites `json`'s `"workspace:"` ranges in place, resolving each against the in-repo package it
99
+ * names. Shared by both manifest paths, so they can never disagree about the substitution. */
100
+ function resolveWorkspaceRanges(json, packagesByName) {
101
+ for (const depKey of DEPENDENCY_KEYS) {
102
+ const deps = json[depKey];
103
+ if (!deps)
104
+ continue;
105
+ for (const depName of Object.keys(deps)) {
106
+ const parsed = parseWorkspaceRange(deps[depName]);
107
+ if (!parsed)
108
+ continue;
109
+ const depPkg = packagesByName.get(depName);
110
+ if (depPkg)
111
+ deps[depName] = resolveWorkspaceRange(parsed, depPkg.version);
112
+ }
113
+ }
114
+ }
11
115
  export var PublishService;
12
116
  (function (PublishService) {
13
117
  /**
@@ -106,12 +210,10 @@ export var PublishService;
106
210
  result.push({ ...entry, status: 'error', reason: `dependency "${blocker}" failed to publish` });
107
211
  continue;
108
212
  }
109
- const restore = rewriteWorkspaceRangesForPublish(pkg, packagesByName);
213
+ const publishDir = resolvePublishDir(pkg, options.contents);
214
+ const restore = preparePublishManifest(pkg, publishDir, packagesByName);
110
215
  try {
111
- await exec(buildPublishCommand(packageManager, options), {
112
- cwd: resolvePublishDir(pkg, options.contents),
113
- stdio: 'inherit',
114
- });
216
+ await exec(buildPublishCommand(packageManager, options), { cwd: publishDir, stdio: 'inherit' });
115
217
  result.push(entry);
116
218
  }
117
219
  catch (e) {
@@ -145,13 +247,14 @@ async function defaultNpmViewVersion(name, cwd, options) {
145
247
  return undefined;
146
248
  }
147
249
  }
148
- /** npm's own native `publishConfig.directory` (if the package declares one) always wins over
149
- * `options.contents` - the package's own package.json is the more authoritative, persistent
150
- * statement of "this is where the publishable output lives", `--contents` is just a fallback for
151
- * when it doesn't declare one at all. */
250
+ /** Where the publishable output lives, most specific statement first: the package's own
251
+ * `publishConfig.directory` (npm/pnpm's native spelling, and a statement about that one package),
252
+ * then `.rmanrc "publish.directory"` (which a `"[*]"` block can say once for a whole repository
253
+ * instead of repeating in every `package.json`), then `--contents` for a single run. */
152
254
  function resolvePublishDir(pkg, contentsOverride) {
153
255
  const native = pkg.json.publishConfig?.directory;
154
- const rel = (typeof native === 'string' && native) || contentsOverride;
256
+ const configured = pkg.config?.publish?.directory;
257
+ const rel = (typeof native === 'string' && native) || configured || contentsOverride;
155
258
  return rel ? path.resolve(pkg.dirname, rel) : pkg.dirname;
156
259
  }
157
260
  function buildPublishCommand(packageManager, options) {
@@ -168,40 +271,3 @@ function buildPublishCommand(packageManager, options) {
168
271
  args.push('--userconfig', options.userconfig);
169
272
  return `${packageManager} ${args.join(' ')}`;
170
273
  }
171
- /**
172
- * Rewrites every `"workspace:"` dependency range in `pkg`'s `package.json` to a real,
173
- * registry-publishable range (the same substitution pnpm/yarn's own publish performs - see
174
- * `resolveWorkspaceRange`), just before shelling out to `npm publish` - which reads whatever is
175
- * actually on disk, unlike pnpm/yarn's own publish this never packs into a staging tarball first.
176
- * Returns a function that restores the file's original bytes (and `pkg`'s in-memory state)
177
- * verbatim; always invoke it from a `finally`, so a failed publish never leaves a rewritten
178
- * `package.json` behind. Returns `undefined` (nothing to restore, no disk write at all) when `pkg`
179
- * has no `"workspace:"` ranges to begin with.
180
- */
181
- function rewriteWorkspaceRangesForPublish(pkg, packagesByName) {
182
- const hasWorkspaceRange = DEPENDENCY_KEYS.some(depKey => {
183
- const deps = pkg.json[depKey];
184
- return deps && Object.values(deps).some(v => parseWorkspaceRange(v));
185
- });
186
- if (!hasWorkspaceRange)
187
- return undefined;
188
- const original = fs.readFileSync(pkg.jsonFileName, 'utf-8');
189
- for (const depKey of DEPENDENCY_KEYS) {
190
- const deps = pkg.json[depKey];
191
- if (!deps)
192
- continue;
193
- for (const depName of Object.keys(deps)) {
194
- const parsed = parseWorkspaceRange(deps[depName]);
195
- if (!parsed)
196
- continue;
197
- const depPkg = packagesByName.get(depName);
198
- if (depPkg)
199
- deps[depName] = resolveWorkspaceRange(parsed, depPkg.version);
200
- }
201
- }
202
- pkg.writeJson();
203
- return () => {
204
- fs.writeFileSync(pkg.jsonFileName, original, 'utf-8');
205
- pkg.reloadJson();
206
- };
207
- }
@@ -42,10 +42,15 @@ export declare namespace RunService {
42
42
  * `getScriptSteps`:
43
43
  * run:
44
44
  * build:
45
- * script: tsc -b
46
- * preScript: [node ./generate.js, node ./validate.js]
47
- * postScript: node ./copy-assets.js
45
+ * before: [node ./generate.js, node ./validate.js]
46
+ * exec: tsc -b
47
+ * after: node ./copy-assets.js
48
48
  * override: true # use these even if the package *does* define its own
49
+ *
50
+ * A bare string is shorthand for `exec`, which is by far the common case - a script that is just
51
+ * a command, with nothing to configure about how it runs:
52
+ * run:
53
+ * test: mocha # same as test: { exec: mocha }
49
54
  */
50
55
  function getConfig(pkg: Package, script: string): Record<string, unknown>;
51
56
  /**
@@ -26,14 +26,21 @@ export var RunService;
26
26
  * `getScriptSteps`:
27
27
  * run:
28
28
  * build:
29
- * script: tsc -b
30
- * preScript: [node ./generate.js, node ./validate.js]
31
- * postScript: node ./copy-assets.js
29
+ * before: [node ./generate.js, node ./validate.js]
30
+ * exec: tsc -b
31
+ * after: node ./copy-assets.js
32
32
  * override: true # use these even if the package *does* define its own
33
+ *
34
+ * A bare string is shorthand for `exec`, which is by far the common case - a script that is just
35
+ * a command, with nothing to configure about how it runs:
36
+ * run:
37
+ * test: mocha # same as test: { exec: mocha }
33
38
  */
34
39
  function getConfig(pkg, script) {
35
40
  const runCfg = pkg.config?.run;
36
41
  const cfg = runCfg && typeof runCfg === 'object' ? runCfg[script] : undefined;
42
+ if (typeof cfg === 'string' || Array.isArray(cfg))
43
+ return { exec: cfg };
37
44
  return cfg && typeof cfg === 'object' ? cfg : {};
38
45
  }
39
46
  RunService.getConfig = getConfig;
@@ -197,10 +204,14 @@ export var RunService;
197
204
  const ifStatusCache = new Map();
198
205
  /** Repo-wide bookend: root's own pre/post hooks run once each, exclusively, around every package
199
206
  * (unless root itself opts out via `run.<script>.skip`, fails its own `run.<script>.if`, or the
200
- * run is scoped to a single package by `cwdScope` - a repo-wide bookend has no place there). */
201
- const rootIf = !cwdScope && parseIfExpr(rootCfg.if);
207
+ * run is scoped to a single package by `cwdScope` - a repo-wide bookend has no place there).
208
+ *
209
+ * Only in a monorepo. Without one the root *is* the single package, already in the loop below
210
+ * with the same hooks and the same directory - a bookend would simply run each of them a
211
+ * second time. */
212
+ const rootIf = !cwdScope && repository.monorepo && parseIfExpr(rootCfg.if);
202
213
  const rootIfPasses = rootIf ? await evaluateIf(repository, repository.rootPackage, rootIf, ifStatusCache) : true;
203
- const rootSkipped = !!cwdScope || rootCfg.skip === true || !rootIfPasses;
214
+ const rootSkipped = !!cwdScope || !repository.monorepo || rootCfg.skip === true || !rootIfPasses;
204
215
  const rootSteps = rootSkipped ? [] : getScriptSteps(repository.rootPackage, script);
205
216
  const rootPre = rootSteps.filter(s => s.name === 'pre' + script);
206
217
  const rootPost = rootSteps.filter(s => s.name === 'post' + script);
@@ -255,11 +266,36 @@ export var RunService;
255
266
  }));
256
267
  }
257
268
  if (!children.length) {
258
- console.log(colors.gray(`No package defines a "${script}" script.`));
259
- return;
269
+ /**
270
+ * Two different nothings, and only one of them is fine.
271
+ *
272
+ * Nobody in the repository defines this script at all: the name is a mistake - a typo, or a
273
+ * script that used to exist - and `npm run` fails on exactly this. Staying silent is how
274
+ * `rman run qc` sat in a CI pipeline for months reporting success while running nothing, with
275
+ * `qc` defined only on the root (whose own scripts a monorepo never runs, only its
276
+ * `pre`/`post` bookends).
277
+ *
278
+ * Everything was filtered out instead - `--scope`, `--changed`, `run.<script>.skip`, an
279
+ * `if:` that didn't match: zero is the correct answer to what was asked, and asking "build
280
+ * only what changed" when nothing changed must not fail a pipeline.
281
+ */
282
+ /** `getPackages()` and nothing else - in a monorepo that excludes the root, which is the
283
+ * point: the root contributes only `pre`/`post` bookends, never the script itself, so a
284
+ * `qc` defined *only* there is exactly the mistake above rather than an excuse for it. (And
285
+ * had the root contributed a bookend, `children` wouldn't be empty.) In a single-package
286
+ * repository the root *is* the one package, and is covered. */
287
+ const definedSomewhere = repository.getPackages().some(pkg => getScriptSteps(pkg, script).length > 0);
288
+ if (definedSomewhere) {
289
+ console.log(colors.gray(`Nothing to run - every package was filtered out of "${script}".`));
290
+ return;
291
+ }
292
+ const message = `No package defines a "${script}" script.`;
293
+ console.log(colors.red(message));
294
+ const err = new Error(message);
295
+ err.logged = true;
296
+ throw err;
260
297
  }
261
298
  panel.start();
262
- let failed = false;
263
299
  try {
264
300
  /** bail:false here - each package's own resolved bail setting decides whether to call
265
301
  * `rootTask.abort()` itself (see `runSteps`), since power-tasks' own `bail` can't vary per package. */
@@ -267,13 +303,25 @@ export var RunService;
267
303
  await rootTask.toPromise();
268
304
  }
269
305
  catch {
270
- failed = true;
306
+ // Swallowed on purpose: whether the root promise rejected says nothing reliable about the
307
+ // run - see below. The per-package tallies are what decide.
271
308
  }
272
309
  finally {
310
+ /** A package's own `bail` aborts the root task, which settles its promise *immediately* while
311
+ * the packages already in flight keep running. Waiting for every child here is what makes
312
+ * the summary below describe a finished run rather than a snapshot of one still going -
313
+ * measured: it printed "0 succeeded, 1 failed, 3 skipped" and then three of those "skipped"
314
+ * packages went on to succeed. */
315
+ await Promise.allSettled(children.map(child => child.toPromise()));
273
316
  panel.stop();
274
317
  }
275
- panel.printSummary();
276
- if (failed) {
318
+ const summary = panel.printSummary();
319
+ /** The tallies, never `rootTask.toPromise()`'s own outcome: with a sibling still in flight at
320
+ * the moment one package failed, that promise *resolves*, and this command used to exit 0 on a
321
+ * run it had just reported as failed - non-deterministically, since it came down to which
322
+ * packages happened to still be running (measured: `1 0 1 1 0` across five identical runs).
323
+ * A single failed package must fail the command, every time. */
324
+ if (summary.failedCount > 0) {
277
325
  const err = new Error(`"${script}" failed`);
278
326
  err.logged = true;
279
327
  throw err;
@@ -336,9 +384,9 @@ function getScriptSteps(pkg, script) {
336
384
  if (cfg.override === true || !hasOwn)
337
385
  json.scripts[key] = value;
338
386
  };
339
- applyConfigScript(script, cfg.script);
340
- applyConfigScript('pre' + script, cfg.preScript);
341
- applyConfigScript('post' + script, cfg.postScript);
387
+ applyConfigScript(script, cfg.exec);
388
+ applyConfigScript('pre' + script, cfg.before);
389
+ applyConfigScript('post' + script, cfg.after);
342
390
  json.scripts[script] = json.scripts[script] || '#';
343
391
  const info = parseNpmScript(json, 'npm run ' + script);
344
392
  if (!info?.raw?.length)