Skip to content

Commit

Permalink
Support npm workspaces in set-workspace-version.js
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Stamm <[email protected]>
  • Loading branch information
timostamm committed Dec 12, 2024
1 parent c3f8942 commit a3b8b8a
Show file tree
Hide file tree
Showing 4 changed files with 94 additions and 63 deletions.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20.17.0
22.12.0
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/examples/react/basic/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@connectrpc/connect-query-example-basic",
"version": "0.0.0",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
151 changes: 91 additions & 60 deletions scripts/set-workspace-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@
// 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) {
process.stderr.write(
[
`USAGE: ${process.argv[1]} <new-version>`,
"",
"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.",
"",
Expand All @@ -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");
}
Expand All @@ -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<string, {name?: name; version?: string}>}} 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;
}
Expand All @@ -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<string, unknown>} packages
* @param {Package[]} deps
* @return {Update[]}
*/
function syncDeps(packages, deps) {
const updates = [];
for (const pkg of packages) {
Expand Down Expand Up @@ -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}`,
});
}
Expand All @@ -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<unknown, unknown>} 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}:`);
Expand All @@ -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;
Expand All @@ -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");
}

0 comments on commit a3b8b8a

Please sign in to comment.