From f10ff0f591853d24bec0ed99632904874e4cd7d5 Mon Sep 17 00:00:00 2001 From: stephansama Date: Tue, 6 Jan 2026 21:03:51 -0500 Subject: [PATCH 01/32] fix: remove dotenv-cli dependency and related config --- package.json | 4 +--- pnpm-lock.yaml | 26 -------------------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/package.json b/package.json index 57cfbd03..cc36473d 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,7 @@ "pretest:ci": "pnpm run scripts:generate-examples", "test:ci": "vitest --run --coverage --reporter=junit --outputFile=test-report.junit.xml", "test:ui": "vitest --ui", - "version": "changeset version && pnpm install --lockfile-only", - "with-env": "dotenv -e .env --" + "version": "changeset version && pnpm install --lockfile-only" }, "dependencies": { "@changesets/cli": "^2.29.8", @@ -54,7 +53,6 @@ "@typescript-eslint/parser": "^8.48.1", "@vitest/coverage-v8": "catalog:vitest", "@vitest/ui": "catalog:vitest", - "dotenv-cli": "^8.0.0", "eslint": "^9.39.1", "eslint-config-flat-gitignore": "^2.1.0", "eslint-config-prettier": "^10.1.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fd9d95e..739f6d70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,9 +219,6 @@ importers: '@vitest/ui': specifier: catalog:vitest version: 4.0.15(vitest@4.0.15) - dotenv-cli: - specifier: ^8.0.0 - version: 8.0.0 eslint: specifier: ^9.39.1 version: 9.39.1(jiti@2.6.1) @@ -5475,18 +5472,6 @@ packages: resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} engines: {node: '>=18'} - dotenv-cli@8.0.0: - resolution: {integrity: sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==} - hasBin: true - - dotenv-expand@10.0.0: - resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} - engines: {node: '>=12'} - - dotenv@16.5.0: - resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} - engines: {node: '>=12'} - dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} @@ -15207,17 +15192,6 @@ snapshots: dependencies: type-fest: 4.41.0 - dotenv-cli@8.0.0: - dependencies: - cross-spawn: 7.0.6 - dotenv: 16.5.0 - dotenv-expand: 10.0.0 - minimist: 1.2.8 - - dotenv-expand@10.0.0: {} - - dotenv@16.5.0: {} - dotenv@17.2.3: {} dset@3.1.4: {} From 8998467cb39108b4370f04084159042dd66589ab Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 8 Jan 2026 00:02:40 -0500 Subject: [PATCH 02/32] feat(multipublish): add new package for publishing to multiple providers This new package provides a CLI tool to facilitate publishing JavaScript packages to multiple package registries or providers. It includes: - core logic for multi-provider publishing - a build script for bundling and schema generation - CLI entry point - configuration schema (using zod) - initial documentation --- .config/.cspell.json | 1 + .gitignore | 2 + README.md | 1 + core/multipublish/README.md | 25 ++++++++ core/multipublish/build.mjs | 32 ++++++++++ core/multipublish/cli.mjs | 5 ++ core/multipublish/package.json | 66 ++++++++++++++++++++ core/multipublish/src/args.ts | 31 ++++++++++ core/multipublish/src/config.ts | 30 ++++++++++ core/multipublish/src/detect.ts | 17 ++++++ core/multipublish/src/index.ts | 42 +++++++++++++ core/multipublish/src/jsr.ts | 48 +++++++++++++++ core/multipublish/src/publish.ts | 70 ++++++++++++++++++++++ core/multipublish/src/schema.ts | 28 +++++++++ core/multipublish/src/types.ts | 17 ++++++ core/multipublish/src/util.ts | 63 +++++++++++++++++++ core/multipublish/test.js | 1 + core/multipublish/tsconfig.json | 4 ++ core/multipublish/typedoc.json | 12 ++++ package.json | 2 +- pnpm-lock.yaml | 100 ++++++++++++++++++++++++------- pnpm-workspace.yaml | 3 + 22 files changed, 577 insertions(+), 23 deletions(-) create mode 100644 core/multipublish/README.md create mode 100644 core/multipublish/build.mjs create mode 100644 core/multipublish/cli.mjs create mode 100644 core/multipublish/package.json create mode 100644 core/multipublish/src/args.ts create mode 100644 core/multipublish/src/config.ts create mode 100644 core/multipublish/src/detect.ts create mode 100644 core/multipublish/src/index.ts create mode 100644 core/multipublish/src/jsr.ts create mode 100644 core/multipublish/src/publish.ts create mode 100644 core/multipublish/src/schema.ts create mode 100644 core/multipublish/src/types.ts create mode 100644 core/multipublish/src/util.ts create mode 100644 core/multipublish/test.js create mode 100644 core/multipublish/tsconfig.json create mode 100644 core/multipublish/typedoc.json diff --git a/.config/.cspell.json b/.config/.cspell.json index 99bd18af..cda58a5f 100644 --- a/.config/.cspell.json +++ b/.config/.cspell.json @@ -25,6 +25,7 @@ "kaomojis", "macchiato", "manypkg", + "multipublish", "nodemon", "nvim", "nvmrc", diff --git a/.gitignore b/.gitignore index c8539813..9c74c486 100644 --- a/.gitignore +++ b/.gitignore @@ -10,12 +10,14 @@ .env** .next .react-router +.publish .svelte-kit .turbo .wrangler core/ai-commit-msg/config core/auto-readme/config core/example/scripts/examples.json +core/multipublish/config dist dist-js examples/catppuccin-xsl/vanilla/public/**/* diff --git a/README.md b/README.md index 2239ed71..782e477f 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ All packages are packaged underneath the `@stephansama` scope (for example: `@st | [create-stephansama-example](core/example/README.md) | ![npm version image](https://img.shields.io/npm/v/create-stephansama-example?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/create-stephansama-example?labelColor=211F1F) | Download an example from the @stephansama/packages examples | | [find-makefile-targets](core/find-makefile-targets/README.md) | ![npm version image](https://img.shields.io/npm/v/%40stephansama%2Ffind-makefile-targets?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/@stephansama/find-makefile-targets?labelColor=211F1F) | Find makefile targets used to pipe into fzf | | [github-env](core/github-env/README.md) | ![npm version image](https://img.shields.io/npm/v/%40stephansama%2Fgithub-env?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/@stephansama/github-env?labelColor=211F1F) | Additional environment variable types for GitHub CI | +| [multipublish](core/multipublish/README.md) | ![npm version image](https://img.shields.io/npm/v/%40stephansama%2Fmultipublish?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/@stephansama/multipublish?labelColor=211F1F) | Publish packages to multiple providers easily | | [prettier-plugin-handlebars](core/prettier-plugin-handlebars/README.md) | ![npm version image](https://img.shields.io/npm/v/%40stephansama%2Fprettier-plugin-handlebars?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/@stephansama/prettier-plugin-handlebars?labelColor=211F1F) | Prettier plugin that automatically assigns the default parser for various handlebars files | | [remark-asciinema](core/remark-asciinema/README.md) | ![npm version image](https://img.shields.io/npm/v/%40stephansama%2Fremark-asciinema?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/@stephansama/remark-asciinema?labelColor=211F1F) | A remark plugin that transforms Asciinema links into embedded players or screenshots. | | [svelte-social-share-links](core/svelte-social-share-links/README.md) | ![npm version image](https://img.shields.io/npm/v/%40stephansama%2Fsvelte-social-share-links?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/@stephansama/svelte-social-share-links?labelColor=211F1F) | Svelte/Web component to share the current url with various social media providers | diff --git a/core/multipublish/README.md b/core/multipublish/README.md new file mode 100644 index 00000000..247b9329 --- /dev/null +++ b/core/multipublish/README.md @@ -0,0 +1,25 @@ +# @stephansama/multipublish + +[![Source code](https://img.shields.io/badge/Source-666666?style=flat&logo=github&label=Github&labelColor=211F1F)](https://github.com/stephansama/packages/tree/main/core/multipublish) +[![Documentation](https://img.shields.io/badge/Documentation-211F1F?style=flat&logo=Wikibooks&labelColor=211F1F)](https://packages.stephansama.info/api/@stephansama/multipublish) +[![NPM Version](https://img.shields.io/npm/v/%40stephansama%2Fmultipublish?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F)](https://www.npmjs.com/package/@stephansama/multipublish) +[![npm downloads](https://img.shields.io/npm/dw/@stephansama/multipublish?labelColor=211F1F)](https://www.npmjs.com/package/@stephansama/multipublish) + +Publish packages to multiple providers easily + +##### Table of contents + +
Open Table of contents + +- [Installation](#installation) +- [Usage](#usage) + +
+ +## Installation + +```sh +pnpm install @stephansama/multipublish +``` + +## Usage diff --git a/core/multipublish/build.mjs b/core/multipublish/build.mjs new file mode 100644 index 00000000..2a0fa7bc --- /dev/null +++ b/core/multipublish/build.mjs @@ -0,0 +1,32 @@ +import * as fsp from "node:fs/promises"; +import * as path from "path"; +import { build as tsdown } from "tsdown"; +import * as z from "zod"; + +const outDir = path.resolve("./dist"); +const schemaDir = path.resolve("./config"); + +await build({ attw: false, entry: ["./src/index.ts"] }); + +await build({ dts: true, entry: ["./src/schema.ts"], outDir: schemaDir }); + +const { configSchema } = await import("./config/schema.js"); + +const jsonSchema = z.toJSONSchema(configSchema); + +const jsonString = JSON.stringify(jsonSchema); + +await fsp.writeFile(path.join(schemaDir, "schema.json"), jsonString); + +/** @param {import('tsdown').Options} opts */ +function build(opts) { + return tsdown({ + attw: { excludeEntrypoints: ["schema.json"] }, + exports: true, + format: ["esm", "cjs"], + outDir, + skipNodeModulesBundle: true, + target: "esnext", + ...opts, + }); +} diff --git a/core/multipublish/cli.mjs b/core/multipublish/cli.mjs new file mode 100644 index 00000000..f20d879d --- /dev/null +++ b/core/multipublish/cli.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +"use strict"; + +import("./dist/index.js").then((mod) => mod.run()); diff --git a/core/multipublish/package.json b/core/multipublish/package.json new file mode 100644 index 00000000..f9bdc609 --- /dev/null +++ b/core/multipublish/package.json @@ -0,0 +1,66 @@ +{ + "name": "@stephansama/multipublish", + "version": "0.0.0", + "description": "Publish packages to multiple providers easily", + "keywords": [ + "multipublish" + ], + "homepage": "https://packages.stephansama.info/api/@stephansama/multipublish", + "repository": { + "type": "git", + "url": "git+https://github.com/stephansama/packages.git", + "directory": "core/multipublish" + }, + "license": "MIT", + "author": { + "name": "Stephan Randle", + "email": "stephanrandle.dev@gmail.com", + "url": "https://stephansama.info" + }, + "type": "module", + "exports": { + ".": { + "import": "./config/schema.js", + "require": "./config/schema.cjs" + }, + "./package.json": "./package.json" + }, + "main": "./config/schema.cjs", + "module": "./config/schema.js", + "types": "./config/schema.d.cts", + "bin": "./cli.mjs", + "files": [ + "./dist", + "./config", + "./cli.mjs" + ], + "scripts": { + "build": "node build.mjs", + "detect": "node ./test.js", + "dev": "node --watch build.mjs", + "lint": "eslint ./ --pass-on-no-patterns --no-error-on-unmatched-pattern" + }, + "dependencies": { + "@manypkg/find-root": "catalog:", + "@manypkg/get-packages": "catalog:", + "cosmiconfig": "catalog:cli", + "obug": "catalog:cli", + "package-manager-detector": "^1.6.0", + "yaml": "^2.8.2", + "yargs": "catalog:cli" + }, + "devDependencies": { + "@types/yargs": "catalog:", + "jsr": "catalog:", + "tsdown": "catalog:", + "zod": "catalog:schema" + }, + "peerDependencies": { + "jsr": ">=0" + }, + "packageManager": "pnpm@10.11.0", + "publishConfig": { + "access": "public", + "registry": "http://localhost:487" + } +} diff --git a/core/multipublish/src/args.ts b/core/multipublish/src/args.ts new file mode 100644 index 00000000..4c73e87c --- /dev/null +++ b/core/multipublish/src/args.ts @@ -0,0 +1,31 @@ +import { enable } from "obug"; +import yargs, { type Options } from "yargs"; +import { hideBin } from "yargs/helpers"; + +import { MODULE_NAME } from "./util"; + +const args = { + config: { alias: "c", description: "Path to config file", type: "string" }, + output: { alias: "s", description: "use changesets", type: "boolean" }, + verbose: { + alias: "v", + description: "Enable verbose logging", + type: "boolean", + }, +} satisfies Record; + +export async function parseArgs() { + const yargsInstance = yargs(hideBin(process.argv)) + .options(args) + .help("h") + .alias("h", "help") + .epilogue(`--> @stephansama open-source ${new Date().getFullYear()}`); + + const parsed = await yargsInstance + .wrap(yargsInstance.terminalWidth()) + .parse(); + + if (parsed.verbose) enable(`${MODULE_NAME}*`); + + return parsed; +} diff --git a/core/multipublish/src/config.ts b/core/multipublish/src/config.ts new file mode 100644 index 00000000..31ba9664 --- /dev/null +++ b/core/multipublish/src/config.ts @@ -0,0 +1,30 @@ +import { cosmiconfig, getDefaultSearchPlaces, type Options } from "cosmiconfig"; + +import { type Config, configSchema } from "./schema"; +import { MODULE_NAME } from "./util"; + +const searchPlaces = getSearchPlaces(); + +const defaultConfig = { + platforms: [["jsr", { experimentalGenerateJSR: true }]], +} satisfies Config; + +export async function loadConfig() { + const opts: Partial = { searchPlaces }; + + const explorer = cosmiconfig(MODULE_NAME, opts); + + const result = await explorer.search(); + + return configSchema.parse(result?.config || defaultConfig); +} + +function getSearchPlaces() { + return [ + ...getDefaultSearchPlaces(MODULE_NAME), + `.config/.${MODULE_NAME}rc.json`, + `.config/.${MODULE_NAME}rc.yaml`, + `.config/.${MODULE_NAME}rc.yml`, + `.config/.${MODULE_NAME}rc`, + ]; +} diff --git a/core/multipublish/src/detect.ts b/core/multipublish/src/detect.ts new file mode 100644 index 00000000..09298d61 --- /dev/null +++ b/core/multipublish/src/detect.ts @@ -0,0 +1,17 @@ +import { detect } from "package-manager-detector/detect"; + +export type AgentName = NonNullable>>["name"]; + +let _detected: AgentName | null = null; + +export async function detectPackageManager() { + if (_detected) return _detected; + + const detected = await detect(); + if (!detected) throw new Error("unable to detect package manager"); + + _detected = detected.name; + if (_detected === "bun") throw new Error("bun is not supported"); + + return _detected; +} diff --git a/core/multipublish/src/index.ts b/core/multipublish/src/index.ts new file mode 100644 index 00000000..9fd5f129 --- /dev/null +++ b/core/multipublish/src/index.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +import { findRoot } from "@manypkg/find-root"; +import { getPackages } from "@manypkg/get-packages"; + +import { parseArgs } from "./args"; +import { loadConfig } from "./config"; +import { publishPlatform } from "./publish"; +import * as util from "./util"; + +export async function run() { + const args = await parseArgs(); + const config = await loadConfig(); + + const root = await findRoot(process.cwd()); + const { packages } = await getPackages(root.rootDir); + const { releases } = await util.getChangesetReleases(); + + const releasedPackages = releases.map((release) => { + const packageJson = packages.find( + (pkg) => pkg.packageJson.name === release.name, + ); + + if (!packageJson) { + throw new Error( + `unable to find package for released package ${release.name}`, + ); + } + + return { + newVersion: release.newVersion, + oldVersion: release.oldVersion, + ...packageJson, + }; + }); + + for (const pkg of releasedPackages) { + for (const platform of config.platforms) { + await publishPlatform(pkg, platform); + } + } +} diff --git a/core/multipublish/src/jsr.ts b/core/multipublish/src/jsr.ts new file mode 100644 index 00000000..63a3ea7e --- /dev/null +++ b/core/multipublish/src/jsr.ts @@ -0,0 +1,48 @@ +import * as z from "zod"; + +export const exportSchema = z.string().or( + z.record( + z.string(), + z.string().or( + z.object({ + import: z.object({ default: z.string() }), + require: z.object({ default: z.string() }), + }), + ), + ), +); + +export const packageJsonSchema = z.object({ + exports: exportSchema, + license: z.string().optional(), + name: z.string().min(1), + version: z.string(), +}); + +export const jsrTransformer = packageJsonSchema.transform((schema) => ({ + exports: convertPkgJsonExportsToJsr(schema.exports), + license: schema.license, + name: schema.name, + version: schema.version, +})); + +export type JsrSchema = z.infer; +export const jsrSchema = z.object({ + exports: z + .string() + .or(z.array(z.string())) + .or(z.record(z.string(), z.string())), + license: z.string().optional(), + name: z.string().min(1), + version: z.string(), +}); + +function convertPkgJsonExportsToJsr(exports: z.infer) { + if (typeof exports === "string") return exports; + return Object.fromEntries( + Object.entries(exports).map(([key, value]) => [ + key, + typeof value === "string" ? value : value.import.default, + ]), + ); +} diff --git a/core/multipublish/src/publish.ts b/core/multipublish/src/publish.ts new file mode 100644 index 00000000..f8d3f885 --- /dev/null +++ b/core/multipublish/src/publish.ts @@ -0,0 +1,70 @@ +import type { Package } from "@manypkg/get-packages"; + +import * as cp from "node:child_process"; +import * as path from "node:path"; + +import type { Config } from "./schema"; + +import { type AgentName, detectPackageManager } from "./detect"; +import { jsrPlatformOptionsSchema, npmPlatformOptionsSchema } from "./schema"; +import * as util from "./util"; + +export const jsrPublishCommand = { + bun: "", + deno: "deno publish", + npm: "npx jsr publish", + pnpm: "pnpm dlx jsr publish", + yarn: "yarn dlx jsr publish", +} satisfies Partial>; + +export async function publishPlatform( + pkg: Package & { newVersion: string; oldVersion: string }, + platform: Config["platforms"][number], +) { + const isString = typeof platform === "string"; + const key = isString ? platform : platform[0]; + const rawConfig = isString ? {} : platform[1]; + + switch (key) { + case "jsr": { + const config = jsrPlatformOptionsSchema.parse(rawConfig); + const pkgRoot = path.dirname(pkg.dir); + const jsr = await util.loadJsrConfigFile(pkgRoot); + + if (config.experimentalGenerateJSR) { + jsr.config = await util.transformPkgJsonForJsr(pkg); + jsr.filename = path.join(pkgRoot, util.JSR_CONFIG_FILENAME); + } + + if (!jsr.config) throw new Error("failed to load jsr config file"); + if (!jsr.filename) { + throw new Error("failed to load jsr config filename"); + } + + // TODO: update jsr.json file + // TODO: update package json with actual catalog: versions + + await util.chdir(pkgRoot, async () => { + const packageManager = await detectPackageManager(); + const command = jsrPublishCommand[packageManager]; + cp.execSync(command + " --allow-dirty --dry-run", { + stdio: "inherit", + }); + }); + + util.gitClean(jsr.filename); + break; + } + case "npm": { + const config = npmPlatformOptionsSchema.parse(rawConfig); + // TODO: create root .npmrc + // TODO: update package.json with registry if not npm + // TODO: update package.json version with next version + // TODO: publish npm package + // TODO: delete changed files + break; + } + default: + throw new Error(`no implementation found for ${key}`); + } +} diff --git a/core/multipublish/src/schema.ts b/core/multipublish/src/schema.ts new file mode 100644 index 00000000..747bc905 --- /dev/null +++ b/core/multipublish/src/schema.ts @@ -0,0 +1,28 @@ +import * as z from "zod"; + +export type Config = z.input; + +export const jsrPlatformOptionsSchema = z.object({ + defaultExclude: z.array(z.string()).optional(), + defaultInclude: z.array(z.string()).optional(), + experimentalGenerateJSR: z.boolean().default(false), + experimentalUpdateBunCatalogs: z.boolean().default(false), + experimentalUpdatePnpmCatalogs: z.boolean().default(false), +}); + +export const npmPlatformOptionsSchema = z.object({ + registry: z.string().default("https://registry.npmjs.org/"), + tokenEnvironmentKey: z.string().default("NODE_AUTH_TOKEN"), +}); + +export const configSchema = z.object({ + platforms: z.array( + z + .literal("jsr") + .or(z.literal("npm")) + .or(z.tuple([z.literal("jsr"), jsrPlatformOptionsSchema])) + .or(z.tuple([z.literal("npm"), npmPlatformOptionsSchema])), + ), + tmpDirectory: z.string().default(".release"), + useChangesets: z.boolean().default(true), +}); diff --git a/core/multipublish/src/types.ts b/core/multipublish/src/types.ts new file mode 100644 index 00000000..67571cde --- /dev/null +++ b/core/multipublish/src/types.ts @@ -0,0 +1,17 @@ +export type Changeset = { changesets: ChangesetElement[]; releases: Release[] }; + +export type ChangesetElement = { + id: string; + releases: ChangesetsRelease[]; + summary: string; +}; + +export type ChangesetsRelease = { name: string; type: string }; + +export type Release = { + changesets: string[]; + name: string; + newVersion: string; + oldVersion: string; + type: string; +}; diff --git a/core/multipublish/src/util.ts b/core/multipublish/src/util.ts new file mode 100644 index 00000000..6ba938be --- /dev/null +++ b/core/multipublish/src/util.ts @@ -0,0 +1,63 @@ +import type { Package } from "@manypkg/get-packages"; + +import * as cp from "node:child_process"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; + +import type { Changeset } from "./types"; + +import { type JsrSchema, jsrTransformer } from "./jsr"; + +export const MODULE_NAME = "multipublish" as const; +export const JSR_CONFIG_FILENAME = "jsr.json" as const; + +export async function chdir(newDir: string, callback: () => Promise) { + const cwd = process.cwd(); + process.chdir(newDir); + await callback(); + process.chdir(cwd); +} + +export async function getChangesetReleases(): Promise { + const tmpFile = await getTmpFile("changeset.json"); + cp.execSync(`changeset status --output=${tmpFile}`, { stdio: "inherit" }); + const file = await fsp.readFile(tmpFile, { encoding: "utf8" }); + await fsp.rm(tmpFile, { recursive: true }); + return JSON.parse(file); +} + +export async function getTmpFile(filename: string) { + const tmpDir = ".publish"; + await fsp.mkdir(tmpDir, { recursive: true }); + return path.join(tmpDir, filename); +} + +export function gitClean(filename: string) { + cp.execSync(`git clean -f ${filename}`, { stdio: "inherit" }); +} + +export async function loadJsrConfigFile( + basePath: string, +): Promise<{ config: JsrSchema | null; filename?: string }> { + const files = fs.globSync(basePath + "/{deno,jsr}.json{,c}"); + if (files.length > 1) { + throw new Error("please only have one deno or jsr configuration file"); + } + + const configFile = files.at(0); + if (!configFile) { + console.info("no jsr config file found"); + return { config: null, filename: configFile }; + } + + const file = await fsp.readFile(configFile, { encoding: "utf8" }); + return { config: JSON.parse(file), filename: configFile }; +} + +export async function transformPkgJsonForJsr(pkg: Package): Promise { + const parsed = jsrTransformer.parse(pkg.packageJson); + const filename = path.join(pkg.dir, JSR_CONFIG_FILENAME); + await fsp.writeFile(filename, JSON.stringify(parsed)); + return parsed; +} diff --git a/core/multipublish/test.js b/core/multipublish/test.js new file mode 100644 index 00000000..b7bd4c88 --- /dev/null +++ b/core/multipublish/test.js @@ -0,0 +1 @@ +console.log(); diff --git a/core/multipublish/tsconfig.json b/core/multipublish/tsconfig.json new file mode 100644 index 00000000..bd3334a8 --- /dev/null +++ b/core/multipublish/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../tsconfig.base.json"], + "include": ["./src/**/*"] +} diff --git a/core/multipublish/typedoc.json b/core/multipublish/typedoc.json new file mode 100644 index 00000000..57eeff2c --- /dev/null +++ b/core/multipublish/typedoc.json @@ -0,0 +1,12 @@ +{ + "entryPoints": ["src/*"], + "tsconfig": "./tsconfig.json", + "exclude": [ + "**/tests/**", + "**/*.test.ts", + "**/*.spec.ts", + "node_modules", + "**/{node_modules,test,book,doc,dist}/**/*", + "**/{pages,components}/**" + ] +} diff --git a/package.json b/package.json index cc36473d..2e45fab0 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@commitlint/types": "^19.8.1", "@eslint/js": "^9.39.1", "@eslint/json": "^0.14.0", - "@manypkg/get-packages": "^3.1.0", + "@manypkg/get-packages": "catalog:", "@stephansama/ai-commit-msg": "workspace:*", "@stephansama/auto-readme": "workspace:*", "@stephansama/prettier-plugin-handlebars": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 739f6d70..bdeb3416 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,12 @@ catalogs: '@iconify/utils': specifier: ^2.3.0 version: 2.3.0 + '@manypkg/find-root': + specifier: ^3.1.0 + version: 3.1.0 + '@manypkg/get-packages': + specifier: ^3.1.0 + version: 3.1.0 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -64,6 +70,9 @@ catalogs: happy-dom: specifier: ^20.0.11 version: 20.0.11 + jsr: + specifier: ^0.13.5 + version: 0.13.5 mdast: specifier: ^3.0.0 version: 3.0.0 @@ -190,7 +199,7 @@ importers: specifier: ^0.14.0 version: 0.14.0 '@manypkg/get-packages': - specifier: ^3.1.0 + specifier: 'catalog:' version: 3.1.0 '@stephansama/ai-commit-msg': specifier: workspace:* @@ -569,6 +578,43 @@ importers: core/github-env: {} + core/multipublish: + dependencies: + '@manypkg/find-root': + specifier: 'catalog:' + version: 3.1.0 + '@manypkg/get-packages': + specifier: 'catalog:' + version: 3.1.0 + cosmiconfig: + specifier: catalog:cli + version: 9.0.0(typescript@5.9.3) + obug: + specifier: catalog:cli + version: 2.1.1 + package-manager-detector: + specifier: ^1.6.0 + version: 1.6.0 + yaml: + specifier: ^2.8.2 + version: 2.8.2 + yargs: + specifier: catalog:cli + version: 18.0.0 + devDependencies: + '@types/yargs': + specifier: 'catalog:' + version: 17.0.35 + jsr: + specifier: 'catalog:' + version: 0.13.5 + tsdown: + specifier: 'catalog:' + version: 0.15.12(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.14.2)(publint@0.3.15)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)) + zod: + specifier: catalog:schema + version: 4.2.1 + core/prettier-plugin-handlebars: devDependencies: prettier: @@ -6853,6 +6899,10 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + jsr@0.13.5: + resolution: {integrity: sha512-qQP20ZcG28pYes7bCq3uuvixl1TL1EpJzwLPfoQadSyWk9j2AID66qhW8+aXpRDRFDvDkXFnONsSRhpnnQAupg==} + hasBin: true + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -7574,6 +7624,10 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-stream-zip@1.15.0: + resolution: {integrity: sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==} + engines: {node: '>=0.12.0'} + normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -7816,12 +7870,12 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} - package-manager-detector@1.3.0: - resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==} - package-manager-detector@1.5.0: resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} @@ -8606,6 +8660,10 @@ packages: search-insights@2.17.3: resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + semiver@1.1.0: + resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==} + engines: {node: '>=6'} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -10070,6 +10128,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} @@ -10242,11 +10301,6 @@ packages: engines: {node: '>= 14'} hasBin: true - yaml@2.8.1: - resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} - engines: {node: '>= 14.6'} - hasBin: true - yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} @@ -10339,9 +10393,6 @@ packages: zod@4.0.15: resolution: {integrity: sha512-2IVHb9h4Mt6+UXkyMs0XbfICUh1eUrlJJAOupBHUhLRnKkruawyDddYRCs0Eizt900ntIMk9/4RksYl+FgSpcQ==} - zod@4.1.12: - resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} - zod@4.2.1: resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} @@ -16976,6 +17027,11 @@ snapshots: jsonparse@1.3.1: {} + jsr@0.13.5: + dependencies: + node-stream-zip: 1.15.0 + semiver: 1.1.0 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -17006,7 +17062,7 @@ snapshots: smol-toml: 1.5.2 strip-json-comments: 5.0.3 typescript: 5.9.3 - zod: 4.1.12 + zod: 4.2.1 kolorist@1.8.0: {} @@ -17097,7 +17153,7 @@ snapshots: nano-spawn: 2.0.0 pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.8.1 + yaml: 2.8.2 listr2@9.0.5: dependencies: @@ -18020,6 +18076,8 @@ snapshots: node-releases@2.0.19: {} + node-stream-zip@1.15.0: {} + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 @@ -18327,10 +18385,10 @@ snapshots: dependencies: quansync: 0.2.10 - package-manager-detector@1.3.0: {} - package-manager-detector@1.5.0: {} + package-manager-detector@1.6.0: {} + pako@0.2.9: {} param-case@2.1.1: @@ -18672,7 +18730,7 @@ snapshots: publint@0.3.15: dependencies: '@publint/pack': 0.1.2 - package-manager-detector: 1.3.0 + package-manager-detector: 1.5.0 picocolors: 1.1.1 sade: 1.8.1 @@ -19256,6 +19314,8 @@ snapshots: search-insights@2.17.3: {} + semiver@1.1.0: {} + semver@5.7.2: {} semver@6.3.1: {} @@ -20104,7 +20164,7 @@ snapshots: markdown-it: 14.1.0 minimatch: 9.0.5 typescript: 5.9.3 - yaml: 2.8.1 + yaml: 2.8.2 typesafe-path@0.2.2: {} @@ -20945,8 +21005,6 @@ snapshots: yaml@2.7.1: {} - yaml@2.8.1: {} - yaml@2.8.2: {} yargs-parser@21.1.1: {} @@ -21042,8 +21100,6 @@ snapshots: zod@4.0.15: {} - zod@4.1.12: {} - zod@4.2.1: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a9a14e72..63f2c8c7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,6 +41,8 @@ catalog: "@catppuccin/palette": 1.7.1 "@iconify/types": ^2.0.0 "@iconify/utils": ^2.3.0 + "@manypkg/find-root": "^3.1.0" + "@manypkg/get-packages": ^3.1.0 "@testing-library/react": ^16.3.0 "@testing-library/jest-dom": ^6.9.1 "@types/debug": ^4.1.12 @@ -54,6 +56,7 @@ catalog: astro: 5.9.3 handlebars: 4.7.8 happy-dom: ^20.0.11 + jsr: ^0.13.5 mdast: ^3.0.0 minify: 14.0.0 prettier: ^3.7.4 From 30e49506058043636903b44e6d2a52ef800e844b Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 8 Jan 2026 01:04:03 -0500 Subject: [PATCH 03/32] feat: implement npm publishing strategies The `multipublish` tool now supports publishing packages to npm registries with two distinct strategies: - **`.npmrc` strategy**: Creates or updates a root-level `.npmrc` file temporarily with registry and authentication token details. This is useful for scenarios where `publishConfig` in `package.json` is not desired or conflicts with other configurations. The `.npmrc` file is cleaned up after publishing. - **`package.json` strategy**: Sets the `publishConfig.registry` field directly in the package's `package.json` file. This is suitable when the registry information can reside within the package's manifest. The `package.json` is reverted to its original state after publishing. Additionally, this change: - Ensures the `version` field in `jsr.json` is updated before publishing. - Introduces the `dedent` package for cleaner multiline string definitions. --- core/multipublish/package.json | 1 + core/multipublish/src/publish.ts | 87 ++++++++++++++++++++++++++++---- core/multipublish/src/schema.ts | 6 ++- core/multipublish/src/util.ts | 5 +- pnpm-lock.yaml | 13 +++++ 5 files changed, 100 insertions(+), 12 deletions(-) diff --git a/core/multipublish/package.json b/core/multipublish/package.json index f9bdc609..4288f14e 100644 --- a/core/multipublish/package.json +++ b/core/multipublish/package.json @@ -44,6 +44,7 @@ "@manypkg/find-root": "catalog:", "@manypkg/get-packages": "catalog:", "cosmiconfig": "catalog:cli", + "dedent": "^1.7.1", "obug": "catalog:cli", "package-manager-detector": "^1.6.0", "yaml": "^2.8.2", diff --git a/core/multipublish/src/publish.ts b/core/multipublish/src/publish.ts index f8d3f885..045e10fd 100644 --- a/core/multipublish/src/publish.ts +++ b/core/multipublish/src/publish.ts @@ -1,6 +1,10 @@ import type { Package } from "@manypkg/get-packages"; +import { findRoot } from "@manypkg/find-root"; +import dedent from "dedent"; import * as cp from "node:child_process"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; import * as path from "node:path"; import type { Config } from "./schema"; @@ -9,13 +13,26 @@ import { type AgentName, detectPackageManager } from "./detect"; import { jsrPlatformOptionsSchema, npmPlatformOptionsSchema } from "./schema"; import * as util from "./util"; +export const npmrcTemplate = dedent` +{{SCOPE}}:registry={{REGISTRY}} +//{{REGISTRY_DOMAIN}}/:_authToken={{AUTH_TOKEN}} +`; + +export const npmPublishCommand = { + bun: "", + deno: "", + npm: "npm publish", + pnpm: "pnpm publish", + yarn: "yarn publish", +} satisfies Record; + export const jsrPublishCommand = { bun: "", deno: "deno publish", npm: "npx jsr publish", pnpm: "pnpm dlx jsr publish", yarn: "yarn dlx jsr publish", -} satisfies Partial>; +} satisfies Record; export async function publishPlatform( pkg: Package & { newVersion: string; oldVersion: string }, @@ -25,6 +42,8 @@ export async function publishPlatform( const key = isString ? platform : platform[0]; const rawConfig = isString ? {} : platform[1]; + const packageManager = await detectPackageManager(); + switch (key) { case "jsr": { const config = jsrPlatformOptionsSchema.parse(rawConfig); @@ -41,11 +60,12 @@ export async function publishPlatform( throw new Error("failed to load jsr config filename"); } - // TODO: update jsr.json file + jsr.config.version = pkg.newVersion; + await fsp.writeFile(jsr.filename, JSON.stringify(jsr.config)); + // TODO: update package json with actual catalog: versions - await util.chdir(pkgRoot, async () => { - const packageManager = await detectPackageManager(); + await util.chdir(pkgRoot, () => { const command = jsrPublishCommand[packageManager]; cp.execSync(command + " --allow-dirty --dry-run", { stdio: "inherit", @@ -57,11 +77,60 @@ export async function publishPlatform( } case "npm": { const config = npmPlatformOptionsSchema.parse(rawConfig); - // TODO: create root .npmrc - // TODO: update package.json with registry if not npm - // TODO: update package.json version with next version - // TODO: publish npm package - // TODO: delete changed files + + switch (config.strategy) { + case ".npmrc": { + const authToken = process.env[config.tokenEnvironmentKey]; + if (!authToken) { + throw new Error( + "no auth token provided. please use an auth token with npmrc strategy", + ); + } + + const { rootDir } = await findRoot(process.cwd()); + const npmrcFile = path.join(rootDir, ".npmrc"); + let existingNpmrcFile = fs.existsSync(npmrcFile) + ? await fsp.readFile(npmrcFile, { encoding: "utf8" }) + : ""; + + const scope = pkg.packageJson.name.split("/").at(0); + + if (!scope?.startsWith("@")) { + throw new Error("scope must start with `@` symbol"); + } + + const { host } = new URL(config.registry); + + existingNpmrcFile += npmrcTemplate + .replace("{{AUTH_TOKEN}}", authToken) + .replace("{{REGISTRY}}", config.registry) + .replace("{{REGISTRY_DOMAIN}}", host) + .replace("{{SCOPE}}", scope); + + await fsp.writeFile(npmrcFile, existingNpmrcFile); + + break; + } + case "package.json": { + pkg.packageJson.publishConfig ??= {}; + pkg.packageJson.publishConfig.registry = config.registry; + break; + } + } + + pkg.packageJson.version = pkg.newVersion; + const file = JSON.stringify(pkg.packageJson); + const packageJsonPath = path.join(pkg.dir, "package.json"); + await fsp.writeFile(packageJsonPath, file); + + await util.chdir(pkg.dir, () => { + const command = npmPublishCommand[packageManager]; + cp.execSync(command, { stdio: "inherit" }); + }); + + if (config.strategy === ".npmrc") util.gitClean(".npmrc"); + util.gitClean(packageJsonPath); + break; } default: diff --git a/core/multipublish/src/schema.ts b/core/multipublish/src/schema.ts index 747bc905..a111de89 100644 --- a/core/multipublish/src/schema.ts +++ b/core/multipublish/src/schema.ts @@ -1,7 +1,6 @@ import * as z from "zod"; -export type Config = z.input; - +export type JsrPlatformOptionsSchema = z.infer; export const jsrPlatformOptionsSchema = z.object({ defaultExclude: z.array(z.string()).optional(), defaultInclude: z.array(z.string()).optional(), @@ -10,11 +9,14 @@ export const jsrPlatformOptionsSchema = z.object({ experimentalUpdatePnpmCatalogs: z.boolean().default(false), }); +export type NpmPlatformOptionsSchema = z.infer; export const npmPlatformOptionsSchema = z.object({ registry: z.string().default("https://registry.npmjs.org/"), + strategy: z.enum([".npmrc", "package.json"]).default(".npmrc"), tokenEnvironmentKey: z.string().default("NODE_AUTH_TOKEN"), }); +export type Config = z.input; export const configSchema = z.object({ platforms: z.array( z diff --git a/core/multipublish/src/util.ts b/core/multipublish/src/util.ts index 6ba938be..62c54e34 100644 --- a/core/multipublish/src/util.ts +++ b/core/multipublish/src/util.ts @@ -12,7 +12,10 @@ import { type JsrSchema, jsrTransformer } from "./jsr"; export const MODULE_NAME = "multipublish" as const; export const JSR_CONFIG_FILENAME = "jsr.json" as const; -export async function chdir(newDir: string, callback: () => Promise) { +export async function chdir( + newDir: string, + callback: () => Promise | void, +) { const cwd = process.cwd(); process.chdir(newDir); await callback(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdeb3416..c9e0f4e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -589,6 +589,9 @@ importers: cosmiconfig: specifier: catalog:cli version: 9.0.0(typescript@5.9.3) + dedent: + specifier: ^1.7.1 + version: 1.7.1 obug: specifier: catalog:cli version: 2.1.1 @@ -5351,6 +5354,14 @@ packages: babel-plugin-macros: optional: true + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -15088,6 +15099,8 @@ snapshots: dedent@1.7.0: {} + dedent@1.7.1: {} + deep-eql@5.0.2: {} deep-extend@0.6.0: {} From 97ded5391092e4fb4df3691a638f9920108466c4 Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 8 Jan 2026 01:08:26 -0500 Subject: [PATCH 04/32] feat(multipublish): initial release of the multipublish cli tool initial release of `@stephansama/multipublish`, a cli tool for publishing packages to multiple registries. features include: - npm publishing strategies: supports .npmrc file modification or package.json's publishconfig.registry. - jsr support: automatically updates `version` in `jsr.json` prior to publishing. - configuration: fully configurable via a zod schema. --- .changeset/stupid-cycles-enjoy.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/stupid-cycles-enjoy.md diff --git a/.changeset/stupid-cycles-enjoy.md b/.changeset/stupid-cycles-enjoy.md new file mode 100644 index 00000000..3f0f277e --- /dev/null +++ b/.changeset/stupid-cycles-enjoy.md @@ -0,0 +1,13 @@ +--- +"@stephansama/multipublish": major +--- + +Initial release of `@stephansama/multipublish`, a CLI tool for publishing packages to multiple registries. + +Features: + +- **npm Publishing Strategies**: Supports two strategies for publishing to npm registries: + - `.npmrc` strategy: Temporarily creates or updates a root-level `.npmrc` file with registry and auth token details. + - `package.json` strategy: Directly sets the `publishConfig.registry` field in the package's `package.json`. +- **JSR Support**: Automatically updates the `version` field in `jsr.json` before publishing. +- **Configuration**: fully configurable via a schema using zod. From 718e3b17ae4f133528f13d15d6daea5196b5a962 Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 8 Jan 2026 01:40:29 -0500 Subject: [PATCH 05/32] feat: add dry run publishing mode introduces a new `--dry` argument to allow users to perform a dry run of the publishing process without actually publishing packages. this applies to both npm and jsr platforms. this change also includes: - refactoring jsr configuration generation to separate transformation from disk writing, improving flexibility and reducing side effects. - adding `fast-glob` dependency to enhance file globbing capabilities. - enabling `provenance` for all npm publishes for improved security. - enhancing `git clean` command robustness by using `execFileSync` and explicitly marking the filename. - memoizing argument parsing for efficiency. - removing a redundant `test.js` file. --- core/multipublish/package.json | 3 ++- core/multipublish/src/args.ts | 9 +++++++++ core/multipublish/src/publish.ts | 24 +++++++++++++++++------- core/multipublish/src/util.ts | 15 +++++++-------- core/multipublish/test.js | 1 - pnpm-lock.yaml | 3 +++ 6 files changed, 38 insertions(+), 17 deletions(-) delete mode 100644 core/multipublish/test.js diff --git a/core/multipublish/package.json b/core/multipublish/package.json index 4288f14e..10377890 100644 --- a/core/multipublish/package.json +++ b/core/multipublish/package.json @@ -45,6 +45,7 @@ "@manypkg/get-packages": "catalog:", "cosmiconfig": "catalog:cli", "dedent": "^1.7.1", + "fast-glob": "^3.3.3", "obug": "catalog:cli", "package-manager-detector": "^1.6.0", "yaml": "^2.8.2", @@ -62,6 +63,6 @@ "packageManager": "pnpm@10.11.0", "publishConfig": { "access": "public", - "registry": "http://localhost:487" + "provenance": true } } diff --git a/core/multipublish/src/args.ts b/core/multipublish/src/args.ts index 4c73e87c..ec1e22b8 100644 --- a/core/multipublish/src/args.ts +++ b/core/multipublish/src/args.ts @@ -4,8 +4,13 @@ import { hideBin } from "yargs/helpers"; import { MODULE_NAME } from "./util"; +export type Args = Awaited>; + +let _args: Args | null = null; + const args = { config: { alias: "c", description: "Path to config file", type: "string" }, + dry: { alias: "d", description: "Perform a dry run", type: "string" }, output: { alias: "s", description: "use changesets", type: "boolean" }, verbose: { alias: "v", @@ -14,6 +19,10 @@ const args = { }, } satisfies Record; +export async function getArgs() { + return (_args ??= await parseArgs()); +} + export async function parseArgs() { const yargsInstance = yargs(hideBin(process.argv)) .options(args) diff --git a/core/multipublish/src/publish.ts b/core/multipublish/src/publish.ts index 045e10fd..e63c3a56 100644 --- a/core/multipublish/src/publish.ts +++ b/core/multipublish/src/publish.ts @@ -9,6 +9,7 @@ import * as path from "node:path"; import type { Config } from "./schema"; +import { getArgs } from "./args"; import { type AgentName, detectPackageManager } from "./detect"; import { jsrPlatformOptionsSchema, npmPlatformOptionsSchema } from "./schema"; import * as util from "./util"; @@ -38,11 +39,12 @@ export async function publishPlatform( pkg: Package & { newVersion: string; oldVersion: string }, platform: Config["platforms"][number], ) { + const packageManager = await detectPackageManager(); const isString = typeof platform === "string"; const key = isString ? platform : platform[0]; const rawConfig = isString ? {} : platform[1]; - - const packageManager = await detectPackageManager(); + const args = await getArgs(); + const isDryRun = !!args.dry; switch (key) { case "jsr": { @@ -51,7 +53,7 @@ export async function publishPlatform( const jsr = await util.loadJsrConfigFile(pkgRoot); if (config.experimentalGenerateJSR) { - jsr.config = await util.transformPkgJsonForJsr(pkg); + jsr.config = util.transformPkgJsonForJsr(pkg); jsr.filename = path.join(pkgRoot, util.JSR_CONFIG_FILENAME); } @@ -67,9 +69,12 @@ export async function publishPlatform( await util.chdir(pkgRoot, () => { const command = jsrPublishCommand[packageManager]; - cp.execSync(command + " --allow-dirty --dry-run", { - stdio: "inherit", - }); + cp.execSync( + [command, "--allow-dirty", isDryRun && "--dry-run"] + .filter((x) => x) + .join(" "), + { stdio: "inherit" }, + ); }); util.gitClean(jsr.filename); @@ -125,7 +130,12 @@ export async function publishPlatform( await util.chdir(pkg.dir, () => { const command = npmPublishCommand[packageManager]; - cp.execSync(command, { stdio: "inherit" }); + cp.execSync( + [command, isDryRun && "--dry-run"] + .filter((x) => x) + .join(" "), + { stdio: "inherit" }, + ); }); if (config.strategy === ".npmrc") util.gitClean(".npmrc"); diff --git a/core/multipublish/src/util.ts b/core/multipublish/src/util.ts index 62c54e34..7c9129d2 100644 --- a/core/multipublish/src/util.ts +++ b/core/multipublish/src/util.ts @@ -1,7 +1,7 @@ import type { Package } from "@manypkg/get-packages"; +import fg from "fast-glob"; import * as cp from "node:child_process"; -import * as fs from "node:fs"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; @@ -37,13 +37,15 @@ export async function getTmpFile(filename: string) { } export function gitClean(filename: string) { - cp.execSync(`git clean -f ${filename}`, { stdio: "inherit" }); + cp.execFileSync("git", ["clean", "-f", "--", filename], { + stdio: "inherit", + }); } export async function loadJsrConfigFile( basePath: string, ): Promise<{ config: JsrSchema | null; filename?: string }> { - const files = fs.globSync(basePath + "/{deno,jsr}.json{,c}"); + const files = await fg(basePath + "/{deno,jsr}.json{,c}"); if (files.length > 1) { throw new Error("please only have one deno or jsr configuration file"); } @@ -58,9 +60,6 @@ export async function loadJsrConfigFile( return { config: JSON.parse(file), filename: configFile }; } -export async function transformPkgJsonForJsr(pkg: Package): Promise { - const parsed = jsrTransformer.parse(pkg.packageJson); - const filename = path.join(pkg.dir, JSR_CONFIG_FILENAME); - await fsp.writeFile(filename, JSON.stringify(parsed)); - return parsed; +export function transformPkgJsonForJsr(pkg: Package): JsrSchema { + return jsrTransformer.parse(pkg.packageJson); } diff --git a/core/multipublish/test.js b/core/multipublish/test.js deleted file mode 100644 index b7bd4c88..00000000 --- a/core/multipublish/test.js +++ /dev/null @@ -1 +0,0 @@ -console.log(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9e0f4e5..39f4792d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -592,6 +592,9 @@ importers: dedent: specifier: ^1.7.1 version: 1.7.1 + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 obug: specifier: catalog:cli version: 2.1.1 From 0eccd2000c205c4424c05431d9d026a1fcfaf5d0 Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 8 Jan 2026 01:46:03 -0500 Subject: [PATCH 06/32] chore(multipublish): narrow lint script scope to src directory --- core/multipublish/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/multipublish/package.json b/core/multipublish/package.json index 10377890..ec89f15b 100644 --- a/core/multipublish/package.json +++ b/core/multipublish/package.json @@ -38,7 +38,7 @@ "build": "node build.mjs", "detect": "node ./test.js", "dev": "node --watch build.mjs", - "lint": "eslint ./ --pass-on-no-patterns --no-error-on-unmatched-pattern" + "lint": "eslint ./src/ --pass-on-no-patterns --no-error-on-unmatched-pattern" }, "dependencies": { "@manypkg/find-root": "catalog:", From 4fb63480759787a14ddce7408d7f7f54131acf79 Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 8 Jan 2026 01:57:20 -0500 Subject: [PATCH 07/32] fix: update jsr config file path and npmrc handling in publish logic --- core/multipublish/src/index.ts | 2 +- core/multipublish/src/publish.ts | 18 ++++++++---------- core/multipublish/src/types.ts | 29 +++++++++++++---------------- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/core/multipublish/src/index.ts b/core/multipublish/src/index.ts index 9fd5f129..75f63f94 100644 --- a/core/multipublish/src/index.ts +++ b/core/multipublish/src/index.ts @@ -9,7 +9,7 @@ import { publishPlatform } from "./publish"; import * as util from "./util"; export async function run() { - const args = await parseArgs(); + await parseArgs(); const config = await loadConfig(); const root = await findRoot(process.cwd()); diff --git a/core/multipublish/src/publish.ts b/core/multipublish/src/publish.ts index e63c3a56..c0c440b2 100644 --- a/core/multipublish/src/publish.ts +++ b/core/multipublish/src/publish.ts @@ -49,12 +49,11 @@ export async function publishPlatform( switch (key) { case "jsr": { const config = jsrPlatformOptionsSchema.parse(rawConfig); - const pkgRoot = path.dirname(pkg.dir); - const jsr = await util.loadJsrConfigFile(pkgRoot); + const jsr = await util.loadJsrConfigFile(pkg.dir); if (config.experimentalGenerateJSR) { jsr.config = util.transformPkgJsonForJsr(pkg); - jsr.filename = path.join(pkgRoot, util.JSR_CONFIG_FILENAME); + jsr.filename = path.join(pkg.dir, util.JSR_CONFIG_FILENAME); } if (!jsr.config) throw new Error("failed to load jsr config file"); @@ -67,7 +66,7 @@ export async function publishPlatform( // TODO: update package json with actual catalog: versions - await util.chdir(pkgRoot, () => { + await util.chdir(pkg.dir, () => { const command = jsrPublishCommand[packageManager]; cp.execSync( [command, "--allow-dirty", isDryRun && "--dry-run"] @@ -81,6 +80,7 @@ export async function publishPlatform( break; } case "npm": { + const { rootDir } = await findRoot(process.cwd()); const config = npmPlatformOptionsSchema.parse(rawConfig); switch (config.strategy) { @@ -92,14 +92,12 @@ export async function publishPlatform( ); } - const { rootDir } = await findRoot(process.cwd()); const npmrcFile = path.join(rootDir, ".npmrc"); let existingNpmrcFile = fs.existsSync(npmrcFile) ? await fsp.readFile(npmrcFile, { encoding: "utf8" }) : ""; const scope = pkg.packageJson.name.split("/").at(0); - if (!scope?.startsWith("@")) { throw new Error("scope must start with `@` symbol"); } @@ -113,7 +111,6 @@ export async function publishPlatform( .replace("{{SCOPE}}", scope); await fsp.writeFile(npmrcFile, existingNpmrcFile); - break; } case "package.json": { @@ -124,7 +121,7 @@ export async function publishPlatform( } pkg.packageJson.version = pkg.newVersion; - const file = JSON.stringify(pkg.packageJson); + const file = JSON.stringify(pkg.packageJson, undefined, 2); const packageJsonPath = path.join(pkg.dir, "package.json"); await fsp.writeFile(packageJsonPath, file); @@ -138,9 +135,10 @@ export async function publishPlatform( ); }); - if (config.strategy === ".npmrc") util.gitClean(".npmrc"); util.gitClean(packageJsonPath); - + if (config.strategy === ".npmrc") { + util.gitClean(path.join(rootDir, ".npmrc")); + } break; } default: diff --git a/core/multipublish/src/types.ts b/core/multipublish/src/types.ts index 67571cde..66b7951a 100644 --- a/core/multipublish/src/types.ts +++ b/core/multipublish/src/types.ts @@ -1,17 +1,14 @@ -export type Changeset = { changesets: ChangesetElement[]; releases: Release[] }; - -export type ChangesetElement = { - id: string; - releases: ChangesetsRelease[]; - summary: string; -}; - -export type ChangesetsRelease = { name: string; type: string }; - -export type Release = { - changesets: string[]; - name: string; - newVersion: string; - oldVersion: string; - type: string; +export type Changeset = { + changesets: { + id: string; + releases: { name: string; type: string }[]; + summary: string; + }[]; + releases: { + changesets: string[]; + name: string; + newVersion: string; + oldVersion: string; + type: string; + }[]; }; From f2af774d9fbf1a437f78aaf15d907467cf5b55ea Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 8 Jan 2026 02:06:13 -0500 Subject: [PATCH 08/32] refactor: use getargs instead of parseargs and update changeset type definition --- core/multipublish/src/index.ts | 4 ++-- core/multipublish/src/types.ts | 14 -------------- core/multipublish/src/util.ts | 17 +++++++++++++++-- 3 files changed, 17 insertions(+), 18 deletions(-) delete mode 100644 core/multipublish/src/types.ts diff --git a/core/multipublish/src/index.ts b/core/multipublish/src/index.ts index 75f63f94..784a9357 100644 --- a/core/multipublish/src/index.ts +++ b/core/multipublish/src/index.ts @@ -3,13 +3,13 @@ import { findRoot } from "@manypkg/find-root"; import { getPackages } from "@manypkg/get-packages"; -import { parseArgs } from "./args"; +import { getArgs } from "./args"; import { loadConfig } from "./config"; import { publishPlatform } from "./publish"; import * as util from "./util"; export async function run() { - await parseArgs(); + await getArgs(); const config = await loadConfig(); const root = await findRoot(process.cwd()); diff --git a/core/multipublish/src/types.ts b/core/multipublish/src/types.ts deleted file mode 100644 index 66b7951a..00000000 --- a/core/multipublish/src/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type Changeset = { - changesets: { - id: string; - releases: { name: string; type: string }[]; - summary: string; - }[]; - releases: { - changesets: string[]; - name: string; - newVersion: string; - oldVersion: string; - type: string; - }[]; -}; diff --git a/core/multipublish/src/util.ts b/core/multipublish/src/util.ts index 7c9129d2..f77ffdd4 100644 --- a/core/multipublish/src/util.ts +++ b/core/multipublish/src/util.ts @@ -5,10 +5,23 @@ import * as cp from "node:child_process"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; -import type { Changeset } from "./types"; - import { type JsrSchema, jsrTransformer } from "./jsr"; +export type Changeset = { + changesets: { + id: string; + releases: { name: string; type: string }[]; + summary: string; + }[]; + releases: { + changesets: string[]; + name: string; + newVersion: string; + oldVersion: string; + type: "major" | "minor" | "patch"; + }[]; +}; + export const MODULE_NAME = "multipublish" as const; export const JSR_CONFIG_FILENAME = "jsr.json" as const; From 6b1ac0e7e7f370872428dbf119dbbef9429359e2 Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 8 Jan 2026 02:34:38 -0500 Subject: [PATCH 09/32] fix: update package.json dependencies and fix util.ts error handling --- core/multipublish/package.json | 9 ++++----- core/multipublish/src/util.ts | 13 +++++++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/core/multipublish/package.json b/core/multipublish/package.json index ec89f15b..b7f29059 100644 --- a/core/multipublish/package.json +++ b/core/multipublish/package.json @@ -36,7 +36,6 @@ ], "scripts": { "build": "node build.mjs", - "detect": "node ./test.js", "dev": "node --watch build.mjs", "lint": "eslint ./src/ --pass-on-no-patterns --no-error-on-unmatched-pattern" }, @@ -49,16 +48,16 @@ "obug": "catalog:cli", "package-manager-detector": "^1.6.0", "yaml": "^2.8.2", - "yargs": "catalog:cli" + "yargs": "catalog:cli", + "zod": "catalog:schema" }, "devDependencies": { "@types/yargs": "catalog:", "jsr": "catalog:", - "tsdown": "catalog:", - "zod": "catalog:schema" + "tsdown": "catalog:" }, "peerDependencies": { - "jsr": ">=0" + "jsr": ">=0.10" }, "packageManager": "pnpm@10.11.0", "publishConfig": { diff --git a/core/multipublish/src/util.ts b/core/multipublish/src/util.ts index f77ffdd4..88173bd6 100644 --- a/core/multipublish/src/util.ts +++ b/core/multipublish/src/util.ts @@ -30,9 +30,14 @@ export async function chdir( callback: () => Promise | void, ) { const cwd = process.cwd(); - process.chdir(newDir); - await callback(); - process.chdir(cwd); + try { + process.chdir(newDir); + await callback(); + } catch (e) { + console.error(e); + } finally { + process.chdir(cwd); + } } export async function getChangesetReleases(): Promise { @@ -66,7 +71,7 @@ export async function loadJsrConfigFile( const configFile = files.at(0); if (!configFile) { console.info("no jsr config file found"); - return { config: null, filename: configFile }; + return { config: null, filename: undefined }; } const file = await fsp.readFile(configFile, { encoding: "utf8" }); From 9f58585a95609721282f87a9288344d1bd2f580b Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 8 Jan 2026 02:48:32 -0500 Subject: [PATCH 10/32] build: move zod to dependencies --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39f4792d..a04df5af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -607,6 +607,9 @@ importers: yargs: specifier: catalog:cli version: 18.0.0 + zod: + specifier: catalog:schema + version: 4.2.1 devDependencies: '@types/yargs': specifier: 'catalog:' @@ -617,9 +620,6 @@ importers: tsdown: specifier: 'catalog:' version: 0.15.12(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.14.2)(publint@0.3.15)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)) - zod: - specifier: catalog:schema - version: 4.2.1 core/prettier-plugin-handlebars: devDependencies: From f2ea1be0d9c8b645afeb102f41f8b909f820f2fc Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 8 Jan 2026 03:12:57 -0500 Subject: [PATCH 11/32] feat: add include/exclude options for jsr and --no-git-checks flag Add `include` and `exclude` properties to the `jsr.json` schema. The `publish` command will now merge the global `defaultInclude` and `defaultExclude` options with the `include` and `exclude` arrays specified in the `jsr.json` configuration file for the platform. Include the `--no-git-checks` flag for `npm publish` commands to prevent failures in environments where git is not configured or in CI/CD pipelines where git status might not be clean. --- core/multipublish/src/jsr.ts | 2 ++ core/multipublish/src/publish.ts | 14 +++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/core/multipublish/src/jsr.ts b/core/multipublish/src/jsr.ts index 63a3ea7e..c60321c0 100644 --- a/core/multipublish/src/jsr.ts +++ b/core/multipublish/src/jsr.ts @@ -28,10 +28,12 @@ export const jsrTransformer = packageJsonSchema.transform((schema) => ({ export type JsrSchema = z.infer; export const jsrSchema = z.object({ + exclude: z.array(z.string()).optional(), exports: z .string() .or(z.array(z.string())) .or(z.record(z.string(), z.string())), + include: z.array(z.string()).optional(), license: z.string().optional(), name: z.string().min(1), version: z.string(), diff --git a/core/multipublish/src/publish.ts b/core/multipublish/src/publish.ts index c0c440b2..99b0e860 100644 --- a/core/multipublish/src/publish.ts +++ b/core/multipublish/src/publish.ts @@ -61,6 +61,18 @@ export async function publishPlatform( throw new Error("failed to load jsr config filename"); } + jsr.config.include ??= []; + jsr.config.include = [ + ...jsr.config.include, + ...(config.defaultInclude ? config.defaultInclude : []), + ]; + + jsr.config.exclude ??= []; + jsr.config.exclude = [ + ...jsr.config.exclude, + ...(config.defaultExclude ? config.defaultExclude : []), + ]; + jsr.config.version = pkg.newVersion; await fsp.writeFile(jsr.filename, JSON.stringify(jsr.config)); @@ -128,7 +140,7 @@ export async function publishPlatform( await util.chdir(pkg.dir, () => { const command = npmPublishCommand[packageManager]; cp.execSync( - [command, isDryRun && "--dry-run"] + [command, "--no-git-checks", isDryRun && "--dry-run"] .filter((x) => x) .join(" "), { stdio: "inherit" }, From 918dba2915d92e66ecdd7cdc965352c1aaf8ae22 Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 8 Jan 2026 03:14:49 -0500 Subject: [PATCH 12/32] feat(multipublish): add option to allow slow types for jsr publish This introduces a new configuration option `allowSlowTypes` within the JSR platform options. When set to `true` (which is the default), the `--allow-slow-types` flag will be passed to the `jsr publish` command. This allows users to control the behavior of the JSR publisher regarding type checking performance. --- core/multipublish/src/publish.ts | 7 ++++++- core/multipublish/src/schema.ts | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/core/multipublish/src/publish.ts b/core/multipublish/src/publish.ts index 99b0e860..be3a4762 100644 --- a/core/multipublish/src/publish.ts +++ b/core/multipublish/src/publish.ts @@ -81,7 +81,12 @@ export async function publishPlatform( await util.chdir(pkg.dir, () => { const command = jsrPublishCommand[packageManager]; cp.execSync( - [command, "--allow-dirty", isDryRun && "--dry-run"] + [ + command, + "--allow-dirty", + isDryRun && "--dry-run", + config.allowSlowTypes && "--allow-slow-types", + ] .filter((x) => x) .join(" "), { stdio: "inherit" }, diff --git a/core/multipublish/src/schema.ts b/core/multipublish/src/schema.ts index a111de89..dde490ee 100644 --- a/core/multipublish/src/schema.ts +++ b/core/multipublish/src/schema.ts @@ -2,6 +2,7 @@ import * as z from "zod"; export type JsrPlatformOptionsSchema = z.infer; export const jsrPlatformOptionsSchema = z.object({ + allowSlowTypes: z.boolean().default(true), defaultExclude: z.array(z.string()).optional(), defaultInclude: z.array(z.string()).optional(), experimentalGenerateJSR: z.boolean().default(false), From 06c9938e6f4222a23eccde9a9c4e465d4810b690 Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 8 Jan 2026 23:59:20 -0500 Subject: [PATCH 13/32] feat: add dependency catalog support and cli config option introduce support for dependency catalogs for bun and pnpm workspaces. this allows resolving dependency versions defined in a `catalog` or `catalogs` section of `package.json` (bun) or `pnpm-workspace.yaml` (pnpm). enable specifying the config file path via a command-line argument using `--config`. update readme with detailed zod schema for configuration options. remove the deprecated `--output` argument; `usechangesets` is now solely controlled through the configuration file. --- core/multipublish/README.md | 52 +++++++++++++++++ core/multipublish/src/args.ts | 1 - core/multipublish/src/catalog.ts | 97 ++++++++++++++++++++++++++++++++ core/multipublish/src/config.ts | 6 +- core/multipublish/src/index.ts | 6 +- core/multipublish/src/jsr.ts | 3 +- core/multipublish/src/publish.ts | 72 +++++++++++++++++------- core/multipublish/src/schema.ts | 20 ++++--- core/multipublish/src/util.ts | 8 +-- 9 files changed, 223 insertions(+), 42 deletions(-) create mode 100644 core/multipublish/src/catalog.ts diff --git a/core/multipublish/README.md b/core/multipublish/README.md index 247b9329..8629cc88 100644 --- a/core/multipublish/README.md +++ b/core/multipublish/README.md @@ -23,3 +23,55 @@ pnpm install @stephansama/multipublish ``` ## Usage + +```sh +multipublish +``` + + + +# Zod Schema + +## Config + +_Object containing the following properties:_ + +| Property | Type | Default | +| :------------------- | :---------------------- | :----------- | +| **`platforms`** (\*) | [Platforms](#platforms) | | +| `tmpDirectory` | `string` | `'.release'` | +| `useChangesets` | `boolean` | `true` | + +_(\*) Required._ + +## JsrPlatformOptions + +_Object containing the following properties:_ + +| Property | Type | Default | +| :--------------------------- | :-------------- | :------ | +| `allowSlowTypes` | `boolean` | `true` | +| `defaultExclude` | `Array` | | +| `defaultInclude` | `Array` | | +| `experimentalGenerateJSR` | `boolean` | `false` | +| `experimentalUpdateCatalogs` | `boolean` | `false` | + +_All properties are optional._ + +## NpmPlatformOptions + +_Object containing the following properties:_ + +| Property | Type | Default | +| :-------------------- | :--------------------------- | :------------------------------ | +| `registry` | `string` | `'https://registry.npmjs.org/'` | +| `strategy` | `'.npmrc' \| 'package.json'` | `'.npmrc'` | +| `tokenEnvironmentKey` | `string` | `'NODE_AUTH_TOKEN'` | + +_All properties are optional._ + +## Platforms + +*Array of `'jsr' | 'npm'` *or\* _Tuple:_
  1. `'jsr'`
  2. [JsrPlatformOptions](#jsrplatformoptions)
_or_ _Tuple:_
  1. `'npm'`
  2. [NpmPlatformOptions](#npmplatformoptions)
items.\* + + diff --git a/core/multipublish/src/args.ts b/core/multipublish/src/args.ts index ec1e22b8..8a8f25ac 100644 --- a/core/multipublish/src/args.ts +++ b/core/multipublish/src/args.ts @@ -11,7 +11,6 @@ let _args: Args | null = null; const args = { config: { alias: "c", description: "Path to config file", type: "string" }, dry: { alias: "d", description: "Perform a dry run", type: "string" }, - output: { alias: "s", description: "use changesets", type: "boolean" }, verbose: { alias: "v", description: "Enable verbose logging", diff --git a/core/multipublish/src/catalog.ts b/core/multipublish/src/catalog.ts new file mode 100644 index 00000000..5ac8ee97 --- /dev/null +++ b/core/multipublish/src/catalog.ts @@ -0,0 +1,97 @@ +import { findRoot } from "@manypkg/find-root"; +import { getPackages, type Package } from "@manypkg/get-packages"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import * as yaml from "yaml"; +import * as z from "zod"; + +import type { AgentName } from "./detect"; + +export type CatalogSchema = z.infer; +export const catalogSchema = z.object({ + catalog: z.record(z.string(), z.string()).optional(), + catalogs: z.record(z.string(), z.record(z.string(), z.string())).optional(), +}); + +export type ValidCatalogAgent = Extract; +export const catalogLoadMap = { + bun: loadBunCatalogs, + pnpm: loadPnpmCatalogs, +} satisfies Record Promise>; + +export async function loadBunCatalogs() { + const { rootDir } = await findRoot(process.cwd()); + const { rootPackage } = await getPackages(rootDir); + const packageJson = rootPackage?.packageJson; + const catalogs = catalogSchema.parse(packageJson); + return catalogs; +} + +export async function loadPnpmCatalogs() { + const { rootDir } = await findRoot(process.cwd()); + const workspacePath = path.join(rootDir, "pnpm-workspace.yaml"); + const workspaceFile = await fsp.readFile(workspacePath, { + encoding: "utf-8", + }); + const catalogs = catalogSchema.parse(yaml.parse(workspaceFile)); + return catalogs; +} + +export function loadVersion({ + catalogs, + dependency, + version, +}: { + catalogs: CatalogSchema; + dependency: string; + version: string; +}) { + if (!version.includes("catalog:")) return version; + const [_, catalogName] = version.split("catalog:"); + const currentCatalog = catalogName + ? catalogs.catalogs?.[catalogName] + : catalogs.catalog; + + const catalogVersion = currentCatalog?.[dependency]; + if (!catalogVersion) { + throw new Error( + `no catalog version found for named catalog (${catalogName || "default"}) dependency (${dependency})`, + ); + } + + return catalogVersion; +} + +export async function updatePackageJsonWithCatalog( + pkg: Package, + agent: ValidCatalogAgent, +) { + const catalogStrategy = catalogLoadMap[agent]; + const catalogs = await catalogStrategy(); + + for (const [dependency, version] of Object.entries( + pkg.packageJson.dependencies || {}, + )) { + pkg.packageJson.dependencies ??= {}; + pkg.packageJson.dependencies[dependency] = loadVersion({ + catalogs, + dependency, + version, + }); + } + + for (const [dependency, version] of Object.entries( + pkg.packageJson.devDependencies || {}, + )) { + pkg.packageJson.devDependencies ??= {}; + pkg.packageJson.devDependencies[dependency] = loadVersion({ + catalogs, + dependency, + version, + }); + } + + const packageJsonPath = path.join(pkg.dir, "package.json"); + const packageJsonFile = JSON.stringify(pkg.packageJson, undefined, 2); + await fsp.writeFile(packageJsonPath, packageJsonFile); +} diff --git a/core/multipublish/src/config.ts b/core/multipublish/src/config.ts index 31ba9664..ad8c54bf 100644 --- a/core/multipublish/src/config.ts +++ b/core/multipublish/src/config.ts @@ -1,5 +1,7 @@ import { cosmiconfig, getDefaultSearchPlaces, type Options } from "cosmiconfig"; +import type { Args } from "./args"; + import { type Config, configSchema } from "./schema"; import { MODULE_NAME } from "./util"; @@ -9,9 +11,11 @@ const defaultConfig = { platforms: [["jsr", { experimentalGenerateJSR: true }]], } satisfies Config; -export async function loadConfig() { +export async function loadConfig(args: Args) { const opts: Partial = { searchPlaces }; + if (args.config) opts.searchPlaces = [args.config]; + const explorer = cosmiconfig(MODULE_NAME, opts); const result = await explorer.search(); diff --git a/core/multipublish/src/index.ts b/core/multipublish/src/index.ts index 784a9357..37416207 100644 --- a/core/multipublish/src/index.ts +++ b/core/multipublish/src/index.ts @@ -9,13 +9,11 @@ import { publishPlatform } from "./publish"; import * as util from "./util"; export async function run() { - await getArgs(); - const config = await loadConfig(); - + const args = await getArgs(); + const config = await loadConfig(args); const root = await findRoot(process.cwd()); const { packages } = await getPackages(root.rootDir); const { releases } = await util.getChangesetReleases(); - const releasedPackages = releases.map((release) => { const packageJson = packages.find( (pkg) => pkg.packageJson.name === release.name, diff --git a/core/multipublish/src/jsr.ts b/core/multipublish/src/jsr.ts index c60321c0..6638bc5b 100644 --- a/core/multipublish/src/jsr.ts +++ b/core/multipublish/src/jsr.ts @@ -1,5 +1,6 @@ import * as z from "zod"; +type ExportsSchema = z.infer; export const exportSchema = z.string().or( z.record( z.string(), @@ -39,7 +40,7 @@ export const jsrSchema = z.object({ version: z.string(), }); -function convertPkgJsonExportsToJsr(exports: z.infer) { +export function convertPkgJsonExportsToJsr(exports: ExportsSchema) { if (typeof exports === "string") return exports; return Object.fromEntries( Object.entries(exports).map(([key, value]) => [ diff --git a/core/multipublish/src/publish.ts b/core/multipublish/src/publish.ts index be3a4762..dfff1f28 100644 --- a/core/multipublish/src/publish.ts +++ b/core/multipublish/src/publish.ts @@ -10,25 +10,41 @@ import * as path from "node:path"; import type { Config } from "./schema"; import { getArgs } from "./args"; +import { updatePackageJsonWithCatalog } from "./catalog"; import { type AgentName, detectPackageManager } from "./detect"; import { jsrPlatformOptionsSchema, npmPlatformOptionsSchema } from "./schema"; import * as util from "./util"; -export const npmrcTemplate = dedent` -{{SCOPE}}:registry={{REGISTRY}} -//{{REGISTRY_DOMAIN}}/:_authToken={{AUTH_TOKEN}} -`; +export function npmrcTemplate({ + authToken: AUTH_TOKEN, + registry: REGISTRY, + registryDomain: REGISTRY_DOMAIN, + scope: SCOPE, +}: { + authToken: string; + registry: string; + registryDomain: string; + scope: string; +}) { + return dedent` + {{SCOPE}}:registry={{REGISTRY}} + //{{REGISTRY_DOMAIN}}/:_authToken={{AUTH_TOKEN}} + ` + .replace("{{AUTH_TOKEN}}", AUTH_TOKEN) + .replace("{{REGISTRY}}", REGISTRY) + .replace("{{REGISTRY_DOMAIN}}", REGISTRY_DOMAIN) + .replace("{{SCOPE}}", SCOPE); +} export const npmPublishCommand = { - bun: "", - deno: "", + bun: "bun publish", npm: "npm publish", pnpm: "pnpm publish", yarn: "yarn publish", -} satisfies Record; +} satisfies Record, string>; export const jsrPublishCommand = { - bun: "", + bun: "bunx publish", deno: "deno publish", npm: "npx jsr publish", pnpm: "pnpm dlx jsr publish", @@ -76,7 +92,15 @@ export async function publishPlatform( jsr.config.version = pkg.newVersion; await fsp.writeFile(jsr.filename, JSON.stringify(jsr.config)); - // TODO: update package json with actual catalog: versions + if (config.experimentalUpdateCatalogs) { + if (packageManager === "pnpm" || packageManager === "bun") { + await updatePackageJsonWithCatalog(pkg, packageManager); + } else { + console.error( + `attempted to update catalogs with an unsupported package manager ${packageManager}`, + ); + } + } await util.chdir(pkg.dir, () => { const command = jsrPublishCommand[packageManager]; @@ -100,6 +124,10 @@ export async function publishPlatform( const { rootDir } = await findRoot(process.cwd()); const config = npmPlatformOptionsSchema.parse(rawConfig); + if (packageManager === "deno") { + throw new Error("deno is not supported for npm publish"); + } + switch (config.strategy) { case ".npmrc": { const authToken = process.env[config.tokenEnvironmentKey]; @@ -109,9 +137,9 @@ export async function publishPlatform( ); } - const npmrcFile = path.join(rootDir, ".npmrc"); - let existingNpmrcFile = fs.existsSync(npmrcFile) - ? await fsp.readFile(npmrcFile, { encoding: "utf8" }) + const npmrcPath = path.join(rootDir, ".npmrc"); + const npmrcPrefix = fs.existsSync(npmrcPath) + ? await fsp.readFile(npmrcPath, { encoding: "utf8" }) : ""; const scope = pkg.packageJson.name.split("/").at(0); @@ -119,15 +147,17 @@ export async function publishPlatform( throw new Error("scope must start with `@` symbol"); } - const { host } = new URL(config.registry); - - existingNpmrcFile += npmrcTemplate - .replace("{{AUTH_TOKEN}}", authToken) - .replace("{{REGISTRY}}", config.registry) - .replace("{{REGISTRY_DOMAIN}}", host) - .replace("{{SCOPE}}", scope); - - await fsp.writeFile(npmrcFile, existingNpmrcFile); + const npmrcFile = + npmrcPrefix + + "\n" + + npmrcTemplate({ + authToken, + registry: config.registry, + registryDomain: new URL(config.registry).host, + scope, + }); + + await fsp.writeFile(npmrcPath, npmrcFile); break; } case "package.json": { diff --git a/core/multipublish/src/schema.ts b/core/multipublish/src/schema.ts index dde490ee..b8d75ba2 100644 --- a/core/multipublish/src/schema.ts +++ b/core/multipublish/src/schema.ts @@ -6,8 +6,7 @@ export const jsrPlatformOptionsSchema = z.object({ defaultExclude: z.array(z.string()).optional(), defaultInclude: z.array(z.string()).optional(), experimentalGenerateJSR: z.boolean().default(false), - experimentalUpdateBunCatalogs: z.boolean().default(false), - experimentalUpdatePnpmCatalogs: z.boolean().default(false), + experimentalUpdateCatalogs: z.boolean().default(false), }); export type NpmPlatformOptionsSchema = z.infer; @@ -17,15 +16,18 @@ export const npmPlatformOptionsSchema = z.object({ tokenEnvironmentKey: z.string().default("NODE_AUTH_TOKEN"), }); +export type PlatformsSchema = z.input; +export const platformsSchema = z.array( + z + .literal("jsr") + .or(z.literal("npm")) + .or(z.tuple([z.literal("jsr"), jsrPlatformOptionsSchema])) + .or(z.tuple([z.literal("npm"), npmPlatformOptionsSchema])), +); + export type Config = z.input; export const configSchema = z.object({ - platforms: z.array( - z - .literal("jsr") - .or(z.literal("npm")) - .or(z.tuple([z.literal("jsr"), jsrPlatformOptionsSchema])) - .or(z.tuple([z.literal("npm"), npmPlatformOptionsSchema])), - ), + platforms: platformsSchema, tmpDirectory: z.string().default(".release"), useChangesets: z.boolean().default(true), }); diff --git a/core/multipublish/src/util.ts b/core/multipublish/src/util.ts index 88173bd6..db9e4e0a 100644 --- a/core/multipublish/src/util.ts +++ b/core/multipublish/src/util.ts @@ -5,7 +5,7 @@ import * as cp from "node:child_process"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; -import { type JsrSchema, jsrTransformer } from "./jsr"; +import { jsrTransformer } from "./jsr"; export type Changeset = { changesets: { @@ -60,9 +60,7 @@ export function gitClean(filename: string) { }); } -export async function loadJsrConfigFile( - basePath: string, -): Promise<{ config: JsrSchema | null; filename?: string }> { +export async function loadJsrConfigFile(basePath: string) { const files = await fg(basePath + "/{deno,jsr}.json{,c}"); if (files.length > 1) { throw new Error("please only have one deno or jsr configuration file"); @@ -78,6 +76,6 @@ export async function loadJsrConfigFile( return { config: JSON.parse(file), filename: configFile }; } -export function transformPkgJsonForJsr(pkg: Package): JsrSchema { +export function transformPkgJsonForJsr(pkg: Package) { return jsrTransformer.parse(pkg.packageJson); } From 445c04237a03031bef283aaf71ca303b768c3c89 Mon Sep 17 00:00:00 2001 From: stephansama Date: Fri, 9 Jan 2026 08:38:14 -0500 Subject: [PATCH 14/32] build(multipublish): clean package.json after jsr publish with experimental catalogs The current JSR publish process might temporarily modify the `package.json` file. This commit adds a `gitClean` operation for `package.json` after a JSR publish, specifically when the `config.experimentalUpdateCatalogs` flag is enabled. This ensures that any temporary modifications to `package.json` are reverted, preventing unintended changes from persisting in the working directory. --- core/multipublish/src/publish.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/multipublish/src/publish.ts b/core/multipublish/src/publish.ts index dfff1f28..4b84bae5 100644 --- a/core/multipublish/src/publish.ts +++ b/core/multipublish/src/publish.ts @@ -118,6 +118,10 @@ export async function publishPlatform( }); util.gitClean(jsr.filename); + if (config.experimentalUpdateCatalogs) { + util.gitClean(path.join(pkg.dir, "package.json")); + } + break; } case "npm": { From d030c4d54dcdbdb757baf75eed0d144b6e3391bc Mon Sep 17 00:00:00 2001 From: stephansama Date: Fri, 9 Jan 2026 09:20:32 -0500 Subject: [PATCH 15/32] docs: add detailed usage and configuration guide This commit significantly expands the README for `core/multipublish` to provide comprehensive guidance on its usage and configuration. New sections include: * Configuration: Details various supported file formats and provides an example configuration for JSR and GitHub NPM registry. * GitHub NPM Registry: Explains necessary GitHub Actions permissions. * JSR: Outlines requirements for publishing to JSR. * Changesets: Provides instructions on integrating `multipublish` with Changesets workflows. --- core/multipublish/README.md | 76 ++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/core/multipublish/README.md b/core/multipublish/README.md index 8629cc88..dee62956 100644 --- a/core/multipublish/README.md +++ b/core/multipublish/README.md @@ -13,6 +13,10 @@ Publish packages to multiple providers easily - [Installation](#installation) - [Usage](#usage) + - [Configuration](#configuration) + - [GitHub NPM Registry](#github-npm-registry) + - [JSR](#jsr) + - [Changesets](#changesets) @@ -24,8 +28,76 @@ pnpm install @stephansama/multipublish ## Usage -```sh -multipublish +### Configuration + +You can configure `multipublish` by creating a configuration file (or object) in the root of your project. The following file formats are supported: + +- `package.json` +- `.multipublishrc.cjs` +- `.multipublishrc.js` +- `.multipublishrc.json` +- `.multipublishrc.mjs` +- `.multipublishrc.ts` +- `.multipublishrc.yaml` +- `.multipublishrc.yml` +- `.multipublishrc` +- `.config/.multipublishrc.json` +- `.config/.multipublishrc.yaml` +- `.config/.multipublishrc.yml` +- `.config/.multipublishrc` +- `.config/multipublishrc.cjs` +- `.config/multipublishrc.js` +- `.config/multipublishrc.json` +- `.config/multipublishrc.mjs` +- `.config/multipublishrc.ts` +- `.config/multipublishrc.yaml` +- `.config/multipublishrc.yml` +- `.config/multipublishrc` +- `multipublish.config.cjs` +- `multipublish.config.js` +- `multipublish.config.mjs` +- `multipublish.config.ts` + +```json +{ + "$schema": "./node_modules/@stephansama/multipublish/config/schema.json", + "platforms": [ + ["jsr", { "experimentalGenerateJSR": true, "defaultExclude": ["!dist"] }], + [ + "npm", + { + "registry": "https://npm.pkg.github.com", + "tokenEnvironmentKey": "GITHUB_TOKEN" + } + ] + ] +} +``` + +### GitHub NPM Registry + +If publishing to the GitHub NPM registry, you must add `packages` to permissions when using a GitHub token. And allow `write` and `read` permissions for workflows (located in repo settings > actions > general). + +```yaml +permissions: + packages: write +``` + +### JSR + +When publishing to JSR, you must either have a valid `jsr.json` or `deno.json`, or allow `experimentalGenerateJSR` using the config option. + +### Changesets + +In order to use this with changesets, please update your version script with a preversion script that calls the `multipublish` CLI. + +```json +{ + "scripts": { + "preversion": "multipublish", + "version": "changeset version" + } +} ``` From ee65ad7dff7b48967d7dadd4c9d44b5bfcb1368f Mon Sep 17 00:00:00 2001 From: stephansama Date: Fri, 9 Jan 2026 17:53:26 -0500 Subject: [PATCH 16/32] feat: add jsr configuration options and update publish logic adds: - new cli arguments for specifying released packages and jsr config - new jsr module to handle updating jsr config files - update multipublish to leverage new arguments and jsr module changes: - refactors dependency update logic to iterate over dependency types - modifies release loading to use new `loadReleases` function - alters `run` function to check for `versionJsr` argument and call `updateJsrConfigVersion` - removes unused `npmrcTemplate` function from `publish` module --- core/multipublish/src/args.ts | 20 ++++++++ core/multipublish/src/catalog.ts | 31 ++++-------- core/multipublish/src/index.ts | 24 +++++---- core/multipublish/src/jsr.ts | 64 +++++++++++++++++++++++- core/multipublish/src/publish.ts | 86 +++++++++++--------------------- core/multipublish/src/release.ts | 62 +++++++++++++++++++++++ core/multipublish/src/schema.ts | 1 + core/multipublish/src/util.ts | 74 ++++++++------------------- 8 files changed, 220 insertions(+), 142 deletions(-) create mode 100644 core/multipublish/src/release.ts diff --git a/core/multipublish/src/args.ts b/core/multipublish/src/args.ts index 8a8f25ac..b536f5a8 100644 --- a/core/multipublish/src/args.ts +++ b/core/multipublish/src/args.ts @@ -11,11 +11,31 @@ let _args: Args | null = null; const args = { config: { alias: "c", description: "Path to config file", type: "string" }, dry: { alias: "d", description: "Perform a dry run", type: "string" }, + released: { + alias: "r", + description: "packages that have been updated and require a publish", + type: "array", + }, + releasedFile: { + alias: "f", + description: "file denoting which packages have been updated", + type: "string", + }, + useChangesetStatus: { + alias: "s", + description: "path to changeset status file used to version release", + type: "string", + }, verbose: { alias: "v", description: "Enable verbose logging", type: "boolean", }, + versionJsr: { + alias: "j", + description: "update version jsr configuration files", + type: "boolean", + }, } satisfies Record; export async function getArgs() { diff --git a/core/multipublish/src/catalog.ts b/core/multipublish/src/catalog.ts index 5ac8ee97..864fa2bc 100644 --- a/core/multipublish/src/catalog.ts +++ b/core/multipublish/src/catalog.ts @@ -69,26 +69,17 @@ export async function updatePackageJsonWithCatalog( const catalogStrategy = catalogLoadMap[agent]; const catalogs = await catalogStrategy(); - for (const [dependency, version] of Object.entries( - pkg.packageJson.dependencies || {}, - )) { - pkg.packageJson.dependencies ??= {}; - pkg.packageJson.dependencies[dependency] = loadVersion({ - catalogs, - dependency, - version, - }); - } - - for (const [dependency, version] of Object.entries( - pkg.packageJson.devDependencies || {}, - )) { - pkg.packageJson.devDependencies ??= {}; - pkg.packageJson.devDependencies[dependency] = loadVersion({ - catalogs, - dependency, - version, - }); + for (const dependencyType of ["dependencies", "devDependencies"] as const) { + for (const [dependency, version] of Object.entries( + pkg.packageJson[dependencyType] || {}, + )) { + pkg.packageJson[dependencyType] ??= {}; + pkg.packageJson[dependencyType][dependency] = loadVersion({ + catalogs, + dependency, + version, + }); + } } const packageJsonPath = path.join(pkg.dir, "package.json"); diff --git a/core/multipublish/src/index.ts b/core/multipublish/src/index.ts index 37416207..8a83414b 100644 --- a/core/multipublish/src/index.ts +++ b/core/multipublish/src/index.ts @@ -5,34 +5,36 @@ import { getPackages } from "@manypkg/get-packages"; import { getArgs } from "./args"; import { loadConfig } from "./config"; +import { updateJsrConfigVersion } from "./jsr"; import { publishPlatform } from "./publish"; -import * as util from "./util"; +import { loadReleases } from "./release"; export async function run() { + const root = await findRoot(process.cwd()); const args = await getArgs(); const config = await loadConfig(args); - const root = await findRoot(process.cwd()); const { packages } = await getPackages(root.rootDir); - const { releases } = await util.getChangesetReleases(); + const releases = await loadReleases(args); const releasedPackages = releases.map((release) => { - const packageJson = packages.find( - (pkg) => pkg.packageJson.name === release.name, + const pkg = packages.find( + (curr) => curr.packageJson.name === release.name, ); - if (!packageJson) { + if (!pkg) { throw new Error( `unable to find package for released package ${release.name}`, ); } - return { - newVersion: release.newVersion, - oldVersion: release.oldVersion, - ...packageJson, - }; + return { ...pkg, version: release.version }; }); for (const pkg of releasedPackages) { + if (args.versionJsr) { + await updateJsrConfigVersion(pkg); + continue; + } + for (const platform of config.platforms) { await publishPlatform(pkg, platform); } diff --git a/core/multipublish/src/jsr.ts b/core/multipublish/src/jsr.ts index 6638bc5b..7b938e8e 100644 --- a/core/multipublish/src/jsr.ts +++ b/core/multipublish/src/jsr.ts @@ -1,5 +1,11 @@ +import type { Package } from "@manypkg/get-packages"; + +import fg from "fast-glob"; +import * as fsp from "node:fs/promises"; import * as z from "zod"; +import type { JsrPlatformOptionsSchema } from "./schema"; + type ExportsSchema = z.infer; export const exportSchema = z.string().or( z.record( @@ -27,6 +33,8 @@ export const jsrTransformer = packageJsonSchema.transform((schema) => ({ version: schema.version, })); +export { jsrTransformer as transformer }; + export type JsrSchema = z.infer; export const jsrSchema = z.object({ exclude: z.array(z.string()).optional(), @@ -40,7 +48,61 @@ export const jsrSchema = z.object({ version: z.string(), }); -export function convertPkgJsonExportsToJsr(exports: ExportsSchema) { +export async function loadConfig(basePath: string) { + const files = await fg(basePath + "/{deno,jsr}.json{,c}"); + if (files.length > 1) { + throw new Error("please only have one deno or jsr configuration file"); + } + + const configFile = files.at(0); + if (!configFile) { + console.info("no jsr config file found"); + return { config: null, filename: undefined }; + } + + const file = await fsp.readFile(configFile, { encoding: "utf8" }); + return { config: jsrSchema.parse(JSON.parse(file)), filename: configFile }; +} + +export function updateIncludeExcludeList( + jsrConfig: JsrSchema, + appConfig: JsrPlatformOptionsSchema, +) { + for (const key of ["include", "exclude"] as const) { + const capitalizedKey = key.at(0)?.toUpperCase() + key.slice(1); + const appConfigKey = + `default${capitalizedKey}` as `default${Capitalize}`; + const appConfigList = appConfig[appConfigKey] || []; + + jsrConfig[key] ??= []; + jsrConfig[key] = [...jsrConfig[key], ...appConfigList]; + } +} + +export async function updateJsrConfigVersion( + pkg: Package & { version: string }, +) { + const userJsr = await loadConfig(pkg.dir); + if (!userJsr.config || !userJsr.filename) { + throw new Error("unable to load user provided deno/jsr config file"); + } + + if (!pkg.version) { + throw new Error( + `no new version supplied for package ${pkg.packageJson.name}`, + ); + } + + userJsr.config.version = pkg.version; + + await fsp.writeFile( + userJsr.filename, + JSON.stringify(userJsr.config, undefined, 2), + "utf8", + ); +} + +function convertPkgJsonExportsToJsr(exports: ExportsSchema) { if (typeof exports === "string") return exports; return Object.fromEntries( Object.entries(exports).map(([key, value]) => [ diff --git a/core/multipublish/src/publish.ts b/core/multipublish/src/publish.ts index 4b84bae5..c1473827 100644 --- a/core/multipublish/src/publish.ts +++ b/core/multipublish/src/publish.ts @@ -1,7 +1,6 @@ import type { Package } from "@manypkg/get-packages"; import { findRoot } from "@manypkg/find-root"; -import dedent from "dedent"; import * as cp from "node:child_process"; import * as fs from "node:fs"; import * as fsp from "node:fs/promises"; @@ -12,30 +11,10 @@ import type { Config } from "./schema"; import { getArgs } from "./args"; import { updatePackageJsonWithCatalog } from "./catalog"; import { type AgentName, detectPackageManager } from "./detect"; +import * as jsr from "./jsr"; import { jsrPlatformOptionsSchema, npmPlatformOptionsSchema } from "./schema"; import * as util from "./util"; -export function npmrcTemplate({ - authToken: AUTH_TOKEN, - registry: REGISTRY, - registryDomain: REGISTRY_DOMAIN, - scope: SCOPE, -}: { - authToken: string; - registry: string; - registryDomain: string; - scope: string; -}) { - return dedent` - {{SCOPE}}:registry={{REGISTRY}} - //{{REGISTRY_DOMAIN}}/:_authToken={{AUTH_TOKEN}} - ` - .replace("{{AUTH_TOKEN}}", AUTH_TOKEN) - .replace("{{REGISTRY}}", REGISTRY) - .replace("{{REGISTRY_DOMAIN}}", REGISTRY_DOMAIN) - .replace("{{SCOPE}}", SCOPE); -} - export const npmPublishCommand = { bun: "bun publish", npm: "npm publish", @@ -52,7 +31,7 @@ export const jsrPublishCommand = { } satisfies Record; export async function publishPlatform( - pkg: Package & { newVersion: string; oldVersion: string }, + pkg: Package, platform: Config["platforms"][number], ) { const packageManager = await detectPackageManager(); @@ -61,36 +40,30 @@ export async function publishPlatform( const rawConfig = isString ? {} : platform[1]; const args = await getArgs(); const isDryRun = !!args.dry; + const packageJsonPath = path.join(pkg.dir, "package.json"); switch (key) { case "jsr": { const config = jsrPlatformOptionsSchema.parse(rawConfig); - const jsr = await util.loadJsrConfigFile(pkg.dir); + const userJsr = await jsr.loadConfig(pkg.dir); if (config.experimentalGenerateJSR) { - jsr.config = util.transformPkgJsonForJsr(pkg); - jsr.filename = path.join(pkg.dir, util.JSR_CONFIG_FILENAME); + userJsr.config = jsr.transformer.parse(pkg.packageJson); + userJsr.filename = path.join(pkg.dir, util.JSR_CONFIG_FILENAME); } - if (!jsr.config) throw new Error("failed to load jsr config file"); - if (!jsr.filename) { - throw new Error("failed to load jsr config filename"); + if (!userJsr.config) { + throw new Error("failed to load userJsr config file"); } - jsr.config.include ??= []; - jsr.config.include = [ - ...jsr.config.include, - ...(config.defaultInclude ? config.defaultInclude : []), - ]; + if (!userJsr.filename) { + throw new Error("failed to load userJsr config filename"); + } - jsr.config.exclude ??= []; - jsr.config.exclude = [ - ...jsr.config.exclude, - ...(config.defaultExclude ? config.defaultExclude : []), - ]; + jsr.updateIncludeExcludeList(userJsr.config, config); - jsr.config.version = pkg.newVersion; - await fsp.writeFile(jsr.filename, JSON.stringify(jsr.config)); + const jsrFile = JSON.stringify(userJsr.config, undefined, 2); + await fsp.writeFile(userJsr.filename, jsrFile); if (config.experimentalUpdateCatalogs) { if (packageManager === "pnpm" || packageManager === "bun") { @@ -103,10 +76,9 @@ export async function publishPlatform( } await util.chdir(pkg.dir, () => { - const command = jsrPublishCommand[packageManager]; cp.execSync( [ - command, + jsrPublishCommand[packageManager], "--allow-dirty", isDryRun && "--dry-run", config.allowSlowTypes && "--allow-slow-types", @@ -117,15 +89,16 @@ export async function publishPlatform( ); }); - util.gitClean(jsr.filename); + util.gitClean(userJsr.filename); if (config.experimentalUpdateCatalogs) { - util.gitClean(path.join(pkg.dir, "package.json")); + util.gitClean(packageJsonPath); } break; } case "npm": { const { rootDir } = await findRoot(process.cwd()); + const npmrcPath = path.join(rootDir, ".npmrc"); const config = npmPlatformOptionsSchema.parse(rawConfig); if (packageManager === "deno") { @@ -141,9 +114,8 @@ export async function publishPlatform( ); } - const npmrcPath = path.join(rootDir, ".npmrc"); const npmrcPrefix = fs.existsSync(npmrcPath) - ? await fsp.readFile(npmrcPath, { encoding: "utf8" }) + ? await fsp.readFile(npmrcPath, "utf8") : ""; const scope = pkg.packageJson.name.split("/").at(0); @@ -154,7 +126,7 @@ export async function publishPlatform( const npmrcFile = npmrcPrefix + "\n" + - npmrcTemplate({ + util.npmrcTemplate({ authToken, registry: config.registry, registryDomain: new URL(config.registry).host, @@ -167,19 +139,19 @@ export async function publishPlatform( case "package.json": { pkg.packageJson.publishConfig ??= {}; pkg.packageJson.publishConfig.registry = config.registry; + const file = JSON.stringify(pkg.packageJson, undefined, 2); + await fsp.writeFile(packageJsonPath, file); break; } } - pkg.packageJson.version = pkg.newVersion; - const file = JSON.stringify(pkg.packageJson, undefined, 2); - const packageJsonPath = path.join(pkg.dir, "package.json"); - await fsp.writeFile(packageJsonPath, file); - await util.chdir(pkg.dir, () => { - const command = npmPublishCommand[packageManager]; cp.execSync( - [command, "--no-git-checks", isDryRun && "--dry-run"] + [ + npmPublishCommand[packageManager], + packageManager === "pnpm" && "--no-git-checks", + isDryRun && "--dry-run", + ] .filter((x) => x) .join(" "), { stdio: "inherit" }, @@ -187,9 +159,7 @@ export async function publishPlatform( }); util.gitClean(packageJsonPath); - if (config.strategy === ".npmrc") { - util.gitClean(path.join(rootDir, ".npmrc")); - } + if (config.strategy === ".npmrc") util.gitClean(npmrcPath); break; } default: diff --git a/core/multipublish/src/release.ts b/core/multipublish/src/release.ts new file mode 100644 index 00000000..872852f3 --- /dev/null +++ b/core/multipublish/src/release.ts @@ -0,0 +1,62 @@ +import * as fsp from "node:fs/promises"; +import path from "node:path"; +import * as z from "zod"; + +import type { Args } from "./args"; + +import { gitClean, readStdin } from "./util"; + +export type ReleaseSchema = z.infer; +export const releaseSchema = z.object({ + name: z.string(), + version: z.string().optional(), +}); + +export type ReleasesSchema = z.infer; +export const releasesSchema = z.array(releaseSchema); + +export type ChangesetStatusSchemaInput = z.input; +export type ChangesetStatusSchemaOutput = z.output< + typeof changesetStatusSchema +>; + +export const changesetStatusSchema = z + .object({ + releases: z.array( + z.object({ name: z.string(), newVersion: z.string() }), + ), + }) + .transform((schema) => + schema.releases.map((release) => ({ + name: release.name, + version: release.newVersion, + })), + ); + +export async function loadReleases(args: Args) { + if (args.released) { + return releasesSchema.parse(args.released.map((name) => ({ name }))); + } + + if (args.releasedFile) { + const releasedFile = await fsp.readFile(args.releasedFile, "utf8"); + const releasedInfo = JSON.parse(releasedFile); + return releasesSchema.parse(releasedInfo); + } + + if (args.useChangesetStatus) { + const changesetStatusPath = path.join( + process.cwd(), + args.useChangesetStatus, + ); + + const file = await fsp.readFile(changesetStatusPath, "utf8"); + + gitClean(args.useChangesetStatus); + + return changesetStatusSchema.parse(JSON.parse(file)); + } + + const input = await readStdin(); + return releasesSchema.parse(input); +} diff --git a/core/multipublish/src/schema.ts b/core/multipublish/src/schema.ts index b8d75ba2..57b31f9b 100644 --- a/core/multipublish/src/schema.ts +++ b/core/multipublish/src/schema.ts @@ -3,6 +3,7 @@ import * as z from "zod"; export type JsrPlatformOptionsSchema = z.infer; export const jsrPlatformOptionsSchema = z.object({ allowSlowTypes: z.boolean().default(true), + commitJsrVersionUpdate: z.boolean().default(false), defaultExclude: z.array(z.string()).optional(), defaultInclude: z.array(z.string()).optional(), experimentalGenerateJSR: z.boolean().default(false), diff --git a/core/multipublish/src/util.ts b/core/multipublish/src/util.ts index db9e4e0a..9430fdaa 100644 --- a/core/multipublish/src/util.ts +++ b/core/multipublish/src/util.ts @@ -1,26 +1,6 @@ -import type { Package } from "@manypkg/get-packages"; - -import fg from "fast-glob"; +import dedent from "dedent"; import * as cp from "node:child_process"; -import * as fsp from "node:fs/promises"; -import * as path from "node:path"; - -import { jsrTransformer } from "./jsr"; - -export type Changeset = { - changesets: { - id: string; - releases: { name: string; type: string }[]; - summary: string; - }[]; - releases: { - changesets: string[]; - name: string; - newVersion: string; - oldVersion: string; - type: "major" | "minor" | "patch"; - }[]; -}; +import * as process from "node:process"; export const MODULE_NAME = "multipublish" as const; export const JSR_CONFIG_FILENAME = "jsr.json" as const; @@ -40,42 +20,32 @@ export async function chdir( } } -export async function getChangesetReleases(): Promise { - const tmpFile = await getTmpFile("changeset.json"); - cp.execSync(`changeset status --output=${tmpFile}`, { stdio: "inherit" }); - const file = await fsp.readFile(tmpFile, { encoding: "utf8" }); - await fsp.rm(tmpFile, { recursive: true }); - return JSON.parse(file); -} - -export async function getTmpFile(filename: string) { - const tmpDir = ".publish"; - await fsp.mkdir(tmpDir, { recursive: true }); - return path.join(tmpDir, filename); -} - export function gitClean(filename: string) { cp.execFileSync("git", ["clean", "-f", "--", filename], { stdio: "inherit", }); } -export async function loadJsrConfigFile(basePath: string) { - const files = await fg(basePath + "/{deno,jsr}.json{,c}"); - if (files.length > 1) { - throw new Error("please only have one deno or jsr configuration file"); - } - - const configFile = files.at(0); - if (!configFile) { - console.info("no jsr config file found"); - return { config: null, filename: undefined }; - } - - const file = await fsp.readFile(configFile, { encoding: "utf8" }); - return { config: JSON.parse(file), filename: configFile }; +export function npmrcTemplate(opts: { + authToken: string; + registry: string; + registryDomain: string; + scope: string; +}) { + return dedent` + {{SCOPE}}:registry={{REGISTRY}} + //{{REGISTRY_DOMAIN}}/:_authToken={{AUTH_TOKEN}} + ` + .replace("{{AUTH_TOKEN}}", opts.authToken) + .replace("{{REGISTRY}}", opts.registry) + .replace("{{REGISTRY_DOMAIN}}", opts.registryDomain) + .replace("{{SCOPE}}", opts.scope); } -export function transformPkgJsonForJsr(pkg: Package) { - return jsrTransformer.parse(pkg.packageJson); +export async function readStdin() { + process.stdin.setEncoding("utf8"); + if (process.stdin.isTTY) return null; + let chunks = ""; + for await (const chunk of process.stdin) chunks += chunk; + return chunks; } From e680b7ecdd1637fa05bae7b4ca493ae68f99552c Mon Sep 17 00:00:00 2001 From: stephansama Date: Fri, 9 Jan 2026 18:58:19 -0500 Subject: [PATCH 17/32] fix(multipublish): correctly handle dry run argument and package versions --- core/multipublish/src/args.ts | 2 +- core/multipublish/src/index.ts | 2 +- core/multipublish/src/jsr.ts | 6 ------ 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/core/multipublish/src/args.ts b/core/multipublish/src/args.ts index b536f5a8..1385f5c5 100644 --- a/core/multipublish/src/args.ts +++ b/core/multipublish/src/args.ts @@ -10,7 +10,7 @@ let _args: Args | null = null; const args = { config: { alias: "c", description: "Path to config file", type: "string" }, - dry: { alias: "d", description: "Perform a dry run", type: "string" }, + dry: { alias: "d", description: "Perform a dry run", type: "boolean" }, released: { alias: "r", description: "packages that have been updated and require a publish", diff --git a/core/multipublish/src/index.ts b/core/multipublish/src/index.ts index 8a83414b..eb54ef08 100644 --- a/core/multipublish/src/index.ts +++ b/core/multipublish/src/index.ts @@ -26,7 +26,7 @@ export async function run() { ); } - return { ...pkg, version: release.version }; + return { ...pkg, version: release.version || pkg.packageJson.version }; }); for (const pkg of releasedPackages) { diff --git a/core/multipublish/src/jsr.ts b/core/multipublish/src/jsr.ts index 7b938e8e..13535e45 100644 --- a/core/multipublish/src/jsr.ts +++ b/core/multipublish/src/jsr.ts @@ -87,12 +87,6 @@ export async function updateJsrConfigVersion( throw new Error("unable to load user provided deno/jsr config file"); } - if (!pkg.version) { - throw new Error( - `no new version supplied for package ${pkg.packageJson.name}`, - ); - } - userJsr.config.version = pkg.version; await fsp.writeFile( From 591203b66614bd45a5873be9679adf173e71d3b1 Mon Sep 17 00:00:00 2001 From: stephansama Date: Sat, 10 Jan 2026 17:43:08 -0500 Subject: [PATCH 18/32] feat: add bun support and automate changeset status generation The `useChangesetStatus` argument in multipublish is now a boolean flag. When enabled, the `changeset status` command is executed directly to generate the necessary status JSON file, streamlining the release process and removing the need for a manually provided path. Additionally, this change removes the previous restriction, enabling full support for Bun as a package manager within multipublish. A minor refactor was also applied to the dependency iteration in the catalog module for improved readability. --- core/multipublish/src/args.ts | 2 +- core/multipublish/src/catalog.ts | 5 ++--- core/multipublish/src/detect.ts | 1 - core/multipublish/src/release.ts | 18 ++++++------------ 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/core/multipublish/src/args.ts b/core/multipublish/src/args.ts index 1385f5c5..f8bbd9b4 100644 --- a/core/multipublish/src/args.ts +++ b/core/multipublish/src/args.ts @@ -24,7 +24,7 @@ const args = { useChangesetStatus: { alias: "s", description: "path to changeset status file used to version release", - type: "string", + type: "boolean", }, verbose: { alias: "v", diff --git a/core/multipublish/src/catalog.ts b/core/multipublish/src/catalog.ts index 864fa2bc..64edead2 100644 --- a/core/multipublish/src/catalog.ts +++ b/core/multipublish/src/catalog.ts @@ -70,9 +70,8 @@ export async function updatePackageJsonWithCatalog( const catalogs = await catalogStrategy(); for (const dependencyType of ["dependencies", "devDependencies"] as const) { - for (const [dependency, version] of Object.entries( - pkg.packageJson[dependencyType] || {}, - )) { + const entries = Object.entries(pkg.packageJson[dependencyType] || {}); + for (const [dependency, version] of entries) { pkg.packageJson[dependencyType] ??= {}; pkg.packageJson[dependencyType][dependency] = loadVersion({ catalogs, diff --git a/core/multipublish/src/detect.ts b/core/multipublish/src/detect.ts index 09298d61..dfc79710 100644 --- a/core/multipublish/src/detect.ts +++ b/core/multipublish/src/detect.ts @@ -11,7 +11,6 @@ export async function detectPackageManager() { if (!detected) throw new Error("unable to detect package manager"); _detected = detected.name; - if (_detected === "bun") throw new Error("bun is not supported"); return _detected; } diff --git a/core/multipublish/src/release.ts b/core/multipublish/src/release.ts index 872852f3..b55cc03c 100644 --- a/core/multipublish/src/release.ts +++ b/core/multipublish/src/release.ts @@ -1,5 +1,5 @@ +import * as cp from "node:child_process"; import * as fsp from "node:fs/promises"; -import path from "node:path"; import * as z from "zod"; import type { Args } from "./args"; @@ -15,11 +15,7 @@ export const releaseSchema = z.object({ export type ReleasesSchema = z.infer; export const releasesSchema = z.array(releaseSchema); -export type ChangesetStatusSchemaInput = z.input; -export type ChangesetStatusSchemaOutput = z.output< - typeof changesetStatusSchema ->; - +export type ChangesetStatusSchema = z.input; export const changesetStatusSchema = z .object({ releases: z.array( @@ -45,14 +41,12 @@ export async function loadReleases(args: Args) { } if (args.useChangesetStatus) { - const changesetStatusPath = path.join( - process.cwd(), - args.useChangesetStatus, - ); + const changesetOutput = ".multipublish.status.json"; + cp.execFileSync("changeset", ["status", `--output=${changesetOutput}`]); - const file = await fsp.readFile(changesetStatusPath, "utf8"); + const file = await fsp.readFile(changesetOutput, "utf8"); - gitClean(args.useChangesetStatus); + gitClean(changesetOutput); return changesetStatusSchema.parse(JSON.parse(file)); } From 2897efd37c568673d7bae7286f7bdf43ce569d95 Mon Sep 17 00:00:00 2001 From: stephansama Date: Wed, 14 Jan 2026 18:51:13 -0500 Subject: [PATCH 19/32] test: add unit tests for core multipublish modules - Adds tests for `catalogSchema` and `loadVersion` in `catalog.ts`. - Includes tests for `jsrTransformer` and `updateIncludeExcludeList` in `jsr.ts`. - Adds tests for `changesetStatusSchema` in `release.ts`. - Covers `platformsSchema` and `configSchema` in `schema.ts`, including default values and option parsing. The shebang `#!/usr/bin/env node` was also removed from `index.ts`, as it is not intended to be a standalone executable script. --- core/multipublish/src/catalog.test.ts | 90 ++++++++++++++++++++++++++ core/multipublish/src/index.ts | 2 - core/multipublish/src/jsr.test.ts | 91 +++++++++++++++++++++++++++ core/multipublish/src/release.test.ts | 37 +++++++++++ core/multipublish/src/schema.test.ts | 74 ++++++++++++++++++++++ core/multipublish/src/util.test.ts | 22 +++++++ core/multipublish/vitest.config.ts | 8 +++ 7 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 core/multipublish/src/catalog.test.ts create mode 100644 core/multipublish/src/jsr.test.ts create mode 100644 core/multipublish/src/release.test.ts create mode 100644 core/multipublish/src/schema.test.ts create mode 100644 core/multipublish/src/util.test.ts create mode 100644 core/multipublish/vitest.config.ts diff --git a/core/multipublish/src/catalog.test.ts b/core/multipublish/src/catalog.test.ts new file mode 100644 index 00000000..2cf6bd82 --- /dev/null +++ b/core/multipublish/src/catalog.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import type { CatalogSchema } from "./catalog.ts"; + +import { catalogSchema, loadVersion } from "./catalog.ts"; + +describe("catalog", () => { + describe("catalogSchema", () => { + it("should parse default catalog", () => { + const input = { + catalog: { + react: "^18.0.0", + }, + }; + const result = catalogSchema.parse(input); + expect(result.catalog).toEqual(input.catalog); + }); + + it("should parse named catalogs", () => { + const input = { + catalogs: { + react18: { react: "^18.0.0" }, + react19: { react: "^19.0.0" }, + }, + }; + const result = catalogSchema.parse(input); + expect(result.catalogs).toEqual(input.catalogs); + }); + }); + + describe("loadVersion", () => { + const catalogs: CatalogSchema = { + catalog: { + foo: "1.0.0", + }, + catalogs: { + legacy: { + foo: "0.9.0", + }, + }, + }; + + it("should return version if not using catalog", () => { + const result = loadVersion({ + catalogs, + dependency: "foo", + version: "^2.0.0", + }); + expect(result).toBe("^2.0.0"); + }); + + it("should resolve default catalog", () => { + const result = loadVersion({ + catalogs, + dependency: "foo", + version: "catalog:", + }); + expect(result).toBe("1.0.0"); + }); + + it("should resolve named catalog", () => { + const result = loadVersion({ + catalogs, + dependency: "foo", + version: "catalog:legacy", + }); + expect(result).toBe("0.9.0"); + }); + + it("should throw if catalog dependency not found", () => { + expect(() => { + loadVersion({ + catalogs, + dependency: "bar", + version: "catalog:", + }); + }).toThrow(); + }); + + it("should throw if named catalog not found", () => { + expect(() => { + loadVersion({ + catalogs, + dependency: "foo", + version: "catalog:missing", + }); + }).toThrow(); + }); + }); +}); diff --git a/core/multipublish/src/index.ts b/core/multipublish/src/index.ts index eb54ef08..793d6e4d 100644 --- a/core/multipublish/src/index.ts +++ b/core/multipublish/src/index.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import { findRoot } from "@manypkg/find-root"; import { getPackages } from "@manypkg/get-packages"; diff --git a/core/multipublish/src/jsr.test.ts b/core/multipublish/src/jsr.test.ts new file mode 100644 index 00000000..1636f477 --- /dev/null +++ b/core/multipublish/src/jsr.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import type { JsrSchema } from "./jsr.ts"; +import type { JsrPlatformOptionsSchema } from "./schema.ts"; + +import { jsrTransformer, updateIncludeExcludeList } from "./jsr.ts"; + +describe("jsr", () => { + describe("jsrTransformer", () => { + it("should transform simple exports", () => { + const input = { + exports: "./index.js", + name: "@scope/pkg", + version: "1.0.0", + }; + const result = jsrTransformer.parse(input); + expect(result.exports).toBe("./index.js"); + }); + + it("should transform complex exports", () => { + const input = { + exports: { + ".": { + import: { default: "./index.js" }, + require: { default: "./index.cjs" }, + }, + "./foo": "./foo.js", + }, + name: "@scope/pkg", + version: "1.0.0", + }; + const result = jsrTransformer.parse(input); + expect(result.exports).toEqual({ + ".": "./index.js", + "./foo": "./foo.js", + }); + }); + }); + + describe("updateIncludeExcludeList", () => { + it("should add default include/exclude", () => { + const jsrConfig: JsrSchema = { + exports: "./index.ts", + name: "@scope/pkg", + version: "1.0.0", + }; + const appConfig: JsrPlatformOptionsSchema = { + allowSlowTypes: true, + commitJsrVersionUpdate: false, + defaultExclude: ["test"], + defaultInclude: ["src"], + experimentalGenerateJSR: false, + experimentalUpdateCatalogs: false, + }; + + updateIncludeExcludeList(jsrConfig, appConfig); + + expect(jsrConfig.include).toEqual(["src"]); + expect(jsrConfig.exclude).toEqual(["test"]); + }); + + it("should merge with existing include/exclude", () => { + const jsrConfig: JsrSchema = { + exclude: ["existing-exclude"], + exports: "./index.ts", + include: ["existing-include"], + name: "@scope/pkg", + version: "1.0.0", + }; + const appConfig: JsrPlatformOptionsSchema = { + allowSlowTypes: true, + commitJsrVersionUpdate: false, + defaultExclude: ["new-exclude"], + defaultInclude: ["new-include"], + experimentalGenerateJSR: false, + experimentalUpdateCatalogs: false, + }; + + updateIncludeExcludeList(jsrConfig, appConfig); + + expect(jsrConfig.include).toEqual([ + "existing-include", + "new-include", + ]); + expect(jsrConfig.exclude).toEqual([ + "existing-exclude", + "new-exclude", + ]); + }); + }); +}); diff --git a/core/multipublish/src/release.test.ts b/core/multipublish/src/release.test.ts new file mode 100644 index 00000000..5a397dee --- /dev/null +++ b/core/multipublish/src/release.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { changesetStatusSchema } from "./release.ts"; + +describe("release", () => { + describe("changesetStatusSchema", () => { + it("should transform changeset status to releases", () => { + const input = { + changesets: [], + releases: [ + { + changesets: [], + name: "pkg-a", + newVersion: "1.0.1", + oldVersion: "1.0.0", + type: "patch", + }, + { + changesets: [], + name: "pkg-b", + newVersion: "2.1.0", + oldVersion: "2.0.0", + type: "minor", + }, + ], + }; + + const expected = [ + { name: "pkg-a", version: "1.0.1" }, + { name: "pkg-b", version: "2.1.0" }, + ]; + + const result = changesetStatusSchema.parse(input); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/core/multipublish/src/schema.test.ts b/core/multipublish/src/schema.test.ts new file mode 100644 index 00000000..aa2a6b5a --- /dev/null +++ b/core/multipublish/src/schema.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { configSchema, platformsSchema } from "./schema.ts"; + +describe("schema", () => { + describe("platformsSchema", () => { + it("should accept simple platform strings", () => { + const valid = ["npm", "jsr"]; + const result = platformsSchema.parse(valid); + expect(result).toEqual(valid); + }); + + it("should accept platform tuples with options", () => { + const input = [ + ["npm", { registry: "https://custom.registry" }], + ["jsr", { allowSlowTypes: false }], + ]; + const expected = [ + [ + "npm", + { + registry: "https://custom.registry", + strategy: ".npmrc", + tokenEnvironmentKey: "NODE_AUTH_TOKEN", + }, + ], + [ + "jsr", + { + allowSlowTypes: false, + commitJsrVersionUpdate: false, + experimentalGenerateJSR: false, + experimentalUpdateCatalogs: false, + }, + ], + ]; + const result = platformsSchema.parse(input); + expect(result).toEqual(expected); + }); + + it("should reject invalid platforms", () => { + expect(() => { + platformsSchema.parse(["invalid"]); + }).toThrow(); + }); + }); + + describe("configSchema", () => { + it("should accept valid config", () => { + const config = { + platforms: ["npm"], + tmpDirectory: "custom-tmp", + useChangesets: false, + }; + const result = configSchema.parse(config); + expect(result).toEqual(config); + }); + + it("should use default values", () => { + const config = { + platforms: ["jsr"], + }; + const result = configSchema.parse(config); + expect(result.tmpDirectory).toBe(".release"); + expect(result.useChangesets).toBe(true); + }); + + it("should reject missing required fields", () => { + expect(() => { + configSchema.parse({}); // platforms is required + }).toThrow(); + }); + }); +}); diff --git a/core/multipublish/src/util.test.ts b/core/multipublish/src/util.test.ts new file mode 100644 index 00000000..7784a23e --- /dev/null +++ b/core/multipublish/src/util.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { npmrcTemplate } from "./util.ts"; + +describe("npmrcTemplate", () => { + it("should generate correct .npmrc content", () => { + const opts = { + authToken: "my-secret-token", + registry: "https://registry.npmjs.org/", + registryDomain: "registry.npmjs.org", + scope: "@my-scope", + }; + + const expected = ` +@my-scope:registry=https://registry.npmjs.org/ +//registry.npmjs.org/:_authToken=my-secret-token +`.trim(); + + const result = npmrcTemplate(opts); + expect(result.trim()).toBe(expected); + }); +}); diff --git a/core/multipublish/vitest.config.ts b/core/multipublish/vitest.config.ts new file mode 100644 index 00000000..2fb5c48d --- /dev/null +++ b/core/multipublish/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, +}); From b5118bf647c6dc0f4bb8f478ac48056551ccfd87 Mon Sep 17 00:00:00 2001 From: stephansama Date: Wed, 14 Jan 2026 18:56:39 -0500 Subject: [PATCH 20/32] refactor: normalize import paths in test files --- core/multipublish/src/catalog.test.ts | 4 ++-- core/multipublish/src/jsr.test.ts | 6 +++--- core/multipublish/src/release.test.ts | 2 +- core/multipublish/src/schema.test.ts | 2 +- core/multipublish/src/util.test.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/core/multipublish/src/catalog.test.ts b/core/multipublish/src/catalog.test.ts index 2cf6bd82..f630d476 100644 --- a/core/multipublish/src/catalog.test.ts +++ b/core/multipublish/src/catalog.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import type { CatalogSchema } from "./catalog.ts"; +import type { CatalogSchema } from "./catalog"; -import { catalogSchema, loadVersion } from "./catalog.ts"; +import { catalogSchema, loadVersion } from "./catalog"; describe("catalog", () => { describe("catalogSchema", () => { diff --git a/core/multipublish/src/jsr.test.ts b/core/multipublish/src/jsr.test.ts index 1636f477..91ab3951 100644 --- a/core/multipublish/src/jsr.test.ts +++ b/core/multipublish/src/jsr.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import type { JsrSchema } from "./jsr.ts"; -import type { JsrPlatformOptionsSchema } from "./schema.ts"; +import type { JsrSchema } from "./jsr"; +import type { JsrPlatformOptionsSchema } from "./schema"; -import { jsrTransformer, updateIncludeExcludeList } from "./jsr.ts"; +import { jsrTransformer, updateIncludeExcludeList } from "./jsr"; describe("jsr", () => { describe("jsrTransformer", () => { diff --git a/core/multipublish/src/release.test.ts b/core/multipublish/src/release.test.ts index 5a397dee..9dd2b3bb 100644 --- a/core/multipublish/src/release.test.ts +++ b/core/multipublish/src/release.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { changesetStatusSchema } from "./release.ts"; +import { changesetStatusSchema } from "./release"; describe("release", () => { describe("changesetStatusSchema", () => { diff --git a/core/multipublish/src/schema.test.ts b/core/multipublish/src/schema.test.ts index aa2a6b5a..663f7755 100644 --- a/core/multipublish/src/schema.test.ts +++ b/core/multipublish/src/schema.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { configSchema, platformsSchema } from "./schema.ts"; +import { configSchema, platformsSchema } from "./schema"; describe("schema", () => { describe("platformsSchema", () => { diff --git a/core/multipublish/src/util.test.ts b/core/multipublish/src/util.test.ts index 7784a23e..99ac9bb4 100644 --- a/core/multipublish/src/util.test.ts +++ b/core/multipublish/src/util.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { npmrcTemplate } from "./util.ts"; +import { npmrcTemplate } from "./util"; describe("npmrcTemplate", () => { it("should generate correct .npmrc content", () => { From 81a90cf7132202ef952b437baec3217f3d096b6d Mon Sep 17 00:00:00 2001 From: stephansama Date: Wed, 14 Jan 2026 19:16:57 -0500 Subject: [PATCH 21/32] test: add comprehensive tests for multipublish package publishing Adds a new test file, `publish.test.ts`, to provide comprehensive unit tests for the `publishPlatform` function, covering npm publishing with both `package.json` and `.npmrc` strategies, as well as jsr publishing. Also adds a test case to `catalog.test.ts` for `updatePackageJsonWithCatalog` to ensure correct updates for pnpm dependencies from a catalog. --- core/multipublish/src/catalog.test.ts | 50 ++++++- core/multipublish/src/publish.test.ts | 187 ++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 core/multipublish/src/publish.test.ts diff --git a/core/multipublish/src/catalog.test.ts b/core/multipublish/src/catalog.test.ts index f630d476..c58a3367 100644 --- a/core/multipublish/src/catalog.test.ts +++ b/core/multipublish/src/catalog.test.ts @@ -1,4 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + writeFile: vi.fn(), + }; +}); + import type { CatalogSchema } from "./catalog"; @@ -87,4 +96,43 @@ describe("catalog", () => { }).toThrow(); }); }); + + describe("updatePackageJsonWithCatalog", () => { + it("should update package.json for pnpm", async () => { + const fsp = await import("node:fs/promises"); + const { updatePackageJsonWithCatalog, catalogLoadMap } = await import( + "./catalog" + ); + + vi.spyOn(catalogLoadMap, "pnpm").mockResolvedValue({ + catalog: { + foo: "1.0.0", + }, + }); + + const pkg = { + dir: "/path/to/pkg", + packageJson: { + dependencies: { + foo: "catalog:", + }, + }, + } as any; + + await updatePackageJsonWithCatalog(pkg, "pnpm"); + + expect(fsp.writeFile).toHaveBeenCalledWith( + "/path/to/pkg/package.json", + JSON.stringify( + { + dependencies: { + foo: "1.0.0", + }, + }, + undefined, + 2, + ), + ); + }); + }); }); diff --git a/core/multipublish/src/publish.test.ts b/core/multipublish/src/publish.test.ts new file mode 100644 index 00000000..4b39a377 --- /dev/null +++ b/core/multipublish/src/publish.test.ts @@ -0,0 +1,187 @@ +import * as findRoot from "@manypkg/find-root"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getArgs } from "./args"; +import { detectPackageManager } from "./detect"; +import * as jsr from "./jsr"; +import { publishPlatform } from "./publish"; + +vi.mock("@manypkg/find-root", () => ({ + findRoot: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + existsSync: vi.fn(), + }; +}); + +vi.mock("node:child_process", () => ({ + execFileSync: vi.fn(), + execSync: vi.fn(), +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + readFile: vi.fn(), + writeFile: vi.fn(), + }; +}); + +vi.mock("./args", () => ({ + getArgs: vi.fn(), +})); + +vi.mock("./detect", () => ({ + detectPackageManager: vi.fn(), +})); + +vi.mock("./util", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + chdir: vi.fn(async (_dir, cb) => { + await cb(); + }), + gitClean: vi.fn(), + }; +}); + +vi.mock("./jsr", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + loadConfig: vi.fn(), + updateIncludeExcludeList: vi.fn(), + }; +}); + +describe("publish", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getArgs).mockResolvedValue({ dry: false }); + vi.mocked(detectPackageManager).mockResolvedValue("pnpm"); + vi.mocked(findRoot.findRoot).mockResolvedValue({ + rootDir: "/fake/root", + } as any); + }); + + describe("publishPlatform", () => { + it("should publish to npm with package.json strategy", async () => { + const fsp = await import("node:fs/promises"); + const cp = await import("node:child_process"); + + const pkg = { + dir: "/path/to/pkg", + packageJson: { + name: "test-pkg", + version: "1.0.0", + }, + } as any; + + await publishPlatform(pkg, [ + "npm", + { + registry: "https://registry.npmjs.org/", + strategy: "package.json", + }, + ]); + + expect(fsp.writeFile).toHaveBeenCalledWith( + "/path/to/pkg/package.json", + JSON.stringify( + { + ...pkg.packageJson, + publishConfig: { + registry: "https://registry.npmjs.org/", + }, + }, + undefined, + 2, + ), + ); + + expect(cp.execSync).toHaveBeenCalledWith( + "pnpm publish --no-git-checks", + { stdio: "inherit" }, + ); + }); + + it("should publish to npm with .npmrc strategy", async () => { + const fsp = await import("node:fs/promises"); + const cp = await import("node:child_process"); + const fs = await import("node:fs"); + + vi.spyOn(fs, "existsSync").mockReturnValue(false); + process.env.NPM_TOKEN = "test-token"; + + const pkg = { + dir: "/path/to/pkg", + packageJson: { + name: "@scope/test-pkg", + version: "1.0.0", + }, + } as any; + + await publishPlatform(pkg, [ + "npm", + { + registry: "https://registry.npmjs.org/", + strategy: ".npmrc", + tokenEnvironmentKey: "NPM_TOKEN", + }, + ]); + + expect(fsp.writeFile).toHaveBeenCalledWith( + "/fake/root/.npmrc", + expect.stringContaining("test-token"), + ); + + expect(cp.execSync).toHaveBeenCalledWith( + "pnpm publish --no-git-checks", + { stdio: "inherit" }, + ); + + delete process.env.NPM_TOKEN; + }); + + it("should publish to jsr", async () => { + const fsp = await import("node:fs/promises"); + const cp = await import("node:child_process"); + + vi.mocked(jsr.loadConfig).mockResolvedValue({ + config: { + exports: "index.ts", + name: "@scope/pkg", + version: "1.0.0", + }, + filename: "/path/to/pkg/jsr.json", + }); + + const pkg = { + dir: "/path/to/pkg", + packageJson: { + name: "@scope/pkg", + version: "1.0.0", + }, + } as any; + const platform = "jsr"; + + await publishPlatform(pkg, platform); + + expect(fsp.writeFile).toHaveBeenCalledWith( + "/path/to/pkg/jsr.json", + expect.any(String), + ); + + expect(cp.execSync).toHaveBeenCalledWith( + "pnpm dlx jsr publish --allow-dirty --allow-slow-types", + { stdio: "inherit" }, + ); + }); + }); +}); From 1f30a1c95c5c1d895294a5deb52a95adabadb0fc Mon Sep 17 00:00:00 2001 From: stephansama Date: Wed, 14 Jan 2026 19:19:20 -0500 Subject: [PATCH 22/32] refactor(multipublish): reorder imports in catalog tests Reorder import statements in `catalog.test.ts` for consistency. This includes moving type and value imports to the top of the file, as well as reordering destructured imports within a test block. --- core/multipublish/src/catalog.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/core/multipublish/src/catalog.test.ts b/core/multipublish/src/catalog.test.ts index c58a3367..8cfb52b4 100644 --- a/core/multipublish/src/catalog.test.ts +++ b/core/multipublish/src/catalog.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; +import type { CatalogSchema } from "./catalog"; + +import { catalogSchema, loadVersion } from "./catalog"; + vi.mock("node:fs/promises", async (importOriginal) => { const mod = await importOriginal(); return { @@ -8,11 +12,6 @@ vi.mock("node:fs/promises", async (importOriginal) => { }; }); - -import type { CatalogSchema } from "./catalog"; - -import { catalogSchema, loadVersion } from "./catalog"; - describe("catalog", () => { describe("catalogSchema", () => { it("should parse default catalog", () => { @@ -100,9 +99,8 @@ describe("catalog", () => { describe("updatePackageJsonWithCatalog", () => { it("should update package.json for pnpm", async () => { const fsp = await import("node:fs/promises"); - const { updatePackageJsonWithCatalog, catalogLoadMap } = await import( - "./catalog" - ); + const { catalogLoadMap, updatePackageJsonWithCatalog } = + await import("./catalog"); vi.spyOn(catalogLoadMap, "pnpm").mockResolvedValue({ catalog: { From 82d0ecf5a791dd6eca24586b8627abe076b2c08a Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 15 Jan 2026 01:13:32 -0500 Subject: [PATCH 23/32] feat: introduce `commitJsrVersionUpdate` option for JSR publishing Adds a new configuration option `commitJsrVersionUpdate` to control whether a commit is created when a `jsr.json` file's version is updated during the JSR publishing process. This change also refactors test mocks to improve clarity and maintainability, and introduces the `es-toolkit` dependency for utility functions in tests. The new option `commitJsrVersionUpdate` defaults to `false`. --- core/multipublish/README.md | 1 + core/multipublish/package.json | 1 + core/multipublish/src/catalog.test.ts | 74 ++++++++++++---------- core/multipublish/src/publish.test.ts | 90 +++++++++++++-------------- pnpm-lock.yaml | 4 ++ 5 files changed, 91 insertions(+), 79 deletions(-) diff --git a/core/multipublish/README.md b/core/multipublish/README.md index dee62956..062bb105 100644 --- a/core/multipublish/README.md +++ b/core/multipublish/README.md @@ -123,6 +123,7 @@ _Object containing the following properties:_ | Property | Type | Default | | :--------------------------- | :-------------- | :------ | | `allowSlowTypes` | `boolean` | `true` | +| `commitJsrVersionUpdate` | `boolean` | `false` | | `defaultExclude` | `Array` | | | `defaultInclude` | `Array` | | | `experimentalGenerateJSR` | `boolean` | `false` | diff --git a/core/multipublish/package.json b/core/multipublish/package.json index b7f29059..2a891f0a 100644 --- a/core/multipublish/package.json +++ b/core/multipublish/package.json @@ -53,6 +53,7 @@ }, "devDependencies": { "@types/yargs": "catalog:", + "es-toolkit": "catalog:", "jsr": "catalog:", "tsdown": "catalog:" }, diff --git a/core/multipublish/src/catalog.test.ts b/core/multipublish/src/catalog.test.ts index 8cfb52b4..8c71bf20 100644 --- a/core/multipublish/src/catalog.test.ts +++ b/core/multipublish/src/catalog.test.ts @@ -1,15 +1,22 @@ +import { merge } from "es-toolkit/compat"; +import * as path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import type { CatalogSchema } from "./catalog"; +import * as catalog from "./catalog"; -import { catalogSchema, loadVersion } from "./catalog"; +const mocks = vi.hoisted(() => ({ + catalogLoadMap: vi.fn(), + writeFile: vi.fn(), +})); + +vi.mock("./catalog", async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, catalogLoadMap: mocks.catalogLoadMap }; +}); vi.mock("node:fs/promises", async (importOriginal) => { const mod = await importOriginal(); - return { - ...mod, - writeFile: vi.fn(), - }; + return { ...mod, writeFile: mocks.writeFile }; }); describe("catalog", () => { @@ -20,7 +27,7 @@ describe("catalog", () => { react: "^18.0.0", }, }; - const result = catalogSchema.parse(input); + const result = catalog.catalogSchema.parse(input); expect(result.catalog).toEqual(input.catalog); }); @@ -31,13 +38,13 @@ describe("catalog", () => { react19: { react: "^19.0.0" }, }, }; - const result = catalogSchema.parse(input); + const result = catalog.catalogSchema.parse(input); expect(result.catalogs).toEqual(input.catalogs); }); }); describe("loadVersion", () => { - const catalogs: CatalogSchema = { + const catalogs: catalog.CatalogSchema = { catalog: { foo: "1.0.0", }, @@ -49,7 +56,7 @@ describe("catalog", () => { }; it("should return version if not using catalog", () => { - const result = loadVersion({ + const result = catalog.loadVersion({ catalogs, dependency: "foo", version: "^2.0.0", @@ -58,7 +65,7 @@ describe("catalog", () => { }); it("should resolve default catalog", () => { - const result = loadVersion({ + const result = catalog.loadVersion({ catalogs, dependency: "foo", version: "catalog:", @@ -67,7 +74,7 @@ describe("catalog", () => { }); it("should resolve named catalog", () => { - const result = loadVersion({ + const result = catalog.loadVersion({ catalogs, dependency: "foo", version: "catalog:legacy", @@ -77,7 +84,7 @@ describe("catalog", () => { it("should throw if catalog dependency not found", () => { expect(() => { - loadVersion({ + catalog.loadVersion({ catalogs, dependency: "bar", version: "catalog:", @@ -87,7 +94,7 @@ describe("catalog", () => { it("should throw if named catalog not found", () => { expect(() => { - loadVersion({ + catalog.loadVersion({ catalogs, dependency: "foo", version: "catalog:missing", @@ -98,14 +105,12 @@ describe("catalog", () => { describe("updatePackageJsonWithCatalog", () => { it("should update package.json for pnpm", async () => { - const fsp = await import("node:fs/promises"); - const { catalogLoadMap, updatePackageJsonWithCatalog } = - await import("./catalog"); + const mockDefaultCatalog = { + foo: "1.0.0", + }; - vi.spyOn(catalogLoadMap, "pnpm").mockResolvedValue({ - catalog: { - foo: "1.0.0", - }, + mocks.catalogLoadMap.mockResolvedValue({ + catalog: mockDefaultCatalog, }); const pkg = { @@ -114,22 +119,23 @@ describe("catalog", () => { dependencies: { foo: "catalog:", }, + name: "example", + version: "0.0.0", }, - } as any; + relativeDir: "./pkg", + }; - await updatePackageJsonWithCatalog(pkg, "pnpm"); + const output = merge(pkg.packageJson, { + dependencies: { + foo: mockDefaultCatalog.foo, + }, + }); - expect(fsp.writeFile).toHaveBeenCalledWith( - "/path/to/pkg/package.json", - JSON.stringify( - { - dependencies: { - foo: "1.0.0", - }, - }, - undefined, - 2, - ), + await catalog.updatePackageJsonWithCatalog(pkg, "pnpm"); + + expect(mocks.writeFile).toHaveBeenCalledWith( + path.join(pkg.dir, "package.json"), + JSON.stringify(output, undefined, 2), ); }); }); diff --git a/core/multipublish/src/publish.test.ts b/core/multipublish/src/publish.test.ts index 4b39a377..7234129a 100644 --- a/core/multipublish/src/publish.test.ts +++ b/core/multipublish/src/publish.test.ts @@ -1,43 +1,49 @@ -import * as findRoot from "@manypkg/find-root"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getArgs } from "./args"; -import { detectPackageManager } from "./detect"; -import * as jsr from "./jsr"; import { publishPlatform } from "./publish"; -vi.mock("@manypkg/find-root", () => ({ +const mocks = vi.hoisted(() => ({ + detectPackageManager: vi.fn(), + execFileSync: vi.fn(), + execSync: vi.fn(), + existsSync: vi.fn(), findRoot: vi.fn(), + getArgs: vi.fn(), + loadConfig: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), +})); + +vi.mock("@manypkg/find-root", () => ({ + findRoot: mocks.findRoot, })); vi.mock("node:fs", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - existsSync: vi.fn(), + existsSync: mocks.existsSync, }; }); vi.mock("node:child_process", () => ({ - execFileSync: vi.fn(), - execSync: vi.fn(), + execFileSync: mocks.execFileSync, + execSync: mocks.execSync, })); vi.mock("node:fs/promises", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - readFile: vi.fn(), - writeFile: vi.fn(), + readFile: mocks.readFile, + writeFile: mocks.writeFile, }; }); -vi.mock("./args", () => ({ - getArgs: vi.fn(), -})); +vi.mock("./args", () => ({ getArgs: mocks.getArgs })); vi.mock("./detect", () => ({ - detectPackageManager: vi.fn(), + detectPackageManager: mocks.detectPackageManager, })); vi.mock("./util", async (importOriginal) => { @@ -55,33 +61,33 @@ vi.mock("./jsr", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - loadConfig: vi.fn(), + loadConfig: mocks.loadConfig, updateIncludeExcludeList: vi.fn(), }; }); describe("publish", () => { beforeEach(() => { + mocks.getArgs.mockResolvedValue({ dry: false }); + mocks.detectPackageManager.mockResolvedValue("pnpm"); + mocks.findRoot.mockResolvedValue({ rootDir: "/fake/root" }); + }); + + afterEach(() => { vi.resetAllMocks(); - vi.mocked(getArgs).mockResolvedValue({ dry: false }); - vi.mocked(detectPackageManager).mockResolvedValue("pnpm"); - vi.mocked(findRoot.findRoot).mockResolvedValue({ - rootDir: "/fake/root", - } as any); + vi.unstubAllEnvs(); }); describe("publishPlatform", () => { it("should publish to npm with package.json strategy", async () => { - const fsp = await import("node:fs/promises"); - const cp = await import("node:child_process"); - const pkg = { dir: "/path/to/pkg", packageJson: { name: "test-pkg", version: "1.0.0", }, - } as any; + relativeDir: "./pkg", + }; await publishPlatform(pkg, [ "npm", @@ -91,7 +97,7 @@ describe("publish", () => { }, ]); - expect(fsp.writeFile).toHaveBeenCalledWith( + expect(mocks.writeFile).toHaveBeenCalledWith( "/path/to/pkg/package.json", JSON.stringify( { @@ -105,19 +111,15 @@ describe("publish", () => { ), ); - expect(cp.execSync).toHaveBeenCalledWith( + expect(mocks.execSync).toHaveBeenCalledWith( "pnpm publish --no-git-checks", { stdio: "inherit" }, ); }); it("should publish to npm with .npmrc strategy", async () => { - const fsp = await import("node:fs/promises"); - const cp = await import("node:child_process"); - const fs = await import("node:fs"); - - vi.spyOn(fs, "existsSync").mockReturnValue(false); - process.env.NPM_TOKEN = "test-token"; + mocks.existsSync.mockReturnValue(false); + vi.stubEnv("NPM_TOKEN", "test-token"); const pkg = { dir: "/path/to/pkg", @@ -125,7 +127,8 @@ describe("publish", () => { name: "@scope/test-pkg", version: "1.0.0", }, - } as any; + relativeDir: "./pkg", + } as const; await publishPlatform(pkg, [ "npm", @@ -136,24 +139,19 @@ describe("publish", () => { }, ]); - expect(fsp.writeFile).toHaveBeenCalledWith( + expect(mocks.writeFile).toHaveBeenCalledWith( "/fake/root/.npmrc", expect.stringContaining("test-token"), ); - expect(cp.execSync).toHaveBeenCalledWith( + expect(mocks.execSync).toHaveBeenCalledWith( "pnpm publish --no-git-checks", { stdio: "inherit" }, ); - - delete process.env.NPM_TOKEN; }); it("should publish to jsr", async () => { - const fsp = await import("node:fs/promises"); - const cp = await import("node:child_process"); - - vi.mocked(jsr.loadConfig).mockResolvedValue({ + vi.mocked(mocks.loadConfig).mockResolvedValue({ config: { exports: "index.ts", name: "@scope/pkg", @@ -168,17 +166,19 @@ describe("publish", () => { name: "@scope/pkg", version: "1.0.0", }, - } as any; + relativeDir: "./pkg", + }; + const platform = "jsr"; await publishPlatform(pkg, platform); - expect(fsp.writeFile).toHaveBeenCalledWith( + expect(mocks.writeFile).toHaveBeenCalledWith( "/path/to/pkg/jsr.json", expect.any(String), ); - expect(cp.execSync).toHaveBeenCalledWith( + expect(mocks.execSync).toHaveBeenCalledWith( "pnpm dlx jsr publish --allow-dirty --allow-slow-types", { stdio: "inherit" }, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a04df5af..18ae96d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -614,6 +614,9 @@ importers: '@types/yargs': specifier: 'catalog:' version: 17.0.35 + es-toolkit: + specifier: 'catalog:' + version: 1.43.0 jsr: specifier: 'catalog:' version: 0.13.5 @@ -5806,6 +5809,7 @@ packages: eslint-plugin-zod-x@1.13.2: resolution: {integrity: sha512-Zw3PkNvKDp2H3wfrSwh+ydbf6WtkKlMqFEzyAN+cxxueiZRj/AtJkdjmll2IFYnOSi+2VBVV9quqDUfZItcNLQ==} engines: {node: ^20 || ^22 || >=24} + deprecated: eslint-plugin-zod-x is deprecated. Use eslint-plugin-zod >=3. See https://github.com/marcalexiei/eslint-plugin-zod/releases/tag/v3.0.0 peerDependencies: eslint: ^9 zod: ^4 From 4fe48a553e5b60a1be8a05538d643876d6cba7ce Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 15 Jan 2026 01:30:55 -0500 Subject: [PATCH 24/32] test(multipublish/util): add tests for directory, file, and stdin utilities Adds unit tests for the `chdir`, `gitClean`, and `readStdin` utility functions. The `chdir` test verifies correct directory switching and restoration. The `gitClean` test ensures a specified file is successfully removed. The `readStdin` tests cover both TTY and non-TTY scenarios, with `process.stdin` mocked to simulate input streams for robust testing. --- core/multipublish/src/util.test.ts | 74 +++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/core/multipublish/src/util.test.ts b/core/multipublish/src/util.test.ts index 99ac9bb4..7d22aebe 100644 --- a/core/multipublish/src/util.test.ts +++ b/core/multipublish/src/util.test.ts @@ -1,6 +1,56 @@ -import { describe, expect, it } from "vitest"; +import { merge } from "es-toolkit/compat"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as process from "node:process"; +import { describe, expect, it, vi } from "vitest"; -import { npmrcTemplate } from "./util"; +import { chdir, gitClean, npmrcTemplate, readStdin } from "./util"; + +const mocks = vi.hoisted(() => ({ + asyncIterator: vi.fn(), + on: vi.fn(), + setEncoding: vi.fn(), +})); + +vi.mock("node:process", async (importOriginal) => { + const originalProcess = await importOriginal(); + return merge(originalProcess, { + stdin: { + on: mocks.on, + setEncoding: mocks.setEncoding, + [Symbol.asyncIterator]: mocks.asyncIterator, + }, + }); +}); + +describe("chdir", () => { + it("should change directory, execute callback, and then change back", async () => { + const initialDir = fs.realpathSync(process.cwd()); + const tempDir = fs.realpathSync(os.tmpdir()); + + let callbackDir: string | undefined; + + await chdir(tempDir, () => { + callbackDir = fs.realpathSync(process.cwd()); + }); + + const finalDir = fs.realpathSync(process.cwd()); + + expect(callbackDir).toBe(tempDir); + expect(finalDir).toBe(initialDir); + }); +}); + +describe("gitClean", () => { + it("should remove the specified file", () => { + const tempFile = "dummy-file-for-testing.tmp"; + fs.writeFileSync(tempFile, "delete me"); + + gitClean(tempFile); + + expect(fs.existsSync(tempFile)).toBe(false); + }); +}); describe("npmrcTemplate", () => { it("should generate correct .npmrc content", () => { @@ -20,3 +70,23 @@ describe("npmrcTemplate", () => { expect(result.trim()).toBe(expected); }); }); + +describe("readStdin", () => { + it("should return null if stdin is a TTY", async () => { + vi.mocked(process.stdin).isTTY = true; + const result = await readStdin(); + expect(result).toBeNull(); + }); + + it("should read from stdin", async () => { + vi.mocked(process.stdin).isTTY = false; + const input = "hello world"; + + mocks.asyncIterator.mockImplementation(async function* () { + yield input; + }); + + const result = await readStdin(); + expect(result).toBe(input); + }); +}); From d398211a603e8034cf77171370f847f68cf6882e Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 15 Jan 2026 01:48:36 -0500 Subject: [PATCH 25/32] test(multipublish): add unit tests for run function adds comprehensive unit tests for the main `run` function within the multipublish package. these tests verify the correct execution flow and function calls based on the `versionJsr` argument, ensuring that either platform publishing or jsr config updates are triggered as expected. all external dependencies are mocked to ensure isolated and reliable testing. --- core/multipublish/src/index.test.ts | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 core/multipublish/src/index.test.ts diff --git a/core/multipublish/src/index.test.ts b/core/multipublish/src/index.test.ts new file mode 100644 index 00000000..268387d8 --- /dev/null +++ b/core/multipublish/src/index.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { run } from "./index"; + +const mocks = vi.hoisted(() => ({ + findRoot: vi.fn(() => Promise.resolve({ rootDir: "/mock/root" })), + getArgs: vi.fn(() => Promise.resolve({ versionJsr: false })), + getPackages: vi.fn(() => + Promise.resolve({ + packages: [ + { + dir: "/mock/root/pkg1", + packageJson: { name: "pkg1", version: "1.0.0" }, + relativeDir: "pkg1", + }, + ], + }), + ), + loadConfig: vi.fn(() => Promise.resolve({ platforms: ["npm"] })), + loadReleases: vi.fn(() => + Promise.resolve([{ name: "pkg1", version: "1.0.0" }]), + ), + publishPlatform: vi.fn(), + updateJsrConfigVersion: vi.fn(), +})); + +vi.mock("@manypkg/find-root", () => ({ findRoot: mocks.findRoot })); +vi.mock("@manypkg/get-packages", () => ({ getPackages: mocks.getPackages })); + +vi.mock("./args", () => ({ getArgs: mocks.getArgs })); +vi.mock("./config", () => ({ loadConfig: mocks.loadConfig })); +vi.mock("./release", () => ({ loadReleases: mocks.loadReleases })); +vi.mock("./publish", () => ({ publishPlatform: mocks.publishPlatform })); +vi.mock("./jsr", () => ({ + updateJsrConfigVersion: mocks.updateJsrConfigVersion, +})); + +describe("run", () => { + afterEach(vi.clearAllMocks); + + it("should call necessary functions and publish platforms", async () => { + await run(); + + expect(mocks.findRoot).toHaveBeenCalledOnce(); + expect(mocks.getArgs).toHaveBeenCalledOnce(); + expect(mocks.loadConfig).toHaveBeenCalledOnce(); + expect(mocks.getPackages).toHaveBeenCalledOnce(); + expect(mocks.loadReleases).toHaveBeenCalledOnce(); + expect(mocks.updateJsrConfigVersion).not.toHaveBeenCalled(); + expect(mocks.publishPlatform).toHaveBeenCalledOnce(); + expect(mocks.publishPlatform).toHaveBeenCalledWith( + expect.objectContaining({ + packageJson: expect.objectContaining({ + name: "pkg1", + version: "1.0.0", + }), + }), + "npm", + ); + }); + + it("should call updateJsrConfigVersion if args.versionJsr is true", async () => { + mocks.getArgs.mockResolvedValueOnce({ versionJsr: true }); + + await run(); + + expect(mocks.updateJsrConfigVersion).toHaveBeenCalledOnce(); + expect(mocks.publishPlatform).not.toHaveBeenCalled(); + }); +}); From 1cc5dbdc09a4d11bd5202e9acfff35d077b86154 Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 15 Jan 2026 02:13:31 -0500 Subject: [PATCH 26/32] chore: integrate multipublish for multi-platform package releases this commit introduces and configures @stephansama/multipublish to manage package releases across multiple platforms, specifically jsr and npm. the changes include: - adding a new `.config/.multipublishrc.json` file to define publishing platforms and their respective configurations. - updating the `release.yml` github actions workflow to pipe the list of published packages from `changesets` directly into `multipublish` for processing. this ensures that `multipublish` handles the actual deployment to the configured registries. - granting `packages: write` permission in the github actions workflow, which is necessary for publishing to npm and jsr registries. - adding `@stephansama/multipublish` as a dev dependency to the workspace. --- .config/.multipublishrc.json | 19 +++++++++++++++++++ .github/workflows/release.yml | 6 ++++++ package.json | 1 + pnpm-lock.yaml | 4 ++++ 4 files changed, 30 insertions(+) create mode 100644 .config/.multipublishrc.json diff --git a/.config/.multipublishrc.json b/.config/.multipublishrc.json new file mode 100644 index 00000000..510d2238 --- /dev/null +++ b/.config/.multipublishrc.json @@ -0,0 +1,19 @@ +{ + "$schema": "../node_modules/@stephansama/multipublish/config/schema.json", + "platforms": [ + [ + "jsr", + { + "experimentalUpdateCatalogs": true, + "experimentalGenerateJSR": true + } + ], + [ + "npm", + { + "registry": "https://registry.npmjs.org/", + "tokenEnvironmentKey": "GITHUB_TOKEN" + } + ] + ] +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3595fb9a..4e78f48b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,7 @@ env: TURBO_TEAM: ${{vars.TURBO_TEAM}} permissions: contents: write + packages: write id-token: write issues: write pull-requests: write @@ -67,8 +68,13 @@ jobs: - if: github.ref_name == 'main' name: 🦋 Create Changeset Release Pull Request uses: changesets/action@v1 + id: changesets with: commit: "chore: Update version for release" title: "chore: Update version for release" publish: pnpm run publish createGithubReleases: true + - name: f + if: steps.changesets.outputs.published == 'true' + run: | + echo "${{ steps.changesets.outputs.publishedPackages }}" | multipublish diff --git a/package.json b/package.json index 2e45fab0..d0c56ae4 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@manypkg/get-packages": "catalog:", "@stephansama/ai-commit-msg": "workspace:*", "@stephansama/auto-readme": "workspace:*", + "@stephansama/multipublish": "workspace:*", "@stephansama/prettier-plugin-handlebars": "workspace:*", "@tsconfig/recommended": "^1.0.13", "@turbo/gen": "^2.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18ae96d8..85c684a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -324,6 +324,10 @@ importers: yaml-eslint-parser: specifier: ^1.3.2 version: 1.3.2 + devDependencies: + '@stephansama/multipublish': + specifier: workspace:* + version: link:core/multipublish .config/www: dependencies: From fff59de023bab41c291ca2598632fdfd85b27337 Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 15 Jan 2026 02:14:15 -0500 Subject: [PATCH 27/32] feat(jsr): allow string import/require in export maps the exportSchema in jsr.ts now supports direct string values for the import and require fields within an export map entry. previously, these fields were strictly expected to be objects containing a default property. the convertPkgJsonExportsToJsr function has been updated to correctly handle both the string and object formats when converting package.json exports to the jsr format. additionally, the catalogLoadMap mock in catalog.test.ts was refactored to an object, enabling package manager-specific mock implementations for 'bun' and 'pnpm'. this improves the flexibility and isolation of tests. --- core/multipublish/src/catalog.test.ts | 8 +++++--- core/multipublish/src/jsr.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/core/multipublish/src/catalog.test.ts b/core/multipublish/src/catalog.test.ts index 8c71bf20..29adbf49 100644 --- a/core/multipublish/src/catalog.test.ts +++ b/core/multipublish/src/catalog.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import * as catalog from "./catalog"; const mocks = vi.hoisted(() => ({ - catalogLoadMap: vi.fn(), + catalogLoadMap: { bun: vi.fn(), pnpm: vi.fn() }, writeFile: vi.fn(), })); @@ -109,7 +109,9 @@ describe("catalog", () => { foo: "1.0.0", }; - mocks.catalogLoadMap.mockResolvedValue({ + const packageManager = "pnpm" as const; + + mocks.catalogLoadMap[packageManager].mockResolvedValue({ catalog: mockDefaultCatalog, }); @@ -131,7 +133,7 @@ describe("catalog", () => { }, }); - await catalog.updatePackageJsonWithCatalog(pkg, "pnpm"); + await catalog.updatePackageJsonWithCatalog(pkg, packageManager); expect(mocks.writeFile).toHaveBeenCalledWith( path.join(pkg.dir, "package.json"), diff --git a/core/multipublish/src/jsr.ts b/core/multipublish/src/jsr.ts index 13535e45..d46d9147 100644 --- a/core/multipublish/src/jsr.ts +++ b/core/multipublish/src/jsr.ts @@ -12,8 +12,8 @@ export const exportSchema = z.string().or( z.string(), z.string().or( z.object({ - import: z.object({ default: z.string() }), - require: z.object({ default: z.string() }), + import: z.object({ default: z.string() }).or(z.string()), + require: z.object({ default: z.string() }).or(z.string()), }), ), ), @@ -101,7 +101,11 @@ function convertPkgJsonExportsToJsr(exports: ExportsSchema) { return Object.fromEntries( Object.entries(exports).map(([key, value]) => [ key, - typeof value === "string" ? value : value.import.default, + typeof value === "string" + ? value + : typeof value.import === "string" + ? value.import + : value.import.default, ]), ); } From 3a615775892cf708de272c1320369b8a89695b1c Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 15 Jan 2026 02:16:26 -0500 Subject: [PATCH 28/32] build(multipublish): configure multipublish cli for use The core/multipublish/cli.mjs script has been made executable to allow direct invocation. The @stephansama/multipublish package was moved from devDependencies to dependencies to ensure its availability for project build and publishing workflows. --- core/multipublish/cli.mjs | 0 pnpm-lock.yaml | 7 +++---- 2 files changed, 3 insertions(+), 4 deletions(-) mode change 100644 => 100755 core/multipublish/cli.mjs diff --git a/core/multipublish/cli.mjs b/core/multipublish/cli.mjs old mode 100644 new mode 100755 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85c684a5..aaafba2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: '@stephansama/auto-readme': specifier: workspace:* version: link:core/auto-readme + '@stephansama/multipublish': + specifier: workspace:* + version: link:core/multipublish '@stephansama/prettier-plugin-handlebars': specifier: workspace:* version: link:core/prettier-plugin-handlebars @@ -324,10 +327,6 @@ importers: yaml-eslint-parser: specifier: ^1.3.2 version: 1.3.2 - devDependencies: - '@stephansama/multipublish': - specifier: workspace:* - version: link:core/multipublish .config/www: dependencies: From 37623f0f831d01a08fa442e98da2da731f6d6490 Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 15 Jan 2026 02:23:36 -0500 Subject: [PATCH 29/32] refactor: remove commitJsrVersionUpdate option The `commitJsrVersionUpdate` configuration option has been removed from the JSR platform options. This change simplifies the configuration by consolidating the version commit behavior under the `--versionJsr` command-line flag. The README has been updated to reflect this new approach. --- core/multipublish/README.md | 3 +-- core/multipublish/src/jsr.test.ts | 2 -- core/multipublish/src/schema.test.ts | 1 - core/multipublish/src/schema.ts | 1 - 4 files changed, 1 insertion(+), 6 deletions(-) diff --git a/core/multipublish/README.md b/core/multipublish/README.md index 062bb105..ae72435c 100644 --- a/core/multipublish/README.md +++ b/core/multipublish/README.md @@ -94,7 +94,7 @@ In order to use this with changesets, please update your version script with a p ```json { "scripts": { - "preversion": "multipublish", + "preversion": "multipublish --versionJsr", "version": "changeset version" } } @@ -123,7 +123,6 @@ _Object containing the following properties:_ | Property | Type | Default | | :--------------------------- | :-------------- | :------ | | `allowSlowTypes` | `boolean` | `true` | -| `commitJsrVersionUpdate` | `boolean` | `false` | | `defaultExclude` | `Array` | | | `defaultInclude` | `Array` | | | `experimentalGenerateJSR` | `boolean` | `false` | diff --git a/core/multipublish/src/jsr.test.ts b/core/multipublish/src/jsr.test.ts index 91ab3951..3f4383b3 100644 --- a/core/multipublish/src/jsr.test.ts +++ b/core/multipublish/src/jsr.test.ts @@ -46,7 +46,6 @@ describe("jsr", () => { }; const appConfig: JsrPlatformOptionsSchema = { allowSlowTypes: true, - commitJsrVersionUpdate: false, defaultExclude: ["test"], defaultInclude: ["src"], experimentalGenerateJSR: false, @@ -69,7 +68,6 @@ describe("jsr", () => { }; const appConfig: JsrPlatformOptionsSchema = { allowSlowTypes: true, - commitJsrVersionUpdate: false, defaultExclude: ["new-exclude"], defaultInclude: ["new-include"], experimentalGenerateJSR: false, diff --git a/core/multipublish/src/schema.test.ts b/core/multipublish/src/schema.test.ts index 663f7755..6beb0a7f 100644 --- a/core/multipublish/src/schema.test.ts +++ b/core/multipublish/src/schema.test.ts @@ -28,7 +28,6 @@ describe("schema", () => { "jsr", { allowSlowTypes: false, - commitJsrVersionUpdate: false, experimentalGenerateJSR: false, experimentalUpdateCatalogs: false, }, diff --git a/core/multipublish/src/schema.ts b/core/multipublish/src/schema.ts index 57b31f9b..b8d75ba2 100644 --- a/core/multipublish/src/schema.ts +++ b/core/multipublish/src/schema.ts @@ -3,7 +3,6 @@ import * as z from "zod"; export type JsrPlatformOptionsSchema = z.infer; export const jsrPlatformOptionsSchema = z.object({ allowSlowTypes: z.boolean().default(true), - commitJsrVersionUpdate: z.boolean().default(false), defaultExclude: z.array(z.string()).optional(), defaultInclude: z.array(z.string()).optional(), experimentalGenerateJSR: z.boolean().default(false), From 57b983ec31b16b870c2cd78c9e9d8708132abcaa Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 15 Jan 2026 02:30:03 -0500 Subject: [PATCH 30/32] docs: enhance jsr and changeset documentation in multipublish readme renamed the "JSR" section to "JSR Configuration" for improved clarity regarding setup requirements. added new subsections under "Changesets" to provide detailed guidance: - "JSR": updated the `preversion` script example to include `--useChangesetStatus` for proper JSR versioning. - "Published packages": included a github actions workflow snippet demonstrating how to run `multipublish` to target other registries immediately following a `changeset publish` step. --- core/multipublish/README.md | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/core/multipublish/README.md b/core/multipublish/README.md index ae72435c..40ba0812 100644 --- a/core/multipublish/README.md +++ b/core/multipublish/README.md @@ -15,8 +15,10 @@ Publish packages to multiple providers easily - [Usage](#usage) - [Configuration](#configuration) - [GitHub NPM Registry](#github-npm-registry) - - [JSR](#jsr) + - [JSR Configuration](#jsr-configuration) - [Changesets](#changesets) + - [JSR](#jsr) + - [Published packages](#published-packages) @@ -83,23 +85,45 @@ permissions: packages: write ``` -### JSR +### JSR Configuration When publishing to JSR, you must either have a valid `jsr.json` or `deno.json`, or allow `experimentalGenerateJSR` using the config option. ### Changesets -In order to use this with changesets, please update your version script with a preversion script that calls the `multipublish` CLI. +If you are using jsr this with changesets, please update your version script with a preversion script that calls the `multipublish` CLI in order to update your existing jsr configurations. + +#### JSR ```json { "scripts": { - "preversion": "multipublish --versionJsr", + "preversion": "multipublish --useChangesetStatus --versionJsr", "version": "changeset version" } } ``` +#### Published packages + +you can run multipublish after a changeset publish like so + +```yaml +- if: github.ref_name == 'main' + name: 🦋 Create Changeset Release + uses: changesets/action@v1 + id: changesets + with: + commit: "chore: Update version for release" + title: "chore: Update version for release" + publish: pnpm run publish + createGithubReleases: true +- name: publish to other registries + if: steps.changesets.outputs.published == 'true' + run: | + echo "${{ steps.changesets.outputs.publishedPackages }}" | multipublish +``` + # Zod Schema From cadd823bf9bf74a85ee1930172390bbd86916702 Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 15 Jan 2026 02:31:40 -0500 Subject: [PATCH 31/32] refactor: improve workflow step name clarity --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e78f48b..6902affd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: title: "chore: Update version for release" publish: pnpm run publish createGithubReleases: true - - name: f + - name: publish to other registries if: steps.changesets.outputs.published == 'true' run: | echo "${{ steps.changesets.outputs.publishedPackages }}" | multipublish From 0991446aec1edc9656fd2c83e2119c82630b9d20 Mon Sep 17 00:00:00 2001 From: stephansama Date: Thu, 15 Jan 2026 02:34:13 -0500 Subject: [PATCH 32/32] chore: switch npm registry to github packages --- .config/.multipublishrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/.multipublishrc.json b/.config/.multipublishrc.json index 510d2238..bb142899 100644 --- a/.config/.multipublishrc.json +++ b/.config/.multipublishrc.json @@ -11,7 +11,7 @@ [ "npm", { - "registry": "https://registry.npmjs.org/", + "registry": "npm.pkg.github.com", "tokenEnvironmentKey": "GITHUB_TOKEN" } ]