rman 0.37.4 → 1.0.1

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.
Files changed (120) hide show
  1. package/README.md +364 -21
  2. package/cli.d.ts +1 -0
  3. package/cli.js +52 -28
  4. package/commands/build.command.d.ts +3 -0
  5. package/commands/build.command.js +14 -0
  6. package/commands/changed.command.d.ts +3 -0
  7. package/commands/changed.command.js +29 -0
  8. package/commands/changelog.command.d.ts +3 -0
  9. package/commands/changelog.command.js +63 -0
  10. package/commands/ci.command.d.ts +3 -0
  11. package/commands/ci.command.js +30 -0
  12. package/commands/clean.command.d.ts +3 -0
  13. package/commands/clean.command.js +36 -0
  14. package/commands/diff.command.d.ts +3 -0
  15. package/commands/diff.command.js +51 -0
  16. package/commands/exec.command.d.ts +3 -0
  17. package/commands/exec.command.js +83 -0
  18. package/commands/import.command.d.ts +3 -0
  19. package/commands/import.command.js +27 -0
  20. package/commands/info.command.d.ts +3 -0
  21. package/commands/info.command.js +61 -0
  22. package/commands/list.command.d.ts +3 -0
  23. package/commands/list.command.js +113 -0
  24. package/commands/publish.command.d.ts +3 -0
  25. package/commands/publish.command.js +147 -0
  26. package/commands/run.command.d.ts +6 -0
  27. package/commands/run.command.js +80 -0
  28. package/commands/test.command.d.ts +3 -0
  29. package/commands/test.command.js +14 -0
  30. package/commands/version.command.d.ts +3 -0
  31. package/commands/version.command.js +125 -0
  32. package/constants.js +1 -1
  33. package/core/config.d.ts +13 -0
  34. package/core/config.js +61 -0
  35. package/core/package.d.ts +2 -0
  36. package/core/package.js +2 -0
  37. package/core/repository.d.ts +36 -3
  38. package/core/repository.js +90 -45
  39. package/index.d.ts +17 -1
  40. package/index.js +15 -1
  41. package/package.json +11 -8
  42. package/services/changelog.service.d.ts +99 -0
  43. package/services/changelog.service.js +272 -0
  44. package/services/ci.service.d.ts +40 -0
  45. package/services/ci.service.js +204 -0
  46. package/services/clean.service.d.ts +42 -0
  47. package/services/clean.service.js +226 -0
  48. package/services/exec.service.d.ts +35 -0
  49. package/services/exec.service.js +114 -0
  50. package/services/import.service.d.ts +30 -0
  51. package/services/import.service.js +84 -0
  52. package/services/list.service.d.ts +27 -0
  53. package/services/list.service.js +25 -0
  54. package/services/publish.service.d.ts +79 -0
  55. package/services/publish.service.js +199 -0
  56. package/services/run.service.d.ts +99 -0
  57. package/services/run.service.js +432 -0
  58. package/services/system-info.d.ts +16 -0
  59. package/services/system-info.js +25 -0
  60. package/services/version.service.d.ts +95 -0
  61. package/services/version.service.js +450 -0
  62. package/services.d.ts +10 -0
  63. package/services.js +10 -0
  64. package/utils/branch-guard.d.ts +25 -0
  65. package/utils/branch-guard.js +61 -0
  66. package/utils/change-hash.d.ts +55 -0
  67. package/utils/change-hash.js +80 -0
  68. package/utils/conventional-commits.d.ts +39 -0
  69. package/utils/conventional-commits.js +43 -0
  70. package/utils/exec.d.ts +5 -6
  71. package/utils/exec.js +46 -60
  72. package/utils/git.d.ts +86 -0
  73. package/utils/git.js +276 -0
  74. package/utils/live-region.d.ts +16 -0
  75. package/utils/live-region.js +59 -0
  76. package/utils/logger.d.ts +24 -0
  77. package/utils/logger.js +39 -0
  78. package/utils/npm-run-path.d.ts +1 -1
  79. package/utils/npm-run-path.js +10 -10
  80. package/utils/package-filter.d.ts +32 -0
  81. package/utils/package-filter.js +73 -0
  82. package/utils/progress-panel.d.ts +59 -0
  83. package/utils/progress-panel.js +149 -0
  84. package/utils/workspace-range.d.ts +17 -0
  85. package/utils/workspace-range.js +28 -0
  86. package/bin/rman.mjs +0 -4
  87. package/commands/build-command.d.ts +0 -11
  88. package/commands/build-command.js +0 -24
  89. package/commands/changed-command.d.ts +0 -16
  90. package/commands/changed-command.js +0 -32
  91. package/commands/ci-command.d.ts +0 -24
  92. package/commands/ci-command.js +0 -68
  93. package/commands/execute-command.d.ts +0 -19
  94. package/commands/execute-command.js +0 -72
  95. package/commands/info-command.d.ts +0 -10
  96. package/commands/info-command.js +0 -55
  97. package/commands/list-command.d.ts +0 -38
  98. package/commands/list-command.js +0 -134
  99. package/commands/multi-task-command.d.ts +0 -22
  100. package/commands/multi-task-command.js +0 -65
  101. package/commands/publish-command.d.ts +0 -24
  102. package/commands/publish-command.js +0 -131
  103. package/commands/run-command.d.ts +0 -28
  104. package/commands/run-command.js +0 -131
  105. package/commands/version-command.d.ts +0 -25
  106. package/commands/version-command.js +0 -195
  107. package/core/command.d.ts +0 -35
  108. package/core/command.js +0 -116
  109. package/core/constants.d.ts +0 -1
  110. package/core/constants.js +0 -2
  111. package/core/logger.d.ts +0 -12
  112. package/core/logger.js +0 -2
  113. package/utils/file-utils.d.ts +0 -4
  114. package/utils/file-utils.js +0 -28
  115. package/utils/git-utils.d.ts +0 -25
  116. package/utils/git-utils.js +0 -66
  117. package/utils/npm-utils.d.ts +0 -11
  118. package/utils/npm-utils.js +0 -33
  119. package/utils/package-not-found-error.d.ts +0 -2
  120. package/utils/package-not-found-error.js +0 -2
