diff --git a/.nvmrc b/.nvmrc index ec09f38d..1d9b7831 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.17.0 \ No newline at end of file +22.12.0 diff --git a/package-lock.json b/package-lock.json index 322b2ef0..8286da02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9027,7 +9027,7 @@ }, "packages/examples/react/basic": { "name": "@connectrpc/connect-query-example-basic", - "version": "0.0.0", + "version": "2.0.0", "dependencies": { "@bufbuild/buf": "1.46.0", "@bufbuild/protobuf": "^2.2.1", diff --git a/packages/examples/react/basic/package.json b/packages/examples/react/basic/package.json index 7c17e7f8..f862414d 100644 --- a/packages/examples/react/basic/package.json +++ b/packages/examples/react/basic/package.json @@ -1,6 +1,6 @@ { "name": "@connectrpc/connect-query-example-basic", - "version": "0.0.0", + "version": "2.0.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/set-workspace-version.js b/scripts/set-workspace-version.js index 5d757b52..deb647b5 100755 --- a/scripts/set-workspace-version.js +++ b/scripts/set-workspace-version.js @@ -14,18 +14,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { readdirSync, readFileSync, writeFileSync } from "fs"; -import { join } from "path"; -import { existsSync } from "node:fs"; -import assert from "node:assert"; +// eslint-disable-next-line n/no-unsupported-features/node-builtins +import { readFileSync, writeFileSync, existsSync, globSync } from "node:fs"; +import { dirname, join } from "node:path"; -if (process.argv.length < 3) { +if (process.argv.length !== 3 || !/^\d+\.\d+\.\d+$/.test(process.argv[2])) { process.stderr.write( [ `USAGE: ${process.argv[1]} `, "", - "Walks through all packages in the given workspace directory and", - "sets the version of each package to the given version.", + "Walks through all workspace packages and sets the version of each ", + "package to the given version.", "If a package depends on another package from the workspace, the", "dependency version is updated as well.", "", @@ -33,143 +32,140 @@ if (process.argv.length < 3) { ); process.exit(1); } +const newVersion = process.argv[2]; +const rootPackagePath = "package.json"; +const lockFilePath = "package-lock.json"; try { - const newVersion = process.argv[2]; - const packagesDir = "packages"; - const lockFile = "package-lock.json"; - const packages = readPackages(packagesDir); - const lock = tryReadLock(lockFile); - const updates = setVersion(packages, lock, newVersion); - if (updates.length > 0) { - writePackages(packagesDir, packages); - if (lock) { - writeLock(lockFile, lock); + const lock = readLockfile(lockFilePath); + const workspaces = readWorkspaces(rootPackagePath); + const allPackages = workspaces.map((ws) => ws.pkg); + const bumpPackages = allPackages.filter( + (pkg) => pkg.version !== undefined && pkg.version !== newVersion, + ); + /** @type {Log[]} */ + const log = []; + for (const pkg of bumpPackages) { + log.push({ + pkg, + message: `updated version from ${pkg.version} to ${newVersion}`, + }); + pkg.version = newVersion; + findLockPackage(lock, pkg.name).version = newVersion; + } + for (const pkg of bumpPackages) { + // update deps in workspace package.json + for (const other of allPackages) { + log.push(...updatePackageDep(other, pkg.name, newVersion)); + } + // update deps in package-lock.json + for (const lockPkg of Object.values(lock.packages)) { + updatePackageDep(lockPkg, pkg.name, newVersion); + } + } + if (log.length > 0) { + for (const { path, pkg } of workspaces) { + writeJson(path, pkg); } - process.stdout.write(formatUpdates(updates) + "\n"); + writeJson(lockFilePath, lock); + process.stdout.write(formatLog(log) + "\n"); } } catch (e) { process.stderr.write(String(e) + "\n"); process.exit(1); } -function setVersion(packages, lock, newVersion) { - const updates = []; - for (const pkg of packages) { - if (typeof pkg.version !== "string") { - continue; - } - assert(pkg.name, "Missing package name"); - if (pkg.version === newVersion) { - continue; - } - pkg.version = newVersion; - if (lock) { - const l = Array.from(Object.values(lock.packages)).find( - (l) => l.name === pkg.name, - ); - if (!pkg.private) { - assert( - l, - `Cannot find lock entry for ${pkg.name} and it is not private`, - ); - } - if (l) { - l.version = newVersion; - } - } - updates.push({ - package: pkg, - message: `updated version from ${pkg.version} to ${newVersion}`, - }); - } - updates.push(...syncDeps(packages, packages)); - if (lock) { - syncDeps(Object.values(lock.packages), packages); - } - return updates; -} +/** + * @typedef {{path: string; pkg: Package}} Workspace + */ -function syncDeps(packages, deps) { - const updates = []; - for (const pkg of packages) { - for (const key of [ - "dependencies", - "devDependencies", - "peerDependencies", - "optionalDependencies", - ]) { - if (!Object.prototype.hasOwnProperty.call(pkg, key)) { - continue; - } - for (const [name, version] of Object.entries(pkg[key])) { - const dep = deps.find((x) => x.name === name); - if (!dep) { - continue; - } - let wantVersion = dep.version; - if (version.startsWith("^")) { - wantVersion = "^" + wantVersion; - } else if (version.startsWith("~")) { - wantVersion = "~" + wantVersion; - } else if (version.startsWith("=")) { - wantVersion = "=" + wantVersion; - } else if (version === "*") { - wantVersion = "*"; - } - if (wantVersion === version) { - continue; - } - pkg[key][name] = wantVersion; - updates.push({ - package: pkg, - message: `updated ${key}["${name}"] from ${version} to ${wantVersion}`, - }); - } - } - } - return updates; +/** + * Read the given root package.json file, and return an array of workspace + * packages. + * + * @param {string} rootPackagePath + * @return {Workspace[]} + */ +function readWorkspaces(rootPackagePath) { + const root = readRootPackage(rootPackagePath); + const rootDir = dirname(rootPackagePath); + return root.workspaces + .flatMap((ws) => globSync(join(rootDir, ws, "package.json"))) + .filter((path) => existsSync(path)) + .map((path) => { + return { path, pkg: readPackage(path) }; + }); } -function readPackages(packagesDir) { - const packagesByPath = readPackagesByPath(packagesDir); - return Object.values(packagesByPath); -} +/** + * @typedef {{message: string, pkg: Package}} Log + */ -function writePackages(packagesDir, packages) { - const packagesByPath = readPackagesByPath(packagesDir); - for (const [path, oldPkg] of Object.entries(packagesByPath)) { - const newPkg = packages.find((p) => p.name === oldPkg.name); - writeFileSync(path, JSON.stringify(newPkg, null, 2) + "\n"); +/** + * @param {Package|LockPackage} pkg + * @param {string} depName + * @param {string} toVersion + * @return {Log[]} + */ +function updatePackageDep(pkg, depName, toVersion) { + if (toVersion === undefined) { + throw new Error("toVersion undefined"); } -} - -function readPackagesByPath(packagesDir) { - const packages = {}; - for (const entry of readdirSync(packagesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { + /** @type {Log[]} */ + const log = []; + for (const key of [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ]) { + // eslint-disable-next-line n/no-unsupported-features/es-builtins,n/no-unsupported-features/es-syntax + if (!Object.hasOwn(pkg, key)) { continue; } - const path = join(packagesDir, entry.name, "package.json"); - if (existsSync(path)) { - const pkg = JSON.parse(readFileSync(path, "utf-8")); - if (!pkg.name) { - throw new Error(`${path} is missing "name"`); - } - packages[path] = pkg; + /** @type { Record } */ + const deps = pkg[key]; + const from = deps[depName]; + if (from === undefined) { + continue; + } + let to; + if (from.startsWith("^")) { + to = `^${toVersion}`; + } else if (from.startsWith("~")) { + to = `~${toVersion}`; + } else if (from.startsWith("=")) { + to = `=${toVersion}`; + } else if (from === "*") { + to = `*`; + } else { + to = toVersion; + } + if (from === to) { + continue; } + deps[depName] = to; + log.push({ + pkg, + message: `updated ${key}["${depName}"] from ${from} to ${to}`, + }); } - return packages; + return log; } -function formatUpdates(updates) { +/** + * + * @param {Log[]} log + * @return {string} + */ +function formatLog(log) { const lines = []; const updatesByName = {}; - for (const update of updates) { - if (updatesByName[update.package.name] === undefined) { - updatesByName[update.package.name] = []; + for (const l of log) { + if (updatesByName[l.pkg.name] === undefined) { + updatesByName[l.pkg.name] = []; } - updatesByName[update.package.name].push(update); + updatesByName[l.pkg.name].push(l); } for (const name of Object.keys(updatesByName).sort()) { lines.push(`${name}:`); @@ -180,17 +176,120 @@ function formatUpdates(updates) { return lines.join("\n"); } -function tryReadLock(lockFile) { - if (!existsSync(lockFile)) { - return null; +/** + * @typedef {{name: string; version?: string; private?: boolean}} Package + */ + +/** + * @param {string} path + * @return {Package} + */ +function readPackage(path) { + const json = JSON.parse(readFileSync(path, "utf-8")); + if (typeof json !== "object" || json === null) { + throw new Error(`Failed to parse ${path}`); + } + const lock = JSON.parse(readFileSync(path, "utf-8")); + if (typeof lock !== "object" || lock === null) { + throw new Error(`Failed to parse ${path}`); + } + if (!("name" in json) || typeof json.name != "string") { + throw new Error(`Missing "name" in ${path}`); + } + if ("version" in json) { + if (typeof json.version != "string") { + throw new Error(`Invalid "version" in ${path}`); + } + } else if (!("private" in json) || json.private !== true) { + throw new Error(`Need either "version" or "private":true in ${path}`); + } + return lock; +} + +/** + * @typedef {{packages: Record}} Lockfile + */ + +/** + * @typedef {{name?: string; version?: string}} LockPackage + */ + +/** + * @param {string} path + * @return {Lockfile} + */ +function readLockfile(path) { + const lock = JSON.parse(readFileSync(path, "utf-8")); + if (typeof lock !== "object" || lock === null) { + throw new Error(`Failed to parse ${path}`); + } + if (!("lockfileVersion" in lock) || lock.lockfileVersion !== 3) { + throw new Error(`Unsupported lock file version in ${path}`); + } + if (typeof lock.packages != "object" || lock.packages == null) { + throw new Error(`Missing "packages" in ${path}`); } - const lock = JSON.parse(readFileSync(lockFile, "utf-8")); - assert(lock.lockfileVersion === 3); - assert(typeof lock.packages == "object"); - assert(lock.packages !== null); return lock; } -function writeLock(lockFile, lock) { - writeFileSync(lockFile, JSON.stringify(lock, null, 2) + "\n"); +/** + * Locates an entry for a local workspace package in a lock file. + * Throws an error if not found. + * + * @param {Lockfile} lock + * @param {string} packageName + * @return {LockPackage} + */ +function findLockPackage(lock, packageName) { + for (const [path, lockPkg] of Object.entries(lock.packages)) { + // eslint-disable-next-line n/no-unsupported-features/es-builtins,n/no-unsupported-features/es-syntax + if (Object.hasOwn(lockPkg, "name") && lockPkg.name === packageName) { + return lockPkg; + } + // In some situations, the entry for a local package doesn't have a "name" property. + // We check the path of the entry instead: If the last path element is the same as + // the package name without scope, it's the entry we are looking for. + if (path.startsWith("node_modules/")) { + // Not a local workspace package + continue; + } + const lastPathEle = path.split("/").pop(); + const packageShortname = packageName.split("/").pop(); + if (lastPathEle === packageShortname) { + return lockPkg; + } + } + throw new Error( + `Cannot find package ${packageName} in lock file. Run npm install?`, + ); +} + +/** + * @typedef {{ name?: string; version?: string; workspaces: string[] }} RootPackage + */ + +/** + * @param {string} path + * @return {RootPackage} + */ +function readRootPackage(path) { + const json = JSON.parse(readFileSync(path, "utf-8")); + if (typeof json !== "object" || json === null) { + throw new Error(`Failed to parse ${path}`); + } + if ( + !Array.isArray(json.workspaces) || + json.workspaces.some((w) => typeof w !== "string") + ) { + throw new Error(`Missing or malformed "workspaces" array in ${path}`); + } + return json; +} + +/** + * @param {string} path + * @param {any} json + */ +function writeJson(path, json) { + writeFileSync(path, JSON.stringify(json, null, 2) + "\n"); }