@smoothbricks/cli 0.1.1 → 0.3.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.
Files changed (131) hide show
  1. package/README.md +161 -32
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +110 -10
  4. package/dist/devenv/index.d.ts +10 -0
  5. package/dist/devenv/index.d.ts.map +1 -0
  6. package/dist/devenv/index.js +27 -0
  7. package/dist/generate/index.d.ts +20 -0
  8. package/dist/generate/index.d.ts.map +1 -0
  9. package/dist/generate/index.js +47 -0
  10. package/dist/lib/cli-package.d.ts +4 -0
  11. package/dist/lib/cli-package.d.ts.map +1 -0
  12. package/dist/lib/cli-package.js +15 -0
  13. package/dist/lib/run.d.ts +6 -0
  14. package/dist/lib/run.d.ts.map +1 -1
  15. package/dist/lib/run.js +32 -0
  16. package/dist/lib/workspace.d.ts +25 -1
  17. package/dist/lib/workspace.d.ts.map +1 -1
  18. package/dist/lib/workspace.js +79 -17
  19. package/dist/monorepo/commit-msg.d.ts +8 -1
  20. package/dist/monorepo/commit-msg.d.ts.map +1 -1
  21. package/dist/monorepo/commit-msg.js +85 -7
  22. package/dist/monorepo/index.d.ts +19 -2
  23. package/dist/monorepo/index.d.ts.map +1 -1
  24. package/dist/monorepo/index.js +104 -16
  25. package/dist/monorepo/lockfile.d.ts.map +1 -1
  26. package/dist/monorepo/lockfile.js +9 -0
  27. package/dist/monorepo/managed-files.js +1 -1
  28. package/dist/monorepo/nx-sync.d.ts +2 -1
  29. package/dist/monorepo/nx-sync.d.ts.map +1 -1
  30. package/dist/monorepo/nx-sync.js +8 -2
  31. package/dist/monorepo/package-hygiene.d.ts +6 -2
  32. package/dist/monorepo/package-hygiene.d.ts.map +1 -1
  33. package/dist/monorepo/package-hygiene.js +12 -4
  34. package/dist/monorepo/package-policy.d.ts +13 -4
  35. package/dist/monorepo/package-policy.d.ts.map +1 -1
  36. package/dist/monorepo/package-policy.js +208 -113
  37. package/dist/monorepo/packed-package.d.ts +3 -0
  38. package/dist/monorepo/packed-package.d.ts.map +1 -1
  39. package/dist/monorepo/packed-package.js +68 -36
  40. package/dist/monorepo/packs/index.d.ts +20 -1
  41. package/dist/monorepo/packs/index.d.ts.map +1 -1
  42. package/dist/monorepo/packs/index.js +238 -19
  43. package/dist/monorepo/publish-workflow.d.ts +0 -4
  44. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  45. package/dist/monorepo/publish-workflow.js +7 -15
  46. package/dist/monorepo/tool-validation.d.ts +22 -0
  47. package/dist/monorepo/tool-validation.d.ts.map +1 -0
  48. package/dist/monorepo/tool-validation.js +399 -0
  49. package/dist/nx/index.d.ts +25 -0
  50. package/dist/nx/index.d.ts.map +1 -0
  51. package/dist/nx/index.js +112 -0
  52. package/dist/release/bootstrap-npm-packages.d.ts +19 -0
  53. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -0
  54. package/dist/release/bootstrap-npm-packages.js +58 -0
  55. package/dist/release/candidates.d.ts +11 -0
  56. package/dist/release/candidates.d.ts.map +1 -0
  57. package/dist/release/candidates.js +181 -0
  58. package/dist/release/core.d.ts +4 -1
  59. package/dist/release/core.d.ts.map +1 -1
  60. package/dist/release/core.js +76 -9
  61. package/dist/release/github-release.d.ts +1 -0
  62. package/dist/release/github-release.d.ts.map +1 -1
  63. package/dist/release/github-release.js +3 -3
  64. package/dist/release/index.d.ts +32 -1
  65. package/dist/release/index.d.ts.map +1 -1
  66. package/dist/release/index.js +488 -64
  67. package/dist/release/npm-auth.d.ts +0 -1
  68. package/dist/release/npm-auth.d.ts.map +1 -1
  69. package/dist/release/npm-auth.js +4 -16
  70. package/dist/release/orchestration.d.ts +11 -2
  71. package/dist/release/orchestration.d.ts.map +1 -1
  72. package/dist/release/orchestration.js +41 -3
  73. package/dist/release/publish-plan.js +1 -1
  74. package/managed/raw/git-format-staged.yml +54 -0
  75. package/managed/raw/tooling/git-hooks/commit-msg.sh +1 -1
  76. package/managed/templates/github/actions/setup-devenv/action.yml +1 -7
  77. package/managed/templates/github/workflows/ci.yml +4 -2
  78. package/package.json +15 -8
  79. package/src/cli.ts +153 -22
  80. package/src/devenv/index.test.ts +49 -0
  81. package/src/devenv/index.ts +38 -0
  82. package/src/generate/index.ts +92 -0
  83. package/src/lib/cli-package.ts +18 -0
  84. package/src/lib/run.ts +44 -0
  85. package/src/lib/workspace.ts +112 -17
  86. package/src/monorepo/__tests__/publish-workflow.test.ts +16 -9
  87. package/src/monorepo/commit-msg.test.ts +45 -0
  88. package/src/monorepo/commit-msg.ts +107 -7
  89. package/src/monorepo/index.ts +142 -16
  90. package/src/monorepo/lockfile.ts +9 -0
  91. package/src/monorepo/managed-files.ts +1 -1
  92. package/src/monorepo/nx-sync.ts +9 -2
  93. package/src/monorepo/package-hygiene.test.ts +34 -0
  94. package/src/monorepo/package-hygiene.ts +26 -4
  95. package/src/monorepo/package-policy.test.ts +1130 -0
  96. package/src/monorepo/package-policy.ts +237 -114
  97. package/src/monorepo/packed-package.ts +89 -37
  98. package/src/monorepo/packs/index.test.ts +189 -0
  99. package/src/monorepo/packs/index.ts +311 -20
  100. package/src/monorepo/publish-workflow.ts +10 -19
  101. package/src/monorepo/setup-test-tracing.test.ts +120 -0
  102. package/src/monorepo/tool-validation.test.ts +261 -0
  103. package/src/monorepo/tool-validation.ts +462 -0
  104. package/src/nx/index.test.ts +73 -0
  105. package/src/nx/index.ts +139 -0
  106. package/src/release/__tests__/bootstrap-npm-packages.test.ts +127 -0
  107. package/src/release/__tests__/candidates.test.ts +262 -0
  108. package/src/release/__tests__/core-properties.test.ts +6 -6
  109. package/src/release/__tests__/core-scenarios.test.ts +27 -20
  110. package/src/release/__tests__/core.test.ts +52 -5
  111. package/src/release/__tests__/fixture-repo.test.ts +90 -31
  112. package/src/release/__tests__/github-release.test.ts +22 -24
  113. package/src/release/__tests__/helpers/fixture-repo.ts +1 -0
  114. package/src/release/__tests__/npm-auth.test.ts +12 -21
  115. package/src/release/__tests__/orchestration.test.ts +142 -12
  116. package/src/release/__tests__/publish-plan.test.ts +9 -3
  117. package/src/release/__tests__/retag-unpublished.test.ts +10 -10
  118. package/src/release/__tests__/trust-publisher.test.ts +165 -0
  119. package/src/release/bootstrap-npm-packages.ts +88 -0
  120. package/src/release/candidates.ts +225 -0
  121. package/src/release/core.ts +96 -10
  122. package/src/release/github-release.ts +3 -3
  123. package/src/release/index.ts +658 -74
  124. package/src/release/npm-auth.ts +4 -33
  125. package/src/release/orchestration.ts +60 -5
  126. package/src/release/publish-plan.ts +1 -1
  127. package/dist/nx-version-actions.cjs +0 -25
  128. package/dist/nx-version-actions.d.cts +0 -6
  129. package/dist/nx-version-actions.d.cts.map +0 -1
  130. package/managed/raw/.git-format-staged.yml +0 -47
  131. package/src/nx-version-actions.cts +0 -36
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
- import type { ReleasePackageInfo, ReleaseTarget } from '../core.js';
2
+ import { type ReleasePackageInfo, type ReleaseTarget, releaseTag } from '../core.js';
3
3
  import {
4
+ bumpStableReleaseToNext,
4
5
  completeReleaseAtHead,
5
6
  type ReleaseRepairShell,
6
7
  type ReleaseVersionShell,
@@ -8,9 +9,15 @@ import {
8
9
  runReleaseVersion,
9
10
  } from '../orchestration.js';
10
11
 
11
- const stable: ReleasePackageInfo = { name: '@scope/stable', path: 'packages/stable', version: '1.0.0' };
12
+ const stable: ReleasePackageInfo = {
13
+ name: '@scope/stable',
14
+ projectName: 'stable',
15
+ path: 'packages/stable',
16
+ version: '1.0.0',
17
+ };
12
18
  const prerelease: ReleasePackageInfo = {
13
19
  name: '@scope/prerelease',
20
+ projectName: 'prerelease',
14
21
  path: 'packages/prerelease',
15
22
  version: '2.0.0-beta.1',
16
23
  };
@@ -27,7 +34,7 @@ describe('release orchestration', () => {
27
34
  expect(shell.builds).toEqual([['@scope/stable']]);
28
35
  expect(shell.publishes).toEqual([{ name: '@scope/stable', distTag: 'latest', dryRun: false }]);
29
36
  expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
30
- expect(shell.pushes).toEqual([['@scope/stable', '@scope/prerelease']]);
37
+ expect(shell.pushes).toEqual([['stable@1.0.0', 'prerelease@2.0.0-beta.1']]);
31
38
  expect(summaries).toHaveLength(1);
32
39
  expect(summaries[0]?.published.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
33
40
  expect(summaries[0]?.githubReleases.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
@@ -44,6 +51,33 @@ describe('release orchestration', () => {
44
51
  expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
45
52
  });
46
53
 
54
+ it('dry-run repair reports planned targets without checkout, devenv, build, publish, or GitHub calls', async () => {
55
+ const target = releaseTarget('github-only', [prerelease], [], [prerelease]);
56
+ const shell = new RecordingRepairShell();
57
+
58
+ const summaries = await repairPendingTargets(shell, [target], 'restore-ref', true);
59
+
60
+ expect(shell.checkouts).toEqual([]);
61
+ expect(shell.devenvLoads).toBe(0);
62
+ expect(shell.builds).toEqual([]);
63
+ expect(shell.publishes).toEqual([]);
64
+ expect(shell.githubCreates).toEqual([]);
65
+ expect(summaries).toEqual([
66
+ {
67
+ sha: 'github-only',
68
+ dryRun: true,
69
+ packages: [prerelease],
70
+ pushed: false,
71
+ published: [],
72
+ alreadyPublished: [prerelease],
73
+ githubReleases: [],
74
+ githubReleaseLinks: [],
75
+ rerunRequired: false,
76
+ noRelease: false,
77
+ },
78
+ ]);
79
+ });
80
+
47
81
  it('publishes a partial HEAD release by building npm-missing packages and creating missing GitHub Releases', async () => {
48
82
  const shell = new RecordingRepairShell({ npmMissing: ['@scope/stable'], githubMissing: ['@scope/prerelease'] });
49
83
 
@@ -54,9 +88,13 @@ describe('release orchestration', () => {
54
88
  expect(shell.builds).toEqual([['@scope/stable']]);
55
89
  expect(shell.publishes).toEqual([{ name: '@scope/stable', distTag: 'latest', dryRun: false }]);
56
90
  expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
91
+ expect(shell.pushes).toEqual([['stable@1.0.0', 'prerelease@2.0.0-beta.1']]);
57
92
  expect(summary.published.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
58
93
  expect(summary.alreadyPublished.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
59
94
  expect(summary.githubReleases.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
95
+ expect(summary.githubReleaseLinks).toEqual([
96
+ { pkg: prerelease, url: 'https://github.test/prerelease@2.0.0-beta.1' },
97
+ ]);
60
98
  expect(summary.rerunRequired).toBe(true);
61
99
  });
62
100
 
@@ -71,6 +109,7 @@ describe('release orchestration', () => {
71
109
  expect(summary.published).toEqual([]);
72
110
  expect(summary.alreadyPublished.map((pkg) => pkg.name)).toEqual(['@scope/stable', '@scope/prerelease']);
73
111
  expect(summary.githubReleases).toEqual([]);
112
+ expect(summary.githubReleaseLinks).toEqual([]);
74
113
  });
75
114
 
76
115
  it('does not run Nx versioning when HEAD is already a release target', async () => {
@@ -84,15 +123,79 @@ describe('release orchestration', () => {
84
123
  });
85
124
 
86
125
  it('runs forced Nx versioning for an untagged HEAD and reports a new release commit', async () => {
87
- const shell = new RecordingVersionShell({ releasePackagesAtHead: [[], [stable]], heads: ['before', 'after'] });
126
+ const shell = new RecordingVersionShell({
127
+ releasePackagesAtHead: [[], [stable]],
128
+ releaseVersionPackages: [stable, prerelease],
129
+ heads: ['before', 'after'],
130
+ });
88
131
 
89
132
  const result = await runReleaseVersion(shell, { bump: 'patch', dryRun: false });
90
133
 
91
134
  expect(result).toEqual({ mode: 'new', packages: [stable], status: 'new-release' });
92
- expect(shell.nxRuns).toEqual([{ bump: 'patch', dryRun: false }]);
135
+ expect(shell.nxRuns).toEqual([{ packages: ['@scope/stable', '@scope/prerelease'], bump: 'patch', dryRun: false }]);
93
136
  expect(shell.cleanChecks).toBe(1);
94
137
  expect(shell.ensureCalls).toEqual([['@scope/stable']]);
95
138
  });
139
+
140
+ it('skips auto Nx versioning when no package-local candidates exist', async () => {
141
+ const shell = new RecordingVersionShell({ releasePackagesAtHead: [[]], releaseVersionPackages: [] });
142
+
143
+ const result = await runReleaseVersion(shell, { bump: 'auto', dryRun: false });
144
+
145
+ expect(result).toEqual({ mode: 'none', packages: [], status: 'no-release-needed' });
146
+ expect(shell.nxRuns).toEqual([]);
147
+ expect(shell.cleanChecks).toBe(0);
148
+ expect(shell.ensureCalls).toEqual([]);
149
+ });
150
+
151
+ it('runs auto Nx versioning only for selected package-local candidates', async () => {
152
+ const shell = new RecordingVersionShell({
153
+ releasePackagesAtHead: [[], [stable]],
154
+ releaseVersionPackages: [stable],
155
+ heads: ['before', 'after'],
156
+ });
157
+
158
+ const result = await runReleaseVersion(shell, { bump: 'auto', dryRun: false });
159
+
160
+ expect(result).toEqual({ mode: 'new', packages: [stable], status: 'new-release' });
161
+ expect(shell.nxRuns).toEqual([{ packages: ['@scope/stable'], bump: 'auto', dryRun: false }]);
162
+ });
163
+
164
+ it('reports dry-run version packages resolved by Nx without creating a release mode', async () => {
165
+ const preview = { ...stable, version: '1.1.0' };
166
+ const shell = new RecordingVersionShell({ releasePackagesAtHead: [[]], releaseVersionPackages: [stable], preview });
167
+
168
+ const result = await runReleaseVersion(shell, { bump: 'auto', dryRun: true });
169
+
170
+ expect(result).toEqual({ mode: 'none', packages: [preview], status: 'dry-run' });
171
+ expect(shell.nxRuns).toEqual([{ packages: ['@scope/stable'], bump: 'auto', dryRun: true }]);
172
+ });
173
+
174
+ it('bumps stable release packages to next after publish completion', async () => {
175
+ const shell = new RecordingNextShell();
176
+
177
+ const bumped = await bumpStableReleaseToNext(shell, [stable, prerelease], false, false);
178
+
179
+ expect(bumped.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
180
+ expect(shell.bumped).toEqual([['@scope/stable']]);
181
+ });
182
+
183
+ it('does not bump prerelease-only or dry-run publishes to next', async () => {
184
+ const shell = new RecordingNextShell();
185
+
186
+ await expect(bumpStableReleaseToNext(shell, [prerelease], false, false)).resolves.toEqual([]);
187
+ await expect(bumpStableReleaseToNext(shell, [stable], true, false)).resolves.toEqual([]);
188
+
189
+ expect(shell.bumped).toEqual([]);
190
+ });
191
+
192
+ it('does not bump stable packages to next when newer branch commits remain', async () => {
193
+ const shell = new RecordingNextShell();
194
+
195
+ await expect(bumpStableReleaseToNext(shell, [stable], false, true)).resolves.toEqual([]);
196
+
197
+ expect(shell.bumped).toEqual([]);
198
+ });
96
199
  });
97
200
 
98
201
  function releaseTarget(
@@ -127,7 +230,7 @@ class RecordingRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
127
230
  }
128
231
 
129
232
  async pushReleaseRefs(packages: ReleasePackageInfo[]): Promise<boolean> {
130
- this.pushes.push(packageNames(packages));
233
+ this.pushes.push(packages.map((pkg) => releaseTag(pkg)));
131
234
  return true;
132
235
  }
133
236
 
@@ -149,8 +252,9 @@ class RecordingRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
149
252
  return packages.filter((pkg) => this.githubMissing.has(pkg.name));
150
253
  }
151
254
 
152
- async createGithubRelease(pkg: ReleasePackageInfo, dryRun: boolean): Promise<void> {
255
+ async createGithubRelease(pkg: ReleasePackageInfo, dryRun: boolean): Promise<string | null> {
153
256
  this.githubCreates.push({ name: pkg.name, dryRun });
257
+ return dryRun ? null : `https://github.test/${releaseTag(pkg)}`;
154
258
  }
155
259
 
156
260
  async checkout(ref: string): Promise<void> {
@@ -166,20 +270,33 @@ class RecordingRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
166
270
 
167
271
  class RecordingVersionShell implements ReleaseVersionShell<ReleasePackageInfo> {
168
272
  readonly ensureCalls: string[][] = [];
169
- readonly nxRuns: Array<{ bump: string; dryRun: boolean }> = [];
273
+ readonly nxRuns: Array<{ packages: string[]; bump: string; dryRun: boolean }> = [];
170
274
  cleanChecks = 0;
171
275
  private readonly releaseBatches: ReleasePackageInfo[][];
276
+ private readonly versionPackages: ReleasePackageInfo[];
172
277
  private readonly heads: string[];
173
-
174
- constructor(options: { releasePackagesAtHead: ReleasePackageInfo[][]; heads?: string[] }) {
278
+ private readonly preview: ReleasePackageInfo[];
279
+
280
+ constructor(options: {
281
+ releasePackagesAtHead: ReleasePackageInfo[][];
282
+ releaseVersionPackages?: ReleasePackageInfo[];
283
+ heads?: string[];
284
+ preview?: ReleasePackageInfo | ReleasePackageInfo[];
285
+ }) {
175
286
  this.releaseBatches = [...options.releasePackagesAtHead];
287
+ this.versionPackages = options.releaseVersionPackages ?? [stable, prerelease];
176
288
  this.heads = [...(options.heads ?? [])];
289
+ this.preview = Array.isArray(options.preview) ? options.preview : options.preview ? [options.preview] : [];
177
290
  }
178
291
 
179
292
  async releasePackagesAtHead(): Promise<ReleasePackageInfo[]> {
180
293
  return this.releaseBatches.shift() ?? [];
181
294
  }
182
295
 
296
+ async releaseVersionPackages(): Promise<ReleasePackageInfo[]> {
297
+ return this.versionPackages;
298
+ }
299
+
183
300
  async ensureLocalReleaseTags(packages: ReleasePackageInfo[]): Promise<void> {
184
301
  this.ensureCalls.push(packageNames(packages));
185
302
  }
@@ -188,8 +305,13 @@ class RecordingVersionShell implements ReleaseVersionShell<ReleasePackageInfo> {
188
305
  return this.heads.shift() ?? 'head';
189
306
  }
190
307
 
191
- async runNxReleaseVersion(bump: string, dryRun: boolean): Promise<void> {
192
- this.nxRuns.push({ bump, dryRun });
308
+ async runNxReleaseVersion(
309
+ packages: ReleasePackageInfo[],
310
+ bump: string,
311
+ dryRun: boolean,
312
+ ): Promise<ReleasePackageInfo[]> {
313
+ this.nxRuns.push({ packages: packageNames(packages), bump, dryRun });
314
+ return this.preview;
193
315
  }
194
316
 
195
317
  async assertCleanGitTree(): Promise<void> {
@@ -197,6 +319,14 @@ class RecordingVersionShell implements ReleaseVersionShell<ReleasePackageInfo> {
197
319
  }
198
320
  }
199
321
 
322
+ class RecordingNextShell {
323
+ readonly bumped: string[][] = [];
324
+
325
+ async bumpStablePackagesToNext(packages: ReleasePackageInfo[]): Promise<void> {
326
+ this.bumped.push(packageNames(packages));
327
+ }
328
+ }
329
+
200
330
  function packageNames(packages: ReleasePackageInfo[]): string[] {
201
331
  return packages.map((pkg) => pkg.name);
202
332
  }
@@ -2,9 +2,15 @@ import { describe, expect, it } from 'bun:test';
2
2
  import type { ReleasePackageInfo } from '../core.js';
3
3
  import { planPublishActions } from '../publish-plan.js';
4
4
 
5
- const stable: ReleasePackageInfo = { name: '@scope/stable', path: 'packages/stable', version: '1.0.0' };
5
+ const stable: ReleasePackageInfo = {
6
+ name: '@scope/stable',
7
+ projectName: 'stable',
8
+ path: 'packages/stable',
9
+ version: '1.0.0',
10
+ };
6
11
  const prerelease: ReleasePackageInfo = {
7
12
  name: '@scope/prerelease',
13
+ projectName: 'prerelease',
8
14
  path: 'packages/prerelease',
9
15
  version: '2.0.0-beta.1',
10
16
  };
@@ -27,7 +33,7 @@ describe('publish plan', () => {
27
33
  githubMissingPackages: [],
28
34
  });
29
35
 
30
- expect(plan.buildProjects).toEqual(['@scope/stable']);
36
+ expect(plan.buildProjects).toEqual(['stable']);
31
37
  expect(plan.publishPackages).toEqual([{ pkg: stable, distTag: 'latest' }]);
32
38
  expect(plan.githubReleasePackages).toEqual([]);
33
39
  });
@@ -51,7 +57,7 @@ describe('publish plan', () => {
51
57
  githubMissingPackages: [],
52
58
  });
53
59
 
54
- expect(plan.buildProjects).toEqual(['@scope/stable', '@scope/prerelease']);
60
+ expect(plan.buildProjects).toEqual(['stable', 'prerelease']);
55
61
  expect(plan.publishPackages).toEqual([
56
62
  { pkg: stable, distTag: 'latest' },
57
63
  { pkg: prerelease, distTag: 'next' },
@@ -7,31 +7,31 @@ import {
7
7
  retagUnpublished,
8
8
  } from '../retag-unpublished.js';
9
9
 
10
- const pkg: ReleasePackageInfo = { name: '@scope/pkg', path: 'packages/pkg', version: '1.2.3' };
10
+ const pkg: ReleasePackageInfo = { name: '@scope/pkg', projectName: 'pkg', path: 'packages/pkg', version: '1.2.3' };
11
11
 
12
12
  describe('retag unpublished releases', () => {
13
13
  it('moves unpublished owned tags, pushes with a remote lease, and dispatches publish auto', async () => {
14
14
  const shell = new RecordingRetagShell({
15
15
  versionAtRef: '1.2.3',
16
- remoteTagObjects: new Map([['@scope/pkg@1.2.3', 'old-tag-object']]),
16
+ remoteTagObjects: new Map([['pkg@1.2.3', 'old-tag-object']]),
17
17
  dispatchSha: 'target-sha',
18
18
  });
19
19
 
20
20
  const updates = await retagUnpublished(shell, retagOptions({ push: true, dispatch: true }));
21
21
 
22
- expect(updates).toEqual([{ tag: '@scope/pkg@1.2.3', pkg, expectedRemoteObject: 'old-tag-object' }]);
23
- expect(shell.movedTags).toEqual([{ tag: '@scope/pkg@1.2.3', ref: 'HEAD' }]);
24
- expect(shell.pushed).toEqual([[{ tag: '@scope/pkg@1.2.3', expectedRemoteObject: 'old-tag-object' }]]);
22
+ expect(updates).toEqual([{ tag: 'pkg@1.2.3', pkg, expectedRemoteObject: 'old-tag-object' }]);
23
+ expect(shell.movedTags).toEqual([{ tag: 'pkg@1.2.3', ref: 'HEAD' }]);
24
+ expect(shell.pushed).toEqual([[{ tag: 'pkg@1.2.3', expectedRemoteObject: 'old-tag-object' }]]);
25
25
  expect(shell.dispatched).toEqual([{ workflow: 'publish.yml', branch: 'main' }]);
26
26
  expect(shell.dispatchRefLookups).toEqual(['main']);
27
- expect(shell.remoteTagLookups).toEqual(['@scope/pkg@1.2.3']);
27
+ expect(shell.remoteTagLookups).toEqual(['pkg@1.2.3']);
28
28
  });
29
29
 
30
30
  it('rejects tags whose package version already exists on npm', async () => {
31
31
  const shell = new RecordingRetagShell({ versionAtRef: '1.2.3', npmPublished: true });
32
32
 
33
33
  await expect(retagUnpublished(shell, retagOptions())).rejects.toThrow(
34
- 'Cannot retag @scope/pkg@1.2.3: @scope/pkg@1.2.3 already exists on npm.',
34
+ 'Cannot retag pkg@1.2.3: @scope/pkg@1.2.3 already exists on npm.',
35
35
  );
36
36
 
37
37
  expect(shell.movedTags).toEqual([]);
@@ -42,7 +42,7 @@ describe('retag unpublished releases', () => {
42
42
  const shell = new RecordingRetagShell({ versionAtRef: '1.2.4' });
43
43
 
44
44
  await expect(retagUnpublished(shell, retagOptions())).rejects.toThrow(
45
- 'Release tag @scope/pkg@1.2.3 cannot move to HEAD: packages/pkg/package.json has version 1.2.4, expected 1.2.3.',
45
+ 'Release tag pkg@1.2.3 cannot move to HEAD: packages/pkg/package.json has version 1.2.4, expected 1.2.3.',
46
46
  );
47
47
  });
48
48
 
@@ -66,7 +66,7 @@ describe('retag unpublished releases', () => {
66
66
  expect(shell.movedTags).toEqual([]);
67
67
  expect(shell.pushed).toEqual([]);
68
68
  expect(shell.dispatched).toEqual([]);
69
- expect(shell.logs).toContain('Would move @scope/pkg@1.2.3 to HEAD (target-sha).');
69
+ expect(shell.logs).toContain('Would move pkg@1.2.3 to HEAD (target-sha).');
70
70
  expect(shell.logs).toContain('Would push 1 retagged release tag.');
71
71
  expect(shell.logs).toContain('Would dispatch publish.yml on main with bump=auto.');
72
72
  });
@@ -74,7 +74,7 @@ describe('retag unpublished releases', () => {
74
74
 
75
75
  function retagOptions(overrides: Partial<RetagUnpublishedOptions> = {}): RetagUnpublishedOptions {
76
76
  return {
77
- tags: ['@scope/pkg@1.2.3'],
77
+ tags: ['pkg@1.2.3'],
78
78
  toRef: 'HEAD',
79
79
  push: false,
80
80
  dispatch: false,
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import type { BootstrapNpmPackagesOptions } from '../bootstrap-npm-packages.js';
3
+ import type { ReleasePackageInfo } from '../core.js';
4
+ import {
5
+ configureTrustedPublishers,
6
+ parseTrustedPublishers,
7
+ type TrustedPublisher,
8
+ type TrustPublisherShell,
9
+ } from '../index.js';
10
+
11
+ const stable: ReleasePackageInfo = {
12
+ name: '@scope/stable',
13
+ projectName: 'stable',
14
+ path: 'packages/stable',
15
+ version: '1.2.3',
16
+ };
17
+ const missing: ReleasePackageInfo = {
18
+ name: '@scope/missing',
19
+ projectName: 'missing',
20
+ path: 'packages/missing',
21
+ version: '2.0.0',
22
+ };
23
+
24
+ describe('trusted publisher setup', () => {
25
+ it('bootstraps missing npm packages before configuring trust', async () => {
26
+ const shell = new RecordingTrustPublisherShell({ packages: [stable, missing], existing: [stable.name] });
27
+
28
+ await configureTrustedPublishers(shell, { bootstrap: true, skipLogin: false });
29
+
30
+ expect(shell.events).toEqual([
31
+ 'bootstrap:false:false',
32
+ `exists:${stable.name}`,
33
+ `exists:${missing.name}`,
34
+ `list:${stable.name}`,
35
+ `trust:${stable.name}:false`,
36
+ `list:${missing.name}`,
37
+ `trust:${missing.name}:false`,
38
+ ]);
39
+ });
40
+
41
+ it('directs missing packages to trust-publisher --bootstrap', async () => {
42
+ const shell = new RecordingTrustPublisherShell({ packages: [missing], existing: [] });
43
+
44
+ await expect(configureTrustedPublishers(shell, {})).rejects.toThrow(
45
+ 'Run smoo release trust-publisher --bootstrap locally',
46
+ );
47
+ expect(shell.events).toEqual([`exists:${missing.name}`]);
48
+ });
49
+
50
+ it('does not pre-login before trust operations', async () => {
51
+ const shell = new RecordingTrustPublisherShell({ packages: [stable], existing: [stable.name] });
52
+
53
+ await configureTrustedPublishers(shell, { bootstrap: true });
54
+
55
+ expect(shell.events).toEqual([
56
+ 'bootstrap:false:false',
57
+ `exists:${stable.name}`,
58
+ `list:${stable.name}`,
59
+ `trust:${stable.name}:false`,
60
+ ]);
61
+ });
62
+
63
+ it('skips packages with matching trusted publishers', async () => {
64
+ const shell = new RecordingTrustPublisherShell({
65
+ packages: [stable],
66
+ existing: [stable.name],
67
+ trustedPublishers: {
68
+ [stable.name]: [{ id: 'trusted-1', type: 'github', file: 'publish.yml', repository: 'scope/repo' }],
69
+ },
70
+ });
71
+
72
+ await configureTrustedPublishers(shell, { skipLogin: true });
73
+
74
+ expect(shell.events).toEqual([`exists:${stable.name}`, `list:${stable.name}`]);
75
+ expect(shell.logs).toContain(`${stable.name}: npm trusted publisher is already configured; skipping.`);
76
+ });
77
+
78
+ it('rejects mismatched trusted publishers', async () => {
79
+ const shell = new RecordingTrustPublisherShell({
80
+ packages: [stable],
81
+ existing: [stable.name],
82
+ trustedPublishers: {
83
+ [stable.name]: [{ id: 'trusted-1', type: 'github', file: 'other.yml', repository: 'scope/repo' }],
84
+ },
85
+ });
86
+
87
+ await expect(configureTrustedPublishers(shell, { skipLogin: true })).rejects.toThrow(
88
+ 'npm trusted publisher exists but does not match',
89
+ );
90
+ expect(shell.events).toEqual([`exists:${stable.name}`, `list:${stable.name}`]);
91
+ });
92
+
93
+ it('configures only selected packages', async () => {
94
+ const shell = new RecordingTrustPublisherShell({
95
+ packages: [stable, missing],
96
+ existing: [stable.name, missing.name],
97
+ });
98
+
99
+ await configureTrustedPublishers(shell, { skipLogin: true, packages: [missing.name] });
100
+
101
+ expect(shell.events).toEqual([`exists:${missing.name}`, `list:${missing.name}`, `trust:${missing.name}:false`]);
102
+ });
103
+
104
+ it('parses empty npm trust list output as no trusted publishers', () => {
105
+ expect(parseTrustedPublishers('', stable.name)).toEqual([]);
106
+ expect(parseTrustedPublishers('null', stable.name)).toEqual([]);
107
+ });
108
+ });
109
+
110
+ class RecordingTrustPublisherShell implements TrustPublisherShell<ReleasePackageInfo> {
111
+ readonly repository = 'scope/repo';
112
+ readonly workflow = 'publish.yml';
113
+ readonly events: string[] = [];
114
+ readonly logs: string[] = [];
115
+ readonly errors: string[] = [];
116
+ private readonly packages: ReleasePackageInfo[];
117
+ private readonly existing: Set<string>;
118
+ private readonly trustedPublisherByPackage: Record<string, TrustedPublisher[]>;
119
+
120
+ constructor(options: {
121
+ packages: ReleasePackageInfo[];
122
+ existing: string[];
123
+ trustedPublishers?: Record<string, TrustedPublisher[]>;
124
+ }) {
125
+ this.packages = options.packages;
126
+ this.existing = new Set(options.existing);
127
+ this.trustedPublisherByPackage = options.trustedPublishers ?? {};
128
+ }
129
+
130
+ listReleasePackages(): ReleasePackageInfo[] {
131
+ return this.packages;
132
+ }
133
+
134
+ async packageExists(name: string): Promise<boolean> {
135
+ this.events.push(`exists:${name}`);
136
+ return this.existing.has(name);
137
+ }
138
+
139
+ async bootstrapNpmPackages(options: BootstrapNpmPackagesOptions): Promise<ReleasePackageInfo[]> {
140
+ this.events.push(`bootstrap:${options.dryRun}:${options.skipLogin}`);
141
+ const missingPackages = this.packages.filter((pkg) => !this.existing.has(pkg.name));
142
+ for (const pkg of missingPackages) {
143
+ this.existing.add(pkg.name);
144
+ }
145
+ return missingPackages;
146
+ }
147
+
148
+ async trustPublisher(pkg: ReleasePackageInfo, dryRun: boolean): Promise<'configured' | 'already-configured'> {
149
+ this.events.push(`trust:${pkg.name}:${dryRun}`);
150
+ return 'configured';
151
+ }
152
+
153
+ async trustedPublishers(pkg: ReleasePackageInfo): Promise<TrustedPublisher[]> {
154
+ this.events.push(`list:${pkg.name}`);
155
+ return this.trustedPublisherByPackage[pkg.name] ?? [];
156
+ }
157
+
158
+ log(message: string): void {
159
+ this.logs.push(message);
160
+ }
161
+
162
+ error(message: string): void {
163
+ this.errors.push(message);
164
+ }
165
+ }
@@ -0,0 +1,88 @@
1
+ import type { ReleasePackageInfo } from './core.js';
2
+
3
+ export const NPM_BOOTSTRAP_VERSION = '0.0.0-bootstrap.0';
4
+ export const NPM_BOOTSTRAP_DIST_TAG = 'bootstrap';
5
+
6
+ export interface BootstrapNpmPackagesOptions {
7
+ dryRun: boolean;
8
+ skipLogin: boolean;
9
+ packages: string[];
10
+ otp?: string;
11
+ }
12
+
13
+ export interface BootstrapNpmPackagesShell<Package extends ReleasePackageInfo = ReleasePackageInfo> {
14
+ listReleasePackages(): Package[];
15
+ packageExists(name: string): Promise<boolean>;
16
+ login(): Promise<void>;
17
+ publishPlaceholder(pkg: Package, env?: Record<string, string>): Promise<void>;
18
+ promptOtp(packageName: string): Promise<string>;
19
+ log(message: string): void;
20
+ }
21
+
22
+ export async function bootstrapNpmPackages<Package extends ReleasePackageInfo>(
23
+ shell: BootstrapNpmPackagesShell<Package>,
24
+ options: BootstrapNpmPackagesOptions,
25
+ ): Promise<Package[]> {
26
+ const packages = selectedReleasePackages(shell.listReleasePackages(), options.packages);
27
+ if (packages.length === 0) {
28
+ throw new Error('No owned release packages found.');
29
+ }
30
+
31
+ const missing: Package[] = [];
32
+ for (const pkg of packages) {
33
+ if (await shell.packageExists(pkg.name)) {
34
+ shell.log(`${pkg.name}: already exists on npm; skipping placeholder bootstrap.`);
35
+ } else {
36
+ missing.push(pkg);
37
+ }
38
+ }
39
+
40
+ if (missing.length === 0) {
41
+ shell.log('All selected owned release packages already exist on npm.');
42
+ return [];
43
+ }
44
+
45
+ shell.log(
46
+ `Bootstrap npm placeholders (${NPM_BOOTSTRAP_VERSION}, dist-tag ${NPM_BOOTSTRAP_DIST_TAG}): ${missing
47
+ .map((pkg) => pkg.name)
48
+ .join(', ')}`,
49
+ );
50
+ if (options.dryRun) {
51
+ return missing;
52
+ }
53
+
54
+ if (!options.skipLogin) {
55
+ await shell.login();
56
+ }
57
+ for (const pkg of missing) {
58
+ shell.log(`${pkg.name}: publishing npm placeholder.`);
59
+ const otp = options.otp ?? (await shell.promptOtp(pkg.name));
60
+ await shell.publishPlaceholder(pkg, { NPM_CONFIG_OTP: otp });
61
+ }
62
+ shell.log('Bootstrap complete. Run smoo release trust-publisher before the first CI publish.');
63
+ return missing;
64
+ }
65
+
66
+ function selectedReleasePackages<Package extends ReleasePackageInfo>(
67
+ packages: Package[],
68
+ selections: string[],
69
+ ): Package[] {
70
+ if (selections.length === 0) {
71
+ return packages;
72
+ }
73
+ const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
74
+ const selected: Package[] = [];
75
+ const unknown: string[] = [];
76
+ for (const name of selections) {
77
+ const pkg = byName.get(name);
78
+ if (pkg) {
79
+ selected.push(pkg);
80
+ } else {
81
+ unknown.push(name);
82
+ }
83
+ }
84
+ if (unknown.length > 0) {
85
+ throw new Error(`Unknown owned release package selection: ${unknown.join(', ')}`);
86
+ }
87
+ return selected;
88
+ }