From a3b8b8ad78b3e266b3cd46e2b508d4c3dc3e344d Mon Sep 17 00:00:00 2001 From: Timo Stamm Date: Thu, 12 Dec 2024 19:33:37 +0100 Subject: [PATCH 1/2] Support npm workspaces in set-workspace-version.js Signed-off-by: Timo Stamm --- .nvmrc | 2 +- package-lock.json | 2 +- packages/examples/react/basic/package.json | 2 +- scripts/set-workspace-version.js | 151 +++++++++++++-------- 4 files changed, 94 insertions(+), 63 deletions(-) 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..692d0c9d 100755 --- a/scripts/set-workspace-version.js +++ b/scripts/set-workspace-version.js @@ -14,9 +14,9 @@ // 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"; +// eslint-disable-next-line n/no-unsupported-features/node-builtins +import { readFileSync, writeFileSync, existsSync, globSync } from "node:fs"; +import { dirname, join } from "node:path"; import assert from "node:assert"; if (process.argv.length < 3) { @@ -24,8 +24,8 @@ if (process.argv.length < 3) { [ `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.", "", @@ -36,15 +36,16 @@ if (process.argv.length < 3) { try { const newVersion = process.argv[2]; - const packagesDir = "packages"; const lockFile = "package-lock.json"; - const packages = readPackages(packagesDir); + const workspaces = findWorkspacePackages("package.json"); const lock = tryReadLock(lockFile); - const updates = setVersion(packages, lock, newVersion); + const updates = setVersion(workspaces, lock, newVersion); if (updates.length > 0) { - writePackages(packagesDir, packages); + for (const { path, pkg } of workspaces) { + writeJson(path, pkg); + } if (lock) { - writeLock(lockFile, lock); + writeJson(lockFile, lock); } process.stdout.write(formatUpdates(updates) + "\n"); } @@ -53,9 +54,22 @@ try { process.exit(1); } -function setVersion(packages, lock, newVersion) { +/** + * @typedef {{path: string; pkg: Package}} Workspace + * @typedef {{name: string; version?: string}} Package + * @typedef {{packages: Record}} Lockfile + * @typedef {{message: string, pkg: Package}} Update + */ + +/** + * @param {Workspace[]} workspaces + * @param {Lockfile | null} lock + * @param {string} newVersion + * @return {Update[]} + */ +function setVersion(workspaces, lock, newVersion) { const updates = []; - for (const pkg of packages) { + for (const { pkg } of workspaces) { if (typeof pkg.version !== "string") { continue; } @@ -65,31 +79,39 @@ function setVersion(packages, lock, newVersion) { } 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`, + const l = Object.entries(lock.packages).find(([path, l]) => { + if ("name" in l) { + return l.name === pkg.name; + } + // 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. + return ( + !path.startsWith("node_modules/") && + path.split("/").pop() === pkg.name.split("/").pop() ); - } - if (l) { - l.version = newVersion; - } + })?.[1]; + assert(l, `Cannot find lock entry for ${pkg.name} and it is not private`); + l.version = newVersion; } updates.push({ - package: pkg, + pkg, message: `updated version from ${pkg.version} to ${newVersion}`, }); } - updates.push(...syncDeps(packages, packages)); + const pkgs = workspaces.map(({ pkg }) => pkg); + updates.push(...syncDeps(pkgs, pkgs)); if (lock) { - syncDeps(Object.values(lock.packages), packages); + syncDeps(Object.values(lock.packages), pkgs); } return updates; } +/** + * @param {Record} packages + * @param {Package[]} deps + * @return {Update[]} + */ function syncDeps(packages, deps) { const updates = []; for (const pkg of packages) { @@ -122,7 +144,7 @@ function syncDeps(packages, deps) { } pkg[key][name] = wantVersion; updates.push({ - package: pkg, + pkg, message: `updated ${key}["${name}"] from ${version} to ${wantVersion}`, }); } @@ -131,45 +153,54 @@ function syncDeps(packages, deps) { return updates; } -function readPackages(packagesDir) { - const packagesByPath = readPackagesByPath(packagesDir); - return Object.values(packagesByPath); -} - -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"); +/** + * Read the given root package.json file, and return an array of workspace + * packages. + * + * @param {string} rootPackageJsonPath + * @return {Workspace[]} + */ +function findWorkspacePackages(rootPackageJsonPath) { + const root = JSON.parse(readFileSync(rootPackageJsonPath, "utf-8")); + if ( + !Array.isArray(root.workspaces) || + root.workspaces.some((w) => typeof w !== "string") + ) { + throw new Error( + `Missing or malformed "workspaces" array in ${rootPackageJsonPath}`, + ); } + const rootDir = dirname(rootPackageJsonPath); + return root.workspaces + .flatMap((ws) => globSync(join(rootDir, ws, "package.json"))) + .filter((path) => existsSync(path)) + .map((path) => { + const pkg = JSON.parse(readFileSync(path, "utf-8")); + return { path, pkg }; + }); } -function readPackagesByPath(packagesDir) { - const packages = {}; - for (const entry of readdirSync(packagesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - 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; - } - } - return packages; +/** + * @param {string} path + * @param {Record} json + */ +function writeJson(path, json) { + writeFileSync(path, JSON.stringify(json, null, 2) + "\n"); } +/** + * + * @param {Update[]} updates + * @return {string} + */ function formatUpdates(updates) { const lines = []; const updatesByName = {}; for (const update of updates) { - if (updatesByName[update.package.name] === undefined) { - updatesByName[update.package.name] = []; + if (updatesByName[update.pkg.name] === undefined) { + updatesByName[update.pkg.name] = []; } - updatesByName[update.package.name].push(update); + updatesByName[update.pkg.name].push(update); } for (const name of Object.keys(updatesByName).sort()) { lines.push(`${name}:`); @@ -180,6 +211,10 @@ function formatUpdates(updates) { return lines.join("\n"); } +/** + * @param {string} lockFile + * @return {Lockfile | null} + */ function tryReadLock(lockFile) { if (!existsSync(lockFile)) { return null; @@ -190,7 +225,3 @@ function tryReadLock(lockFile) { assert(lock.packages !== null); return lock; } - -function writeLock(lockFile, lock) { - writeFileSync(lockFile, JSON.stringify(lock, null, 2) + "\n"); -} From 16f4364b1aa57209cd75e311acacb57bd40bf642 Mon Sep 17 00:00:00 2001 From: Timo Stamm Date: Fri, 13 Dec 2024 13:15:17 +0100 Subject: [PATCH 2/2] Refactor, add checks Signed-off-by: Timo Stamm --- scripts/set-workspace-version.js | 348 ++++++++++++++++++------------- 1 file changed, 208 insertions(+), 140 deletions(-) diff --git a/scripts/set-workspace-version.js b/scripts/set-workspace-version.js index 692d0c9d..deb647b5 100755 --- a/scripts/set-workspace-version.js +++ b/scripts/set-workspace-version.js @@ -17,9 +17,8 @@ // eslint-disable-next-line n/no-unsupported-features/node-builtins import { readFileSync, writeFileSync, existsSync, globSync } from "node:fs"; import { dirname, join } from "node:path"; -import assert from "node:assert"; -if (process.argv.length < 3) { +if (process.argv.length !== 3 || !/^\d+\.\d+\.\d+$/.test(process.argv[2])) { process.stderr.write( [ `USAGE: ${process.argv[1]} `, @@ -33,21 +32,43 @@ 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 lockFile = "package-lock.json"; - const workspaces = findWorkspacePackages("package.json"); - const lock = tryReadLock(lockFile); - const updates = setVersion(workspaces, lock, newVersion); - if (updates.length > 0) { + 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); } - if (lock) { - writeJson(lockFile, lock); - } - process.stdout.write(formatUpdates(updates) + "\n"); + writeJson(lockFilePath, lock); + process.stdout.write(formatLog(log) + "\n"); } } catch (e) { process.stderr.write(String(e) + "\n"); @@ -56,172 +77,219 @@ try { /** * @typedef {{path: string; pkg: Package}} Workspace - * @typedef {{name: string; version?: string}} Package - * @typedef {{packages: Record}} Lockfile - * @typedef {{message: string, pkg: Package}} Update */ /** - * @param {Workspace[]} workspaces - * @param {Lockfile | null} lock - * @param {string} newVersion - * @return {Update[]} + * 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) }; + }); +} + +/** + * @typedef {{message: string, pkg: Package}} Log + */ + +/** + * @param {Package|LockPackage} pkg + * @param {string} depName + * @param {string} toVersion + * @return {Log[]} */ -function setVersion(workspaces, lock, newVersion) { - const updates = []; - for (const { pkg } of workspaces) { - if (typeof pkg.version !== "string") { +function updatePackageDep(pkg, depName, toVersion) { + if (toVersion === undefined) { + throw new Error("toVersion undefined"); + } + /** @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; } - assert(pkg.name, "Missing package name"); - if (pkg.version === newVersion) { + /** @type { Record } */ + const deps = pkg[key]; + const from = deps[depName]; + if (from === undefined) { continue; } - pkg.version = newVersion; - if (lock) { - const l = Object.entries(lock.packages).find(([path, l]) => { - if ("name" in l) { - return l.name === pkg.name; - } - // 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. - return ( - !path.startsWith("node_modules/") && - path.split("/").pop() === pkg.name.split("/").pop() - ); - })?.[1]; - assert(l, `Cannot find lock entry for ${pkg.name} and it is not private`); - l.version = newVersion; + 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; } - updates.push({ + deps[depName] = to; + log.push({ pkg, - message: `updated version from ${pkg.version} to ${newVersion}`, + message: `updated ${key}["${depName}"] from ${from} to ${to}`, }); } - const pkgs = workspaces.map(({ pkg }) => pkg); - updates.push(...syncDeps(pkgs, pkgs)); - if (lock) { - syncDeps(Object.values(lock.packages), pkgs); - } - return updates; + return log; } /** - * @param {Record} packages - * @param {Package[]} deps - * @return {Update[]} + * + * @param {Log[]} log + * @return {string} */ -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({ - pkg, - message: `updated ${key}["${name}"] from ${version} to ${wantVersion}`, - }); - } +function formatLog(log) { + const lines = []; + const updatesByName = {}; + for (const l of log) { + if (updatesByName[l.pkg.name] === undefined) { + updatesByName[l.pkg.name] = []; + } + updatesByName[l.pkg.name].push(l); + } + for (const name of Object.keys(updatesByName).sort()) { + lines.push(`${name}:`); + for (const update of updatesByName[name]) { + lines.push(` ${update.message}`); } } - return updates; + return lines.join("\n"); } /** - * Read the given root package.json file, and return an array of workspace - * packages. - * - * @param {string} rootPackageJsonPath - * @return {Workspace[]} + * @typedef {{name: string; version?: string; private?: boolean}} Package */ -function findWorkspacePackages(rootPackageJsonPath) { - const root = JSON.parse(readFileSync(rootPackageJsonPath, "utf-8")); - if ( - !Array.isArray(root.workspaces) || - root.workspaces.some((w) => typeof w !== "string") - ) { - throw new Error( - `Missing or malformed "workspaces" array in ${rootPackageJsonPath}`, - ); + +/** + * @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 rootDir = dirname(rootPackageJsonPath); - return root.workspaces - .flatMap((ws) => globSync(join(rootDir, ws, "package.json"))) - .filter((path) => existsSync(path)) - .map((path) => { - const pkg = JSON.parse(readFileSync(path, "utf-8")); - return { path, pkg }; - }); + 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 - * @param {Record} json + * @return {Lockfile} */ -function writeJson(path, json) { - writeFileSync(path, JSON.stringify(json, null, 2) + "\n"); +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}`); + } + return lock; } /** + * Locates an entry for a local workspace package in a lock file. + * Throws an error if not found. * - * @param {Update[]} updates - * @return {string} + * @param {Lockfile} lock + * @param {string} packageName + * @return {LockPackage} */ -function formatUpdates(updates) { - const lines = []; - const updatesByName = {}; - for (const update of updates) { - if (updatesByName[update.pkg.name] === undefined) { - updatesByName[update.pkg.name] = []; +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; } - updatesByName[update.pkg.name].push(update); - } - for (const name of Object.keys(updatesByName).sort()) { - lines.push(`${name}:`); - for (const update of updatesByName[name]) { - lines.push(` ${update.message}`); + // 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; } } - return lines.join("\n"); + throw new Error( + `Cannot find package ${packageName} in lock file. Run npm install?`, + ); } /** - * @param {string} lockFile - * @return {Lockfile | null} + * @typedef {{ name?: string; version?: string; workspaces: string[] }} RootPackage */ -function tryReadLock(lockFile) { - if (!existsSync(lockFile)) { - return null; - } - const lock = JSON.parse(readFileSync(lockFile, "utf-8")); - assert(lock.lockfileVersion === 3); - assert(typeof lock.packages == "object"); - assert(lock.packages !== null); - return lock; + +/** + * @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"); }