mopub 0.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.
- package/dist/cli.js +77 -0
- package/dist/publish.js +1188 -0
- package/package.json +40 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as trpcServer from '@trpc/server';
|
|
5
|
+
import * as trpcCli from 'trpc-cli';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { releaseNotes, ReleaseNotesInput, publish, PublishInput, PrebuiltInput, publishPrebuilt, Ctx } from './publish.js';
|
|
8
|
+
const t = trpcServer.initTRPC.meta().create();
|
|
9
|
+
const router = t.router({
|
|
10
|
+
publish: t.procedure
|
|
11
|
+
.meta({ default: true })
|
|
12
|
+
.input(PublishInput) //
|
|
13
|
+
.mutation(async ({ input }) => {
|
|
14
|
+
await publish(input);
|
|
15
|
+
}),
|
|
16
|
+
prebuilt: t.procedure
|
|
17
|
+
.input(PrebuiltInput) //
|
|
18
|
+
.mutation(async ({ input }) => publishPrebuilt(input)),
|
|
19
|
+
releaseNotes: t.procedure
|
|
20
|
+
.meta({
|
|
21
|
+
description: `Creates a GitHub release notes draft in markdown format, and opens it in the default browser. It does not perform any side-effects - if you're happy with the draft, just click "Create release" on GitHub. That will create the release and the git tag.`,
|
|
22
|
+
})
|
|
23
|
+
.input(ReleaseNotesInput) //
|
|
24
|
+
.mutation(async ({ input }) => {
|
|
25
|
+
return releaseNotes(input);
|
|
26
|
+
}),
|
|
27
|
+
local: t.procedure
|
|
28
|
+
.meta({
|
|
29
|
+
description: 'Preps packages for local installation. Builds and packs all packages, and replaces dependencies with local versions using the file protocol. Prints install commands to stdout.',
|
|
30
|
+
})
|
|
31
|
+
.input(z.object({
|
|
32
|
+
prebuilt: z
|
|
33
|
+
.string()
|
|
34
|
+
.describe('path to prebuilt folder. if ommitted a dry-run publish will be done to build and pack packages into a temp folder.\nNOTE: if you provide this value, dependencies in this folder will be replaced with local versions using the file protocol.')
|
|
35
|
+
.optional(),
|
|
36
|
+
}))
|
|
37
|
+
.mutation(async ({ input: options }) => {
|
|
38
|
+
const date = new Date(Number(options.prebuilt?.match(/\b\d{13}$/)?.[0] || Date.now()));
|
|
39
|
+
const dateVersion = date.toISOString().split('T')[0].split('-').join('.').replaceAll('.0', '.') + String(-date.getTime());
|
|
40
|
+
const ctx = options.prebuilt
|
|
41
|
+
? Ctx.parse(JSON.parse(fs.readFileSync(options.prebuilt + '/context.json').toString()))
|
|
42
|
+
: await publish({
|
|
43
|
+
version: dateVersion,
|
|
44
|
+
publish: false,
|
|
45
|
+
skipRegistryPull: true,
|
|
46
|
+
});
|
|
47
|
+
for (const pkg of ctx.packages) {
|
|
48
|
+
const packagePath = path.join(pkg.folder, 'right/package');
|
|
49
|
+
const packageJsonPath = path.join(packagePath, 'package.json');
|
|
50
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath).toString());
|
|
51
|
+
packageJson.version = dateVersion;
|
|
52
|
+
const warning = `ONLY INTENDED FOR LOCAL INSTALLATION VIA 'file:' PROTOCOL. DO NOT PUBLISH!`;
|
|
53
|
+
packageJson.description = [warning, packageJson.description].filter(Boolean).join('\n\n');
|
|
54
|
+
for (const dep of Object.keys(packageJson.dependencies || {})) {
|
|
55
|
+
const foundDep = ctx.packages.find(p => p.name === dep);
|
|
56
|
+
if (foundDep) {
|
|
57
|
+
packageJson.dependencies[dep] = `file:${path.join(foundDep.folder, 'right/package')}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const peerDep of Object.keys(packageJson.peerDependencies || {})) {
|
|
61
|
+
const foundPeerDep = ctx.packages.find(p => p.name === peerDep);
|
|
62
|
+
if (foundPeerDep) {
|
|
63
|
+
packageJson.peerDependencies[foundPeerDep.name] = dateVersion;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
|
67
|
+
}
|
|
68
|
+
for (const pkg of ctx.packages) {
|
|
69
|
+
const packagePath = path.join(pkg.folder, 'right/package');
|
|
70
|
+
const packageManager = process.env.npm_config_user_agent?.split(/\W/)[0] || 'npm';
|
|
71
|
+
// eslint-disable-next-line no-console
|
|
72
|
+
console.log(`${packageManager} add file:${packagePath}`);
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
const cli = trpcCli.createCli({ router });
|
|
77
|
+
void cli.run();
|
package/dist/publish.js
ADDED
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
import { ListrEnquirerPromptAdapter } from '@listr2/prompt-adapter-enquirer';
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import { findUpSync } from 'find-up';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import { Listr } from 'listr2';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as semver from 'semver';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
const VERSION_ZERO = '0.0.0';
|
|
10
|
+
/** "left" folder i.e. base for comparison - usually from the registry */
|
|
11
|
+
const LHS_FOLDER = 'left';
|
|
12
|
+
/** "right" folder i.e. head for publishing - usually build from local source */
|
|
13
|
+
const RHS_FOLDER = 'right';
|
|
14
|
+
export const PublishInput = z
|
|
15
|
+
.object({
|
|
16
|
+
publish: z
|
|
17
|
+
.boolean()
|
|
18
|
+
.describe('actually publish packages - if not provided the tool will run in dry-run mode and create a directory containing the packages to be published.')
|
|
19
|
+
.optional(),
|
|
20
|
+
otp: z.string().describe('npm otp - needed with --publish. If not provided you will be prompted for it').optional(),
|
|
21
|
+
bump: z
|
|
22
|
+
.enum(['major', 'minor', 'patch', 'premajor', 'preminor', 'prepatch', 'prerelease', 'other', 'independent'])
|
|
23
|
+
.describe('semver "bump" strategy - if not provided you will be prompted for it. Do not use with --version.')
|
|
24
|
+
.optional(),
|
|
25
|
+
version: z.string().describe('specify an exact version to bump to. Do not use with --bump.').optional(),
|
|
26
|
+
skipRegistryPull: z
|
|
27
|
+
.boolean()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe('skip pulling registry packages. Note: if publishing, the full history will appear in changelogs'),
|
|
30
|
+
scope: z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(/^@[\w-_.]+$/)
|
|
33
|
+
.describe('change the scope of the package before publishing')
|
|
34
|
+
.optional(),
|
|
35
|
+
})
|
|
36
|
+
.refine(obj => !(obj.bump && obj.version), { message: `Don't use --bump and --version together` });
|
|
37
|
+
export const setupContextTasks = [
|
|
38
|
+
{
|
|
39
|
+
title: 'Set working directory',
|
|
40
|
+
task: async () => process.chdir(getWorkspaceRoot()),
|
|
41
|
+
},
|
|
42
|
+
...Object.keys(loadPackageJson(path.join(getWorkspaceRoot(), 'package.json'))?.scripts || {})
|
|
43
|
+
.filter(s => s.match(/^(build)$/))
|
|
44
|
+
.map((s) => ({
|
|
45
|
+
title: `Running ${s} script`,
|
|
46
|
+
task: async (_ctx, task) => {
|
|
47
|
+
const isPnpmMonorepo = fs.existsSync('pnpm-workspace.yaml');
|
|
48
|
+
const buildArgs = [s];
|
|
49
|
+
if (isPnpmMonorepo)
|
|
50
|
+
buildArgs.unshift('--workspace-root');
|
|
51
|
+
await pipeExeca(task, 'pnpm', buildArgs);
|
|
52
|
+
},
|
|
53
|
+
})),
|
|
54
|
+
{
|
|
55
|
+
title: 'Get temp directory',
|
|
56
|
+
rendererOptions: { persistentOutput: true },
|
|
57
|
+
task: async (ctx, task) => {
|
|
58
|
+
const list = await execa('pnpm', ['list', '--json', '--depth', '0', '--filter', '.']);
|
|
59
|
+
let projectName = JSON.parse(list.stdout)?.[0]?.name;
|
|
60
|
+
if (!projectName) {
|
|
61
|
+
projectName = path.basename(getWorkspaceRoot());
|
|
62
|
+
}
|
|
63
|
+
if (!projectName)
|
|
64
|
+
throw new Error(`Couldn't get package name from pnpm list output: ${list.stdout}`);
|
|
65
|
+
ctx.tempDir = path.join('/tmp/npmono', projectName, Date.now().toString());
|
|
66
|
+
task.output = ctx.tempDir;
|
|
67
|
+
fs.mkdirSync(ctx.tempDir, { recursive: true });
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
title: 'Collecting packages',
|
|
72
|
+
rendererOptions: { persistentOutput: true },
|
|
73
|
+
task: async (ctx, task) => {
|
|
74
|
+
const listResult = await execa('pnpm', ['list', '--json', '--recursive', '--only-projects', '--prod']);
|
|
75
|
+
const firstResult = listResult.stdout.split('\n\n')[0]; // pnpm can find unrelated packages that live in the repo, but they're returned in a separate json object after two newlines
|
|
76
|
+
// e.g. for pnpm first it lists just the `np` package.json, but then it finds various test packages like `test/fixtures/foo/package.json` etc.
|
|
77
|
+
try {
|
|
78
|
+
ctx.packages = JSON.parse(firstResult);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new Error(`Failed to parse pnpm list output as JSON: ${firstResult}`);
|
|
82
|
+
}
|
|
83
|
+
ctx.packages = ctx.packages.filter(pkg => !pkg.private);
|
|
84
|
+
const pwdsCommand = await execa('pnpm', ['recursive', 'exec', 'pwd']); // use `pnpm recursive exec` to get the correct topological sort order // https://github.com/pnpm/pnpm/issues/7716
|
|
85
|
+
const pwdIndexMap = Object.fromEntries(pwdsCommand.stdout.split('\n').map((s, i) => [s.trim(), i]));
|
|
86
|
+
// sort topologically, alphabetically as a tiebreaker. note that this implementation relies on there being fewer than one million packages in the repo. if this affects you, congratulations. i will fix it. the bug bounty for the fix is $100 per package.
|
|
87
|
+
ctx.packages.sort((a, b) => pwdIndexMap[a.path] - pwdIndexMap[b.path] + a.name.localeCompare(b.name) / 1_000_000);
|
|
88
|
+
ctx.packages.forEach((pkg, i, { length }) => {
|
|
89
|
+
const number = Number(`1${'0'.repeat(length.toString().length + 1)}`) + i;
|
|
90
|
+
pkg.folder = path.join(ctx.tempDir, `${number}.${pkg.name.replace('/', '__')}`);
|
|
91
|
+
});
|
|
92
|
+
task.output = ctx.packages.map(pkg => `${pkg.name}`).join(', ');
|
|
93
|
+
return `Found ${ctx.packages.length} packages to publish`;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
title: 'Getting published versions',
|
|
98
|
+
task: (ctx, task) => {
|
|
99
|
+
return task.newListr(ctx.packages.map(pkg => ({
|
|
100
|
+
title: `Getting published versions for ${pkg.name}`,
|
|
101
|
+
task: async (_ctx) => {
|
|
102
|
+
fs.mkdirSync(path.join(pkg.folder, LHS_FOLDER), { recursive: true });
|
|
103
|
+
pkg.publishedVersions = await getRegistryVersions(pkg);
|
|
104
|
+
},
|
|
105
|
+
})), { concurrent: true });
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
async function getRegistryVersions(pkg) {
|
|
110
|
+
const registryVersionsResult = await execa('npm', ['view', pkg.name, 'versions', '--json'], { reject: false });
|
|
111
|
+
const StdoutShape = z.union([
|
|
112
|
+
z.array(z.string()).nonempty(), // success
|
|
113
|
+
z.object({ error: z.object({ code: z.literal('E404') }) }), // not found - package doesn't exist (yet!). this is ok. other errors are not.
|
|
114
|
+
]);
|
|
115
|
+
const registryVersionsStdout = StdoutShape.parse(JSON.parse(registryVersionsResult.stdout));
|
|
116
|
+
return Array.isArray(registryVersionsStdout) ? registryVersionsStdout : [];
|
|
117
|
+
}
|
|
118
|
+
const _breakpoint = (why = 'hmm') => ({
|
|
119
|
+
title: `wait a bit because ${why}`,
|
|
120
|
+
task: async (ctx, task) => {
|
|
121
|
+
await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
122
|
+
type: 'Input',
|
|
123
|
+
message: `press enter to continue past ${why}`,
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
export const publish = async (input) => {
|
|
128
|
+
const { sortPackageJson } = await import('sort-package-json');
|
|
129
|
+
const tasks = new Listr([
|
|
130
|
+
...setupContextTasks,
|
|
131
|
+
{
|
|
132
|
+
title: `Writing local packages`,
|
|
133
|
+
task: (ctx, task) => {
|
|
134
|
+
return task.newListr(ctx.packages.map(pkg => ({
|
|
135
|
+
title: `Packing ${pkg.name}`,
|
|
136
|
+
task: async (_ctx, subtask) => {
|
|
137
|
+
const toBePublishedFolderPath = path.join(pkg.folder, RHS_FOLDER);
|
|
138
|
+
await pipeExeca(subtask, 'pnpm', ['pack', '--pack-destination', toBePublishedFolderPath], {
|
|
139
|
+
cwd: pkg.path,
|
|
140
|
+
});
|
|
141
|
+
const tgzFileName = () => fs.readdirSync(toBePublishedFolderPath).at(0);
|
|
142
|
+
await fs.promises.rename(path.join(toBePublishedFolderPath, tgzFileName()), path.join(toBePublishedFolderPath, `${pkg.name.replace('@', '').replace('/', '-')}-local.tgz`));
|
|
143
|
+
await pipeExeca(subtask, 'tar', ['-xvzf', tgzFileName()], { cwd: toBePublishedFolderPath });
|
|
144
|
+
},
|
|
145
|
+
})), { concurrent: true });
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
title: 'Get version strategy',
|
|
150
|
+
rendererOptions: { persistentOutput: true },
|
|
151
|
+
task: async (ctx, task) => {
|
|
152
|
+
const allVersions = [
|
|
153
|
+
...ctx.packages.map(pkg => pkg.version),
|
|
154
|
+
...ctx.packages.flatMap(pkg => pkg.publishedVersions.slice()),
|
|
155
|
+
];
|
|
156
|
+
const maxVersion = allVersions.sort(semver.compare).at(-1) || VERSION_ZERO;
|
|
157
|
+
if (!maxVersion)
|
|
158
|
+
throw new Error(`No versions found`);
|
|
159
|
+
let bumpedVersion;
|
|
160
|
+
if (input.bump) {
|
|
161
|
+
bumpedVersion = bumpChoices(maxVersion).find(c => c.type === input.bump)?.value || input.bump;
|
|
162
|
+
}
|
|
163
|
+
else if (input.version) {
|
|
164
|
+
bumpedVersion = input.version;
|
|
165
|
+
}
|
|
166
|
+
bumpedVersion ||= await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
167
|
+
type: 'Select',
|
|
168
|
+
message: `Select semver increment for all packages, specify new version, or publish packages independently`,
|
|
169
|
+
hint: `Current latest version across all packageas is ${maxVersion}`,
|
|
170
|
+
choices: [
|
|
171
|
+
...bumpChoices(maxVersion),
|
|
172
|
+
{
|
|
173
|
+
message: 'Independent (each package will have its own version)',
|
|
174
|
+
value: 'independent',
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
if (bumpedVersion === 'independent') {
|
|
179
|
+
const bumpMethod = await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
180
|
+
type: 'Select',
|
|
181
|
+
message: 'Select semver increment for each package',
|
|
182
|
+
choices: [
|
|
183
|
+
...allReleaseTypes.map(type => ({ message: type, value: type })),
|
|
184
|
+
{
|
|
185
|
+
message: 'Ask for each package',
|
|
186
|
+
value: 'ask',
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
ctx.versionStrategy = {
|
|
191
|
+
type: 'independent',
|
|
192
|
+
bump: bumpMethod === 'ask' ? null : bumpMethod,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
if (bumpedVersion === 'other') {
|
|
197
|
+
bumpedVersion = await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
198
|
+
type: 'Input',
|
|
199
|
+
message: `Enter a custom version (must be greater than ${maxVersion})`,
|
|
200
|
+
validate: (v) => getBumpedVersionValidation(maxVersion, v),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
ensureValidBumpedVersion(maxVersion, bumpedVersion);
|
|
204
|
+
ctx.versionStrategy = {
|
|
205
|
+
type: 'fixed',
|
|
206
|
+
version: bumpedVersion,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
task.output = Object.entries(ctx.versionStrategy)
|
|
210
|
+
.map(e => e.join(': '))
|
|
211
|
+
.join(', ');
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
title: `Pulling registry packages`,
|
|
216
|
+
rendererOptions: { persistentOutput: true },
|
|
217
|
+
skip: input.skipRegistryPull,
|
|
218
|
+
task: (ctx, task) => {
|
|
219
|
+
return task.newListr(ctx.packages.map(pkg => ({
|
|
220
|
+
title: `Pulling ${pkg.name}`,
|
|
221
|
+
rendererOptions: { persistentOutput: true },
|
|
222
|
+
task: async (_ctx, subtask) => {
|
|
223
|
+
const registryVersions = pkg.publishedVersions;
|
|
224
|
+
const publishedAlreadyFolder = path.join(pkg.folder, LHS_FOLDER);
|
|
225
|
+
const publishedVersionForComparison = registryVersions
|
|
226
|
+
.slice()
|
|
227
|
+
.reverse()
|
|
228
|
+
.find(v => ctx.versionStrategy.type === 'independent' || // independent mode: compare to prerelease versions
|
|
229
|
+
ctx.versionStrategy.version.includes('-') || // fixed mode prerelease version wanted: compare to prerelease versions
|
|
230
|
+
!v.includes('-'));
|
|
231
|
+
if (!publishedVersionForComparison) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// note: `npm pack foobar` will actually pull foobar.1-2-3.tgz from the registry. It's not actually doing a "pack" at all. `pnpm pack` does not do the same thing - it packs the local directory
|
|
235
|
+
await pipeExeca(subtask, 'npm', ['pack', `${pkg.name}@${publishedVersionForComparison}`], {
|
|
236
|
+
reject: false,
|
|
237
|
+
cwd: publishedAlreadyFolder,
|
|
238
|
+
});
|
|
239
|
+
const tgzFileName = fs.readdirSync(publishedAlreadyFolder).at(0);
|
|
240
|
+
if (!tgzFileName) {
|
|
241
|
+
const msg = `No tgz file found in ${publishedAlreadyFolder}, even though a last release was found. Registry versions: ${registryVersions.join(', ')}`;
|
|
242
|
+
throw new Error(msg);
|
|
243
|
+
}
|
|
244
|
+
await pipeExeca(subtask, 'tar', ['-xvzf', tgzFileName], { cwd: publishedAlreadyFolder });
|
|
245
|
+
const leftPackageJson = loadLHSPackageJson(pkg);
|
|
246
|
+
if (leftPackageJson) {
|
|
247
|
+
const leftPackageJsonPath = packageJsonFilepath(pkg, LHS_FOLDER);
|
|
248
|
+
fs.writeFileSync(leftPackageJsonPath,
|
|
249
|
+
// avoid churn on package.json field ordering, which npm seems to mess with
|
|
250
|
+
sortPackageJson(JSON.stringify(leftPackageJson, null, 2)));
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
})), { concurrent: true });
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
title: 'Set target versions',
|
|
258
|
+
task: async function setTargetVersions(ctx, task) {
|
|
259
|
+
const { stdout: rightSha } = await execa('git', ['log', '-n', '1', '--pretty=format:%h', '--', '.']);
|
|
260
|
+
for (const pkg of ctx.packages) {
|
|
261
|
+
pkg.shas = {
|
|
262
|
+
left: await getPackageLastPublishRef(pkg, task),
|
|
263
|
+
right: rightSha,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
for (const pkg of ctx.packages) {
|
|
267
|
+
const changelog = await getChangelog(ctx, pkg);
|
|
268
|
+
if (ctx.versionStrategy.type === 'fixed') {
|
|
269
|
+
pkg.targetVersion = ctx.versionStrategy.version;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const currentVersion = [loadRHSPackageJson(pkg)?.version, pkg.version]
|
|
273
|
+
.sort((a, b) => semver.compare(a || VERSION_ZERO, b || VERSION_ZERO))
|
|
274
|
+
.at(-1);
|
|
275
|
+
if (ctx.versionStrategy.bump) {
|
|
276
|
+
pkg.targetVersion = semver.inc(currentVersion || VERSION_ZERO, ctx.versionStrategy.bump);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const choices = bumpChoices(currentVersion || VERSION_ZERO);
|
|
280
|
+
if (changelog) {
|
|
281
|
+
choices.push({ type: 'none', message: `Do not publish (note: package is changed)`, value: 'none' });
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
choices.unshift({ type: 'none', message: `Do not publish (package is unchanged)`, value: 'none' });
|
|
285
|
+
}
|
|
286
|
+
let newVersion = await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
287
|
+
type: 'Select',
|
|
288
|
+
message: `Select semver increment for ${pkg.name} or specify new version (current latest is ${currentVersion})`,
|
|
289
|
+
choices,
|
|
290
|
+
});
|
|
291
|
+
if (newVersion === 'none') {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (newVersion === 'other') {
|
|
295
|
+
newVersion = await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
296
|
+
type: 'Input',
|
|
297
|
+
message: `Enter a custom version (must be greater than ${currentVersion})`,
|
|
298
|
+
validate: (v) => getBumpedVersionValidation(currentVersion || VERSION_ZERO, v),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
ensureValidBumpedVersion(currentVersion || VERSION_ZERO, newVersion);
|
|
302
|
+
pkg.targetVersion = newVersion;
|
|
303
|
+
pkg.changeTypes = new Set(Array.from(changelog?.matchAll(/data-change-type="(\w+)"/g) || []).map(m => m[1]));
|
|
304
|
+
}
|
|
305
|
+
if (ctx.versionStrategy.type === 'fixed')
|
|
306
|
+
return;
|
|
307
|
+
if (ctx.versionStrategy.type === 'independent' && !ctx.versionStrategy.bump)
|
|
308
|
+
return;
|
|
309
|
+
const include = await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
310
|
+
type: 'MultiSelect',
|
|
311
|
+
message: 'Select packages',
|
|
312
|
+
hint: 'Press <space> to toggle, <a> to toggle all, <i> to invert selection',
|
|
313
|
+
initial: ctx.packages.flatMap((pkg, i) => (pkg.changeTypes?.size ? [i] : [])),
|
|
314
|
+
choices: ctx.packages.map(pkg => {
|
|
315
|
+
const changeTypes = [...(pkg.changeTypes || [])];
|
|
316
|
+
return {
|
|
317
|
+
name: `${pkg.name} ${changeTypes.length ? `(changes: ${changeTypes.join(', ')})` : '(unchanged)'}`.trim(),
|
|
318
|
+
value: pkg.name,
|
|
319
|
+
};
|
|
320
|
+
}),
|
|
321
|
+
validate: (values) => {
|
|
322
|
+
const names = values.map(v => v.split(' ')[0]);
|
|
323
|
+
const problems = [];
|
|
324
|
+
for (const name of names) {
|
|
325
|
+
const pkg = ctx.packages.find(p => p.name === name);
|
|
326
|
+
if (!pkg) {
|
|
327
|
+
problems.push(`Package ${name} not found`);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const dependencies = workspaceDependencies(pkg, ctx);
|
|
331
|
+
const missing = dependencies.filter(d => !names.includes(d.name));
|
|
332
|
+
problems.push(...missing.map(m => `Package ${name} depends on ${m.name}`));
|
|
333
|
+
}
|
|
334
|
+
if (problems.length > 0)
|
|
335
|
+
return `Can't publish that selection of packages:\n` + problems.join('\n');
|
|
336
|
+
return true;
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
const includeSet = new Set(include.map(inc => inc.split(' ')[0]));
|
|
340
|
+
ctx.packages = ctx.packages.filter(pkg => includeSet.has(pkg.name));
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
title: `Modify local packages`,
|
|
345
|
+
task: (ctx, task) => {
|
|
346
|
+
return task.newListr(ctx.packages.map(pkg => ({
|
|
347
|
+
title: `Modify ${pkg.name}`,
|
|
348
|
+
task: async (_ctx, _subtask) => {
|
|
349
|
+
if (!pkg.targetVersion || !semver.valid(pkg.targetVersion)) {
|
|
350
|
+
throw new Error(`Invalid version for ${pkg.name}: ${pkg.targetVersion}`);
|
|
351
|
+
}
|
|
352
|
+
const sha = await execa('git', ['log', '-n', '1', '--pretty=format:%h', '--', '.'], {
|
|
353
|
+
cwd: pkg.path,
|
|
354
|
+
});
|
|
355
|
+
const packageJson = loadRHSPackageJson(pkg);
|
|
356
|
+
packageJson.version = pkg.targetVersion;
|
|
357
|
+
packageJson.git = {
|
|
358
|
+
...packageJson.git,
|
|
359
|
+
sha: sha.stdout,
|
|
360
|
+
};
|
|
361
|
+
if (input.scope) {
|
|
362
|
+
const name = packageJson.name.split('/').at(-1);
|
|
363
|
+
packageJson.name = `${input.scope}/${name}`;
|
|
364
|
+
}
|
|
365
|
+
const bumped = await getBumpedDependencies(ctx, { pkg });
|
|
366
|
+
packageJson.dependencies = bumped.dependencies;
|
|
367
|
+
packageJson.peerDependencies = bumped.peerDependencies;
|
|
368
|
+
packageJson.optionalDependencies = bumped.optionalDependencies;
|
|
369
|
+
fs.writeFileSync(packageJsonFilepath(pkg, RHS_FOLDER), sortPackageJson(JSON.stringify(packageJson, null, 2)));
|
|
370
|
+
},
|
|
371
|
+
})));
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
title: 'Write changelogs',
|
|
376
|
+
task: async (ctx, task) => {
|
|
377
|
+
return task.newListr(ctx.packages.map(pkg => ({
|
|
378
|
+
title: `Write ${pkg.name} changelog`,
|
|
379
|
+
task: async () => getOrCreateChangelog(ctx, pkg),
|
|
380
|
+
})), { concurrent: true });
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
title: 'Diff packages',
|
|
385
|
+
task: (ctx, task) => task.newListr(ctx.packages.map(pkg => ({
|
|
386
|
+
title: `Diff ${pkg.name}`,
|
|
387
|
+
task: async (_ctx, subtask) => {
|
|
388
|
+
const leftFolderPath = path.dirname(packageJsonFilepath(pkg, LHS_FOLDER));
|
|
389
|
+
const rightFolderPath = path.dirname(packageJsonFilepath(pkg, RHS_FOLDER));
|
|
390
|
+
const changesFolder = path.join(pkg.folder, 'changes');
|
|
391
|
+
await fs.promises.mkdir(changesFolder, { recursive: true });
|
|
392
|
+
const leftPackageJson = loadLHSPackageJson(pkg);
|
|
393
|
+
const rightPackageJson = loadRHSPackageJson(pkg);
|
|
394
|
+
if (!leftPackageJson) {
|
|
395
|
+
subtask.output = 'No published version';
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
await execa('git', ['diff', '--no-index', leftFolderPath, rightFolderPath], {
|
|
399
|
+
reject: false, // git diff --no-index implies --exit-code. So when there are changes it exits with 1
|
|
400
|
+
stdout: {
|
|
401
|
+
file: path.join(changesFolder, 'package.diff'),
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
const leftRef = leftPackageJson.git?.sha;
|
|
405
|
+
const rightRef = rightPackageJson?.git?.sha;
|
|
406
|
+
if (!rightRef) {
|
|
407
|
+
throw new Error(`No ref found for package.json preparing to be plubished for ${pkg.name}`);
|
|
408
|
+
}
|
|
409
|
+
if (leftRef && rightRef) {
|
|
410
|
+
await execa('git', ['diff', leftRef, rightRef, '--', '.'], {
|
|
411
|
+
cwd: pkg.path,
|
|
412
|
+
stdout: {
|
|
413
|
+
file: path.join(changesFolder, 'source.diff'),
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
})), { concurrent: true }),
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
title: 'Write changelogs',
|
|
422
|
+
task: async (ctx, task) => {
|
|
423
|
+
return task.newListr(ctx.packages.map(pkg => ({
|
|
424
|
+
title: `Write ${pkg.name} changelog`,
|
|
425
|
+
task: async () => getOrCreateChangelog(ctx, pkg),
|
|
426
|
+
})), { concurrent: true });
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
title: 'Write context',
|
|
431
|
+
task: async (ctx) => {
|
|
432
|
+
fs.writeFileSync(path.join(ctx.tempDir, 'context.json'), JSON.stringify(ctx, null, 2));
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
title: ['Publish packages', !input.publish && '(dry run)'].filter(Boolean).join(' '),
|
|
437
|
+
rendererOptions: { persistentOutput: true },
|
|
438
|
+
task: async (ctx, task) => {
|
|
439
|
+
const shouldActuallyPublish = input.publish;
|
|
440
|
+
let otp = input.otp;
|
|
441
|
+
if (shouldActuallyPublish) {
|
|
442
|
+
otp ||= await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
443
|
+
message: 'Enter npm OTP (press enter to try publishing without MFA)',
|
|
444
|
+
type: 'Input',
|
|
445
|
+
validate: v => v === '' || (typeof v === 'string' && /^\d{6}$/.test(v)),
|
|
446
|
+
});
|
|
447
|
+
if (otp.length === 0) {
|
|
448
|
+
task.output = 'No OTP provided - publish will likely error unless you have disabled MFA.';
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const publishTasks = createPublishTasks(ctx, { otp });
|
|
452
|
+
if (!shouldActuallyPublish)
|
|
453
|
+
publishTasks.forEach(t => (t.skip = true));
|
|
454
|
+
return task.newListr(publishTasks, { rendererOptions: { collapseSubtasks: false } });
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
title: 'Dry run complete',
|
|
459
|
+
enabled: !input.publish,
|
|
460
|
+
rendererOptions: { persistentOutput: true },
|
|
461
|
+
task: async (ctx, task) => {
|
|
462
|
+
task.output = `To publish, run the following command:`;
|
|
463
|
+
task.output += `\n\n`;
|
|
464
|
+
task.output += `pnpm npmono prebuilt ${ctx.tempDir}`;
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
], { ctx: {} });
|
|
468
|
+
return tasks.run();
|
|
469
|
+
};
|
|
470
|
+
function loadContext(folderPath) {
|
|
471
|
+
const contextFilepath = path.join(folderPath, 'context.json');
|
|
472
|
+
if (!fs.existsSync(contextFilepath))
|
|
473
|
+
throw new Error(`No context found at ${contextFilepath}`);
|
|
474
|
+
const ctxString = fs.readFileSync(contextFilepath).toString();
|
|
475
|
+
try {
|
|
476
|
+
const json = JSON.parse(ctxString);
|
|
477
|
+
return Ctx.parse(json);
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
throw new Error(`Invalid context at ${contextFilepath}: ${String(e)}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
export const PrebuiltInput = z.tuple([
|
|
484
|
+
z.string().describe('Path to the prebuilt publishable folder'),
|
|
485
|
+
z.object({
|
|
486
|
+
otp: z.string().describe('OTP for publishing. If not provided, you will be prompted for it.').optional(),
|
|
487
|
+
}),
|
|
488
|
+
]);
|
|
489
|
+
export async function publishPrebuilt([folder, options]) {
|
|
490
|
+
const me = await execa('npm', ['whoami']);
|
|
491
|
+
if (me.exitCode)
|
|
492
|
+
throw new Error(`Failed to get npm username: ${me.stderr}`);
|
|
493
|
+
console.log(me.stdout, '<<<<<<< npm whoami');
|
|
494
|
+
const ctx = loadContext(folder);
|
|
495
|
+
const tasks = new Listr([
|
|
496
|
+
{
|
|
497
|
+
title: 'Get OTP',
|
|
498
|
+
enabled: !options.otp,
|
|
499
|
+
task: async (_ctx, task) => {
|
|
500
|
+
options.otp = await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
501
|
+
message: 'Enter npm OTP',
|
|
502
|
+
type: 'Input',
|
|
503
|
+
validate: v => v === '' || (typeof v === 'string' && /^\d{6}$/.test(v)),
|
|
504
|
+
});
|
|
505
|
+
if (options.otp === '') {
|
|
506
|
+
const confirmed = await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
507
|
+
message: 'This will fail unless you have disabled MFA, which is not recommended.',
|
|
508
|
+
type: 'confirm',
|
|
509
|
+
});
|
|
510
|
+
if (!confirmed) {
|
|
511
|
+
throw new Error('OTP not provided');
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
...createPublishTasks(ctx, options),
|
|
517
|
+
], { ctx });
|
|
518
|
+
await tasks.run();
|
|
519
|
+
}
|
|
520
|
+
function createPublishTasks(ctx, options) {
|
|
521
|
+
return [
|
|
522
|
+
...ctx.packages.map((pkg) => {
|
|
523
|
+
const lhsPackageJson = loadLHSPackageJson(pkg);
|
|
524
|
+
const rhsPackageJson = loadRHSPackageJson(pkg);
|
|
525
|
+
ensureValidBumpedVersion(lhsPackageJson?.version || VERSION_ZERO, rhsPackageJson?.version);
|
|
526
|
+
return {
|
|
527
|
+
title: `Publish ${pkg.name} ${lhsPackageJson?.version || 'β¨NEW!β¨'} -> ${rhsPackageJson?.version}`,
|
|
528
|
+
task: async (_ctx, subtask) => {
|
|
529
|
+
const publishArgs = ['--access', 'public'];
|
|
530
|
+
if (options.otp)
|
|
531
|
+
publishArgs.push('--otp', options.otp);
|
|
532
|
+
if (rhsPackageJson && semver.prerelease(rhsPackageJson.version)) {
|
|
533
|
+
const first = semver.prerelease(rhsPackageJson.version)?.[0];
|
|
534
|
+
const tag = typeof first === 'string' ? first : 'next';
|
|
535
|
+
publishArgs.push('--tag', tag);
|
|
536
|
+
}
|
|
537
|
+
await pipeExeca(subtask, 'pnpm', ['publish', ...publishArgs], {
|
|
538
|
+
cwd: path.dirname(packageJsonFilepath(pkg, RHS_FOLDER)),
|
|
539
|
+
env: {
|
|
540
|
+
...process.env,
|
|
541
|
+
COREPACK_ENABLE_AUTO_PIN: '0',
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}),
|
|
547
|
+
{
|
|
548
|
+
title: 'Publish complete',
|
|
549
|
+
rendererOptions: { persistentOutput: true },
|
|
550
|
+
task: async (_ctx, task) => {
|
|
551
|
+
task.output = `To open a release draft, run the following command:`;
|
|
552
|
+
task.output += `\n\n`;
|
|
553
|
+
task.output += `pnpm npmono release-notes`;
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
];
|
|
557
|
+
}
|
|
558
|
+
export const ReleaseNotesInput = z.object({
|
|
559
|
+
comparison: z
|
|
560
|
+
.string()
|
|
561
|
+
.regex(/^\S+\.{2,3}\S+$/)
|
|
562
|
+
.transform((s, ctx) => {
|
|
563
|
+
const [left, right, ...{ length: excess }] = s.split(/\.{2,3}/);
|
|
564
|
+
if (excess > 0) {
|
|
565
|
+
ctx.addIssue({ code: 'custom', message: `Invalid comparison (too many ellipses): ${s}` });
|
|
566
|
+
return z.NEVER;
|
|
567
|
+
}
|
|
568
|
+
return { left, right, original: s };
|
|
569
|
+
})
|
|
570
|
+
.optional()
|
|
571
|
+
.describe('The comparison to use for the release notes. Format: `left...right` e.g. `1.0.0...1.0.1`'),
|
|
572
|
+
mode: z
|
|
573
|
+
.enum(['individual', 'unified'])
|
|
574
|
+
.describe(`Only relevant in a monorepo - 'unified' mode drafts one release with all packages' changes, 'individual' drafts one release per package.`)
|
|
575
|
+
.default('unified'),
|
|
576
|
+
scope: z
|
|
577
|
+
.string()
|
|
578
|
+
.regex(/^@[\w-_.]+$/)
|
|
579
|
+
.describe('check for packages with this scope too')
|
|
580
|
+
.optional(),
|
|
581
|
+
});
|
|
582
|
+
async function pullRegistryPackage(subtask, pkg, { version, folder }) {
|
|
583
|
+
// note: `npm pack foobar` will actually pull foobar.1-2-3.tgz from the registry. It's not actually doing a "pack" at all. `pnpm pack` does not do the same thing - it packs the local directory
|
|
584
|
+
await pipeExeca(subtask, 'npm', ['pack', `${pkg.name}@${version}`], { cwd: folder });
|
|
585
|
+
const tgzFileName = fs.readdirSync(folder).at(0);
|
|
586
|
+
if (!tgzFileName) {
|
|
587
|
+
throw new Error(`No tgz file found in ${folder}, tried to pull ${pkg.name}@${version}`);
|
|
588
|
+
}
|
|
589
|
+
await pipeExeca(subtask, 'tar', ['-xvzf', tgzFileName], { cwd: folder });
|
|
590
|
+
const filepath = path.join(folder, 'package', 'package.json');
|
|
591
|
+
const packageJson = JSON.parse(fs.readFileSync(filepath).toString());
|
|
592
|
+
return packageJson;
|
|
593
|
+
}
|
|
594
|
+
async function openReleaseDraft(repoUrl, params) {
|
|
595
|
+
const getUrl = () => `${repoUrl}/releases/new?${new URLSearchParams(params).toString()}`;
|
|
596
|
+
if (getUrl().length > 8192) {
|
|
597
|
+
// copy body to clipboard using clipboardy
|
|
598
|
+
const clipboardy = await import('clipboardy');
|
|
599
|
+
await clipboardy.default.write(params.body);
|
|
600
|
+
params = { ...params, body: '<!-- body copied to clipboard -->' };
|
|
601
|
+
}
|
|
602
|
+
await execa('open', [getUrl()]);
|
|
603
|
+
}
|
|
604
|
+
function packageJsonFilepath(pkg, type) {
|
|
605
|
+
return path.join(pkg.folder, type, 'package', 'package.json');
|
|
606
|
+
}
|
|
607
|
+
function loadPackageJson(filepath) {
|
|
608
|
+
const packageJson = JSON.parse(fs.readFileSync(filepath).toString());
|
|
609
|
+
return packageJson;
|
|
610
|
+
}
|
|
611
|
+
function loadLHSPackageJson(pkg) {
|
|
612
|
+
const filepath = packageJsonFilepath(pkg, LHS_FOLDER);
|
|
613
|
+
if (!fs.existsSync(filepath))
|
|
614
|
+
return null;
|
|
615
|
+
return JSON.parse(fs.readFileSync(filepath).toString());
|
|
616
|
+
}
|
|
617
|
+
function loadRHSPackageJson(pkg) {
|
|
618
|
+
const filepath = packageJsonFilepath(pkg, RHS_FOLDER);
|
|
619
|
+
if (!fs.existsSync(filepath))
|
|
620
|
+
return null;
|
|
621
|
+
return JSON.parse(fs.readFileSync(filepath).toString());
|
|
622
|
+
}
|
|
623
|
+
function bumpChoices(oldVersion) {
|
|
624
|
+
const releaseTypes = allReleaseTypes.filter(r => r !== 'prerelease');
|
|
625
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
626
|
+
semver.prerelease(oldVersion) ? releaseTypes.unshift('prerelease') : releaseTypes.push('prerelease');
|
|
627
|
+
return [
|
|
628
|
+
...releaseTypes.map(type => {
|
|
629
|
+
const result = semver.inc(oldVersion, type);
|
|
630
|
+
return {
|
|
631
|
+
type: type,
|
|
632
|
+
message: `${type} ${result}`,
|
|
633
|
+
value: result,
|
|
634
|
+
};
|
|
635
|
+
}),
|
|
636
|
+
{
|
|
637
|
+
type: 'other',
|
|
638
|
+
message: 'Other (please specify)',
|
|
639
|
+
value: 'other',
|
|
640
|
+
},
|
|
641
|
+
];
|
|
642
|
+
}
|
|
643
|
+
function ensureValidBumpedVersion(lowerBoundVersion, v) {
|
|
644
|
+
const validation = getBumpedVersionValidation(lowerBoundVersion, v);
|
|
645
|
+
if (validation !== true)
|
|
646
|
+
throw new Error(`Invalid bumped version ${v}: ${validation || 'invalid'}`);
|
|
647
|
+
}
|
|
648
|
+
function getBumpedVersionValidation(lowerBoundVersion, v) {
|
|
649
|
+
if (typeof v !== 'string')
|
|
650
|
+
return 'Must be a string';
|
|
651
|
+
if (!semver.valid(v))
|
|
652
|
+
return 'Must be a valid semver version';
|
|
653
|
+
if (!semver.gt(v, lowerBoundVersion || VERSION_ZERO))
|
|
654
|
+
return `Must be greater than the current version ${lowerBoundVersion}`;
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
function getWorkspaceRoot() {
|
|
658
|
+
return path.dirname(findUpSync('pnpm-workspace.yaml') || findUpSync('pnpm-lock.yaml') || findUpOrThrow('.git', { type: 'directory' }));
|
|
659
|
+
}
|
|
660
|
+
/** "Pessimistic" comparison ref. Tries to use the registry package.json's `git.sha` property, falls back to a matching version tag, and if neither exists prompts the user (with the first commit to the package folder as the default). */
|
|
661
|
+
async function getPackageLastPublishRef(pkg, task) {
|
|
662
|
+
const packageJson = loadLHSPackageJson(pkg);
|
|
663
|
+
return first7(await getPackageJsonGitSha(pkg, packageJson, task));
|
|
664
|
+
}
|
|
665
|
+
const first7 = (s) => s.slice(0, 7);
|
|
666
|
+
async function getPackageJsonGitSha(pkg, packageJson, task) {
|
|
667
|
+
const explicitSha = packageJson?.git?.sha;
|
|
668
|
+
if (explicitSha)
|
|
669
|
+
return explicitSha;
|
|
670
|
+
const tagSha = await getPackageJsonShaFromVersionTag(pkg, packageJson);
|
|
671
|
+
if (tagSha)
|
|
672
|
+
return tagSha;
|
|
673
|
+
const firstRef = await getFirstRef(pkg);
|
|
674
|
+
const { stdout: recentCommits } = await execa('git', ['log', '-n', '20', '--pretty=format:%h %s', '--', '.'], { cwd: pkg.path, reject: false });
|
|
675
|
+
const commitLines = recentCommits.split('\n').filter(Boolean);
|
|
676
|
+
if (packageJson?.version) {
|
|
677
|
+
const versionCommitMessage = `version ${packageJson.version}`;
|
|
678
|
+
const match = commitLines.find(line => line.slice(line.indexOf(' ') + 1) === versionCommitMessage);
|
|
679
|
+
if (match)
|
|
680
|
+
return match.split(' ', 1)[0];
|
|
681
|
+
}
|
|
682
|
+
const oldestShownSha = commitLines.at(-1)?.split(' ', 1)[0];
|
|
683
|
+
const repoUrl = getPackageJsonRepository(loadRHSPackageJson(pkg) || loadLHSPackageJson(pkg) || loadPackageJson(path.join(getWorkspaceRoot(), 'package.json')));
|
|
684
|
+
const olderUrl = repoUrl && oldestShownSha ? `\nOlder commits: ${repoUrl}/commits/${oldestShownSha}` : '';
|
|
685
|
+
const recentList = commitLines.length ? `\nRecent commits in ${pkg.path}:\n${commitLines.join('\n')}${olderUrl}\n` : '';
|
|
686
|
+
const promptAnswer = await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
687
|
+
type: 'Input',
|
|
688
|
+
message: `Couldn't find a git sha for the last published version of ${pkg.name}.${recentList}\nEnter the sha to diff from (leave empty to use ${firstRef.slice(0, 7)}, the first commit in ${pkg.path}):`,
|
|
689
|
+
validate: (v) => {
|
|
690
|
+
if (typeof v !== 'string')
|
|
691
|
+
return 'Enter a sha, or leave empty to use the first commit.';
|
|
692
|
+
if (!v.trim())
|
|
693
|
+
return true;
|
|
694
|
+
return /^[0-9a-f]{4,40}$/i.test(v.trim()) ? true : 'Enter a valid git sha (4-40 hex chars) or leave empty.';
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
return promptAnswer.trim() || firstRef;
|
|
698
|
+
}
|
|
699
|
+
async function getPackageJsonShaFromVersionTag(pkg, packageJson) {
|
|
700
|
+
// throw new Error(
|
|
701
|
+
// `Getting ${pkg.name} (${packageJson?.version}) sha from version tag. v[] thing: ${packageJson?.version ? (await execa('git', ['log', '-n', '1', `v${packageJson?.version}`], {cwd: pkg.path})).stdout : JSON.stringify({pkg, packageJson}, null, 2)}`,
|
|
702
|
+
// )
|
|
703
|
+
if (!packageJson?.version)
|
|
704
|
+
return null;
|
|
705
|
+
const { stdout: vTagSha } = await execa('git', ['rev-list', '-n', '1', `v${packageJson.version}`], {
|
|
706
|
+
cwd: pkg.path,
|
|
707
|
+
reject: false,
|
|
708
|
+
});
|
|
709
|
+
if (vTagSha)
|
|
710
|
+
return vTagSha;
|
|
711
|
+
const { stdout: tagSha } = await execa('git', ['rev-list', '-n', '1', packageJson.version], {
|
|
712
|
+
cwd: pkg.path,
|
|
713
|
+
reject: false,
|
|
714
|
+
});
|
|
715
|
+
return tagSha;
|
|
716
|
+
}
|
|
717
|
+
async function getFirstRef(pkg) {
|
|
718
|
+
const { stdout: commitsOldestFirst } = await execa('git', ['log', '--reverse', '--pretty=format:%h', '--', '.'], {
|
|
719
|
+
cwd: pkg.path,
|
|
720
|
+
});
|
|
721
|
+
return commitsOldestFirst.split('\n')[0];
|
|
722
|
+
}
|
|
723
|
+
const DEP_SECTIONS = ['dependencies', 'peerDependencies', 'optionalDependencies'];
|
|
724
|
+
/**
|
|
725
|
+
* For a particular package, get the `dependencies` / `peerDependencies` / `optionalDependencies`
|
|
726
|
+
* objects with any necessary version bumps. Each workspace dep is written back to whichever
|
|
727
|
+
* section it was originally in, so peer/optional deps aren't silently promoted to regular deps.
|
|
728
|
+
*/
|
|
729
|
+
async function getBumpedDependencies(ctx, params) {
|
|
730
|
+
const leftPackageJson = loadLHSPackageJson(params.pkg);
|
|
731
|
+
const rightPackageJson = loadRHSPackageJson(params.pkg);
|
|
732
|
+
const bumped = {
|
|
733
|
+
dependencies: rightPackageJson?.dependencies && { ...rightPackageJson.dependencies },
|
|
734
|
+
peerDependencies: rightPackageJson?.peerDependencies && { ...rightPackageJson.peerDependencies },
|
|
735
|
+
optionalDependencies: rightPackageJson?.optionalDependencies && { ...rightPackageJson.optionalDependencies },
|
|
736
|
+
};
|
|
737
|
+
const updates = {};
|
|
738
|
+
for (const depName of Object.keys(params.pkg.dependencies || {})) {
|
|
739
|
+
const foundDep = ctx.packages.find(other => other.name === depName);
|
|
740
|
+
if (!foundDep)
|
|
741
|
+
throw new Error(`Package ${params.pkg.name} depends on ${depName} but ${depName} is not found in the workspace`);
|
|
742
|
+
let expected = foundDep.targetVersion;
|
|
743
|
+
if (!expected) {
|
|
744
|
+
const dependencyPackageJsonOnRHS = loadRHSPackageJson(foundDep);
|
|
745
|
+
expected = dependencyPackageJsonOnRHS?.version || null;
|
|
746
|
+
}
|
|
747
|
+
if (!expected) {
|
|
748
|
+
throw new Error(`Package ${params.pkg.name} depends on ${depName} but ${depName} is not published, and no target version was set for publishing now. Did you opt to skip publishing ${depName}? If so, please re-run and make sure to publish ${depName}. You can't publish ${params.pkg.name} until you do that.`);
|
|
749
|
+
}
|
|
750
|
+
const section = DEP_SECTIONS.find(s => rightPackageJson?.[s]?.[depName]) ||
|
|
751
|
+
DEP_SECTIONS.find(s => leftPackageJson?.[s]?.[depName]) ||
|
|
752
|
+
'dependencies';
|
|
753
|
+
const currentVersionInSection = rightPackageJson?.[section]?.[depName];
|
|
754
|
+
let prefix = ['^', '~', ''].find(p => currentVersionInSection?.startsWith(p)) || '';
|
|
755
|
+
if (semver.prerelease(expected)) {
|
|
756
|
+
prefix = '';
|
|
757
|
+
}
|
|
758
|
+
const newVersion = prefix + expected;
|
|
759
|
+
bumped[section] = { ...bumped[section], [depName]: newVersion };
|
|
760
|
+
const leftVersion = leftPackageJson?.dependencies?.[depName] ||
|
|
761
|
+
leftPackageJson?.peerDependencies?.[depName] ||
|
|
762
|
+
leftPackageJson?.optionalDependencies?.[depName];
|
|
763
|
+
updates[depName] = `${leftVersion || '(new)'} -> ${newVersion}`;
|
|
764
|
+
}
|
|
765
|
+
if (Object.keys(updates).length === 0) {
|
|
766
|
+
// keep reference equality, avoid `undefined` -> `{}` churn
|
|
767
|
+
return {
|
|
768
|
+
updated: updates,
|
|
769
|
+
dependencies: rightPackageJson?.dependencies,
|
|
770
|
+
peerDependencies: rightPackageJson?.peerDependencies,
|
|
771
|
+
optionalDependencies: rightPackageJson?.optionalDependencies,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
return { updated: updates, ...bumped };
|
|
775
|
+
}
|
|
776
|
+
/** Get a markdown formatted list of commits for a package. */
|
|
777
|
+
async function getPackageRevList(pkg) {
|
|
778
|
+
// const fromRef = await getPackageLastPublishRef(pkg)
|
|
779
|
+
// const {stdout: localRef} = await execa('git', ['log', '-n', '1', '--pretty=format:%h', '--', '.'])
|
|
780
|
+
const fromRef = pkg.shas.left;
|
|
781
|
+
const localRef = pkg.shas.right;
|
|
782
|
+
const { stdout } = await execa('git', ['rev-list', '--ancestry-path', `${fromRef}...${localRef}`, '--format=oneline', '--abbrev-commit', '--', '.'], { cwd: pkg.path });
|
|
783
|
+
const { stdout: mergeBase } = await execa('git', ['merge-base', fromRef, localRef]);
|
|
784
|
+
let mergeBaseNote = '';
|
|
785
|
+
if (mergeBase.slice(0, 7) !== fromRef.slice(0, 7)) {
|
|
786
|
+
const { stdout: mergeBaseSummary } = await execa('git', ['log', '-n', '1', '--format=%h %s (%ad)', mergeBase]);
|
|
787
|
+
mergeBaseNote = `\nπ§π§ NOTE π§π§: ${localRef} (current) is not a descendant of ${fromRef} (last publish). The last publish may have beendone on a separate branch. The merge base is:\n${mergeBaseSummary}`;
|
|
788
|
+
}
|
|
789
|
+
const { stdout: uncommitedChanges } = await execa('git', ['status', '--porcelain', '--', '.'], {
|
|
790
|
+
cwd: pkg.path,
|
|
791
|
+
});
|
|
792
|
+
const commitBullets = stdout
|
|
793
|
+
.split('\n')
|
|
794
|
+
.filter(Boolean)
|
|
795
|
+
.map(line => `- ${line}`);
|
|
796
|
+
const sections = [
|
|
797
|
+
commitBullets.length > 0 && `<h3 data-commits>Commits</h3>\n`,
|
|
798
|
+
...commitBullets,
|
|
799
|
+
uncommitedChanges.trim() && 'Uncommitted changes:\n' + uncommitedChanges,
|
|
800
|
+
mergeBaseNote,
|
|
801
|
+
];
|
|
802
|
+
return {
|
|
803
|
+
commitBullets,
|
|
804
|
+
// commitComparisonString,
|
|
805
|
+
// versionComparisonString,
|
|
806
|
+
markdown: sections.filter(Boolean).join('\n').trim() ||
|
|
807
|
+
`No commits to ${pkg.name} between found between ${fromRef.slice(0, 7)} (last publish) and ${localRef.slice(0, 7)} (current).`,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
const changelogFilepath = (pkg) => path.join(pkg.folder, 'changes/changelog.md');
|
|
811
|
+
function workspaceDependencies(pkg, ctx, depth = 0) {
|
|
812
|
+
return Object.keys(pkg.dependencies || {}).flatMap(name => {
|
|
813
|
+
const dep = ctx.packages.find(p => p.name === name);
|
|
814
|
+
return dep ? [dep, ...(depth > 0 ? workspaceDependencies(dep, ctx, depth - 1) : [])] : [];
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Creates a changelog.md and returns its content. If one already exists, its content is returned without being regenerated.
|
|
819
|
+
* Note: this recursively calls itself on finding workspace dependencies so will cause stack overflows if there are dependency loops.
|
|
820
|
+
* It's important that this is called in toplogical order, so that dependency changelogs are accurate.
|
|
821
|
+
*/
|
|
822
|
+
async function getOrCreateChangelog(ctx, pkg) {
|
|
823
|
+
const changelogFile = changelogFilepath(pkg);
|
|
824
|
+
if (fs.existsSync(changelogFile)) {
|
|
825
|
+
const existingContent = fs.readFileSync(changelogFile).toString();
|
|
826
|
+
// hack: sometimes we get a changelog a bit early before the targetVersion is set. Check that the existing version doesn't include 'undefined' so we can try again after the targetVersion is set.
|
|
827
|
+
if (!existingContent.includes('undefined')) {
|
|
828
|
+
return existingContent;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
const changelogContent = await getChangelog(ctx, pkg);
|
|
832
|
+
fs.mkdirSync(path.dirname(changelogFile), { recursive: true });
|
|
833
|
+
fs.writeFileSync(changelogFile, changelogContent);
|
|
834
|
+
return changelogContent;
|
|
835
|
+
}
|
|
836
|
+
async function getChangelog(ctx, pkg) {
|
|
837
|
+
const sourceChanges = await getPackageRevList(pkg);
|
|
838
|
+
const npmVersionLink = (packageJson) => {
|
|
839
|
+
const version = packageJson?.version;
|
|
840
|
+
return `[${version || 'unknown'}](https://npmjs.com/package/${pkg.name}${version ? `/v/${version}` : ''})`;
|
|
841
|
+
};
|
|
842
|
+
const repoUrl = getPackageJsonRepository(loadRHSPackageJson(pkg) ||
|
|
843
|
+
loadLHSPackageJson(pkg) ||
|
|
844
|
+
loadPackageJson(path.join(getWorkspaceRoot(), 'package.json')));
|
|
845
|
+
const changes = [];
|
|
846
|
+
if (sourceChanges) {
|
|
847
|
+
changes.push([
|
|
848
|
+
`<!-- data-change-type="source" data-pkg="${pkg.name}" -->`, // break
|
|
849
|
+
`# ${pkg.name}`,
|
|
850
|
+
'',
|
|
851
|
+
'|old version|new version|compare|',
|
|
852
|
+
'|-|-|-|',
|
|
853
|
+
`|${npmVersionLink(loadLHSPackageJson(pkg))} | ${npmVersionLink(loadRHSPackageJson(pkg))} | [${pkg.shas.left}...${pkg.shas.right}](${repoUrl}/compare/${pkg.shas.left}...${pkg.shas.right})`,
|
|
854
|
+
'',
|
|
855
|
+
sourceChanges.markdown,
|
|
856
|
+
].join('\n'));
|
|
857
|
+
}
|
|
858
|
+
const bumpedDeps = await getBumpedDependencies(ctx, { pkg });
|
|
859
|
+
// reverse order of pkg dependencies so the most interesting ones go first
|
|
860
|
+
for (const depPkg of workspaceDependencies(pkg, ctx).slice().reverse()) {
|
|
861
|
+
const dep = depPkg.name;
|
|
862
|
+
if (bumpedDeps.updated[dep]) {
|
|
863
|
+
const depChanges = await getChangelog(ctx, depPkg);
|
|
864
|
+
const verb = depChanges?.includes('data-commits') ? 'changed' : 'bumped';
|
|
865
|
+
const newMessage = [
|
|
866
|
+
'<!-- data-change-type="dependencies" -->',
|
|
867
|
+
`<details>`,
|
|
868
|
+
`<summary>Dependency ${depPkg.name} ${verb} (${bumpedDeps.updated[dep]})</summary>`,
|
|
869
|
+
'',
|
|
870
|
+
'<blockquote>',
|
|
871
|
+
depChanges
|
|
872
|
+
.split('\n')
|
|
873
|
+
.filter(line => !/^<!-- data-change-type=".*" -->$/.test(line))
|
|
874
|
+
.map(line => (line.trim() ? `${line}` : line))
|
|
875
|
+
.join('\n') || `${bumpedDeps.updated[dep]} (Version bump)`,
|
|
876
|
+
'</blockquote>',
|
|
877
|
+
'</details>',
|
|
878
|
+
].join('\n');
|
|
879
|
+
changes.push(newMessage);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const changelogContent = changes.filter(Boolean).join('\n\n');
|
|
883
|
+
return changelogContent;
|
|
884
|
+
}
|
|
885
|
+
/** lovely algorith which i think supports all the ways a repository can be specified */
|
|
886
|
+
function getPackageJsonRepository(packageJson) {
|
|
887
|
+
let repoString;
|
|
888
|
+
if (!packageJson.repository)
|
|
889
|
+
return null;
|
|
890
|
+
if (typeof packageJson.repository === 'object' && packageJson.repository.type !== 'git') {
|
|
891
|
+
throw new Error(`Unsupported repository type ${packageJson.repository.type}`);
|
|
892
|
+
}
|
|
893
|
+
// mmkal
|
|
894
|
+
// eslint-disable-next-line unicorn/prefer-ternary
|
|
895
|
+
if (typeof packageJson.repository === 'object') {
|
|
896
|
+
repoString = packageJson.repository.url;
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
// must be a string
|
|
900
|
+
repoString = packageJson.repository;
|
|
901
|
+
}
|
|
902
|
+
if (repoString.startsWith('git+')) {
|
|
903
|
+
repoString = repoString.replace('git+', '');
|
|
904
|
+
}
|
|
905
|
+
if (repoString.startsWith('github:')) {
|
|
906
|
+
repoString = repoString.replace('github:', 'https://github.com/');
|
|
907
|
+
}
|
|
908
|
+
if (repoString.startsWith('gitlab:')) {
|
|
909
|
+
repoString = repoString.replace('gitlab:', 'https://gitlab.com/');
|
|
910
|
+
}
|
|
911
|
+
if (repoString.startsWith('bitbucket:')) {
|
|
912
|
+
repoString = repoString.replace('bitbucket:', 'https://bitbucket.org/');
|
|
913
|
+
}
|
|
914
|
+
if (repoString.endsWith('.git')) {
|
|
915
|
+
repoString = repoString.slice(0, -4);
|
|
916
|
+
}
|
|
917
|
+
if (/^[\w-.]+\/[\w-.]+$/.test(repoString)) {
|
|
918
|
+
repoString = `https://github.com/${repoString}`;
|
|
919
|
+
}
|
|
920
|
+
const url = new URL(repoString);
|
|
921
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
922
|
+
throw new Error(`Unsupported repository protocol ${url.protocol}`);
|
|
923
|
+
}
|
|
924
|
+
return repoString;
|
|
925
|
+
}
|
|
926
|
+
const allReleaseTypes = [
|
|
927
|
+
'patch',
|
|
928
|
+
'minor',
|
|
929
|
+
'major',
|
|
930
|
+
'prepatch',
|
|
931
|
+
'preminor',
|
|
932
|
+
'premajor',
|
|
933
|
+
'prerelease',
|
|
934
|
+
];
|
|
935
|
+
const PackageJson = z.object({
|
|
936
|
+
name: z.string(),
|
|
937
|
+
version: z.string(),
|
|
938
|
+
dependencies: z.record(z.string(), z.string()).optional(),
|
|
939
|
+
peerDependencies: z.record(z.string(), z.string()).optional(),
|
|
940
|
+
optionalDependencies: z.record(z.string(), z.string()).optional(),
|
|
941
|
+
scripts: z.record(z.string(), z.string()).optional(),
|
|
942
|
+
/**
|
|
943
|
+
* not an official package.json field, but there is a library that does something similar to this: https://github.com/Metnew/git-hash-package
|
|
944
|
+
* having git.sha point to a commit hash seems pretty useful to me, even if it's not standard.
|
|
945
|
+
* tagging versions in git is still a good best practice but there are many different ways, e.g. `1.2.3` vs `v1.2.3` vs `mypkg@1.2.3` vs `mypkg@v1.2.3`
|
|
946
|
+
* plus, that's only useful in going from git to npm, not npm to git.
|
|
947
|
+
*/
|
|
948
|
+
git: z.object({ sha: z.string().optional() }).optional(),
|
|
949
|
+
repository: z
|
|
950
|
+
.object({
|
|
951
|
+
type: z.string(),
|
|
952
|
+
url: z.string(),
|
|
953
|
+
})
|
|
954
|
+
.optional(),
|
|
955
|
+
});
|
|
956
|
+
const PkgMeta = z.object({
|
|
957
|
+
folder: z.string(),
|
|
958
|
+
targetVersion: z.string().nullable(),
|
|
959
|
+
changeTypes: z.set(z.string()).optional(),
|
|
960
|
+
shas: z.object({
|
|
961
|
+
left: z.string(),
|
|
962
|
+
right: z.string(),
|
|
963
|
+
}),
|
|
964
|
+
});
|
|
965
|
+
const Pkg = PkgMeta.extend({
|
|
966
|
+
name: z.string(),
|
|
967
|
+
version: z.string(),
|
|
968
|
+
path: z.string(),
|
|
969
|
+
private: z.boolean(),
|
|
970
|
+
publishedVersions: z.array(z.string()),
|
|
971
|
+
dependencies: z
|
|
972
|
+
.record(z.string(), z.object({
|
|
973
|
+
from: z.string(),
|
|
974
|
+
version: z.string(),
|
|
975
|
+
path: z.string(),
|
|
976
|
+
}))
|
|
977
|
+
.optional(),
|
|
978
|
+
});
|
|
979
|
+
export const Ctx = z.object({
|
|
980
|
+
tempDir: z.string(),
|
|
981
|
+
versionStrategy: z.union([
|
|
982
|
+
z.object({ type: z.literal('fixed'), version: z.string() }).readonly(),
|
|
983
|
+
z
|
|
984
|
+
.object({
|
|
985
|
+
type: z.literal('independent'),
|
|
986
|
+
bump: z.union([
|
|
987
|
+
z.enum(['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']),
|
|
988
|
+
z.null(),
|
|
989
|
+
]),
|
|
990
|
+
})
|
|
991
|
+
.readonly(),
|
|
992
|
+
]),
|
|
993
|
+
packages: z.array(Pkg),
|
|
994
|
+
});
|
|
995
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
996
|
+
async function pipeExeca(task, file, args, options) {
|
|
997
|
+
const cmd = execa(file, args, {
|
|
998
|
+
...options,
|
|
999
|
+
env: {
|
|
1000
|
+
...options?.env,
|
|
1001
|
+
COREPACK_ENABLE_STRICT: '0',
|
|
1002
|
+
},
|
|
1003
|
+
});
|
|
1004
|
+
cmd.stdout.pipe(task.stdout());
|
|
1005
|
+
return cmd;
|
|
1006
|
+
}
|
|
1007
|
+
function findUpOrThrow(file, options) {
|
|
1008
|
+
const result = findUpSync(file, options);
|
|
1009
|
+
if (!result) {
|
|
1010
|
+
throw new Error(`Could not find ${file}`);
|
|
1011
|
+
}
|
|
1012
|
+
return result;
|
|
1013
|
+
}
|
|
1014
|
+
export async function releaseNotes(input) {
|
|
1015
|
+
const tasks = new Listr([
|
|
1016
|
+
...setupContextTasks,
|
|
1017
|
+
{
|
|
1018
|
+
title: `Get comparison`,
|
|
1019
|
+
enabled: () => !input.comparison,
|
|
1020
|
+
task: async (ctx, task) => {
|
|
1021
|
+
const pkgVersions = await Promise.all(ctx.packages.map(async (pkg) => ({
|
|
1022
|
+
name: pkg.name,
|
|
1023
|
+
versions: await getRegistryVersions(pkg),
|
|
1024
|
+
})));
|
|
1025
|
+
if (pkgVersions.some(v => v.versions.length === 0)) {
|
|
1026
|
+
throw new Error(`No versions found for some packages: ${JSON.stringify({ pkgVersions }, null, 2)}`);
|
|
1027
|
+
}
|
|
1028
|
+
const lasts = pkgVersions.map(v => v.versions.at(-1).slice());
|
|
1029
|
+
const latest = lasts.sort((a, b) => semver.compare(a, b)).at(-1);
|
|
1030
|
+
const all = pkgVersions.flatMap(v => v.versions).sort((a, b) => semver.compare(a, b));
|
|
1031
|
+
const left = all
|
|
1032
|
+
.slice(0, -1)
|
|
1033
|
+
.reverse()
|
|
1034
|
+
.find(v => {
|
|
1035
|
+
if (v === latest)
|
|
1036
|
+
return false;
|
|
1037
|
+
if (semver.prerelease(latest))
|
|
1038
|
+
return true; // if RHS is prerelease, then we whichever version was immediately before it
|
|
1039
|
+
return !semver.prerelease(v); // if RHS was a proper release, then we want the last proper release
|
|
1040
|
+
}) || all[0];
|
|
1041
|
+
input.comparison = { left, right: latest, original: `${left}...${latest}` };
|
|
1042
|
+
task.output = `Using ${input.comparison.original} for release notes`;
|
|
1043
|
+
},
|
|
1044
|
+
rendererOptions: { persistentOutput: true },
|
|
1045
|
+
},
|
|
1046
|
+
{
|
|
1047
|
+
title: 'Write packages locally',
|
|
1048
|
+
task: async (ctx, task) => {
|
|
1049
|
+
return task.newListr(ctx.packages.map(pkg => ({
|
|
1050
|
+
title: `Write ${pkg.name} locally`,
|
|
1051
|
+
task: async (_ctx, subtask) => {
|
|
1052
|
+
if (!input.comparison)
|
|
1053
|
+
throw new Error('No comparison provided');
|
|
1054
|
+
const pullables = [
|
|
1055
|
+
{ str: input.comparison.left, folder: path.join(pkg.folder, LHS_FOLDER), side: 'left' },
|
|
1056
|
+
{ str: input.comparison.right, folder: path.join(pkg.folder, RHS_FOLDER), side: 'right' },
|
|
1057
|
+
];
|
|
1058
|
+
for (const pullable of pullables) {
|
|
1059
|
+
fs.mkdirSync(pullable.folder, { recursive: true });
|
|
1060
|
+
if (semver.valid(pullable.str)) {
|
|
1061
|
+
if (pullable.side === 'right')
|
|
1062
|
+
pkg.targetVersion = pullable.str;
|
|
1063
|
+
subtask.output = `Pulling ${pullable.str} for ${pkg.name}`;
|
|
1064
|
+
const packageJson = await pullRegistryPackage(subtask, pkg, {
|
|
1065
|
+
version: pullable.str,
|
|
1066
|
+
folder: pullable.folder,
|
|
1067
|
+
})
|
|
1068
|
+
.catch(e => {
|
|
1069
|
+
if (`${e}`.includes('No matching version found') && input.scope) {
|
|
1070
|
+
const scopedPkg = { ...pkg, name: `${input.scope}/${pkg.name}` };
|
|
1071
|
+
return pullRegistryPackage(subtask, scopedPkg, {
|
|
1072
|
+
version: pullable.str,
|
|
1073
|
+
folder: pullable.folder,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
throw e;
|
|
1077
|
+
})
|
|
1078
|
+
.catch(async (e) => {
|
|
1079
|
+
if (`${e}`.includes('No matching version found')) {
|
|
1080
|
+
// if the version doesn't exist on the registry, we might have passed a comparison string from before it existed (can happen with fixed versioning)
|
|
1081
|
+
const versions = await getRegistryVersions(pkg);
|
|
1082
|
+
return pullRegistryPackage(subtask, pkg, {
|
|
1083
|
+
version: pullable.side === 'left' ? versions[0] : versions.at(-1),
|
|
1084
|
+
folder: pullable.folder,
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
throw e;
|
|
1088
|
+
});
|
|
1089
|
+
pkg.shas ||= {};
|
|
1090
|
+
pkg.shas[pullable.side] = await getPackageJsonGitSha(pkg, packageJson, subtask);
|
|
1091
|
+
}
|
|
1092
|
+
else if (/^[\da-f]+/.test(pullable.str)) {
|
|
1093
|
+
// actually it's a sha
|
|
1094
|
+
pkg.shas ||= {};
|
|
1095
|
+
pkg.shas[pullable.side] = pullable.str;
|
|
1096
|
+
const versions = await getRegistryVersions(pkg);
|
|
1097
|
+
for (const version of versions.slice().reverse()) {
|
|
1098
|
+
subtask.output = `Pulling ${version} for ${pkg.name}`;
|
|
1099
|
+
const packedPackageJson = await pullRegistryPackage(subtask, pkg, {
|
|
1100
|
+
version,
|
|
1101
|
+
folder: pullable.folder,
|
|
1102
|
+
});
|
|
1103
|
+
if (packedPackageJson.git?.sha === pullable.str) {
|
|
1104
|
+
if (pullable.side === 'right')
|
|
1105
|
+
pkg.targetVersion = packedPackageJson.version;
|
|
1106
|
+
break;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
else {
|
|
1111
|
+
throw new Error(`Unknown release ${pullable.str} for ${pkg.name}`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
},
|
|
1115
|
+
})), { concurrent: true });
|
|
1116
|
+
},
|
|
1117
|
+
},
|
|
1118
|
+
{
|
|
1119
|
+
title: 'Write changelogs',
|
|
1120
|
+
task: async (ctx, task) => {
|
|
1121
|
+
return task.newListr(ctx.packages.map(pkg => ({
|
|
1122
|
+
title: `Write ${pkg.name} changelog`,
|
|
1123
|
+
task: async () => getOrCreateChangelog(ctx, pkg),
|
|
1124
|
+
})), { concurrent: true });
|
|
1125
|
+
},
|
|
1126
|
+
},
|
|
1127
|
+
{
|
|
1128
|
+
title: 'Generate release notes',
|
|
1129
|
+
rendererOptions: { persistentOutput: true },
|
|
1130
|
+
task: async (ctx, task) => {
|
|
1131
|
+
const allChangelogs = await Promise.all(ctx.packages.map(async (pkg) => ({
|
|
1132
|
+
pkg,
|
|
1133
|
+
changelog: await getOrCreateChangelog(ctx, pkg),
|
|
1134
|
+
})));
|
|
1135
|
+
const rootPackageJson = loadPackageJson(path.join(getWorkspaceRoot(), 'package.json'));
|
|
1136
|
+
const repoUrl = getPackageJsonRepository(rootPackageJson);
|
|
1137
|
+
if (!repoUrl) {
|
|
1138
|
+
const message = 'No repository URL found in root package.json - please add a repository field like `"repository": {"type": "git", "url": "https://githbu.com/foo/bar"}`';
|
|
1139
|
+
throw new Error(message);
|
|
1140
|
+
}
|
|
1141
|
+
const versions = [
|
|
1142
|
+
...new Set(ctx.packages.map(p => (p.targetVersion ? `v${p.targetVersion}` : '')).filter(Boolean)),
|
|
1143
|
+
];
|
|
1144
|
+
if (input.mode === 'unified') {
|
|
1145
|
+
if (versions.length !== 1) {
|
|
1146
|
+
throw new Error('Unified mode requires exactly one version');
|
|
1147
|
+
}
|
|
1148
|
+
const unified = allChangelogs.map(p => p.changelog).join('\n\n---\n\n');
|
|
1149
|
+
const doRelease = await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
1150
|
+
type: 'confirm',
|
|
1151
|
+
message: unified + '\n\nDraft release?',
|
|
1152
|
+
});
|
|
1153
|
+
if (doRelease) {
|
|
1154
|
+
const releaseParams = {
|
|
1155
|
+
tag: versions[0],
|
|
1156
|
+
title: versions[0],
|
|
1157
|
+
body: unified,
|
|
1158
|
+
};
|
|
1159
|
+
// await execa('open', [`${repoUrl}/releases/new?${new URLSearchParams(releaseParams).toString()}`])
|
|
1160
|
+
await openReleaseDraft(repoUrl, releaseParams);
|
|
1161
|
+
task.output = 'Release draft opened';
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
task.output = 'Skipping release';
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
else {
|
|
1168
|
+
for (const pkg of ctx.packages) {
|
|
1169
|
+
const body = await getOrCreateChangelog(ctx, pkg);
|
|
1170
|
+
const title = `${pkg.name}@v${pkg.targetVersion}`;
|
|
1171
|
+
const tag = `${pkg.name}@v${pkg.targetVersion}`; // same as title... today
|
|
1172
|
+
const message = `πππ${title} changelogπππ\n\n${body}\n\nπππ${title} changelogπππ`;
|
|
1173
|
+
const doRelease = await task.prompt(ListrEnquirerPromptAdapter).run({
|
|
1174
|
+
type: 'confirm',
|
|
1175
|
+
message: message + '\n\nDraft release?',
|
|
1176
|
+
initial: false,
|
|
1177
|
+
});
|
|
1178
|
+
if (doRelease) {
|
|
1179
|
+
const releaseParams = { tag, title, body };
|
|
1180
|
+
await openReleaseDraft(repoUrl, releaseParams);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
},
|
|
1185
|
+
},
|
|
1186
|
+
], { ctx: {} });
|
|
1187
|
+
await tasks.run();
|
|
1188
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mopub",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Publish packages in a pnpm monorepo",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mopub": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"license": "ISC",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@listr2/prompt-adapter-enquirer": "^4.2.1",
|
|
15
|
+
"@trpc/server": "^11.16.0",
|
|
16
|
+
"clipboardy": "^5.3.1",
|
|
17
|
+
"execa": "^9.6.1",
|
|
18
|
+
"find-up": "^8.0.0",
|
|
19
|
+
"listr2": "^10.2.1",
|
|
20
|
+
"semver": "^7.7.4",
|
|
21
|
+
"sort-package-json": "^3.6.1",
|
|
22
|
+
"trpc-cli": "^0.14.0",
|
|
23
|
+
"type-fest": "^5.6.0",
|
|
24
|
+
"zod": "^4.3.6"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^25.6.0",
|
|
28
|
+
"@types/semver": "^7.7.1",
|
|
29
|
+
"eslint": "^10.2.1",
|
|
30
|
+
"eslint-plugin-mmkal": "^0.11.3",
|
|
31
|
+
"tsx": "^4.21.0",
|
|
32
|
+
"typescript": "^6.0.3"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"dogfood": "tsx src/cli",
|
|
36
|
+
"build": "tsc --noEmit false --outDir dist",
|
|
37
|
+
"lint": "eslint .",
|
|
38
|
+
"test": "echo maybe later"
|
|
39
|
+
}
|
|
40
|
+
}
|