From 51387addc17a2202c3e0e28b9176bd75b3471692 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 4 Aug 2026 09:55:04 +1000 Subject: [PATCH 1/7] AP-9905 # Changed update-dependents to publish intermediate packages --- CHANGELOG.md | 5 + package-lock.json | 1264 ++++++++++++++++- package.json | 5 +- src/bin.ts | 79 +- src/enumerateProductRepositories.ts | 6 +- src/startUpdateDependents.ts | 479 +++++-- src/topologicallySortByPackageDependencies.ts | 43 + src/updateDependentsPlanning.ts | 135 ++ src/waitForNpmPackageVersion.ts | 51 + ...logicallySortByPackageDependencies.test.ts | 64 + test/updateDependentsPlanning.test.ts | 290 ++++ test/waitForNpmPackageVersion.test.ts | 106 ++ tsconfig.eslint.json | 2 +- vitest.config.ts | 7 + 14 files changed, 2376 insertions(+), 160 deletions(-) create mode 100644 src/topologicallySortByPackageDependencies.ts create mode 100644 src/updateDependentsPlanning.ts create mode 100644 src/waitForNpmPackageVersion.ts create mode 100644 test/topologicallySortByPackageDependencies.test.ts create mode 100644 test/updateDependentsPlanning.test.ts create mode 100644 test/waitForNpmPackageVersion.test.ts create mode 100644 vitest.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b6eb4c..b0eef83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `update-dependents` discovers intermediate NPM dependents at runtime, prompts to release them, waits for npm publish, and folds those versions into a single downstream pull request +- `--update-dependents` flag to `oneblink-release repository` to wait for npm publish after release, then run `update-dependents` + ### Fixed - github action permissions to allow docs and github release jobs diff --git a/package-lock.json b/package-lock.json index f087538..176a35c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,8 @@ "eslint-plugin-prettier": "^5.5.5", "fixpack": "^4.0.0", "prettier-plugin-jsdoc": "^1.8.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.10" }, "engines": { "node": ">=24", @@ -269,6 +270,13 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@microsoft/eslint-plugin-sdl": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@microsoft/eslint-plugin-sdl/-/eslint-plugin-sdl-1.1.0.tgz", @@ -441,6 +449,16 @@ "@octokit/openapi-types": "^27.0.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -495,6 +513,269 @@ "node": ">=12" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -513,6 +794,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/changelog-parser": { "version": "2.8.4", "resolved": "https://registry.npmjs.org/@types/changelog-parser/-/changelog-parser-2.8.4.tgz", @@ -537,6 +836,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -942,9 +1248,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -952,13 +1258,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1022,6 +1328,119 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -1052,9 +1471,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1295,6 +1714,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -1374,9 +1803,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1465,6 +1894,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -1628,6 +2067,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -1833,6 +2279,16 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2054,6 +2510,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2279,9 +2742,9 @@ } }, "node_modules/eslint-plugin-n/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -2302,13 +2765,13 @@ } }, "node_modules/eslint-plugin-n/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2557,6 +3020,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2593,6 +3066,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend-object": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/extend-object/-/extend-object-1.0.0.tgz", @@ -2785,9 +3268,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -2821,6 +3304,21 @@ "node": ">=12" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3800,15 +4298,25 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { "js-yaml": "bin/js-yaml.js" } }, @@ -3949,6 +4457,279 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/line-reader": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/line-reader/-/line-reader-0.2.4.tgz", @@ -4016,6 +4797,16 @@ "node": "20 || >=22" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4565,9 +5356,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4593,6 +5384,25 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4749,6 +5559,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -5060,6 +5884,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5067,9 +5898,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -5088,6 +5919,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5447,6 +6307,39 @@ "node": ">=0.12" } }, + "node_modules/rolldown": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.2", + "@rolldown/binding-darwin-arm64": "1.2.2", + "@rolldown/binding-darwin-x64": "1.2.2", + "@rolldown/binding-freebsd-x64": "1.2.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", + "@rolldown/binding-linux-arm64-gnu": "1.2.2", + "@rolldown/binding-linux-arm64-musl": "1.2.2", + "@rolldown/binding-linux-ppc64-gnu": "1.2.2", + "@rolldown/binding-linux-s390x-gnu": "1.2.2", + "@rolldown/binding-linux-x64-gnu": "1.2.2", + "@rolldown/binding-linux-x64-musl": "1.2.2", + "@rolldown/binding-openharmony-arm64": "1.2.2", + "@rolldown/binding-win32-arm64-msvc": "1.2.2", + "@rolldown/binding-win32-x64-msvc": "1.2.2" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -5669,6 +6562,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5690,6 +6590,16 @@ "node": ">=6" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -5722,6 +6632,20 @@ "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", "license": "CC0-1.0" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -6009,15 +6933,32 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -6045,9 +6986,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -6057,10 +6998,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "license": "MIT", "engines": { "node": ">=14.14" @@ -6319,6 +7270,200 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/when-exit": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", @@ -6429,6 +7574,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", @@ -6511,9 +7673,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 0bb4431..03c397b 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "eslint-plugin-prettier": "^5.5.5", "fixpack": "^4.0.0", "prettier-plugin-jsdoc": "^1.8.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.10" }, "directories": { "test": "test" @@ -80,7 +81,7 @@ "prerelease": "npm run build", "pretest": "npm run eslint && npm run typescript && fixpack", "release": "node dist/bin.js repository --no-name --cwd .", - "test": "echo \"no tests\"", + "test": "vitest run", "typescript": "tsc --noEmit" }, "type": "module" diff --git a/src/bin.ts b/src/bin.ts index 3a723b7..79eb307 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -14,6 +14,8 @@ import startProductRelease from './startProductRelease.js' import promptForReleaseName from './promptForReleaseName.js' import getRepositoryPlugin from './repositories-plugins/plugins-factory.js' import startUpdateDependents from './startUpdateDependents.js' +import waitForNpmPackageVersion from './waitForNpmPackageVersion.js' +import { readPackageUp } from 'read-package-up' const cli = meow( ` @@ -33,32 +35,40 @@ ${chalk.bold('Examples')} oneblink-release product --name="Inappropriate Release Name" ${chalk.bold.blue( - 'oneblink-release repository [next-version] [--no-git] [--name] [--no-name] [--cwd path]', + 'oneblink-release repository [next-version] [--no-git] [--name] [--no-name] [--cwd path] [--update-dependents] [--force]', )} ${chalk.grey('Release a single repository.')} - next-version ..... The next version, will prompt for this if not supplied, - must be a valid semver number. + next-version ........... The next version, will prompt for this if not supplied, + must be a valid semver number. - --no-git ....... Skip committing changes and creating an annotated git tag. + --no-git ............. Skip committing changes and creating an annotated git tag. - --increment .... Increment the version automatically using "major" | "minor" | "patch". + --increment .......... Increment the version automatically using "major" | "minor" | "patch". - --name ......... Skip the question to enter a name for the release by passing - a release name as a flag. + --name ............... Skip the question to enter a name for the release by passing + a release name as a flag. - --no-name ...... Skip the question to enter a name for the release. Use - option when running a release for an open source repository. + --no-name ............ Skip the question to enter a name for the release. Use + option when running a release for an open source repository. - --cwd .......... Directory of the repository to release relative to the - current working directory, defaults to the current - working directory. + --cwd ................ Directory of the repository to release relative to the + current working directory, defaults to the current + working directory. + + --update-dependents .. After the release is tagged, wait for the NPM package to be + published, then run update-dependents. + + --force .............. When used with --update-dependents, update all dependents + without prompting the user. ${chalk.bold('Examples')} oneblink-release repository oneblink-release repository --no-name + oneblink-release repository --no-name --update-dependents + oneblink-release repository --no-name --update-dependents --force oneblink-release repository --name="Inappropriate Release Name" oneblink-release repository --increment="major" oneblink-release repository --increment="minor" @@ -67,6 +77,10 @@ ${chalk.bold('Examples')} oneblink-release repository 1.1.1 --cwd ../path/to/code oneblink-release repository 1.1.1-uat.1 --no-git +${chalk.bold.blue( + 'oneblink-release update-dependents [--cwd path] [--force]', +)} + ${chalk.grey('Update all product code bases that depend on an NPM package.')} --cwd .......... Directory of the repository that is the dependency relative @@ -113,6 +127,10 @@ ${chalk.bold('Examples')} type: 'boolean', default: false, }, + updateDependents: { + type: 'boolean', + default: false, + }, }, }, ) @@ -200,6 +218,43 @@ async function run(): Promise { releaseName, repositoryPlugin, }) + + if (cli.flags.updateDependents) { + if (!cli.flags.git) { + throw new Error( + 'Cannot use "--update-dependents" with "--no-git" because the package will not be published.', + ) + } + if (!repositoryPlugin.supportsDependencyUpdates) { + throw new Error( + `"${repositoryPlugin.displayType}" repositories do not support updating dependents.`, + ) + } + + const packageResult = await readPackageUp({ + cwd, + }) + const packageName = packageResult?.packageJson.name + if (!packageName) { + throw new Error( + `Could not determine the package name for updating dependents in: ${cwd}`, + ) + } + if (packageResult.packageJson.private) { + throw new Error( + `Cannot use "--update-dependents" for private package "${packageName}" because it is not published to npm.`, + ) + } + + await waitForNpmPackageVersion({ + packageName, + version: input, + }) + await startUpdateDependents({ + cwd, + force: cli.flags.force, + }) + } break } case undefined: { diff --git a/src/enumerateProductRepositories.ts b/src/enumerateProductRepositories.ts index 75cc514..c7d5214 100644 --- a/src/enumerateProductRepositories.ts +++ b/src/enumerateProductRepositories.ts @@ -2,13 +2,13 @@ import path from 'path' import prepareCloneRepository from './prepareCloneRepository.js' import { RepositoryType } from './repositories-plugins/plugins-factory.js' -type Repository = { +export type ProductRepository = { label: string repositoryName: string isPublic: boolean } & RepositoryType -const productRepositories: Repository[] = [ +const productRepositories: ProductRepository[] = [ { label: '@oneblink/apps-react (NPM package)', repositoryName: 'apps-react', @@ -136,7 +136,7 @@ export default async function enumerateProductRepositories( fn: (options: { cloneUrl: string repositoryWorkingDirectory: string - productRepository: Repository + productRepository: ProductRepository }) => Promise, ) { for (const productRepository of productRepositories) { diff --git a/src/startUpdateDependents.ts b/src/startUpdateDependents.ts index f7b1efb..ca72539 100644 --- a/src/startUpdateDependents.ts +++ b/src/startUpdateDependents.ts @@ -4,6 +4,22 @@ import getRepositoryPlugin from './repositories-plugins/plugins-factory.js' import enquirer from 'enquirer' import executeCommand from './executeCommand.js' import boxen from 'boxen' +import chalk from 'chalk' +import prepareCloneRepository from './prepareCloneRepository.js' +import topologicallySortByPackageDependencies from './topologicallySortByPackageDependencies.js' +import waitForNpmPackageVersion from './waitForNpmPackageVersion.js' +import promptForNextVersion from './promptForNextVersion.js' +import promptForReleaseName from './promptForReleaseName.js' +import startRepositoryRelease from './startRepositoryRelease.js' +import getPreRelease from './getPreRelease.js' +import { + buildDependencyBumpCommitMessage, + getDependencyInstallSpecs, + getDependentCandidates, + getDownstreamRepositoryNames, + getIntermediateNpmDependents, + ScannedProductRepository, +} from './updateDependentsPlanning.js' export default async function startUpdateDependents({ cwd, @@ -30,6 +46,7 @@ export default async function startUpdateDependents({ } const dependency = dependencyResult.packageJson.name + const dependencyVersion = dependencyResult.packageJson.version console.log('Beginning to update the dependents of:', dependency) const { ticket } = await enquirer.prompt<{ @@ -66,109 +83,223 @@ export default async function startUpdateDependents({ ], }) + const scannedRepositories = await scanProductRepositories() + const candidates = getDependentCandidates({ + scannedRepositories, + dependency, + dependencyVersion, + }) + + for (const scannedRepository of scannedRepositories) { + logSkippedRepository({ + scannedRepository, + dependency, + dependencyVersion, + candidates, + }) + } + + if (!candidates.length) { + console.log(`No product repositories need "${dependency}" updated.`) + return + } + + const intermediates = getIntermediateNpmDependents(candidates) + const sortedIntermediates = + topologicallySortByPackageDependencies(intermediates) + + const releasedPackageVersions = new Map([ + [dependency, dependencyVersion], + ]) + const releasedRepositoryNames = new Set() + const releasedPackageSummaries: string[] = [] const createPullRequestUrls: string[] = [] - await enumerateProductRepositories( - async ({ productRepository, repositoryWorkingDirectory }) => { - const repositoryPlugin = await getRepositoryPlugin({ - cwd: repositoryWorkingDirectory, - repositoryType: productRepository, - }) - if (!repositoryPlugin.supportsDependencyUpdates) { - console.log( - `Skipping "${productRepository.repositoryName}" as "${repositoryPlugin.displayType}" does not support updating dependencies.`, - ) - return - } + for (const intermediate of sortedIntermediates) { + const downstreamRepositoryNames = getDownstreamRepositoryNames({ + candidates, + intermediate, + }) - const result = await readPackageUp({ - cwd: repositoryWorkingDirectory, - }) - const currentDependencyVersion = - result?.packageJson.dependencies?.[dependency] - if (!currentDependencyVersion) { - console.log( - `Skipping "${productRepository.repositoryName}" as it does not contain "${dependency}" as a dependency.`, + const { shouldRelease } = await enquirer.prompt<{ + shouldRelease: 'yes' | 'no' + }>({ + type: 'select', + name: 'shouldRelease', + message: `"${intermediate.productRepository.repositoryName}" (${intermediate.packageName}) also needs a release because it is depended on by: ${downstreamRepositoryNames.join(', ')}. Update "${dependency}" and release "${intermediate.packageName}" now so downstream pull requests can include both version bumps?`, + choices: [ + { + message: `Yes, update and release "${intermediate.packageName}"`, + name: 'yes', + }, + { + message: `No, only open a dependency bump pull request for "${intermediate.productRepository.repositoryName}"`, + name: 'no', + }, + ], + }) + + if (shouldRelease === 'no') { + console.log( + chalk.yellow( + `Skipping release of "${intermediate.packageName}". Downstream pull requests may conflict later when "${intermediate.packageName}" is released and dependents are updated again.`, + ), + ) + continue + } + + await withClonedRepository( + intermediate.productRepository.repositoryName, + async (repositoryWorkingDirectory) => { + const bumpedPackageNames = await installDependencyUpdates({ + cwd: repositoryWorkingDirectory, + dependencies: intermediate.dependencies, + releasedPackageVersions, + isUpdatingTypes: isUpdatingTypes === 'yes', + }) + + if (!bumpedPackageNames.length && isUpdatingTypes !== 'yes') { + console.log( + `Skipping release of "${intermediate.packageName}" as there were no dependency updates to apply.`, + ) + return + } + + await commitDependencyUpdates({ + cwd: repositoryWorkingDirectory, + ticket, + bumpedPackageNames, + isUpdatingTypes: isUpdatingTypes === 'yes', + }) + + const repositoryPlugin = await getRepositoryPlugin({ + cwd: repositoryWorkingDirectory, + repositoryType: intermediate.productRepository, + }) + const { nextVersion } = await promptForNextVersion({ + repositoryPlugin, + noPreRelease: false, + }) + const preRelease = getPreRelease(nextVersion) + const releaseName = + intermediate.productRepository.isPublic || preRelease + ? undefined + : await promptForReleaseName() + + await startRepositoryRelease({ + nextVersion, + git: true, + releaseName, + repositoryPlugin, + }) + + await waitForNpmPackageVersion({ + packageName: intermediate.packageName, + version: nextVersion, + }) + + releasedPackageVersions.set(intermediate.packageName, nextVersion) + releasedRepositoryNames.add( + intermediate.productRepository.repositoryName, ) - return - } - if ( - currentDependencyVersion === `^${dependencyResult.packageJson.version}` - ) { - console.log( - `Skipping "${productRepository.repositoryName}" as it's version of "${dependency}" is already "${currentDependencyVersion}".`, + releasedPackageSummaries.push( + `${intermediate.packageName}@${nextVersion}`, ) - return - } + }, + ) + } - let isUpdating: 'yes' | 'no' - if (force) { - isUpdating = 'yes' - console.log( - `Auto-updating "${dependency}" in "${productRepository.repositoryName}" (${currentDependencyVersion} > ${dependencyResult.packageJson.version})`, + for (const candidate of candidates) { + if ( + releasedRepositoryNames.has(candidate.productRepository.repositoryName) + ) { + continue + } + + let isUpdating: 'yes' | 'no' + if (force) { + isUpdating = 'yes' + console.log( + `Auto-updating "${dependency}" in "${candidate.productRepository.repositoryName}" (${candidate.currentSourceDependencyVersion} > ${dependencyVersion})`, + ) + } else { + const updateResult = await enquirer.prompt<{ + isUpdating: 'yes' | 'no' + }>({ + type: 'select', + name: 'isUpdating', + message: `Would you like to update dependencies in "${candidate.productRepository.repositoryName}" (${candidate.currentSourceDependencyVersion} > ${dependencyVersion})?`, + choices: [ + { + message: 'Yes, update dependency!', + name: 'yes', + }, + { + message: `No! "${candidate.productRepository.repositoryName}" does not need to be updated.`, + name: 'no', + }, + ], + }) + isUpdating = updateResult.isUpdating + } + if (isUpdating === 'no') { + continue + } + + await withClonedRepository( + candidate.productRepository.repositoryName, + async (repositoryWorkingDirectory) => { + await executeCommand( + 'git', + ['checkout', '-b', ticket], + repositoryWorkingDirectory, ) - } else { - const updateResult = await enquirer.prompt<{ - isUpdating: 'yes' | 'no' - }>({ - type: 'select', - name: 'isUpdating', - message: `Would you like to update "${dependency}" in "${productRepository.repositoryName}" (${currentDependencyVersion} > ${dependencyResult.packageJson.version})?`, - choices: [ - { - message: 'Yes, update dependency!', - name: 'yes', - }, - { - message: `No! "${productRepository.repositoryName}" does not need to be updated.`, - name: 'no', - }, - ], + + const bumpedPackageNames = await installDependencyUpdates({ + cwd: repositoryWorkingDirectory, + dependencies: candidate.dependencies, + releasedPackageVersions, + isUpdatingTypes: isUpdatingTypes === 'yes', }) - isUpdating = updateResult.isUpdating - } - if (isUpdating === 'no') { - return - } - await executeCommand( - 'git', - ['checkout', '-b', ticket], - repositoryWorkingDirectory, - ) - if (isUpdatingTypes === 'yes') { + if (!bumpedPackageNames.length && isUpdatingTypes !== 'yes') { + console.log( + `Skipping "${candidate.productRepository.repositoryName}" as there were no dependency updates to apply.`, + ) + return + } + + await commitDependencyUpdates({ + cwd: repositoryWorkingDirectory, + ticket, + bumpedPackageNames, + isUpdatingTypes: isUpdatingTypes === 'yes', + }) await executeCommand( - 'npm', - ['install', '--package-lock-only', '-D', '@oneblink/types'], + 'git', + ['push', '-u', 'origin', ticket], repositoryWorkingDirectory, ) - } - await executeCommand( - 'npm', - [ - 'install', - '--package-lock-only', - '--save', - `${dependency}@${dependencyResult.packageJson.version}`, - ], - repositoryWorkingDirectory, - ) - await executeCommand('git', ['add', '-A'], repositoryWorkingDirectory) - await executeCommand( - 'git', - ['commit', '--message', `${ticket} # Bumped ${dependency}`], - repositoryWorkingDirectory, - ) - await executeCommand( - 'git', - ['push', '-u', 'origin', ticket], - repositoryWorkingDirectory, - ) - createPullRequestUrls.push( - `https://github.com/oneblink/${productRepository.repositoryName}/pull/new/${ticket}`, - ) - }, - ) + createPullRequestUrls.push( + `https://github.com/oneblink/${candidate.productRepository.repositoryName}/pull/new/${ticket}`, + ) + }, + ) + } + + if (releasedPackageSummaries.length) { + console.log( + boxen( + `The following NPM packages were released during this run: + + ${releasedPackageSummaries.join(` + `)}`, + { + padding: 1, + }, + ), + ) + } if (createPullRequestUrls.length) { console.log( @@ -184,3 +315,169 @@ export default async function startUpdateDependents({ ) } } + +async function scanProductRepositories(): Promise { + const scannedRepositories: ScannedProductRepository[] = [] + + await enumerateProductRepositories( + async ({ productRepository, repositoryWorkingDirectory }) => { + const repositoryPlugin = await getRepositoryPlugin({ + cwd: repositoryWorkingDirectory, + repositoryType: productRepository, + }) + if (!repositoryPlugin.supportsDependencyUpdates) { + console.log( + `Skipping "${productRepository.repositoryName}" as "${repositoryPlugin.displayType}" does not support updating dependencies.`, + ) + scannedRepositories.push({ + productRepository, + packageName: undefined, + packageVersion: undefined, + dependencies: {}, + supportsDependencyUpdates: false, + }) + return + } + + const result = await readPackageUp({ + cwd: repositoryWorkingDirectory, + }) + scannedRepositories.push({ + productRepository, + packageName: result?.packageJson.name, + packageVersion: result?.packageJson.version, + dependencies: result?.packageJson.dependencies || {}, + supportsDependencyUpdates: true, + }) + }, + ) + + return scannedRepositories +} + +function logSkippedRepository({ + scannedRepository, + dependency, + dependencyVersion, + candidates, +}: { + scannedRepository: ScannedProductRepository + dependency: string + dependencyVersion: string + candidates: ReturnType +}) { + if (!scannedRepository.supportsDependencyUpdates) { + return + } + + if ( + candidates.some( + (candidate) => + candidate.productRepository.repositoryName === + scannedRepository.productRepository.repositoryName, + ) + ) { + return + } + + const currentDependencyVersion = scannedRepository.dependencies[dependency] + if (!currentDependencyVersion) { + console.log( + `Skipping "${scannedRepository.productRepository.repositoryName}" as it does not contain "${dependency}" as a dependency.`, + ) + return + } + if (currentDependencyVersion === `^${dependencyVersion}`) { + console.log( + `Skipping "${scannedRepository.productRepository.repositoryName}" as it's version of "${dependency}" is already "${currentDependencyVersion}".`, + ) + return + } + if (!scannedRepository.packageName || !scannedRepository.packageVersion) { + console.log( + `Skipping "${scannedRepository.productRepository.repositoryName}" as a package name/version could not be determined.`, + ) + } +} + +async function withClonedRepository( + repositoryName: string, + fn: (repositoryWorkingDirectory: string) => Promise, +) { + const { + cloneRepository, + repositoryWorkingDirectory, + removeRepositoryWorkingDirectory, + } = await prepareCloneRepository({ + repositoryName, + }) + + try { + await cloneRepository() + await fn(repositoryWorkingDirectory) + } finally { + await removeRepositoryWorkingDirectory() + } +} + +async function installDependencyUpdates({ + cwd, + dependencies, + releasedPackageVersions, + isUpdatingTypes, +}: { + cwd: string + dependencies: Record + releasedPackageVersions: Map + isUpdatingTypes: boolean +}): Promise { + const { bumpedPackageNames, installSpecs } = getDependencyInstallSpecs({ + dependencies, + releasedPackageVersions, + }) + + if (isUpdatingTypes) { + await executeCommand( + 'npm', + ['install', '--package-lock-only', '-D', '@oneblink/types'], + cwd, + ) + } + + if (installSpecs.length) { + await executeCommand( + 'npm', + ['install', '--package-lock-only', '--save', ...installSpecs], + cwd, + ) + } + + return bumpedPackageNames +} + +async function commitDependencyUpdates({ + cwd, + ticket, + bumpedPackageNames, + isUpdatingTypes, +}: { + cwd: string + ticket: string + bumpedPackageNames: string[] + isUpdatingTypes: boolean +}) { + await executeCommand('git', ['add', '-A'], cwd) + await executeCommand( + 'git', + [ + 'commit', + '--message', + buildDependencyBumpCommitMessage({ + ticket, + bumpedPackageNames, + isUpdatingTypes, + }), + ], + cwd, + ) +} diff --git a/src/topologicallySortByPackageDependencies.ts b/src/topologicallySortByPackageDependencies.ts new file mode 100644 index 0000000..fd0780b --- /dev/null +++ b/src/topologicallySortByPackageDependencies.ts @@ -0,0 +1,43 @@ +export default function topologicallySortByPackageDependencies< + T extends { + packageName: string + dependencies: Record + }, +>(packages: T[]): T[] { + const packagesByName = new Map( + packages.map((pkg) => [pkg.packageName, pkg] as const), + ) + const sorted: T[] = [] + const visited = new Set() + const visiting = new Set() + + function visit(pkg: T) { + if (visited.has(pkg.packageName)) { + return + } + if (visiting.has(pkg.packageName)) { + throw new Error( + `Circular dependency detected while ordering NPM packages to release (involving "${pkg.packageName}")`, + ) + } + + visiting.add(pkg.packageName) + + for (const dependencyName of Object.keys(pkg.dependencies)) { + const dependency = packagesByName.get(dependencyName) + if (dependency) { + visit(dependency) + } + } + + visiting.delete(pkg.packageName) + visited.add(pkg.packageName) + sorted.push(pkg) + } + + for (const pkg of packages) { + visit(pkg) + } + + return sorted +} diff --git a/src/updateDependentsPlanning.ts b/src/updateDependentsPlanning.ts new file mode 100644 index 0000000..31ace18 --- /dev/null +++ b/src/updateDependentsPlanning.ts @@ -0,0 +1,135 @@ +import { ProductRepository } from './enumerateProductRepositories.js' + +export type ScannedProductRepository = { + productRepository: ProductRepository + packageName: string | undefined + packageVersion: string | undefined + dependencies: Record + supportsDependencyUpdates: boolean +} + +export type DependentCandidate = { + productRepository: ProductRepository + packageName: string + packageVersion: string + dependencies: Record + currentSourceDependencyVersion: string +} + +export function getDependentCandidates({ + scannedRepositories, + dependency, + dependencyVersion, +}: { + scannedRepositories: ScannedProductRepository[] + dependency: string + dependencyVersion: string +}): DependentCandidate[] { + const expectedDependencyVersion = `^${dependencyVersion}` + const candidates: DependentCandidate[] = [] + + for (const scannedRepository of scannedRepositories) { + if (!scannedRepository.supportsDependencyUpdates) { + continue + } + + const currentDependencyVersion = scannedRepository.dependencies[dependency] + if (!currentDependencyVersion) { + continue + } + if (currentDependencyVersion === expectedDependencyVersion) { + continue + } + if (!scannedRepository.packageName || !scannedRepository.packageVersion) { + continue + } + + candidates.push({ + productRepository: scannedRepository.productRepository, + packageName: scannedRepository.packageName, + packageVersion: scannedRepository.packageVersion, + dependencies: scannedRepository.dependencies, + currentSourceDependencyVersion: currentDependencyVersion, + }) + } + + return candidates +} + +export function getIntermediateNpmDependents( + candidates: DependentCandidate[], +): DependentCandidate[] { + return candidates.filter((candidate) => { + if (candidate.productRepository.type !== 'NPM') { + return false + } + + return candidates.some( + (otherCandidate) => + otherCandidate.productRepository.repositoryName !== + candidate.productRepository.repositoryName && + !!otherCandidate.dependencies[candidate.packageName], + ) + }) +} + +export function getDownstreamRepositoryNames({ + candidates, + intermediate, +}: { + candidates: DependentCandidate[] + intermediate: DependentCandidate +}): string[] { + return candidates + .filter( + (candidate) => + candidate.productRepository.repositoryName !== + intermediate.productRepository.repositoryName && + candidate.dependencies[intermediate.packageName], + ) + .map((candidate) => candidate.productRepository.repositoryName) +} + +export function getDependencyInstallSpecs({ + dependencies, + releasedPackageVersions, +}: { + dependencies: Record + releasedPackageVersions: Map +}): { + bumpedPackageNames: string[] + installSpecs: string[] +} { + const bumpedPackageNames: string[] = [] + const installSpecs: string[] = [] + + for (const [packageName, version] of releasedPackageVersions) { + const currentVersion = dependencies[packageName] + if (currentVersion && currentVersion !== `^${version}`) { + installSpecs.push(`${packageName}@${version}`) + bumpedPackageNames.push(packageName) + } + } + + return { + bumpedPackageNames, + installSpecs, + } +} + +export function buildDependencyBumpCommitMessage({ + ticket, + bumpedPackageNames, + isUpdatingTypes, +}: { + ticket: string + bumpedPackageNames: string[] + isUpdatingTypes: boolean +}): string { + const commitPackageNames = [...bumpedPackageNames] + if (isUpdatingTypes) { + commitPackageNames.push('@oneblink/types') + } + + return `${ticket} # Bumped ${commitPackageNames.join(', ')}` +} diff --git a/src/waitForNpmPackageVersion.ts b/src/waitForNpmPackageVersion.ts new file mode 100644 index 0000000..6929154 --- /dev/null +++ b/src/waitForNpmPackageVersion.ts @@ -0,0 +1,51 @@ +import { setTimeout } from 'node:timers/promises' +import { execa } from 'execa' +import wrapWithLoading from './wrapWithLoading.js' + +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000 +const DEFAULT_POLL_INTERVAL_MS = 15_000 + +export default async function waitForNpmPackageVersion({ + packageName, + version, + timeoutMs = DEFAULT_TIMEOUT_MS, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, +}: { + packageName: string + version: string + timeoutMs?: number + pollIntervalMs?: number +}): Promise { + await wrapWithLoading( + { + startText: `Waiting for ${packageName}@${version} to be published to npm`, + failText: `Timed out waiting for ${packageName}@${version} to be published to npm`, + }, + async (spinner) => { + const startedAt = Date.now() + + while (Date.now() - startedAt < timeoutMs) { + const { stdout, exitCode } = await execa( + 'npm', + ['view', `${packageName}@${version}`, 'version'], + { + reject: false, + }, + ) + + if (exitCode === 0 && stdout.trim() === version) { + spinner.succeed(`${packageName}@${version} is available on npm`) + return + } + + const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000) + spinner.text = `Waiting for ${packageName}@${version} to be published to npm (${elapsedSeconds}s)` + await setTimeout(pollIntervalMs) + } + + throw new Error( + `Timed out after ${timeoutMs}ms waiting for ${packageName}@${version} to be published to npm`, + ) + }, + ) +} diff --git a/test/topologicallySortByPackageDependencies.test.ts b/test/topologicallySortByPackageDependencies.test.ts new file mode 100644 index 0000000..7b052f2 --- /dev/null +++ b/test/topologicallySortByPackageDependencies.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import topologicallySortByPackageDependencies from '../src/topologicallySortByPackageDependencies.js' + +describe('topologicallySortByPackageDependencies', () => { + it('orders packages so dependencies are released before dependents', () => { + const sorted = topologicallySortByPackageDependencies([ + { + packageName: '@oneblink/apps-react', + dependencies: { + '@oneblink/sdk-core': '^10.0.0', + '@oneblink/storage': '^7.0.0', + }, + }, + { + packageName: '@oneblink/storage', + dependencies: { + '@oneblink/sdk-core': '^10.0.0', + }, + }, + ]) + + expect(sorted.map((pkg) => pkg.packageName)).toEqual([ + '@oneblink/storage', + '@oneblink/apps-react', + ]) + }) + + it('keeps unrelated packages in a stable visit order', () => { + const sorted = topologicallySortByPackageDependencies([ + { + packageName: '@oneblink/cli', + dependencies: {}, + }, + { + packageName: '@oneblink/sdk', + dependencies: {}, + }, + ]) + + expect(sorted.map((pkg) => pkg.packageName)).toEqual([ + '@oneblink/cli', + '@oneblink/sdk', + ]) + }) + + it('throws when a circular dependency is detected', () => { + expect(() => + topologicallySortByPackageDependencies([ + { + packageName: 'a', + dependencies: { + b: '^1.0.0', + }, + }, + { + packageName: 'b', + dependencies: { + a: '^1.0.0', + }, + }, + ]), + ).toThrow('Circular dependency detected') + }) +}) diff --git a/test/updateDependentsPlanning.test.ts b/test/updateDependentsPlanning.test.ts new file mode 100644 index 0000000..22b3da1 --- /dev/null +++ b/test/updateDependentsPlanning.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest' +import { ProductRepository } from '../src/enumerateProductRepositories.js' +import { + buildDependencyBumpCommitMessage, + getDependencyInstallSpecs, + getDependentCandidates, + getDownstreamRepositoryNames, + getIntermediateNpmDependents, + ScannedProductRepository, +} from '../src/updateDependentsPlanning.js' + +const sdkCore = createProductRepository({ + label: '@oneblink/sdk-core (NPM package)', + repositoryName: 'sdk-core-js', + type: 'NPM', + isPublic: true, +}) + +const appsReact = createProductRepository({ + label: '@oneblink/apps-react (NPM package)', + repositoryName: 'apps-react', + type: 'NPM', + isPublic: true, +}) + +const formsRenderer = createProductRepository({ + label: 'Forms Renderer', + repositoryName: 'product-forms-renderer', + type: 'NODE_JS', + isPublic: false, +}) + +const nugetSdk = createProductRepository({ + label: 'OneBlink.SDK (Nuget package)', + repositoryName: 'sdk-dotnet', + type: 'NUGET', + isPublic: true, + relativeProjectFile: 'OneBlink.SDK/OneBlink.SDK.csproj', +}) + +describe('getDependentCandidates', () => { + it('returns repositories that depend on an outdated source package version', () => { + const candidates = getDependentCandidates({ + scannedRepositories: [ + createScannedRepository({ + productRepository: appsReact, + packageName: '@oneblink/apps-react', + packageVersion: '11.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + }, + }), + createScannedRepository({ + productRepository: formsRenderer, + packageName: 'forms-renderer', + packageVersion: '2.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + '@oneblink/apps-react': '^11.0.0', + }, + }), + createScannedRepository({ + productRepository: nugetSdk, + supportsDependencyUpdates: false, + }), + ], + dependency: '@oneblink/sdk-core', + dependencyVersion: '10.1.0', + }) + + expect(candidates.map((candidate) => candidate.packageName)).toEqual([ + '@oneblink/apps-react', + 'forms-renderer', + ]) + }) + + it('skips repositories already on the expected dependency version', () => { + const candidates = getDependentCandidates({ + scannedRepositories: [ + createScannedRepository({ + productRepository: appsReact, + packageName: '@oneblink/apps-react', + packageVersion: '11.0.0', + dependencies: { + '@oneblink/sdk-core': '^10.1.0', + }, + }), + ], + dependency: '@oneblink/sdk-core', + dependencyVersion: '10.1.0', + }) + + expect(candidates).toEqual([]) + }) + + it('skips repositories that do not depend on the source package', () => { + const candidates = getDependentCandidates({ + scannedRepositories: [ + createScannedRepository({ + productRepository: sdkCore, + packageName: '@oneblink/sdk-core', + packageVersion: '10.1.0', + dependencies: {}, + }), + ], + dependency: '@oneblink/sdk-core', + dependencyVersion: '10.1.0', + }) + + expect(candidates).toEqual([]) + }) +}) + +describe('getIntermediateNpmDependents', () => { + it('identifies NPM packages that other candidates also depend on', () => { + const candidates = getDependentCandidates({ + scannedRepositories: [ + createScannedRepository({ + productRepository: appsReact, + packageName: '@oneblink/apps-react', + packageVersion: '11.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + }, + }), + createScannedRepository({ + productRepository: formsRenderer, + packageName: 'forms-renderer', + packageVersion: '2.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + '@oneblink/apps-react': '^11.0.0', + }, + }), + ], + dependency: '@oneblink/sdk-core', + dependencyVersion: '10.1.0', + }) + + const intermediates = getIntermediateNpmDependents(candidates) + + expect(intermediates.map((candidate) => candidate.packageName)).toEqual([ + '@oneblink/apps-react', + ]) + }) + + it('does not treat NODE_JS repositories as intermediate NPM packages', () => { + const candidates = getDependentCandidates({ + scannedRepositories: [ + createScannedRepository({ + productRepository: formsRenderer, + packageName: 'forms-renderer', + packageVersion: '2.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + }, + }), + ], + dependency: '@oneblink/sdk-core', + dependencyVersion: '10.1.0', + }) + + expect(getIntermediateNpmDependents(candidates)).toEqual([]) + }) +}) + +describe('getDownstreamRepositoryNames', () => { + it('returns repository names that depend on the intermediate package', () => { + const candidates = getDependentCandidates({ + scannedRepositories: [ + createScannedRepository({ + productRepository: appsReact, + packageName: '@oneblink/apps-react', + packageVersion: '11.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + }, + }), + createScannedRepository({ + productRepository: formsRenderer, + packageName: 'forms-renderer', + packageVersion: '2.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + '@oneblink/apps-react': '^11.0.0', + }, + }), + ], + dependency: '@oneblink/sdk-core', + dependencyVersion: '10.1.0', + }) + const [intermediate] = getIntermediateNpmDependents(candidates) + + expect( + getDownstreamRepositoryNames({ + candidates, + intermediate, + }), + ).toEqual(['product-forms-renderer']) + }) +}) + +describe('getDependencyInstallSpecs', () => { + it('returns install specs for outdated released packages that are already dependencies', () => { + expect( + getDependencyInstallSpecs({ + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + '@oneblink/apps-react': '^11.0.0', + lodash: '^4.0.0', + }, + releasedPackageVersions: new Map([ + ['@oneblink/sdk-core', '10.1.0'], + ['@oneblink/apps-react', '11.2.0'], + ['@oneblink/storage', '7.1.0'], + ]), + }), + ).toEqual({ + bumpedPackageNames: ['@oneblink/sdk-core', '@oneblink/apps-react'], + installSpecs: [ + '@oneblink/sdk-core@10.1.0', + '@oneblink/apps-react@11.2.0', + ], + }) + }) + + it('skips packages already at the released version', () => { + expect( + getDependencyInstallSpecs({ + dependencies: { + '@oneblink/sdk-core': '^10.1.0', + }, + releasedPackageVersions: new Map([['@oneblink/sdk-core', '10.1.0']]), + }), + ).toEqual({ + bumpedPackageNames: [], + installSpecs: [], + }) + }) +}) + +describe('buildDependencyBumpCommitMessage', () => { + it('includes bumped package names', () => { + expect( + buildDependencyBumpCommitMessage({ + ticket: 'ON-1234', + bumpedPackageNames: ['@oneblink/sdk-core', '@oneblink/apps-react'], + isUpdatingTypes: false, + }), + ).toBe('ON-1234 # Bumped @oneblink/sdk-core, @oneblink/apps-react') + }) + + it('appends @oneblink/types when types are being updated', () => { + expect( + buildDependencyBumpCommitMessage({ + ticket: 'ON-1234', + bumpedPackageNames: ['@oneblink/sdk-core'], + isUpdatingTypes: true, + }), + ).toBe('ON-1234 # Bumped @oneblink/sdk-core, @oneblink/types') + }) +}) + +function createProductRepository( + productRepository: ProductRepository, +): ProductRepository { + return productRepository +} + +function createScannedRepository({ + productRepository, + packageName, + packageVersion, + dependencies = {}, + supportsDependencyUpdates = true, +}: { + productRepository: ProductRepository + packageName?: string + packageVersion?: string + dependencies?: Record + supportsDependencyUpdates?: boolean +}): ScannedProductRepository { + return { + productRepository, + packageName, + packageVersion, + dependencies, + supportsDependencyUpdates, + } +} diff --git a/test/waitForNpmPackageVersion.test.ts b/test/waitForNpmPackageVersion.test.ts new file mode 100644 index 0000000..a4c1806 --- /dev/null +++ b/test/waitForNpmPackageVersion.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { execaMock, setTimeoutMock, wrapWithLoadingMock } = vi.hoisted(() => ({ + execaMock: vi.fn(), + setTimeoutMock: vi.fn(async () => undefined), + wrapWithLoadingMock: vi.fn( + async ( + _options: { startText: string; failText: string }, + fn: (spinner: { succeed: ReturnType; text: string }) => Promise, + ) => { + const spinner = { + succeed: vi.fn(), + text: '', + } + await fn(spinner) + return undefined + }, + ), +})) + +vi.mock('execa', () => ({ + execa: execaMock, +})) + +vi.mock('node:timers/promises', () => ({ + setTimeout: setTimeoutMock, +})) + +vi.mock('../src/wrapWithLoading.js', () => ({ + default: wrapWithLoadingMock, +})) + +import waitForNpmPackageVersion from '../src/waitForNpmPackageVersion.js' + +describe('waitForNpmPackageVersion', () => { + beforeEach(() => { + vi.clearAllMocks() + setTimeoutMock.mockResolvedValue(undefined) + }) + + it('resolves when npm view returns the expected version', async () => { + execaMock.mockResolvedValueOnce({ + stdout: '10.1.0\n', + exitCode: 0, + }) + + await waitForNpmPackageVersion({ + packageName: '@oneblink/sdk-core', + version: '10.1.0', + timeoutMs: 1_000, + pollIntervalMs: 10, + }) + + expect(execaMock).toHaveBeenCalledWith( + 'npm', + ['view', '@oneblink/sdk-core@10.1.0', 'version'], + { reject: false }, + ) + expect(setTimeoutMock).not.toHaveBeenCalled() + }) + + it('polls until the package version becomes available', async () => { + execaMock + .mockResolvedValueOnce({ + stdout: '', + exitCode: 1, + }) + .mockResolvedValueOnce({ + stdout: '10.1.0', + exitCode: 0, + }) + + await waitForNpmPackageVersion({ + packageName: '@oneblink/sdk-core', + version: '10.1.0', + timeoutMs: 1_000, + pollIntervalMs: 25, + }) + + expect(execaMock).toHaveBeenCalledTimes(2) + expect(setTimeoutMock).toHaveBeenCalledWith(25) + }) + + it('throws when the package is not published before the timeout', async () => { + execaMock.mockResolvedValue({ + stdout: '', + exitCode: 1, + }) + + const nowSpy = vi.spyOn(Date, 'now') + nowSpy.mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(2_000) + + await expect( + waitForNpmPackageVersion({ + packageName: '@oneblink/sdk-core', + version: '10.1.0', + timeoutMs: 1_000, + pollIntervalMs: 10, + }), + ).rejects.toThrow( + 'Timed out after 1000ms waiting for @oneblink/sdk-core@10.1.0 to be published to npm', + ) + + nowSpy.mockRestore() + }) +}) diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 5d5c990..d6ae085 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -1,6 +1,6 @@ { "extends": "./tsconfig.json", - "include": ["./src", "eslint.config.mjs"], + "include": ["./src", "./test", "eslint.config.mjs", "vitest.config.ts"], "compilerOptions": { "rootDir": "./" } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fee72c8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, +}) From 85cb6e7763443aae55867545809e29db9ce98e56 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 4 Aug 2026 10:10:32 +1000 Subject: [PATCH 2/7] AP-9905 # Only clone repos once --- src/enumerateProductRepositories.ts | 2 +- src/startUpdateDependents.ts | 572 ++++++++++++++++------------ 2 files changed, 326 insertions(+), 248 deletions(-) diff --git a/src/enumerateProductRepositories.ts b/src/enumerateProductRepositories.ts index c7d5214..8ed6c5f 100644 --- a/src/enumerateProductRepositories.ts +++ b/src/enumerateProductRepositories.ts @@ -8,7 +8,7 @@ export type ProductRepository = { isPublic: boolean } & RepositoryType -const productRepositories: ProductRepository[] = [ +export const productRepositories: readonly ProductRepository[] = [ { label: '@oneblink/apps-react (NPM package)', repositoryName: 'apps-react', diff --git a/src/startUpdateDependents.ts b/src/startUpdateDependents.ts index ca72539..b34e952 100644 --- a/src/startUpdateDependents.ts +++ b/src/startUpdateDependents.ts @@ -1,5 +1,8 @@ import { readPackageUp } from 'read-package-up' -import enumerateProductRepositories from './enumerateProductRepositories.js' +import { + productRepositories, + ProductRepository, +} from './enumerateProductRepositories.js' import getRepositoryPlugin from './repositories-plugins/plugins-factory.js' import enquirer from 'enquirer' import executeCommand from './executeCommand.js' @@ -14,6 +17,7 @@ import startRepositoryRelease from './startRepositoryRelease.js' import getPreRelease from './getPreRelease.js' import { buildDependencyBumpCommitMessage, + DependentCandidate, getDependencyInstallSpecs, getDependentCandidates, getDownstreamRepositoryNames, @@ -21,6 +25,11 @@ import { ScannedProductRepository, } from './updateDependentsPlanning.js' +type RetainedClone = { + repositoryWorkingDirectory: string + removeRepositoryWorkingDirectory: () => Promise +} + export default async function startUpdateDependents({ cwd, force = false, @@ -83,276 +92,365 @@ export default async function startUpdateDependents({ ], }) - const scannedRepositories = await scanProductRepositories() - const candidates = getDependentCandidates({ - scannedRepositories, - dependency, - dependencyVersion, - }) - - for (const scannedRepository of scannedRepositories) { - logSkippedRepository({ - scannedRepository, + const { scannedRepositories, retainedClones } = await scanProductRepositories( + { dependency, dependencyVersion, - candidates, - }) - } - - if (!candidates.length) { - console.log(`No product repositories need "${dependency}" updated.`) - return - } - - const intermediates = getIntermediateNpmDependents(candidates) - const sortedIntermediates = - topologicallySortByPackageDependencies(intermediates) - - const releasedPackageVersions = new Map([ - [dependency, dependencyVersion], - ]) - const releasedRepositoryNames = new Set() - const releasedPackageSummaries: string[] = [] - const createPullRequestUrls: string[] = [] - - for (const intermediate of sortedIntermediates) { - const downstreamRepositoryNames = getDownstreamRepositoryNames({ - candidates, - intermediate, - }) + }, + ) - const { shouldRelease } = await enquirer.prompt<{ - shouldRelease: 'yes' | 'no' - }>({ - type: 'select', - name: 'shouldRelease', - message: `"${intermediate.productRepository.repositoryName}" (${intermediate.packageName}) also needs a release because it is depended on by: ${downstreamRepositoryNames.join(', ')}. Update "${dependency}" and release "${intermediate.packageName}" now so downstream pull requests can include both version bumps?`, - choices: [ - { - message: `Yes, update and release "${intermediate.packageName}"`, - name: 'yes', - }, - { - message: `No, only open a dependency bump pull request for "${intermediate.productRepository.repositoryName}"`, - name: 'no', - }, - ], + try { + const candidates = getDependentCandidates({ + scannedRepositories, + dependency, + dependencyVersion, }) - if (shouldRelease === 'no') { - console.log( - chalk.yellow( - `Skipping release of "${intermediate.packageName}". Downstream pull requests may conflict later when "${intermediate.packageName}" is released and dependents are updated again.`, - ), - ) - continue + for (const scannedRepository of scannedRepositories) { + logSkippedRepository({ + scannedRepository, + dependency, + dependencyVersion, + candidates, + }) } - await withClonedRepository( - intermediate.productRepository.repositoryName, - async (repositoryWorkingDirectory) => { - const bumpedPackageNames = await installDependencyUpdates({ - cwd: repositoryWorkingDirectory, - dependencies: intermediate.dependencies, - releasedPackageVersions, - isUpdatingTypes: isUpdatingTypes === 'yes', - }) - - if (!bumpedPackageNames.length && isUpdatingTypes !== 'yes') { - console.log( - `Skipping release of "${intermediate.packageName}" as there were no dependency updates to apply.`, - ) - return - } - - await commitDependencyUpdates({ - cwd: repositoryWorkingDirectory, - ticket, - bumpedPackageNames, - isUpdatingTypes: isUpdatingTypes === 'yes', - }) - - const repositoryPlugin = await getRepositoryPlugin({ - cwd: repositoryWorkingDirectory, - repositoryType: intermediate.productRepository, - }) - const { nextVersion } = await promptForNextVersion({ - repositoryPlugin, - noPreRelease: false, - }) - const preRelease = getPreRelease(nextVersion) - const releaseName = - intermediate.productRepository.isPublic || preRelease - ? undefined - : await promptForReleaseName() - - await startRepositoryRelease({ - nextVersion, - git: true, - releaseName, - repositoryPlugin, - }) - - await waitForNpmPackageVersion({ - packageName: intermediate.packageName, - version: nextVersion, - }) - - releasedPackageVersions.set(intermediate.packageName, nextVersion) - releasedRepositoryNames.add( - intermediate.productRepository.repositoryName, - ) - releasedPackageSummaries.push( - `${intermediate.packageName}@${nextVersion}`, - ) - }, - ) - } - - for (const candidate of candidates) { - if ( - releasedRepositoryNames.has(candidate.productRepository.repositoryName) - ) { - continue + if (!candidates.length) { + console.log(`No product repositories need "${dependency}" updated.`) + return } - let isUpdating: 'yes' | 'no' - if (force) { - isUpdating = 'yes' - console.log( - `Auto-updating "${dependency}" in "${candidate.productRepository.repositoryName}" (${candidate.currentSourceDependencyVersion} > ${dependencyVersion})`, - ) - } else { - const updateResult = await enquirer.prompt<{ - isUpdating: 'yes' | 'no' + const intermediates = getIntermediateNpmDependents(candidates) + const sortedIntermediates = + topologicallySortByPackageDependencies(intermediates) + + const releasedPackageVersions = new Map([ + [dependency, dependencyVersion], + ]) + const releasedRepositoryNames = new Set() + const releasedPackageSummaries: string[] = [] + const createPullRequestUrls: string[] = [] + + for (const intermediate of sortedIntermediates) { + const downstreamRepositoryNames = getDownstreamRepositoryNames({ + candidates, + intermediate, + }) + + const { shouldRelease } = await enquirer.prompt<{ + shouldRelease: 'yes' | 'no' }>({ type: 'select', - name: 'isUpdating', - message: `Would you like to update dependencies in "${candidate.productRepository.repositoryName}" (${candidate.currentSourceDependencyVersion} > ${dependencyVersion})?`, + name: 'shouldRelease', + message: `"${intermediate.productRepository.repositoryName}" (${intermediate.packageName}) also needs a release because it is depended on by: ${downstreamRepositoryNames.join(', ')}. Update "${dependency}" and release "${intermediate.packageName}" now so downstream pull requests can include both version bumps?`, choices: [ { - message: 'Yes, update dependency!', + message: `Yes, update and release "${intermediate.packageName}"`, name: 'yes', }, { - message: `No! "${candidate.productRepository.repositoryName}" does not need to be updated.`, + message: `No, only open a dependency bump pull request for "${intermediate.productRepository.repositoryName}"`, name: 'no', }, ], }) - isUpdating = updateResult.isUpdating - } - if (isUpdating === 'no') { - continue - } - await withClonedRepository( - candidate.productRepository.repositoryName, - async (repositoryWorkingDirectory) => { - await executeCommand( - 'git', - ['checkout', '-b', ticket], - repositoryWorkingDirectory, + if (shouldRelease === 'no') { + console.log( + chalk.yellow( + `Skipping release of "${intermediate.packageName}". Downstream pull requests may conflict later when "${intermediate.packageName}" is released and dependents are updated again.`, + ), ) + continue + } - const bumpedPackageNames = await installDependencyUpdates({ - cwd: repositoryWorkingDirectory, - dependencies: candidate.dependencies, - releasedPackageVersions, - isUpdatingTypes: isUpdatingTypes === 'yes', - }) + const repositoryWorkingDirectory = getRetainedWorkingDirectory({ + retainedClones, + candidate: intermediate, + }) - if (!bumpedPackageNames.length && isUpdatingTypes !== 'yes') { - console.log( - `Skipping "${candidate.productRepository.repositoryName}" as there were no dependency updates to apply.`, - ) - return - } - - await commitDependencyUpdates({ - cwd: repositoryWorkingDirectory, - ticket, - bumpedPackageNames, - isUpdatingTypes: isUpdatingTypes === 'yes', - }) - await executeCommand( - 'git', - ['push', '-u', 'origin', ticket], - repositoryWorkingDirectory, + const bumpedPackageNames = await installDependencyUpdates({ + cwd: repositoryWorkingDirectory, + dependencies: intermediate.dependencies, + releasedPackageVersions, + isUpdatingTypes: isUpdatingTypes === 'yes', + }) + + if (!bumpedPackageNames.length && isUpdatingTypes !== 'yes') { + console.log( + `Skipping release of "${intermediate.packageName}" as there were no dependency updates to apply.`, ) - createPullRequestUrls.push( - `https://github.com/oneblink/${candidate.productRepository.repositoryName}/pull/new/${ticket}`, + continue + } + + await commitDependencyUpdates({ + cwd: repositoryWorkingDirectory, + ticket, + bumpedPackageNames, + isUpdatingTypes: isUpdatingTypes === 'yes', + }) + + const repositoryPlugin = await getRepositoryPlugin({ + cwd: repositoryWorkingDirectory, + repositoryType: intermediate.productRepository, + }) + const { nextVersion } = await promptForNextVersion({ + repositoryPlugin, + noPreRelease: false, + }) + const preRelease = getPreRelease(nextVersion) + const releaseName = + intermediate.productRepository.isPublic || preRelease + ? undefined + : await promptForReleaseName() + + await startRepositoryRelease({ + nextVersion, + git: true, + releaseName, + repositoryPlugin, + }) + + await waitForNpmPackageVersion({ + packageName: intermediate.packageName, + version: nextVersion, + }) + + releasedPackageVersions.set(intermediate.packageName, nextVersion) + releasedRepositoryNames.add(intermediate.productRepository.repositoryName) + releasedPackageSummaries.push( + `${intermediate.packageName}@${nextVersion}`, + ) + } + + for (const candidate of candidates) { + if ( + releasedRepositoryNames.has(candidate.productRepository.repositoryName) + ) { + continue + } + + let isUpdating: 'yes' | 'no' + if (force) { + isUpdating = 'yes' + console.log( + `Auto-updating "${dependency}" in "${candidate.productRepository.repositoryName}" (${candidate.currentSourceDependencyVersion} > ${dependencyVersion})`, ) - }, - ) - } + } else { + const updateResult = await enquirer.prompt<{ + isUpdating: 'yes' | 'no' + }>({ + type: 'select', + name: 'isUpdating', + message: `Would you like to update dependencies in "${candidate.productRepository.repositoryName}" (${candidate.currentSourceDependencyVersion} > ${dependencyVersion})?`, + choices: [ + { + message: 'Yes, update dependency!', + name: 'yes', + }, + { + message: `No! "${candidate.productRepository.repositoryName}" does not need to be updated.`, + name: 'no', + }, + ], + }) + isUpdating = updateResult.isUpdating + } + if (isUpdating === 'no') { + continue + } - if (releasedPackageSummaries.length) { - console.log( - boxen( - `The following NPM packages were released during this run: + const repositoryWorkingDirectory = getRetainedWorkingDirectory({ + retainedClones, + candidate, + }) + + await executeCommand( + 'git', + ['checkout', '-b', ticket], + repositoryWorkingDirectory, + ) + + const bumpedPackageNames = await installDependencyUpdates({ + cwd: repositoryWorkingDirectory, + dependencies: candidate.dependencies, + releasedPackageVersions, + isUpdatingTypes: isUpdatingTypes === 'yes', + }) + + if (!bumpedPackageNames.length && isUpdatingTypes !== 'yes') { + console.log( + `Skipping "${candidate.productRepository.repositoryName}" as there were no dependency updates to apply.`, + ) + continue + } + + await commitDependencyUpdates({ + cwd: repositoryWorkingDirectory, + ticket, + bumpedPackageNames, + isUpdatingTypes: isUpdatingTypes === 'yes', + }) + await executeCommand( + 'git', + ['push', '-u', 'origin', ticket], + repositoryWorkingDirectory, + ) + createPullRequestUrls.push( + `https://github.com/oneblink/${candidate.productRepository.repositoryName}/pull/new/${ticket}`, + ) + } + + if (releasedPackageSummaries.length) { + console.log( + boxen( + `The following NPM packages were released during this run: ${releasedPackageSummaries.join(` `)}`, - { - padding: 1, - }, - ), - ) - } + { + padding: 1, + }, + ), + ) + } - if (createPullRequestUrls.length) { - console.log( - boxen( - `The following Pull Requests can be created: + if (createPullRequestUrls.length) { + console.log( + boxen( + `The following Pull Requests can be created: ${createPullRequestUrls.join(` `)}`, - { - padding: 1, - }, - ), - ) + { + padding: 1, + }, + ), + ) + } + } finally { + await removeRetainedClones(retainedClones) } } -async function scanProductRepositories(): Promise { +async function scanProductRepositories({ + dependency, + dependencyVersion, +}: { + dependency: string + dependencyVersion: string +}): Promise<{ + scannedRepositories: ScannedProductRepository[] + retainedClones: Map +}> { const scannedRepositories: ScannedProductRepository[] = [] + const retainedClones = new Map() + + for (const productRepository of productRepositories) { + const { + cloneRepository, + repositoryWorkingDirectory, + removeRepositoryWorkingDirectory, + } = await prepareCloneRepository({ + repositoryName: productRepository.repositoryName, + }) - await enumerateProductRepositories( - async ({ productRepository, repositoryWorkingDirectory }) => { - const repositoryPlugin = await getRepositoryPlugin({ - cwd: repositoryWorkingDirectory, - repositoryType: productRepository, + try { + await cloneRepository() + + const scannedRepository = await readScannedProductRepository({ + productRepository, + repositoryWorkingDirectory, }) - if (!repositoryPlugin.supportsDependencyUpdates) { - console.log( - `Skipping "${productRepository.repositoryName}" as "${repositoryPlugin.displayType}" does not support updating dependencies.`, - ) - scannedRepositories.push({ - productRepository, - packageName: undefined, - packageVersion: undefined, - dependencies: {}, - supportsDependencyUpdates: false, + scannedRepositories.push(scannedRepository) + + const [candidate] = getDependentCandidates({ + scannedRepositories: [scannedRepository], + dependency, + dependencyVersion, + }) + + if (candidate) { + retainedClones.set(productRepository.repositoryName, { + repositoryWorkingDirectory, + removeRepositoryWorkingDirectory, }) - return + } else { + await removeRepositoryWorkingDirectory() } + } catch (error) { + await removeRepositoryWorkingDirectory() + throw error + } + } - const result = await readPackageUp({ - cwd: repositoryWorkingDirectory, - }) - scannedRepositories.push({ - productRepository, - packageName: result?.packageJson.name, - packageVersion: result?.packageJson.version, - dependencies: result?.packageJson.dependencies || {}, - supportsDependencyUpdates: true, - }) - }, + return { + scannedRepositories, + retainedClones, + } +} + +async function readScannedProductRepository({ + productRepository, + repositoryWorkingDirectory, +}: { + productRepository: ProductRepository + repositoryWorkingDirectory: string +}): Promise { + const repositoryPlugin = await getRepositoryPlugin({ + cwd: repositoryWorkingDirectory, + repositoryType: productRepository, + }) + if (!repositoryPlugin.supportsDependencyUpdates) { + console.log( + `Skipping "${productRepository.repositoryName}" as "${repositoryPlugin.displayType}" does not support updating dependencies.`, + ) + return { + productRepository, + packageName: undefined, + packageVersion: undefined, + dependencies: {}, + supportsDependencyUpdates: false, + } + } + + const result = await readPackageUp({ + cwd: repositoryWorkingDirectory, + }) + return { + productRepository, + packageName: result?.packageJson.name, + packageVersion: result?.packageJson.version, + dependencies: result?.packageJson.dependencies || {}, + supportsDependencyUpdates: true, + } +} + +function getRetainedWorkingDirectory({ + retainedClones, + candidate, +}: { + retainedClones: Map + candidate: DependentCandidate +}): string { + const retainedClone = retainedClones.get( + candidate.productRepository.repositoryName, ) + if (!retainedClone) { + throw new Error( + `Expected retained clone for "${candidate.productRepository.repositoryName}" but none was found.`, + ) + } + return retainedClone.repositoryWorkingDirectory +} - return scannedRepositories +async function removeRetainedClones( + retainedClones: Map, +) { + for (const retainedClone of retainedClones.values()) { + await retainedClone.removeRepositoryWorkingDirectory() + } + retainedClones.clear() } function logSkippedRepository({ @@ -364,7 +462,7 @@ function logSkippedRepository({ scannedRepository: ScannedProductRepository dependency: string dependencyVersion: string - candidates: ReturnType + candidates: DependentCandidate[] }) { if (!scannedRepository.supportsDependencyUpdates) { return @@ -400,26 +498,6 @@ function logSkippedRepository({ } } -async function withClonedRepository( - repositoryName: string, - fn: (repositoryWorkingDirectory: string) => Promise, -) { - const { - cloneRepository, - repositoryWorkingDirectory, - removeRepositoryWorkingDirectory, - } = await prepareCloneRepository({ - repositoryName, - }) - - try { - await cloneRepository() - await fn(repositoryWorkingDirectory) - } finally { - await removeRepositoryWorkingDirectory() - } -} - async function installDependencyUpdates({ cwd, dependencies, From 62e6f280be38fcbbacf252e30c3c7fa549c22ed4 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 4 Aug 2026 10:19:32 +1000 Subject: [PATCH 3/7] AP-9905 # Added granular force flags --- CHANGELOG.md | 5 + src/bin.ts | 85 ++++++++++---- src/startUpdateDependents.ts | 220 +++++++++++++++++++++++++++-------- 3 files changed, 236 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0eef83..928784b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `update-dependents` discovers intermediate NPM dependents at runtime, prompts to release them, waits for npm publish, and folds those versions into a single downstream pull request - `--update-dependents` flag to `oneblink-release repository` to wait for npm publish after release, then run `update-dependents` +- `--force-update-dependency`, `--force-publish-intermediate-dependency`, and `--ticket` flags for `update-dependents` + +### Changed + +- **[BREAKING]** `--force` for `update-dependents` now skips all prompts and requires `--ticket` (use `--force-update-dependency` for the previous behaviour) ### Fixed diff --git a/src/bin.ts b/src/bin.ts index 79eb307..e96c779 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -35,40 +35,50 @@ ${chalk.bold('Examples')} oneblink-release product --name="Inappropriate Release Name" ${chalk.bold.blue( - 'oneblink-release repository [next-version] [--no-git] [--name] [--no-name] [--cwd path] [--update-dependents] [--force]', + 'oneblink-release repository [next-version] [--no-git] [--name] [--no-name] [--cwd path] [--update-dependents] [--force] [--force-update-dependency] [--force-publish-intermediate-dependency] [--ticket]', )} ${chalk.grey('Release a single repository.')} - next-version ........... The next version, will prompt for this if not supplied, - must be a valid semver number. + next-version ............................. The next version, will prompt for this if not supplied, + must be a valid semver number. - --no-git ............. Skip committing changes and creating an annotated git tag. + --no-git ............................... Skip committing changes and creating an annotated git tag. - --increment .......... Increment the version automatically using "major" | "minor" | "patch". + --increment ............................ Increment the version automatically using "major" | "minor" | "patch". - --name ............... Skip the question to enter a name for the release by passing - a release name as a flag. + --name ................................. Skip the question to enter a name for the release by passing + a release name as a flag. - --no-name ............ Skip the question to enter a name for the release. Use - option when running a release for an open source repository. + --no-name .............................. Skip the question to enter a name for the release. Use + option when running a release for an open source repository. - --cwd ................ Directory of the repository to release relative to the - current working directory, defaults to the current - working directory. + --cwd .................................. Directory of the repository to release relative to the + current working directory, defaults to the current + working directory. - --update-dependents .. After the release is tagged, wait for the NPM package to be - published, then run update-dependents. + --update-dependents .................... After the release is tagged, wait for the NPM package to be + published, then run update-dependents. - --force .............. When used with --update-dependents, update all dependents - without prompting the user. + --force ................................ When used with --update-dependents, skip all update-dependents + prompts. Requires --ticket. + + --force-update-dependency .............. When used with --update-dependents, skip prompts confirming + dependency updates. + + --force-publish-intermediate-dependency When used with --update-dependents, skip prompts confirming + intermediate NPM package releases. + + --ticket ............................... Ticket to associate with update-dependents pull requests + (e.g. ON-4323). Required with --force. ${chalk.bold('Examples')} oneblink-release repository oneblink-release repository --no-name oneblink-release repository --no-name --update-dependents - oneblink-release repository --no-name --update-dependents --force + oneblink-release repository --no-name --update-dependents --force-update-dependency + oneblink-release repository --no-name --update-dependents --force --ticket ON-4323 oneblink-release repository --name="Inappropriate Release Name" oneblink-release repository --increment="major" oneblink-release repository --increment="minor" @@ -78,22 +88,32 @@ ${chalk.bold('Examples')} oneblink-release repository 1.1.1-uat.1 --no-git ${chalk.bold.blue( - 'oneblink-release update-dependents [--cwd path] [--force]', + 'oneblink-release update-dependents [--cwd path] [--force] [--force-update-dependency] [--force-publish-intermediate-dependency] [--ticket]', )} ${chalk.grey('Update all product code bases that depend on an NPM package.')} - --cwd .......... Directory of the repository that is the dependency relative - to the current working directory, defaults to the current - working directory. + --cwd .................................... Directory of the repository that is the dependency relative + to the current working directory, defaults to the current + working directory. + + --force .................................. Skip all prompts. Requires --ticket. - --force ........ Update all dependencies without prompting the user. + --force-update-dependency ................ Skip prompts confirming dependency updates. + + --force-publish-intermediate-dependency .. Skip prompts confirming intermediate NPM package releases. + + --ticket ................................. Ticket to associate with pull requests (e.g. ON-4323). + Required with --force. ${chalk.bold('Examples')} oneblink-release update-dependents oneblink-release update-dependents --cwd ../path/to/code - oneblink-release update-dependents --force + oneblink-release update-dependents --force-update-dependency + oneblink-release update-dependents --force-publish-intermediate-dependency + oneblink-release update-dependents --force-update-dependency --force-publish-intermediate-dependency + oneblink-release update-dependents --force --ticket ON-4323 `, { importMeta: import.meta, @@ -131,6 +151,17 @@ ${chalk.bold('Examples')} type: 'boolean', default: false, }, + forceUpdateDependency: { + type: 'boolean', + default: false, + }, + forcePublishIntermediateDependency: { + type: 'boolean', + default: false, + }, + ticket: { + type: 'string', + }, }, }, ) @@ -174,6 +205,10 @@ async function run(): Promise { await startUpdateDependents({ cwd, force: cli.flags.force, + forceUpdateDependency: cli.flags.forceUpdateDependency, + forcePublishIntermediateDependency: + cli.flags.forcePublishIntermediateDependency, + ticket: cli.flags.ticket, }) break } @@ -253,6 +288,10 @@ async function run(): Promise { await startUpdateDependents({ cwd, force: cli.flags.force, + forceUpdateDependency: cli.flags.forceUpdateDependency, + forcePublishIntermediateDependency: + cli.flags.forcePublishIntermediateDependency, + ticket: cli.flags.ticket, }) } break diff --git a/src/startUpdateDependents.ts b/src/startUpdateDependents.ts index b34e952..e64ca54 100644 --- a/src/startUpdateDependents.ts +++ b/src/startUpdateDependents.ts @@ -15,6 +15,7 @@ import promptForNextVersion from './promptForNextVersion.js' import promptForReleaseName from './promptForReleaseName.js' import startRepositoryRelease from './startRepositoryRelease.js' import getPreRelease from './getPreRelease.js' +import semver from 'semver' import { buildDependencyBumpCommitMessage, DependentCandidate, @@ -30,13 +31,25 @@ type RetainedClone = { removeRepositoryWorkingDirectory: () => Promise } +const TICKET_PATTERN = /^[a-z]{1,3}-\d+$/i + export default async function startUpdateDependents({ cwd, force = false, + forceUpdateDependency = false, + forcePublishIntermediateDependency = false, + ticket: ticketFlag, }: { cwd: string force?: boolean + forceUpdateDependency?: boolean + forcePublishIntermediateDependency?: boolean + ticket?: string }) { + const shouldForceUpdateDependency = force || forceUpdateDependency + const shouldForcePublishIntermediateDependency = + force || forcePublishIntermediateDependency + const dependencyRepositoryPlugin = await getRepositoryPlugin({ cwd, }) @@ -58,38 +71,13 @@ export default async function startUpdateDependents({ const dependencyVersion = dependencyResult.packageJson.version console.log('Beginning to update the dependents of:', dependency) - const { ticket } = await enquirer.prompt<{ - ticket: string - }>({ - type: 'input', - name: 'ticket', - message: `Ticket to associate with pull requests? (e.g. ON-4323, AP-4323, MS-4323)`, - required: true, - validate: (input) => { - if (!/^[a-z]{1,3}-\d+$/i.test(input)) { - return 'Ticket must be 1-3 alpha characters, then a hyphen followed by a number' - } - return true - }, - result: (input) => input.toUpperCase(), + const ticket = await resolveTicket({ + ticketFlag, + force, }) - const { isUpdatingTypes } = await enquirer.prompt<{ - isUpdatingTypes: 'yes' | 'no' - }>({ - type: 'select', - name: 'isUpdatingTypes', - message: `Would you like to update "@oneblink/types" as well?`, - choices: [ - { - message: 'Yes, update @oneblink/types', - name: 'yes', - }, - { - message: 'No! "@oneblink/types" does not need to be updated.', - name: 'no', - }, - ], + const isUpdatingTypes = await resolveIsUpdatingTypes({ + force, }) const { scannedRepositories, retainedClones } = await scanProductRepositories( @@ -137,23 +125,32 @@ export default async function startUpdateDependents({ intermediate, }) - const { shouldRelease } = await enquirer.prompt<{ - shouldRelease: 'yes' | 'no' - }>({ - type: 'select', - name: 'shouldRelease', - message: `"${intermediate.productRepository.repositoryName}" (${intermediate.packageName}) also needs a release because it is depended on by: ${downstreamRepositoryNames.join(', ')}. Update "${dependency}" and release "${intermediate.packageName}" now so downstream pull requests can include both version bumps?`, - choices: [ - { - message: `Yes, update and release "${intermediate.packageName}"`, - name: 'yes', - }, - { - message: `No, only open a dependency bump pull request for "${intermediate.productRepository.repositoryName}"`, - name: 'no', - }, - ], - }) + let shouldRelease: 'yes' | 'no' + if (shouldForcePublishIntermediateDependency) { + shouldRelease = 'yes' + console.log( + `Auto-releasing "${intermediate.packageName}" because it is depended on by: ${downstreamRepositoryNames.join(', ')}`, + ) + } else { + const releaseResult = await enquirer.prompt<{ + shouldRelease: 'yes' | 'no' + }>({ + type: 'select', + name: 'shouldRelease', + message: `"${intermediate.productRepository.repositoryName}" (${intermediate.packageName}) also needs a release because it is depended on by: ${downstreamRepositoryNames.join(', ')}. Update "${dependency}" and release "${intermediate.packageName}" now so downstream pull requests can include both version bumps?`, + choices: [ + { + message: `Yes, update and release "${intermediate.packageName}"`, + name: 'yes', + }, + { + message: `No, only open a dependency bump pull request for "${intermediate.productRepository.repositoryName}"`, + name: 'no', + }, + ], + }) + shouldRelease = releaseResult.shouldRelease + } if (shouldRelease === 'no') { console.log( @@ -194,13 +191,13 @@ export default async function startUpdateDependents({ cwd: repositoryWorkingDirectory, repositoryType: intermediate.productRepository, }) - const { nextVersion } = await promptForNextVersion({ + const nextVersion = await resolveNextVersion({ repositoryPlugin, - noPreRelease: false, + force, }) const preRelease = getPreRelease(nextVersion) const releaseName = - intermediate.productRepository.isPublic || preRelease + force || intermediate.productRepository.isPublic || preRelease ? undefined : await promptForReleaseName() @@ -231,7 +228,7 @@ export default async function startUpdateDependents({ } let isUpdating: 'yes' | 'no' - if (force) { + if (shouldForceUpdateDependency) { isUpdating = 'yes' console.log( `Auto-updating "${dependency}" in "${candidate.productRepository.repositoryName}" (${candidate.currentSourceDependencyVersion} > ${dependencyVersion})`, @@ -333,6 +330,127 @@ export default async function startUpdateDependents({ } } +async function resolveTicket({ + ticketFlag, + force, +}: { + ticketFlag: string | undefined + force: boolean +}): Promise { + if (ticketFlag) { + if (!TICKET_PATTERN.test(ticketFlag)) { + throw new Error( + 'Ticket must be 1-3 alpha characters, then a hyphen followed by a number', + ) + } + return ticketFlag.toUpperCase() + } + + if (force) { + throw new Error( + 'Cannot use "--force" without "--ticket" because all prompts are skipped.', + ) + } + + const { ticket } = await enquirer.prompt<{ + ticket: string + }>({ + type: 'input', + name: 'ticket', + message: `Ticket to associate with pull requests? (e.g. ON-4323, AP-4323, MS-4323)`, + required: true, + validate: (input) => { + if (!TICKET_PATTERN.test(input)) { + return 'Ticket must be 1-3 alpha characters, then a hyphen followed by a number' + } + return true + }, + result: (input) => input.toUpperCase(), + }) + + return ticket +} + +async function resolveIsUpdatingTypes({ + force, +}: { + force: boolean +}): Promise<'yes' | 'no'> { + if (force) { + console.log( + 'Skipping "@oneblink/types" update because "--force" was provided.', + ) + return 'no' + } + + const { isUpdatingTypes } = await enquirer.prompt<{ + isUpdatingTypes: 'yes' | 'no' + }>({ + type: 'select', + name: 'isUpdatingTypes', + message: `Would you like to update "@oneblink/types" as well?`, + choices: [ + { + message: 'Yes, update @oneblink/types', + name: 'yes', + }, + { + message: 'No! "@oneblink/types" does not need to be updated.', + name: 'no', + }, + ], + }) + + return isUpdatingTypes +} + +async function resolveNextVersion({ + repositoryPlugin, + force, +}: { + repositoryPlugin: Awaited> + force: boolean +}): Promise { + if (!force) { + const { nextVersion } = await promptForNextVersion({ + repositoryPlugin, + noPreRelease: false, + }) + return nextVersion + } + + const currentVersion = await repositoryPlugin.getCurrentVersion() + const currentSemverVersion = semver.parse(currentVersion) + if (!currentSemverVersion) { + throw new Error( + `Could not determine current version for ${repositoryPlugin.displayType} repository: ${repositoryPlugin.cwd}`, + ) + } + + const autoIncrementVersion = + await repositoryPlugin.autoIncrementVersion?.(currentSemverVersion) + if (autoIncrementVersion) { + console.log( + `Auto-selecting next version "${autoIncrementVersion}" because "--force" was provided.`, + ) + return autoIncrementVersion + } + + const nextSemverVersion = currentSemverVersion.inc( + currentSemverVersion.prerelease.length ? 'prerelease' : 'patch', + ) + if (!nextSemverVersion) { + throw new Error( + `Could not auto-increment version "${currentVersion}" because "--force" was provided.`, + ) + } + + console.log( + `Auto-selecting next version "${nextSemverVersion.version}" because "--force" was provided.`, + ) + return nextSemverVersion.version +} + async function scanProductRepositories({ dependency, dependencyVersion, From 747d3131e4e1b10a7e5adffe2f1b6a12ee15b2c0 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 4 Aug 2026 10:26:39 +1000 Subject: [PATCH 4/7] AP-9905 # Removed product-cognito-hosted-login-css --- CHANGELOG.md | 4 ++++ src/enumerateProductRepositories.ts | 6 ------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 928784b..5d105ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - github action permissions to allow docs and github release jobs +### Removed + +- `product-cognito-hosted-login-css` repository + ## [4.0.0] - 2026-02-02 ### Removed diff --git a/src/enumerateProductRepositories.ts b/src/enumerateProductRepositories.ts index 8ed6c5f..9a4d35d 100644 --- a/src/enumerateProductRepositories.ts +++ b/src/enumerateProductRepositories.ts @@ -45,12 +45,6 @@ export const productRepositories: readonly ProductRepository[] = [ isPublic: false, type: 'NODE_JS', }, - { - label: 'Cognito Hosted Login CSS', - repositoryName: 'product-cognito-hosted-login-css', - isPublic: false, - type: 'NODE_JS', - }, { label: 'Console', repositoryName: 'product-console', From 09d04aef4931c7193d8d945a5c0f3b1c1a5cd32e Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 4 Aug 2026 10:52:05 +1000 Subject: [PATCH 5/7] AP-9905 # Clean up retained clones if product repo scanning fails --- src/startUpdateDependents.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/startUpdateDependents.ts b/src/startUpdateDependents.ts index e64ca54..4d84674 100644 --- a/src/startUpdateDependents.ts +++ b/src/startUpdateDependents.ts @@ -80,14 +80,15 @@ export default async function startUpdateDependents({ force, }) - const { scannedRepositories, retainedClones } = await scanProductRepositories( - { + const retainedClones = new Map() + + try { + const { scannedRepositories } = await scanProductRepositories({ dependency, dependencyVersion, - }, - ) + retainedClones, + }) - try { const candidates = getDependentCandidates({ scannedRepositories, dependency, @@ -454,15 +455,15 @@ async function resolveNextVersion({ async function scanProductRepositories({ dependency, dependencyVersion, + retainedClones, }: { dependency: string dependencyVersion: string + retainedClones: Map }): Promise<{ scannedRepositories: ScannedProductRepository[] - retainedClones: Map }> { const scannedRepositories: ScannedProductRepository[] = [] - const retainedClones = new Map() for (const productRepository of productRepositories) { const { @@ -504,7 +505,6 @@ async function scanProductRepositories({ return { scannedRepositories, - retainedClones, } } From 8f9837469f9d5e080a47cfb75b20f1a329d506ca Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 4 Aug 2026 11:24:27 +1000 Subject: [PATCH 6/7] AP-9905 # Update dependents of released intermediate packages --- CHANGELOG.md | 1 + src/startUpdateDependents.ts | 143 ++++++++++++---- src/updateDependentsPlanning.ts | 65 ++++++-- test/updateDependentsPlanning.test.ts | 224 +++++++++++++++++++------- 4 files changed, 332 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d105ec..7b34969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **[BREAKING]** `--force` for `update-dependents` now skips all prompts and requires `--ticket` (use `--force-update-dependency` for the previous behaviour) +- `update-dependents` also bumps repositories that depend on newly released intermediate NPM packages even when they do not depend on the original package ### Fixed diff --git a/src/startUpdateDependents.ts b/src/startUpdateDependents.ts index 4d84674..8558a18 100644 --- a/src/startUpdateDependents.ts +++ b/src/startUpdateDependents.ts @@ -23,6 +23,7 @@ import { getDependentCandidates, getDownstreamRepositoryNames, getIntermediateNpmDependents, + getRepositoriesNeedingDependencyUpdates, ScannedProductRepository, } from './updateDependentsPlanning.js' @@ -84,8 +85,6 @@ export default async function startUpdateDependents({ try { const { scannedRepositories } = await scanProductRepositories({ - dependency, - dependencyVersion, retainedClones, }) @@ -109,10 +108,22 @@ export default async function startUpdateDependents({ return } - const intermediates = getIntermediateNpmDependents(candidates) + const intermediates = getIntermediateNpmDependents({ + candidates, + scannedRepositories, + }) const sortedIntermediates = topologicallySortByPackageDependencies(intermediates) + await pruneRetainedClones({ + retainedClones, + repositoryNamesToRetain: getRepositoryNamesToRetain({ + candidates, + intermediates, + scannedRepositories, + }), + }) + const releasedPackageVersions = new Map([ [dependency, dependencyVersion], ]) @@ -122,7 +133,7 @@ export default async function startUpdateDependents({ for (const intermediate of sortedIntermediates) { const downstreamRepositoryNames = getDownstreamRepositoryNames({ - candidates, + scannedRepositories, intermediate, }) @@ -164,7 +175,7 @@ export default async function startUpdateDependents({ const repositoryWorkingDirectory = getRetainedWorkingDirectory({ retainedClones, - candidate: intermediate, + repositoryName: intermediate.productRepository.repositoryName, }) const bumpedPackageNames = await installDependencyUpdates({ @@ -221,18 +232,25 @@ export default async function startUpdateDependents({ ) } - for (const candidate of candidates) { - if ( - releasedRepositoryNames.has(candidate.productRepository.repositoryName) - ) { - continue - } + const updateTargets = getRepositoriesNeedingDependencyUpdates({ + scannedRepositories, + releasedPackageVersions, + excludeRepositoryNames: releasedRepositoryNames, + }) + + for (const candidate of updateTargets) { + const { bumpedPackageNames: packagesToUpdate } = getDependencyInstallSpecs( + { + dependencies: candidate.dependencies, + releasedPackageVersions, + }, + ) let isUpdating: 'yes' | 'no' if (shouldForceUpdateDependency) { isUpdating = 'yes' console.log( - `Auto-updating "${dependency}" in "${candidate.productRepository.repositoryName}" (${candidate.currentSourceDependencyVersion} > ${dependencyVersion})`, + `Auto-updating ${packagesToUpdate.join(', ')} in "${candidate.productRepository.repositoryName}"`, ) } else { const updateResult = await enquirer.prompt<{ @@ -240,7 +258,7 @@ export default async function startUpdateDependents({ }>({ type: 'select', name: 'isUpdating', - message: `Would you like to update dependencies in "${candidate.productRepository.repositoryName}" (${candidate.currentSourceDependencyVersion} > ${dependencyVersion})?`, + message: `Would you like to update ${packagesToUpdate.join(', ')} in "${candidate.productRepository.repositoryName}"?`, choices: [ { message: 'Yes, update dependency!', @@ -258,9 +276,9 @@ export default async function startUpdateDependents({ continue } - const repositoryWorkingDirectory = getRetainedWorkingDirectory({ + const repositoryWorkingDirectory = await ensureRetainedWorkingDirectory({ retainedClones, - candidate, + repositoryName: candidate.productRepository.repositoryName, }) await executeCommand( @@ -453,12 +471,8 @@ async function resolveNextVersion({ } async function scanProductRepositories({ - dependency, - dependencyVersion, retainedClones, }: { - dependency: string - dependencyVersion: string retainedClones: Map }): Promise<{ scannedRepositories: ScannedProductRepository[] @@ -483,13 +497,7 @@ async function scanProductRepositories({ }) scannedRepositories.push(scannedRepository) - const [candidate] = getDependentCandidates({ - scannedRepositories: [scannedRepository], - dependency, - dependencyVersion, - }) - - if (candidate) { + if (scannedRepository.supportsDependencyUpdates) { retainedClones.set(productRepository.repositoryName, { repositoryWorkingDirectory, removeRepositoryWorkingDirectory, @@ -544,24 +552,95 @@ async function readScannedProductRepository({ } } +function getRepositoryNamesToRetain({ + candidates, + intermediates, + scannedRepositories, +}: { + candidates: DependentCandidate[] + intermediates: DependentCandidate[] + scannedRepositories: ScannedProductRepository[] +}): Set { + const intermediatePackageNames = new Set( + intermediates.map((intermediate) => intermediate.packageName), + ) + const repositoryNamesToRetain = new Set( + candidates.map((candidate) => candidate.productRepository.repositoryName), + ) + + for (const scannedRepository of scannedRepositories) { + const dependsOnIntermediate = [...intermediatePackageNames].some( + (packageName) => scannedRepository.dependencies[packageName], + ) + if (dependsOnIntermediate) { + repositoryNamesToRetain.add( + scannedRepository.productRepository.repositoryName, + ) + } + } + + return repositoryNamesToRetain +} + +async function pruneRetainedClones({ + retainedClones, + repositoryNamesToRetain, +}: { + retainedClones: Map + repositoryNamesToRetain: Set +}) { + for (const [repositoryName, retainedClone] of retainedClones) { + if (repositoryNamesToRetain.has(repositoryName)) { + continue + } + await retainedClone.removeRepositoryWorkingDirectory() + retainedClones.delete(repositoryName) + } +} + function getRetainedWorkingDirectory({ retainedClones, - candidate, + repositoryName, }: { retainedClones: Map - candidate: DependentCandidate + repositoryName: string }): string { - const retainedClone = retainedClones.get( - candidate.productRepository.repositoryName, - ) + const retainedClone = retainedClones.get(repositoryName) if (!retainedClone) { throw new Error( - `Expected retained clone for "${candidate.productRepository.repositoryName}" but none was found.`, + `Expected retained clone for "${repositoryName}" but none was found.`, ) } return retainedClone.repositoryWorkingDirectory } +async function ensureRetainedWorkingDirectory({ + retainedClones, + repositoryName, +}: { + retainedClones: Map + repositoryName: string +}): Promise { + const existingClone = retainedClones.get(repositoryName) + if (existingClone) { + return existingClone.repositoryWorkingDirectory + } + + const { + cloneRepository, + repositoryWorkingDirectory, + removeRepositoryWorkingDirectory, + } = await prepareCloneRepository({ + repositoryName, + }) + await cloneRepository() + retainedClones.set(repositoryName, { + repositoryWorkingDirectory, + removeRepositoryWorkingDirectory, + }) + return repositoryWorkingDirectory +} + async function removeRetainedClones( retainedClones: Map, ) { diff --git a/src/updateDependentsPlanning.ts b/src/updateDependentsPlanning.ts index 31ace18..a6a77e8 100644 --- a/src/updateDependentsPlanning.ts +++ b/src/updateDependentsPlanning.ts @@ -56,38 +56,75 @@ export function getDependentCandidates({ return candidates } -export function getIntermediateNpmDependents( - candidates: DependentCandidate[], -): DependentCandidate[] { +export function getIntermediateNpmDependents({ + candidates, + scannedRepositories, +}: { + candidates: DependentCandidate[] + scannedRepositories: ScannedProductRepository[] +}): DependentCandidate[] { return candidates.filter((candidate) => { if (candidate.productRepository.type !== 'NPM') { return false } - return candidates.some( - (otherCandidate) => - otherCandidate.productRepository.repositoryName !== + return scannedRepositories.some( + (scannedRepository) => + scannedRepository.productRepository.repositoryName !== candidate.productRepository.repositoryName && - !!otherCandidate.dependencies[candidate.packageName], + !!scannedRepository.dependencies[candidate.packageName], ) }) } export function getDownstreamRepositoryNames({ - candidates, + scannedRepositories, intermediate, }: { - candidates: DependentCandidate[] + scannedRepositories: ScannedProductRepository[] intermediate: DependentCandidate }): string[] { - return candidates + return scannedRepositories .filter( - (candidate) => - candidate.productRepository.repositoryName !== + (scannedRepository) => + scannedRepository.productRepository.repositoryName !== intermediate.productRepository.repositoryName && - candidate.dependencies[intermediate.packageName], + !!scannedRepository.dependencies[intermediate.packageName], + ) + .map( + (scannedRepository) => + scannedRepository.productRepository.repositoryName, ) - .map((candidate) => candidate.productRepository.repositoryName) +} + +export function getRepositoriesNeedingDependencyUpdates({ + scannedRepositories, + releasedPackageVersions, + excludeRepositoryNames, +}: { + scannedRepositories: ScannedProductRepository[] + releasedPackageVersions: Map + excludeRepositoryNames?: Set +}): DependentCandidate[] { + const candidatesByRepositoryName = new Map() + + for (const [packageName, version] of releasedPackageVersions) { + for (const candidate of getDependentCandidates({ + scannedRepositories, + dependency: packageName, + dependencyVersion: version, + })) { + const repositoryName = candidate.productRepository.repositoryName + if (excludeRepositoryNames?.has(repositoryName)) { + continue + } + if (!candidatesByRepositoryName.has(repositoryName)) { + candidatesByRepositoryName.set(repositoryName, candidate) + } + } + } + + return [...candidatesByRepositoryName.values()] } export function getDependencyInstallSpecs({ diff --git a/test/updateDependentsPlanning.test.ts b/test/updateDependentsPlanning.test.ts index 22b3da1..39992cc 100644 --- a/test/updateDependentsPlanning.test.ts +++ b/test/updateDependentsPlanning.test.ts @@ -6,6 +6,7 @@ import { getDependentCandidates, getDownstreamRepositoryNames, getIntermediateNpmDependents, + getRepositoriesNeedingDependencyUpdates, ScannedProductRepository, } from '../src/updateDependentsPlanning.js' @@ -30,6 +31,13 @@ const formsRenderer = createProductRepository({ isPublic: false, }) +const formsCdn = createProductRepository({ + label: 'Embedded Forms Script', + repositoryName: 'forms-cdn', + type: 'CDN_HOSTING', + isPublic: true, +}) + const nugetSdk = createProductRepository({ label: 'OneBlink.SDK (Nuget package)', repositoryName: 'sdk-dotnet', @@ -112,91 +120,197 @@ describe('getDependentCandidates', () => { }) describe('getIntermediateNpmDependents', () => { - it('identifies NPM packages that other candidates also depend on', () => { + it('identifies NPM packages that other scanned repositories also depend on', () => { + const scannedRepositories = [ + createScannedRepository({ + productRepository: appsReact, + packageName: '@oneblink/apps-react', + packageVersion: '11.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + }, + }), + createScannedRepository({ + productRepository: formsRenderer, + packageName: 'forms-renderer', + packageVersion: '2.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + '@oneblink/apps-react': '^11.0.0', + }, + }), + createScannedRepository({ + productRepository: formsCdn, + packageName: 'forms-cdn', + packageVersion: '1.0.0', + dependencies: { + '@oneblink/apps-react': '^11.0.0', + }, + }), + ] const candidates = getDependentCandidates({ - scannedRepositories: [ - createScannedRepository({ - productRepository: appsReact, - packageName: '@oneblink/apps-react', - packageVersion: '11.0.0', - dependencies: { - '@oneblink/sdk-core': '^9.0.0', - }, - }), - createScannedRepository({ - productRepository: formsRenderer, - packageName: 'forms-renderer', - packageVersion: '2.0.0', - dependencies: { - '@oneblink/sdk-core': '^9.0.0', - '@oneblink/apps-react': '^11.0.0', - }, - }), - ], + scannedRepositories, dependency: '@oneblink/sdk-core', dependencyVersion: '10.1.0', }) - const intermediates = getIntermediateNpmDependents(candidates) + const intermediates = getIntermediateNpmDependents({ + candidates, + scannedRepositories, + }) expect(intermediates.map((candidate) => candidate.packageName)).toEqual([ '@oneblink/apps-react', ]) }) + it('treats an NPM candidate as intermediate when only non-source dependents use it', () => { + const scannedRepositories = [ + createScannedRepository({ + productRepository: appsReact, + packageName: '@oneblink/apps-react', + packageVersion: '11.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + }, + }), + createScannedRepository({ + productRepository: formsCdn, + packageName: 'forms-cdn', + packageVersion: '1.0.0', + dependencies: { + '@oneblink/apps-react': '^11.0.0', + }, + }), + ] + const candidates = getDependentCandidates({ + scannedRepositories, + dependency: '@oneblink/sdk-core', + dependencyVersion: '10.1.0', + }) + + expect( + getIntermediateNpmDependents({ + candidates, + scannedRepositories, + }).map((candidate) => candidate.packageName), + ).toEqual(['@oneblink/apps-react']) + }) + it('does not treat NODE_JS repositories as intermediate NPM packages', () => { + const scannedRepositories = [ + createScannedRepository({ + productRepository: formsRenderer, + packageName: 'forms-renderer', + packageVersion: '2.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + }, + }), + ] const candidates = getDependentCandidates({ - scannedRepositories: [ - createScannedRepository({ - productRepository: formsRenderer, - packageName: 'forms-renderer', - packageVersion: '2.0.0', - dependencies: { - '@oneblink/sdk-core': '^9.0.0', - }, - }), - ], + scannedRepositories, dependency: '@oneblink/sdk-core', dependencyVersion: '10.1.0', }) - expect(getIntermediateNpmDependents(candidates)).toEqual([]) + expect( + getIntermediateNpmDependents({ + candidates, + scannedRepositories, + }), + ).toEqual([]) }) }) describe('getDownstreamRepositoryNames', () => { - it('returns repository names that depend on the intermediate package', () => { + it('includes repositories that depend on the intermediate even without the source package', () => { + const scannedRepositories = [ + createScannedRepository({ + productRepository: appsReact, + packageName: '@oneblink/apps-react', + packageVersion: '11.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + }, + }), + createScannedRepository({ + productRepository: formsRenderer, + packageName: 'forms-renderer', + packageVersion: '2.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + '@oneblink/apps-react': '^11.0.0', + }, + }), + createScannedRepository({ + productRepository: formsCdn, + packageName: 'forms-cdn', + packageVersion: '1.0.0', + dependencies: { + '@oneblink/apps-react': '^11.0.0', + }, + }), + ] const candidates = getDependentCandidates({ - scannedRepositories: [ - createScannedRepository({ - productRepository: appsReact, - packageName: '@oneblink/apps-react', - packageVersion: '11.0.0', - dependencies: { - '@oneblink/sdk-core': '^9.0.0', - }, - }), - createScannedRepository({ - productRepository: formsRenderer, - packageName: 'forms-renderer', - packageVersion: '2.0.0', - dependencies: { - '@oneblink/sdk-core': '^9.0.0', - '@oneblink/apps-react': '^11.0.0', - }, - }), - ], + scannedRepositories, dependency: '@oneblink/sdk-core', dependencyVersion: '10.1.0', }) - const [intermediate] = getIntermediateNpmDependents(candidates) + const [intermediate] = getIntermediateNpmDependents({ + candidates, + scannedRepositories, + }) expect( getDownstreamRepositoryNames({ - candidates, + scannedRepositories, intermediate, }), - ).toEqual(['product-forms-renderer']) + ).toEqual(['product-forms-renderer', 'forms-cdn']) + }) +}) + +describe('getRepositoriesNeedingDependencyUpdates', () => { + it('includes repositories that only depend on a released intermediate package', () => { + const scannedRepositories = [ + createScannedRepository({ + productRepository: appsReact, + packageName: '@oneblink/apps-react', + packageVersion: '11.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + }, + }), + createScannedRepository({ + productRepository: formsRenderer, + packageName: 'forms-renderer', + packageVersion: '2.0.0', + dependencies: { + '@oneblink/sdk-core': '^9.0.0', + '@oneblink/apps-react': '^11.0.0', + }, + }), + createScannedRepository({ + productRepository: formsCdn, + packageName: 'forms-cdn', + packageVersion: '1.0.0', + dependencies: { + '@oneblink/apps-react': '^11.0.0', + }, + }), + ] + + expect( + getRepositoriesNeedingDependencyUpdates({ + scannedRepositories, + releasedPackageVersions: new Map([ + ['@oneblink/sdk-core', '10.1.0'], + ['@oneblink/apps-react', '11.2.0'], + ]), + excludeRepositoryNames: new Set(['apps-react']), + }).map((candidate) => candidate.productRepository.repositoryName), + ).toEqual(['product-forms-renderer', 'forms-cdn']) }) }) From 107e1f11e6838033e8f12675a20d3379b91fcfef Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 4 Aug 2026 11:30:03 +1000 Subject: [PATCH 7/7] AP-9905 # Clarify npm publish timeout recovery steps --- src/waitForNpmPackageVersion.ts | 2 +- test/waitForNpmPackageVersion.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/waitForNpmPackageVersion.ts b/src/waitForNpmPackageVersion.ts index 6929154..e820874 100644 --- a/src/waitForNpmPackageVersion.ts +++ b/src/waitForNpmPackageVersion.ts @@ -44,7 +44,7 @@ export default async function waitForNpmPackageVersion({ } throw new Error( - `Timed out after ${timeoutMs}ms waiting for ${packageName}@${version} to be published to npm`, + `Timed out after ${timeoutMs}ms waiting for ${packageName}@${version} to be published to npm. Investigate why the package was not published (for example CI or npm publish failures), then decide whether it is safe to run the command again from the beginning.`, ) }, ) diff --git a/test/waitForNpmPackageVersion.test.ts b/test/waitForNpmPackageVersion.test.ts index a4c1806..ff79427 100644 --- a/test/waitForNpmPackageVersion.test.ts +++ b/test/waitForNpmPackageVersion.test.ts @@ -98,7 +98,7 @@ describe('waitForNpmPackageVersion', () => { pollIntervalMs: 10, }), ).rejects.toThrow( - 'Timed out after 1000ms waiting for @oneblink/sdk-core@10.1.0 to be published to npm', + 'Timed out after 1000ms waiting for @oneblink/sdk-core@10.1.0 to be published to npm. Investigate why the package was not published (for example CI or npm publish failures), then decide whether it is safe to run the command again from the beginning.', ) nowSpy.mockRestore()