@@ -0,0 +1,450 @@
1
+ import path from 'node:path';
2
+ import semver from 'semver';
3
+ import { findLatestTag, tagPattern } from '../utils/change-hash.js';
4
+ import { hasBreakingChangeFooter, parseConventionalCommit, parseReleaseAs, VERSION_BUMP_PATTERN, } from '../utils/conventional-commits.js';
5
+ import { exec } from '../utils/exec.js';
6
+ import { GitHelper } from '../utils/git.js';
7
+ import { filterPackages } from '../utils/package-filter.js';
8
+ import { parseWorkspaceRange } from '../utils/workspace-range.js';
9
+ import { ChangelogService } from './changelog.service.js';
10
+ export var VersionService;
11
+ (function (VersionService) {
12
+ function isBumpKeyword(value) {
13
+ return value === 'patch' || value === 'minor' || value === 'major';
14
+ }
15
+ VersionService.isBumpKeyword = isBumpKeyword;
16
+ /**
17
+ * Computes what a version bump *would* do, across every package `.rmanrc group` puts together -
18
+ * never writes anything (no package.json edits, no git commits/tags) and safe to call any time,
19
+ * including as the "preview" a bare `rman version` (no bump given) stops at.
20
+ *
21
+ * Packages are partitioned into groups by their resolved `group` value (cascaded): `true`
22
+ * (the default) puts every such package into one implicit repo-wide group; a string joins
23
+ * exactly the other packages sharing that same string, regardless of the repo's default; `false`
24
+ * makes a package its own solo group. Each group's "current version" is always the highest
25
+ * version currently found among its own members (never persisted anywhere) - see
26
+ * `resolveGroupKey`.
27
+ *
28
+ * Within a group, a member with real commits since its own last release tag (or an explicit
29
+ * `bump`) sets the group's severity to the highest found among changed members; the new version
30
+ * is that current version bumped by that severity. Which members actually receive it depends on
31
+ * the severity: **patch** only the changed member(s) (a caret dependency range already tolerates
32
+ * a patch bump, no republish needed downstream); **minor** also every transitive in-group
33
+ * dependent; **major** the entire group, changed or not - see `computeGroupPlan`.
34
+ *
35
+ * Across groups: a package depending on another group's bumped package always gets exactly a
36
+ * **patch** bump of its own (never inheriting the source's severity) - the dependency reference
37
+ * itself is the only thing that changed for it. This never re-triggers *its own* group's
38
+ * minor/major cascade (a patch never cascades), but can itself ripple into a third group, and so
39
+ * on, until nothing new is affected - see `rippleCrossGroup`.
40
+ *
41
+ * A monorepo's root package is never a real member of any group (it's never published on its
42
+ * own) - it gets one trailing informational entry instead, always `'bump'`ed to whatever single
43
+ * version every group ended up sharing, or the overall highest version when groups diverged.
44
+ */
45
+ async function getPlan(repository, options = {}) {
46
+ const bump = options.bump?.trim();
47
+ const explicitSeverity = bump && isBumpKeyword(bump) ? bump : undefined;
48
+ const explicitVersion = bump && !explicitSeverity ? (semver.valid(bump) ?? undefined) : undefined;
49
+ if (bump && !explicitSeverity && !explicitVersion) {
50
+ throw new Error(`Invalid "bump": "${bump}" (expected "patch", "minor", "major", or a valid semver version)`);
51
+ }
52
+ const git = new GitHelper({ cwd: repository.dirname });
53
+ const packages = filterPackages(repository.getPackages(), options);
54
+ const dirtyFiles = await git.listDirtyFiles({ absolute: true });
55
+ const isDirty = (pkg) => dirtyFiles.some(f => !path.relative(pkg.dirname, f).startsWith('..'));
56
+ const entries = new Map();
57
+ const eligible = [];
58
+ for (const pkg of packages) {
59
+ if (isDirty(pkg)) {
60
+ entries.set(pkg.name, {
61
+ package: pkg,
62
+ groupKey: resolveGroupKey(pkg),
63
+ group: groupLabel(resolveGroupKey(pkg)),
64
+ status: options.ignoreDirty ? 'skip' : 'error',
65
+ from: pkg.version,
66
+ reason: 'uncommitted local changes',
67
+ });
68
+ continue;
69
+ }
70
+ eligible.push(pkg);
71
+ }
72
+ const changeByPackage = new Map();
73
+ await Promise.all(eligible.map(async (pkg) => {
74
+ if (explicitVersion) {
75
+ changeByPackage.set(pkg.name, { severity: undefined, reason: `explicit version ${explicitVersion}` });
76
+ return;
77
+ }
78
+ const tag = await findLatestTag(git, pkg);
79
+ const commits = tag ? await git.listCommits({ hash: tag }) : await git.listAllCommits();
80
+ const belongsToPkg = (c) => c.files.some(f => !path.relative(pkg.dirname, f).startsWith('..'));
81
+ const real = commits.filter(c => belongsToPkg(c) && !VERSION_BUMP_PATTERN.test(c.subject));
82
+ if (!real.length)
83
+ return;
84
+ changeByPackage.set(pkg.name, {
85
+ severity: explicitSeverity ?? detectSeverity(real),
86
+ reason: tag ? `changed since ${tag}` : 'unreleased commits',
87
+ });
88
+ }));
89
+ const groups = new Map();
90
+ for (const pkg of eligible) {
91
+ const key = resolveGroupKey(pkg);
92
+ const list = groups.get(key);
93
+ if (list)
94
+ list.push(pkg);
95
+ else
96
+ groups.set(key, [pkg]);
97
+ }
98
+ for (const [key, members] of groups) {
99
+ computeGroupPlan(key, members, changeByPackage, explicitVersion, options.preid, entries);
100
+ }
101
+ rippleCrossGroup(packages, entries, options.preid);
102
+ const result = packages.map(pkg => entries.get(pkg.name));
103
+ if (repository.monorepo)
104
+ result.push(buildRootEntry(repository, result));
105
+ return result;
106
+ }
107
+ VersionService.getPlan = getPlan;
108
+ /**
109
+ * Same as `getPlan`, and additionally writes every `'bump'` entry's new version into its own
110
+ * `package.json` (and refreshes any other bumped package's dependency range on it), runs that
111
+ * package's `version.preScript`/`.script`/`.postScript` (or its own real `preversion`/`version`/
112
+ * `postversion` npm scripts) around the write, then commits and tags **once per group** - so
113
+ * independently-versioned groups each get their own clean commit/tag rather than one entangled
114
+ * commit spanning unrelated version lines. Pushes only when `options.push` is set - same as a
115
+ * plain `npm version`, this never reaches the network on its own otherwise.
116
+ */
117
+ async function applyPlan(repository, plan, options = {}) {
118
+ const git = new GitHelper({ cwd: repository.dirname });
119
+ // The root's own entry is only ever a real package to write/commit like any other when this
120
+ // *isn't* a monorepo (see `getPlan`) - in a monorepo it's the separate, purely informational
121
+ // entry handled below instead, since it's never published on its own.
122
+ const isRealEntry = (e) => !(repository.monorepo && e.package === repository.rootPackage);
123
+ const bumped = plan.filter(e => e.status === 'bump' && isRealEntry(e));
124
+ const bumpedByName = new Map(bumped.map(e => [e.package.name, e]));
125
+ for (const entry of bumped) {
126
+ const pkg = entry.package;
127
+ await runVersionScript(pkg, 'preScript', 'preversion');
128
+ pkg.json.version = entry.to;
129
+ for (const depKey of DEPENDENCY_KEYS) {
130
+ const deps = pkg.json[depKey];
131
+ if (!deps)
132
+ continue;
133
+ for (const depName of Object.keys(deps)) {
134
+ const depEntry = bumpedByName.get(depName);
135
+ if (!depEntry)
136
+ continue;
137
+ const workspace = parseWorkspaceRange(deps[depName]);
138
+ if (workspace) {
139
+ // A bare "workspace:*"/"^"/"~" selector always resolves to the dependency's *current*
140
+ // version at publish time (see `resolveWorkspaceRange`) - nothing to rewrite here. Only
141
+ // an explicit version/range after "workspace:" needs bumping, same as a plain range.
142
+ if (workspace.selector === 'explicit')
143
+ deps[depName] = `workspace:^${depEntry.to}`;
144
+ continue;
145
+ }
146
+ deps[depName] = '^' + depEntry.to;
147
+ }
148
+ }
149
+ await runVersionScript(pkg, 'script', 'version');
150
+ pkg.writeJson();
151
+ await runVersionScript(pkg, 'postScript', 'postversion');
152
+ }
153
+ const rootEntry = repository.monorepo ? plan.find(e => e.package === repository.rootPackage) : undefined;
154
+ if (rootEntry?.status === 'bump') {
155
+ repository.rootPackage.json.version = rootEntry.to;
156
+ repository.rootPackage.writeJson();
157
+ }
158
+ /** Written before the per-group commits below so each group's changelog file lands in the
159
+ * *same* commit as its version bump, rather than needing a separate `changelog --write` run.
160
+ * Bounded by each package's own *pre-bump* tag (the same one `getPlan` measured "changed
161
+ * since" from - see `expandTag`) rather than `changelog`'s own default auto-detection (an npm
162
+ * registry lookup, falling back to not-yet-pushed commits) - that boundary can drift from the
163
+ * one `version` itself just used, and the not-yet-pushed fallback needs a configured remote
164
+ * `version` never required at all. Falls back to `changelog`'s own default only when this
165
+ * package genuinely has no prior tag (a first-ever release). */
166
+ const changelogFileByPackage = new Map();
167
+ if (options.changelog) {
168
+ for (const entry of bumped) {
169
+ const fromTag = expandTag(entry.package, entry.from);
170
+ const from = (await git.tagExists(fromTag)) ? fromTag : undefined;
171
+ const changelogEntries = await ChangelogService.generateToFile(repository, {
172
+ scope: entry.package.name,
173
+ root: true,
174
+ from,
175
+ });
176
+ for (const ce of changelogEntries) {
177
+ changelogFileByPackage.set(ce.package.name, path.relative(repository.dirname, path.join(ce.package.dirname, ce.filePath)));
178
+ }
179
+ }
180
+ }
181
+ const byGroup = new Map();
182
+ for (const entry of bumped) {
183
+ const list = byGroup.get(entry.groupKey);
184
+ if (list)
185
+ list.push(entry);
186
+ else
187
+ byGroup.set(entry.groupKey, [entry]);
188
+ }
189
+ for (const [, groupEntries] of byGroup) {
190
+ const files = groupEntries.map(e => path.relative(repository.dirname, e.package.jsonFileName));
191
+ for (const e of groupEntries) {
192
+ const changelogFile = changelogFileByPackage.get(e.package.name);
193
+ if (changelogFile)
194
+ files.push(changelogFile);
195
+ }
196
+ await git.commit(files, buildCommitMessage(repository, groupEntries, options.message));
197
+ const tags = new Set(groupEntries.map(e => expandTag(e.package, e.to)));
198
+ for (const tag of tags)
199
+ if (!(await git.tagExists(tag)))
200
+ await git.createTag(tag);
201
+ }
202
+ /** The root's own informational version write isn't part of any group's release, but still
203
+ * needs to land in *some* commit rather than being left as an uncommitted local edit. */
204
+ if (rootEntry?.status === 'bump') {
205
+ await git.commit([path.relative(repository.dirname, repository.rootPackage.jsonFileName)], `chore: sync root version to ${rootEntry.to}`);
206
+ }
207
+ if (options.push && bumped.length)
208
+ await git.push();
209
+ return plan;
210
+ }
211
+ VersionService.applyPlan = applyPlan;
212
+ })(VersionService || (VersionService = {}));
213
+ /** Every `package.json` field holding dependency ranges - shared with `PublishService`'s own
214
+ * `"workspace:"` rewrite-for-publish step, since it needs to scan the same fields. */
215
+ export const DEPENDENCY_KEYS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
216
+ const SEVERITY_RANK = { patch: 0, minor: 1, major: 2 };
217
+ /** `.rmanrc group` (cascaded): `true` (the default - see `resolveConfig`'s cascade, this is what a
218
+ * package inherits when nobody sets it at all) puts a package in the one implicit repo-wide
219
+ * group; a non-empty string joins exactly the other packages sharing that string, regardless of
220
+ * the repo's own default; `false` makes it a solo group of one. */
221
+ function resolveGroupKey(pkg) {
222
+ const g = pkg.config?.group;
223
+ if (g === false)
224
+ return `solo:${pkg.name}`;
225
+ if (typeof g === 'string' && g)
226
+ return `named:${g}`;
227
+ return 'default';
228
+ }
229
+ function groupLabel(key) {
230
+ if (key.startsWith('named:'))
231
+ return key.slice('named:'.length);
232
+ if (key.startsWith('solo:'))
233
+ return key.slice('solo:'.length);
234
+ return key;
235
+ }
236
+ /**
237
+ * Highest bump type implied by `commits`: a `Release-As: patch|minor|major` footer (see
238
+ * `parseReleaseAs`) replaces what that one commit's own subject/footers would otherwise imply,
239
+ * entirely - the escape hatch for e.g. a `feat:` that needs to ship as a patch right now, without
240
+ * waiting for the rest of a minor's worth of work. Absent that, a `!` marker or a `BREAKING
241
+ * CHANGE:` footer wins outright; otherwise `feat` implies minor; anything else (a `fix`, an
242
+ * unrecognized type, a non-conventional message) defaults to patch - something changed, so at
243
+ * least a patch release is warranted.
244
+ */
245
+ function detectSeverity(commits) {
246
+ let severity = 'patch';
247
+ for (const c of commits) {
248
+ const override = parseReleaseAs(c.body);
249
+ if (override === 'major')
250
+ return 'major';
251
+ if (override) {
252
+ if (SEVERITY_RANK[override] > SEVERITY_RANK[severity])
253
+ severity = override;
254
+ continue;
255
+ }
256
+ const parsed = parseConventionalCommit(c.subject);
257
+ if (parsed?.breaking || hasBreakingChangeFooter(c.body))
258
+ return 'major';
259
+ if (parsed?.type === 'feat')
260
+ severity = 'minor';
261
+ }
262
+ return severity;
263
+ }
264
+ function maxVersion(versions) {
265
+ return versions.reduce((m, v) => (semver.gt(v, m) ? v : m), versions[0]);
266
+ }
267
+ /**
268
+ * `semver.inc`, "pre-ified" when `preid` is given: `current` already sitting on a prerelease with
269
+ * that *same* identifier just has its prerelease counter incremented (`'prerelease'`, e.g.
270
+ * `1.2.3-beta.0` -> `1.2.3-beta.1`) rather than jumping to a new base version every time this
271
+ * runs again during the same beta/rc cycle; anything else (a plain release, or a prerelease under
272
+ * a *different* identifier - switching from `beta` to `rc`, say) starts a fresh prerelease of
273
+ * `severity`'s own type (`'prepatch'`/`'preminor'`/`'premajor'`, e.g. `1.2.3` -> `1.3.0-beta.0`
274
+ * for a `minor`). Without `preid`, this is just `semver.inc(current, severity)`.
275
+ */
276
+ function incVersion(current, severity, preid) {
277
+ if (!preid)
278
+ return semver.inc(current, severity) ?? current;
279
+ const existing = semver.prerelease(current);
280
+ const releaseType = existing && String(existing[0]) === preid ? 'prerelease' : `pre${severity}`;
281
+ return semver.inc(current, releaseType, preid) ?? current;
282
+ }
283
+ /**
284
+ * Decides one group's new version and which of its members actually receive it, writing an
285
+ * `Entry` per member into `entries`. `changeByPackage` holds each eligible package's own detected
286
+ * severity (or `undefined` for one with no real commits since its last tag) - `undefined` here
287
+ * always means "unchanged", never "explicit version" (that path is handled separately below).
288
+ */
289
+ function computeGroupPlan(key, members, changeByPackage, explicitVersion, preid, entries) {
290
+ const label = groupLabel(key);
291
+ const changed = members.filter(m => changeByPackage.has(m.name));
292
+ if (!changed.length) {
293
+ for (const m of members) {
294
+ entries.set(m.name, { package: m, groupKey: key, group: label, status: 'no-change', from: m.version });
295
+ }
296
+ return;
297
+ }
298
+ const current = maxVersion(members.map(m => m.version));
299
+ let to;
300
+ let severity;
301
+ if (explicitVersion) {
302
+ to = explicitVersion;
303
+ }
304
+ else {
305
+ severity = changed.reduce((worst, m) => {
306
+ const s = changeByPackage.get(m.name).severity;
307
+ return SEVERITY_RANK[s] > SEVERITY_RANK[worst] ? s : worst;
308
+ }, 'patch');
309
+ to = incVersion(current, severity, preid);
310
+ }
311
+ const bumping = new Set(changed);
312
+ if (!explicitVersion && severity === 'major') {
313
+ for (const m of members)
314
+ bumping.add(m);
315
+ }
316
+ else if (!explicitVersion && severity === 'minor') {
317
+ const worklist = [...changed];
318
+ while (worklist.length) {
319
+ const cur = worklist.pop();
320
+ for (const m of members) {
321
+ if (bumping.has(m))
322
+ continue;
323
+ if (m.dependencies.includes(cur.name)) {
324
+ bumping.add(m);
325
+ worklist.push(m);
326
+ }
327
+ }
328
+ }
329
+ }
330
+ for (const m of members) {
331
+ if (bumping.has(m)) {
332
+ const own = changeByPackage.get(m.name);
333
+ entries.set(m.name, {
334
+ package: m,
335
+ groupKey: key,
336
+ group: label,
337
+ status: 'bump',
338
+ from: m.version,
339
+ to,
340
+ reason: own?.reason ?? `in-group dependent of a ${severity} change`,
341
+ });
342
+ }
343
+ else {
344
+ entries.set(m.name, { package: m, groupKey: key, group: label, status: 'no-change', from: m.version });
345
+ }
346
+ }
347
+ }
348
+ /**
349
+ * A package depending on another group's bumped package always receives exactly a patch bump of
350
+ * its own, computed from its *own* group's current ceiling (the highest `to`/version among its
351
+ * group right now) - never the source's version, and never the source's severity. Runs as a
352
+ * worklist until nothing new is affected, since patching one package can itself cross into a third
353
+ * group, and so on; never touches a same-group dependent that a plain patch deliberately left
354
+ * alone (see `computeGroupPlan`'s patch case).
355
+ */
356
+ function rippleCrossGroup(packages, entries, preid) {
357
+ const worklist = [...entries.values()].filter(e => e.status === 'bump');
358
+ while (worklist.length) {
359
+ const source = worklist.shift();
360
+ for (const pkg of packages) {
361
+ const entry = entries.get(pkg.name);
362
+ if (entry.status === 'bump' || entry.groupKey === source.groupKey)
363
+ continue;
364
+ if (!pkg.dependencies.includes(source.package.name))
365
+ continue;
366
+ const groupCeiling = maxVersion(packages
367
+ .filter(p => entries.get(p.name).groupKey === entry.groupKey)
368
+ .map(p => entries.get(p.name).to ?? p.version));
369
+ const next = {
370
+ ...entry,
371
+ status: 'bump',
372
+ to: incVersion(groupCeiling, 'patch', preid),
373
+ reason: `depends on ${source.package.name}@${source.to}`,
374
+ };
375
+ entries.set(pkg.name, next);
376
+ worklist.push(next);
377
+ }
378
+ }
379
+ }
380
+ /** The root's own `version` field is purely informational in a monorepo (it's never published on
381
+ * its own) - it always reflects whatever single version every group ended up sharing, or the
382
+ * overall highest version when groups diverged onto different numbers. Reports `'no-change'`
383
+ * (not `'bump'`) when nothing in the repository changed at all. */
384
+ function buildRootEntry(repository, memberEntries) {
385
+ const root = repository.rootPackage;
386
+ const anyBumped = memberEntries.some(e => e.status === 'bump');
387
+ if (!anyBumped) {
388
+ return { package: root, groupKey: '__root__', group: 'root', status: 'no-change', from: root.version };
389
+ }
390
+ const finalVersions = memberEntries.map(e => e.to ?? e.from);
391
+ const unique = new Set(finalVersions);
392
+ const to = unique.size === 1 ? finalVersions[0] : maxVersion(finalVersions);
393
+ return {
394
+ package: root,
395
+ groupKey: '__root__',
396
+ group: 'root',
397
+ status: 'bump',
398
+ from: root.version,
399
+ to,
400
+ reason: 'informational - monorepo root is never published on its own',
401
+ };
402
+ }
403
+ /** `.rmanrc version.commitMessage` (root-level; `{version}` is replaced when every bumped package
404
+ * in this commit shares one version) - defaults to `"chore(release): v{version}"`, or a plain
405
+ * listing of `name@version` pairs when this particular commit spans different versions (a
406
+ * cross-group ripple can land a lone forced patch in a group that otherwise didn't move). */
407
+ function buildCommitMessage(repository, entries, messageOverride) {
408
+ const versions = new Set(entries.map(e => e.to));
409
+ if (versions.size === 1) {
410
+ const template = messageOverride ?? repository.rootPackage.config?.version?.commitMessage;
411
+ const version = entries[0].to;
412
+ if (typeof template === 'string' && template)
413
+ return template.replace(/\{version\}/g, version);
414
+ return `chore(release): v${version}`;
415
+ }
416
+ if (messageOverride)
417
+ return messageOverride;
418
+ return `chore(release): ${entries.map(e => `${e.package.name}@${e.to}`).join(', ')}`;
419
+ }
420
+ /** Expands `pkg`'s (cascaded) `.rmanrc changelog.tagPattern` into a concrete tag name for
421
+ * `version` - the same pattern `changelog` reads tags back with (see `findLatestTag`), just run
422
+ * forward: `{name}` becomes the package's own name, and `*` becomes `version`. */
423
+ function expandTag(pkg, version) {
424
+ const pattern = tagPattern(pkg).replace('{name}', pkg.name);
425
+ const starIdx = pattern.indexOf('*');
426
+ return starIdx === -1 ? pattern : pattern.slice(0, starIdx) + version + pattern.slice(starIdx + 1);
427
+ }
428
+ /** A `version.<key>` value: one command, or several to run in sequence - same shape as
429
+ * `run.<script>.script`/`.preScript`/`.postScript`. */
430
+ function normalizeScriptValue(value) {
431
+ if (typeof value === 'string')
432
+ return value || undefined;
433
+ if (Array.isArray(value)) {
434
+ const parts = value.filter((v) => typeof v === 'string' && !!v);
435
+ return parts.length ? parts.join(' && ') : undefined;
436
+ }
437
+ return undefined;
438
+ }
439
+ /**
440
+ * Runs `pkg`'s own real npm lifecycle script (`preversion`/`version`/`postversion`) if it defines
441
+ * one for this phase, otherwise its `.rmanrc version.<cfgKey>` equivalent if configured - neither
442
+ * replaces the version write itself (unlike `ci`'s own-script override), they're hooks around a
443
+ * write that always happens, since dependency ranges and tags depend on it happening consistently.
444
+ */
445
+ async function runVersionScript(pkg, cfgKey, npmScriptName) {
446
+ const own = pkg.json.scripts?.[npmScriptName];
447
+ const command = typeof own === 'string' && own ? own : normalizeScriptValue(pkg.config?.version?.[cfgKey]);
448
+ if (command)
449
+ await exec(command, { cwd: pkg.dirname, stdio: 'inherit' });
450
+ }
package/services.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { ChangelogService } from './services/changelog.service.js';
2
+ export { CiService } from './services/ci.service.js';
3
+ export { CleanService } from './services/clean.service.js';
4
+ export { ExecService } from './services/exec.service.js';
5
+ export { ImportService } from './services/import.service.js';
6
+ export { ListService } from './services/list.service.js';
7
+ export { PublishService } from './services/publish.service.js';
8
+ export { RunService } from './services/run.service.js';
9
+ export { SystemInfo } from './services/system-info.js';
10
+ export { VersionService } from './services/version.service.js';
package/services.js ADDED
@@ -0,0 +1,10 @@
1
+ export { ChangelogService } from './services/changelog.service.js';
2
+ export { CiService } from './services/ci.service.js';
3
+ export { CleanService } from './services/clean.service.js';
4
+ export { ExecService } from './services/exec.service.js';
5
+ export { ImportService } from './services/import.service.js';
6
+ export { ListService } from './services/list.service.js';
7
+ export { PublishService } from './services/publish.service.js';
8
+ export { RunService } from './services/run.service.js';
9
+ export { SystemInfo } from './services/system-info.js';
10
+ export { VersionService } from './services/version.service.js';
@@ -0,0 +1,25 @@
1
+ import type { Argv } from 'yargs';
2
+ import type { Repository } from '../core/repository.js';
3
+ /** Same idea as GitHub Actions' own `branches`/`branches-ignore` workflow filters - restricts a
4
+ * command to running only from certain branches (or refuses certain ones), instead of any. */
5
+ export interface BranchGuardOptions {
6
+ allowBranch?: string | string[];
7
+ ignoreBranch?: string | string[];
8
+ }
9
+ /** `--allow-branch`/`--ignore-branch`, the same shape and describe text in every command that
10
+ * supports them - mirrors `package-filter.ts`'s own `applyPackageFilterOptions`. */
11
+ export declare function applyBranchGuardOptions<T>(cmd: Argv<T>): Argv<T>;
12
+ export declare function readBranchGuardOptions(args: any): BranchGuardOptions;
13
+ /**
14
+ * Refuses to let the command continue when the current branch doesn't satisfy `options` - an
15
+ * explicit CLI `allowBranch`/`ignoreBranch` replaces the root's own (cascade-free) `.rmanrc`
16
+ * equivalent entirely, same as `packageManager`'s own CLI-over-config precedence; with neither
17
+ * set anywhere, every branch is allowed (this is purely opt-in, same as GitHub Actions' workflows
18
+ * running on every branch until a `branches:` filter is added). A detached HEAD (or a directory
19
+ * that isn't a git repository at all) is never blocked - there's no branch name to check against.
20
+ *
21
+ * Already prints its own message and marks the error `logged` before throwing (matching every
22
+ * other guard check's convention in this codebase, e.g. `version`'s own dirty-check) - callers
23
+ * just need to `await` it before doing anything else.
24
+ */
25
+ export declare function assertAllowedBranch(repository: Repository, options?: BranchGuardOptions): Promise<void>;
@@ -0,0 +1,61 @@
1
+ import colors from 'ansi-colors';
2
+ import micromatch from 'micromatch';
3
+ import { GitHelper } from './git.js';
4
+ /** `--allow-branch`/`--ignore-branch`, the same shape and describe text in every command that
5
+ * supports them - mirrors `package-filter.ts`'s own `applyPackageFilterOptions`. */
6
+ export function applyBranchGuardOptions(cmd) {
7
+ return cmd
8
+ .option('allow-branch', {
9
+ describe: 'Refuse to run unless the current branch matches this glob (repeatable) - default: .rmanrc ' +
10
+ '"allowBranch", or no restriction at all',
11
+ type: 'string',
12
+ })
13
+ .option('ignore-branch', {
14
+ describe: 'Refuse to run if the current branch matches this glob (repeatable) - default: .rmanrc ' +
15
+ '"ignoreBranch", or no restriction at all',
16
+ type: 'string',
17
+ });
18
+ }
19
+ export function readBranchGuardOptions(args) {
20
+ return {
21
+ allowBranch: args.allowBranch,
22
+ ignoreBranch: args.ignoreBranch,
23
+ };
24
+ }
25
+ /**
26
+ * Refuses to let the command continue when the current branch doesn't satisfy `options` - an
27
+ * explicit CLI `allowBranch`/`ignoreBranch` replaces the root's own (cascade-free) `.rmanrc`
28
+ * equivalent entirely, same as `packageManager`'s own CLI-over-config precedence; with neither
29
+ * set anywhere, every branch is allowed (this is purely opt-in, same as GitHub Actions' workflows
30
+ * running on every branch until a `branches:` filter is added). A detached HEAD (or a directory
31
+ * that isn't a git repository at all) is never blocked - there's no branch name to check against.
32
+ *
33
+ * Already prints its own message and marks the error `logged` before throwing (matching every
34
+ * other guard check's convention in this codebase, e.g. `version`'s own dirty-check) - callers
35
+ * just need to `await` it before doing anything else.
36
+ */
37
+ export async function assertAllowedBranch(repository, options = {}) {
38
+ const allow = options.allowBranch ?? repository.config?.allowBranch;
39
+ const ignore = options.ignoreBranch ?? repository.config?.ignoreBranch;
40
+ if (!allow && !ignore)
41
+ return;
42
+ const git = new GitHelper({ cwd: repository.dirname });
43
+ const branch = await git.currentBranch();
44
+ if (!branch)
45
+ return;
46
+ if (allow && !micromatch.isMatch(branch, toArray(allow))) {
47
+ fail(`Branch "${branch}" is not allowed to run this command (must match: ${toArray(allow).join(', ')})`);
48
+ }
49
+ if (ignore && micromatch.isMatch(branch, toArray(ignore))) {
50
+ fail(`Branch "${branch}" is not allowed to run this command (blocked by: ${toArray(ignore).join(', ')})`);
51
+ }
52
+ }
53
+ function toArray(value) {
54
+ return Array.isArray(value) ? value : [value];
55
+ }
56
+ function fail(message) {
57
+ console.log(colors.red(message));
58
+ const err = new Error(message);
59
+ err.logged = true;
60
+ throw err;
61
+ }
@@ -0,0 +1,55 @@
1
+ import type { Package } from '../core/package.js';
2
+ import type { GitHelper } from './git.js';
3
+ /** `.rmanrc changelog.tagPattern` (cascaded, per-package overridable) - a glob for this package's
4
+ * release tags. `{name}` (if present) is replaced with the package's own name, e.g. `{name}@*`
5
+ * for independent per-package versioning (`@scope/pkg@1.2.3`, the same scheme lerna/changesets
6
+ * use - `@`/`/` are both fine in a git tag name). Without `{name}`, it's a single repo-wide tag
7
+ * shared by every package (e.g. the default `v*`). */
8
+ export declare function tagPattern(pkg: Package): string;
9
+ /** This package's most recent release tag - the `{name}`-bearing pattern looks up that package's
10
+ * *own* tags directly (newest by version sort); a repo-wide pattern instead finds the nearest tag
11
+ * HEAD actually descends from, since no single package "owns" that tag. `undefined` if never
12
+ * tagged at all (a fresh package, or one that's never been released). Shared by `changelog`
13
+ * (reading the last-documented version) and `version` (finding the boundary a bump measures
14
+ * "since"). */
15
+ export declare function findLatestTag(git: GitHelper, pkg: Package): Promise<string | undefined>;
16
+ /** Strips the pattern's literal prefix (everything before its first `*`) from `tag` to get just
17
+ * the version part - e.g. tag `@sqb/builder@1.2.3` against pattern `@sqb/builder@*` -> `1.2.3`.
18
+ * A pattern with no `*` is returned as its own "version" verbatim (an exact tag, nothing to strip). */
19
+ export declare function extractVersion(tag: string, expandedPattern: string): string;
20
+ /** Looks up `name`'s currently-published version on the npm registry, or `undefined` if it isn't
21
+ * published there at all (private, scoped-but-unpublished, no network, ...) - the real npm CLI
22
+ * call `detectChangeHash` uses by default; injectable via its `npmViewVersion` option so tests
23
+ * aren't making real registry calls. */
24
+ export declare function defaultNpmViewVersion(name: string, cwd: string): Promise<string | undefined>;
25
+ export interface DetectChangeHashOptions {
26
+ /** Use this commit/hash directly instead of auto-detecting - applies the same way to every
27
+ * package. `"npm"` (or omitting `from` entirely) triggers auto-detection instead of being
28
+ * treated as a literal ref. */
29
+ from?: string;
30
+ /** Overrides the real npm registry lookup made when auto-detecting - mainly for tests, so they
31
+ * don't depend on network access or a real published package. */
32
+ npmViewVersion?: (name: string, cwd: string) => Promise<string | undefined>;
33
+ /** An existing record of what's already been documented (typically a changelog file) - only
34
+ * consulted while auto-detecting (ignored when `from` is an explicit hash). Guards against a
35
+ * gap: if this file's own last-modifying commit is *older* than the npm-detected tag - e.g. the
36
+ * file was last updated for 1.1.0, but 1.2.0-1.5.0 were released without ever documenting them,
37
+ * and npm now reports 1.5.0 - starting from the tag alone would silently skip everything the
38
+ * file never recorded. The boundary becomes the merge-base of the two, so the result always
39
+ * covers at least as much as the file is missing. Has no effect when the file doesn't exist. */
40
+ catchUpFile?: string;
41
+ }
42
+ /**
43
+ * Resolves the commit/hash a package's changes should be measured "since" - the boundary
44
+ * `changelog --from` uses, but reusable anywhere a command wants to answer "what changed for this
45
+ * package". An explicit `options.from` (anything but `"npm"`) is returned as-is, applying the same
46
+ * way to every package. Otherwise, it's auto-detected from the package's currently-published npm
47
+ * version: looked up via `npmViewVersion`, then mapped to a git tag using `.rmanrc
48
+ * changelog.tagPattern` (so independent and fixed monorepo versioning schemes both work - see
49
+ * `tagPattern`) - and, if `catchUpFile` is given and exists, widened to also cover anything that
50
+ * file hasn't caught up on yet (see its doc comment). Returns `undefined` when nothing can be
51
+ * resolved at all (unpublished, no network, no matching tag, no catch-up file) - callers should
52
+ * fall back to their own default in that case (e.g. `GitHelper.listCommits`'s "not yet pushed"
53
+ * default when no hash is given).
54
+ */
55
+ export declare function detectChangeHash(git: GitHelper, pkg: Package, options?: DetectChangeHashOptions): Promise<string | undefined>;