mopub 0.0.3 → 0.1.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 (2) hide show
  1. package/dist/publish.js +156 -22
  2. package/package.json +6 -3
package/dist/publish.js CHANGED
@@ -19,7 +19,7 @@ export const PublishInput = z
19
19
  .optional(),
20
20
  otp: z
21
21
  .string()
22
- .describe('npm OTP to pass through to `npm publish --otp`. Only needed for registries that still require OTP-based 2FA. Leave unset to use npm\'s default browser-based auth flow.')
22
+ .describe('npm OTP to pass through to `npm publish --otp`. If not provided you will be prompted for it (press enter at the prompt to try publishing without MFA, e.g. for registries that use browser-based auth).')
23
23
  .optional(),
24
24
  tag: z
25
25
  .string()
@@ -122,6 +122,21 @@ async function getRegistryVersions(pkg) {
122
122
  const registryVersionsStdout = StdoutShape.parse(JSON.parse(registryVersionsResult.stdout));
123
123
  return Array.isArray(registryVersionsStdout) ? registryVersionsStdout : [];
124
124
  }
125
+ async function getRegistryDistTags(pkgName) {
126
+ const result = await execa('npm', ['view', pkgName, 'dist-tags', '--json'], { reject: false });
127
+ if (!result.stdout)
128
+ return {};
129
+ try {
130
+ const parsed = JSON.parse(result.stdout);
131
+ if (parsed && typeof parsed === 'object' && !('error' in parsed)) {
132
+ return parsed;
133
+ }
134
+ }
135
+ catch {
136
+ // ignore - fall through to empty
137
+ }
138
+ return {};
139
+ }
125
140
  const _breakpoint = (why = 'hmm') => ({
126
141
  title: `wait a bit because ${why}`,
127
142
  task: async (ctx, task) => {
@@ -444,7 +459,18 @@ export const publish = async (input) => {
444
459
  rendererOptions: { persistentOutput: true },
445
460
  task: async (ctx, task) => {
446
461
  const shouldActuallyPublish = input.publish;
447
- const publishTasks = createPublishTasks(ctx, { otp: input.otp, tag: input.tag });
462
+ let otp = input.otp;
463
+ if (shouldActuallyPublish) {
464
+ otp ||= await task.prompt(ListrEnquirerPromptAdapter).run({
465
+ message: 'Enter npm OTP (press enter to try publishing without MFA)',
466
+ type: 'Input',
467
+ validate: v => v === '' || (typeof v === 'string' && /^\d{6}$/.test(v)),
468
+ });
469
+ if (otp.length === 0) {
470
+ task.output = 'No OTP provided - publish will likely error unless you have disabled MFA.';
471
+ }
472
+ }
473
+ const publishTasks = createPublishTasks(ctx, { otp, tag: input.tag });
448
474
  if (!shouldActuallyPublish)
449
475
  publishTasks.forEach(t => (t.skip = true));
450
476
  return task.newListr(publishTasks, { rendererOptions: { collapseSubtasks: false } });
@@ -481,7 +507,7 @@ export const PrebuiltInput = z.tuple([
481
507
  z.object({
482
508
  otp: z
483
509
  .string()
484
- .describe('npm OTP to pass through to `npm publish --otp`. Only needed for registries that still require OTP-based 2FA. Leave unset to use npm\'s default browser-based auth flow.')
510
+ .describe('npm OTP to pass through to `npm publish --otp`. If not provided you will be prompted for it (press enter at the prompt to try publishing without MFA, e.g. for registries that use browser-based auth).')
485
511
  .optional(),
486
512
  tag: z
487
513
  .string()
@@ -495,7 +521,29 @@ export async function publishPrebuilt([folder, options]) {
495
521
  throw new Error(`Failed to get npm username: ${me.stderr}`);
496
522
  console.log(me.stdout, '<<<<<<< npm whoami');
497
523
  const ctx = loadContext(folder);
498
- const tasks = new Listr(createPublishTasks(ctx, options), { ctx });
524
+ const tasks = new Listr([
525
+ {
526
+ title: 'Get OTP',
527
+ enabled: () => !options.otp,
528
+ task: async (_ctx, task) => {
529
+ options.otp = await task.prompt(ListrEnquirerPromptAdapter).run({
530
+ message: 'Enter npm OTP (press enter to try publishing without MFA)',
531
+ type: 'Input',
532
+ validate: v => v === '' || (typeof v === 'string' && /^\d{6}$/.test(v)),
533
+ });
534
+ if (options.otp === '') {
535
+ const confirmed = await task.prompt(ListrEnquirerPromptAdapter).run({
536
+ message: 'This will fail unless you have disabled MFA, which is not recommended.',
537
+ type: 'confirm',
538
+ });
539
+ if (!confirmed) {
540
+ throw new Error('OTP not provided');
541
+ }
542
+ }
543
+ },
544
+ },
545
+ ...createPublishTasks(ctx, options),
546
+ ], { ctx });
499
547
  await tasks.run();
500
548
  }
501
549
  function createPublishTasks(ctx, options) {
@@ -510,11 +558,30 @@ function createPublishTasks(ctx, options) {
510
558
  const publishArgs = ['--access', 'public'];
511
559
  if (options.otp)
512
560
  publishArgs.push('--otp', options.otp);
513
- if (rhsPackageJson && semver.prerelease(rhsPackageJson.version)) {
561
+ let resolvedTag = options.tag;
562
+ // default to whichever dist-tag the previous comparable release was published under,
563
+ // when both versions are the same prerelease/non-prerelease type. e.g. if 0.0.1-1 was
564
+ // published as "latest", a follow-up 0.0.1-2 should also go to "latest" (not "next");
565
+ // if 4.0.0 was published as "next", 4.0.1 should also be "next" (not "latest").
566
+ if (!resolvedTag && lhsPackageJson?.version && rhsPackageJson?.version) {
567
+ const sameType = Boolean(semver.prerelease(lhsPackageJson.version)) ===
568
+ Boolean(semver.prerelease(rhsPackageJson.version));
569
+ if (sameType) {
570
+ const distTags = await getRegistryDistTags(pkg.name);
571
+ const matchingTags = Object.entries(distTags)
572
+ .filter(([, v]) => v === lhsPackageJson.version)
573
+ .map(([t]) => t);
574
+ if (matchingTags.length > 0) {
575
+ resolvedTag = matchingTags.find(t => t === 'latest') || matchingTags[0];
576
+ }
577
+ }
578
+ }
579
+ if (resolvedTag) {
580
+ publishArgs.push('--tag', resolvedTag);
581
+ }
582
+ else if (rhsPackageJson && semver.prerelease(rhsPackageJson.version)) {
514
583
  const first = semver.prerelease(rhsPackageJson.version)?.[0];
515
- let tag = options.tag;
516
- tag ||= typeof first === 'string' ? first : 'next';
517
- publishArgs.push('--tag', tag);
584
+ publishArgs.push('--tag', typeof first === 'string' ? first : 'next');
518
585
  }
519
586
  await pipeExeca(subtask, 'pnpm', ['publish', ...publishArgs], {
520
587
  cwd: path.dirname(packageJsonFilepath(pkg, RHS_FOLDER)),
@@ -567,10 +634,18 @@ export const ReleaseNotesInput = z.object({
567
634
  });
568
635
  async function pullRegistryPackage(subtask, pkg, { version, folder }) {
569
636
  // 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
570
- await pipeExeca(subtask, 'npm', ['pack', `${pkg.name}@${version}`], { cwd: folder });
571
- const tgzFileName = fs.readdirSync(folder).at(0);
572
- if (!tgzFileName) {
573
- throw new Error(`No tgz file found in ${folder}, tried to pull ${pkg.name}@${version}`);
637
+ const packResult = await pipeExeca(subtask, 'npm', ['pack', `${pkg.name}@${version}`], { cwd: folder });
638
+ const packStdout = typeof packResult.stdout === 'string' ? packResult.stdout : '';
639
+ const tgzFileName = packStdout
640
+ .split('\n')
641
+ .map(line => line.trim())
642
+ .find(line => line.endsWith('.tgz'));
643
+ const folderEntries = fs.readdirSync(folder);
644
+ if (!tgzFileName || !folderEntries.includes(tgzFileName)) {
645
+ throw new Error([
646
+ `Expected npm pack ${pkg.name}@${version} to write ${tgzFileName || 'a .tgz file'} in ${folder}.`,
647
+ `Found: ${folderEntries.length ? folderEntries.join(', ') : '(nothing)'}`,
648
+ ].join('\n'));
574
649
  }
575
650
  await pipeExeca(subtask, 'tar', ['-xvzf', tgzFileName], { cwd: folder });
576
651
  const filepath = path.join(folder, 'package', 'package.json');
@@ -643,7 +718,7 @@ function getBumpedVersionValidation(lowerBoundVersion, v) {
643
718
  function getWorkspaceRoot() {
644
719
  return path.dirname(findUpSync('pnpm-workspace.yaml') || findUpSync('pnpm-lock.yaml') || findUpOrThrow('.git', { type: 'directory' }));
645
720
  }
646
- /** "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). */
721
+ /** "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 to choose between the last publish with a findable sha, the last commit before the previous publish, the first commit to the package folder, or a manually-entered sha. */
647
722
  async function getPackageLastPublishRef(pkg, task) {
648
723
  const packageJson = loadLHSPackageJson(pkg);
649
724
  return first7(await getPackageJsonGitSha(pkg, packageJson, task));
@@ -669,18 +744,74 @@ async function getPackageJsonGitSha(pkg, packageJson, task) {
669
744
  const repoUrl = getPackageJsonRepository(loadRHSPackageJson(pkg) || loadLHSPackageJson(pkg) || loadPackageJson(path.join(getWorkspaceRoot(), 'package.json')));
670
745
  const olderUrl = repoUrl && oldestShownSha ? `\nOlder commits: ${repoUrl}/commits/${oldestShownSha}` : '';
671
746
  const recentList = commitLines.length ? `\nRecent commits in ${pkg.path}:\n${commitLines.join('\n')}${olderUrl}\n` : '';
747
+ const choices = [];
748
+ const lastPublishWithSha = await findLastPublishWithFindableSha(pkg, commitLines);
749
+ if (lastPublishWithSha) {
750
+ choices.push({
751
+ message: `${first7(lastPublishWithSha.sha)} - last publish with a findable sha (${lastPublishWithSha.version})`,
752
+ value: lastPublishWithSha.sha,
753
+ });
754
+ }
755
+ const commitBeforePreviousPublish = await getLastCommitBeforePreviousPublish(pkg, packageJson);
756
+ if (commitBeforePreviousPublish) {
757
+ choices.push({
758
+ message: `${first7(commitBeforePreviousPublish.sha)} - last commit before previous publish (${commitBeforePreviousPublish.version} @ ${commitBeforePreviousPublish.time})`,
759
+ value: commitBeforePreviousPublish.sha,
760
+ });
761
+ }
762
+ choices.push({ message: `${first7(firstRef)} - first commit in ${pkg.path}`, value: firstRef }, { message: 'Other (enter a sha manually)', value: 'other' });
763
+ const selected = await task.prompt(ListrEnquirerPromptAdapter).run({
764
+ type: 'Select',
765
+ message: `Couldn't find a git sha for the last published version of ${pkg.name}.${recentList}\nSelect the sha to diff from:`,
766
+ choices,
767
+ });
768
+ if (selected !== 'other')
769
+ return selected;
672
770
  const promptAnswer = await task.prompt(ListrEnquirerPromptAdapter).run({
673
771
  type: 'Input',
674
- 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}):`,
772
+ message: `Enter the sha to diff from:`,
675
773
  validate: (v) => {
676
- if (typeof v !== 'string')
677
- return 'Enter a sha, or leave empty to use the first commit.';
678
- if (!v.trim())
679
- return true;
680
- return /^[0-9a-f]{4,40}$/i.test(v.trim()) ? true : 'Enter a valid git sha (4-40 hex chars) or leave empty.';
774
+ if (typeof v !== 'string' || !/^[0-9a-f]{4,40}$/i.test(v.trim()))
775
+ return 'Enter a valid git sha (4-40 hex chars).';
776
+ return true;
681
777
  },
682
778
  });
683
- return promptAnswer.trim() || firstRef;
779
+ return promptAnswer.trim();
780
+ }
781
+ /**
782
+ * Walks the registry's published versions newest-first looking for one whose git sha can be determined - via the
783
+ * registry package.json's `git.sha`, a version tag, or a `version x.y.z` commit message - even if its
784
+ * prerelease-ness doesn't match the version being compared against. Capped at the 20 newest versions to bound
785
+ * `npm view` calls.
786
+ */
787
+ async function findLastPublishWithFindableSha(pkg, recentCommitLines) {
788
+ const newestFirst = pkg.publishedVersions.slice().reverse().slice(0, 20);
789
+ for (const version of newestFirst) {
790
+ const { stdout: registrySha } = await execa('npm', ['view', `${pkg.name}@${version}`, 'git.sha'], { reject: false });
791
+ if (registrySha.trim())
792
+ return { version, sha: registrySha.trim() };
793
+ const tagSha = await getShaFromVersionTag(pkg, version);
794
+ if (tagSha)
795
+ return { version, sha: tagSha };
796
+ const versionCommitMessage = `version ${version}`;
797
+ const match = recentCommitLines.find(line => line.slice(line.indexOf(' ') + 1) === versionCommitMessage);
798
+ if (match)
799
+ return { version, sha: match.split(' ', 1)[0] };
800
+ }
801
+ return null;
802
+ }
803
+ /** The last commit in the package folder made before the previous version was published, based on the registry's publish timestamp. */
804
+ async function getLastCommitBeforePreviousPublish(pkg, packageJson) {
805
+ const previousVersion = packageJson?.version || pkg.publishedVersions.at(-1);
806
+ if (!previousVersion)
807
+ return null;
808
+ const timesResult = await execa('npm', ['view', pkg.name, 'time', '--json'], { reject: false });
809
+ const times = z.record(z.string(), z.string()).safeParse(JSON.parse(timesResult.stdout || '{}'));
810
+ const publishTime = times.success ? times.data[previousVersion] : undefined;
811
+ if (!publishTime)
812
+ return null;
813
+ const { stdout: sha } = await execa('git', ['log', '-n', '1', `--before=${publishTime}`, '--pretty=format:%h', '--', '.'], { cwd: pkg.path, reject: false });
814
+ return sha ? { version: previousVersion, time: publishTime, sha } : null;
684
815
  }
685
816
  async function getPackageJsonShaFromVersionTag(pkg, packageJson) {
686
817
  // throw new Error(
@@ -688,13 +819,16 @@ async function getPackageJsonShaFromVersionTag(pkg, packageJson) {
688
819
  // )
689
820
  if (!packageJson?.version)
690
821
  return null;
691
- const { stdout: vTagSha } = await execa('git', ['rev-list', '-n', '1', `v${packageJson.version}`], {
822
+ return getShaFromVersionTag(pkg, packageJson.version);
823
+ }
824
+ async function getShaFromVersionTag(pkg, version) {
825
+ const { stdout: vTagSha } = await execa('git', ['rev-list', '-n', '1', `v${version}`], {
692
826
  cwd: pkg.path,
693
827
  reject: false,
694
828
  });
695
829
  if (vTagSha)
696
830
  return vTagSha;
697
- const { stdout: tagSha } = await execa('git', ['rev-list', '-n', '1', packageJson.version], {
831
+ const { stdout: tagSha } = await execa('git', ['rev-list', '-n', '1', version], {
698
832
  cwd: pkg.path,
699
833
  reject: false,
700
834
  });
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "mopub",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "description": "Publish packages in a pnpm monorepo",
5
+ "license": "ISC",
5
6
  "type": "module",
6
7
  "bin": {
7
8
  "mopub": "dist/cli.js"
@@ -9,7 +10,6 @@
9
10
  "files": [
10
11
  "dist"
11
12
  ],
12
- "license": "ISC",
13
13
  "dependencies": {
14
14
  "@listr2/prompt-adapter-enquirer": "^4.2.1",
15
15
  "@trpc/server": "^11.16.0",
@@ -31,9 +31,12 @@
31
31
  "tsx": "^4.21.0",
32
32
  "typescript": "^6.0.3"
33
33
  },
34
+ "git": {
35
+ "sha": "43d2162"
36
+ },
34
37
  "scripts": {
35
- "clean": "rm -rf dist",
36
38
  "build": "tsc --noEmit false --outDir dist",
39
+ "clean": "rm -rf dist",
37
40
  "lint": "eslint .",
38
41
  "test": "echo maybe later"
39
42
  }