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. 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/.config/.multipublishrc.json b/.config/.multipublishrc.json new file mode 100644 index 00000000..bb142899 --- /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": "npm.pkg.github.com", + "tokenEnvironmentKey": "GITHUB_TOKEN" + } + ] + ] +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3595fb9a..6902affd 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: publish to other registries + if: steps.changesets.outputs.published == 'true' + run: | + echo "${{ steps.changesets.outputs.publishedPackages }}" | multipublish 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..40ba0812 --- /dev/null +++ b/core/multipublish/README.md @@ -0,0 +1,173 @@ +# @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) + - [Configuration](#configuration) + - [GitHub NPM Registry](#github-npm-registry) + - [JSR Configuration](#jsr-configuration) + - [Changesets](#changesets) + - [JSR](#jsr) + - [Published packages](#published-packages) + +
+ +## Installation + +```sh +pnpm install @stephansama/multipublish +``` + +## Usage + +### 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 Configuration + +When publishing to JSR, you must either have a valid `jsr.json` or `deno.json`, or allow `experimentalGenerateJSR` using the config option. + +### Changesets + +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 --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 + +## 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/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 100755 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..2a891f0a --- /dev/null +++ b/core/multipublish/package.json @@ -0,0 +1,68 @@ +{ + "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", + "dev": "node --watch build.mjs", + "lint": "eslint ./src/ --pass-on-no-patterns --no-error-on-unmatched-pattern" + }, + "dependencies": { + "@manypkg/find-root": "catalog:", + "@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", + "yargs": "catalog:cli", + "zod": "catalog:schema" + }, + "devDependencies": { + "@types/yargs": "catalog:", + "es-toolkit": "catalog:", + "jsr": "catalog:", + "tsdown": "catalog:" + }, + "peerDependencies": { + "jsr": ">=0.10" + }, + "packageManager": "pnpm@10.11.0", + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/core/multipublish/src/args.ts b/core/multipublish/src/args.ts new file mode 100644 index 00000000..f8bbd9b4 --- /dev/null +++ b/core/multipublish/src/args.ts @@ -0,0 +1,59 @@ +import { enable } from "obug"; +import yargs, { type Options } from "yargs"; +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: "boolean" }, + 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: "boolean", + }, + 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() { + return (_args ??= await parseArgs()); +} + +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/catalog.test.ts b/core/multipublish/src/catalog.test.ts new file mode 100644 index 00000000..29adbf49 --- /dev/null +++ b/core/multipublish/src/catalog.test.ts @@ -0,0 +1,144 @@ +import { merge } from "es-toolkit/compat"; +import * as path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import * as catalog from "./catalog"; + +const mocks = vi.hoisted(() => ({ + catalogLoadMap: { bun: vi.fn(), pnpm: 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: mocks.writeFile }; +}); + +describe("catalog", () => { + describe("catalogSchema", () => { + it("should parse default catalog", () => { + const input = { + catalog: { + react: "^18.0.0", + }, + }; + const result = catalog.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 = catalog.catalogSchema.parse(input); + expect(result.catalogs).toEqual(input.catalogs); + }); + }); + + describe("loadVersion", () => { + const catalogs: catalog.CatalogSchema = { + catalog: { + foo: "1.0.0", + }, + catalogs: { + legacy: { + foo: "0.9.0", + }, + }, + }; + + it("should return version if not using catalog", () => { + const result = catalog.loadVersion({ + catalogs, + dependency: "foo", + version: "^2.0.0", + }); + expect(result).toBe("^2.0.0"); + }); + + it("should resolve default catalog", () => { + const result = catalog.loadVersion({ + catalogs, + dependency: "foo", + version: "catalog:", + }); + expect(result).toBe("1.0.0"); + }); + + it("should resolve named catalog", () => { + const result = catalog.loadVersion({ + catalogs, + dependency: "foo", + version: "catalog:legacy", + }); + expect(result).toBe("0.9.0"); + }); + + it("should throw if catalog dependency not found", () => { + expect(() => { + catalog.loadVersion({ + catalogs, + dependency: "bar", + version: "catalog:", + }); + }).toThrow(); + }); + + it("should throw if named catalog not found", () => { + expect(() => { + catalog.loadVersion({ + catalogs, + dependency: "foo", + version: "catalog:missing", + }); + }).toThrow(); + }); + }); + + describe("updatePackageJsonWithCatalog", () => { + it("should update package.json for pnpm", async () => { + const mockDefaultCatalog = { + foo: "1.0.0", + }; + + const packageManager = "pnpm" as const; + + mocks.catalogLoadMap[packageManager].mockResolvedValue({ + catalog: mockDefaultCatalog, + }); + + const pkg = { + dir: "/path/to/pkg", + packageJson: { + dependencies: { + foo: "catalog:", + }, + name: "example", + version: "0.0.0", + }, + relativeDir: "./pkg", + }; + + const output = merge(pkg.packageJson, { + dependencies: { + foo: mockDefaultCatalog.foo, + }, + }); + + await catalog.updatePackageJsonWithCatalog(pkg, packageManager); + + expect(mocks.writeFile).toHaveBeenCalledWith( + path.join(pkg.dir, "package.json"), + JSON.stringify(output, undefined, 2), + ); + }); + }); +}); diff --git a/core/multipublish/src/catalog.ts b/core/multipublish/src/catalog.ts new file mode 100644 index 00000000..64edead2 --- /dev/null +++ b/core/multipublish/src/catalog.ts @@ -0,0 +1,87 @@ +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 dependencyType of ["dependencies", "devDependencies"] as const) { + const entries = Object.entries(pkg.packageJson[dependencyType] || {}); + for (const [dependency, version] of entries) { + pkg.packageJson[dependencyType] ??= {}; + pkg.packageJson[dependencyType][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 new file mode 100644 index 00000000..ad8c54bf --- /dev/null +++ b/core/multipublish/src/config.ts @@ -0,0 +1,34 @@ +import { cosmiconfig, getDefaultSearchPlaces, type Options } from "cosmiconfig"; + +import type { Args } from "./args"; + +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(args: Args) { + const opts: Partial = { searchPlaces }; + + if (args.config) opts.searchPlaces = [args.config]; + + 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..dfc79710 --- /dev/null +++ b/core/multipublish/src/detect.ts @@ -0,0 +1,16 @@ +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; + + return _detected; +} 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(); + }); +}); diff --git a/core/multipublish/src/index.ts b/core/multipublish/src/index.ts new file mode 100644 index 00000000..793d6e4d --- /dev/null +++ b/core/multipublish/src/index.ts @@ -0,0 +1,40 @@ +import { findRoot } from "@manypkg/find-root"; +import { getPackages } from "@manypkg/get-packages"; + +import { getArgs } from "./args"; +import { loadConfig } from "./config"; +import { updateJsrConfigVersion } from "./jsr"; +import { publishPlatform } from "./publish"; +import { loadReleases } from "./release"; + +export async function run() { + const root = await findRoot(process.cwd()); + const args = await getArgs(); + const config = await loadConfig(args); + const { packages } = await getPackages(root.rootDir); + const releases = await loadReleases(args); + const releasedPackages = releases.map((release) => { + const pkg = packages.find( + (curr) => curr.packageJson.name === release.name, + ); + + if (!pkg) { + throw new Error( + `unable to find package for released package ${release.name}`, + ); + } + + return { ...pkg, version: release.version || pkg.packageJson.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.test.ts b/core/multipublish/src/jsr.test.ts new file mode 100644 index 00000000..3f4383b3 --- /dev/null +++ b/core/multipublish/src/jsr.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import type { JsrSchema } from "./jsr"; +import type { JsrPlatformOptionsSchema } from "./schema"; + +import { jsrTransformer, updateIncludeExcludeList } from "./jsr"; + +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, + 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, + 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/jsr.ts b/core/multipublish/src/jsr.ts new file mode 100644 index 00000000..d46d9147 --- /dev/null +++ b/core/multipublish/src/jsr.ts @@ -0,0 +1,111 @@ +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( + z.string(), + z.string().or( + z.object({ + import: z.object({ default: z.string() }).or(z.string()), + require: z.object({ default: z.string() }).or(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 { jsrTransformer as transformer }; + +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(), +}); + +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"); + } + + 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]) => [ + key, + typeof value === "string" + ? value + : typeof value.import === "string" + ? value.import + : value.import.default, + ]), + ); +} diff --git a/core/multipublish/src/publish.test.ts b/core/multipublish/src/publish.test.ts new file mode 100644 index 00000000..7234129a --- /dev/null +++ b/core/multipublish/src/publish.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { publishPlatform } from "./publish"; + +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: mocks.existsSync, + }; +}); + +vi.mock("node:child_process", () => ({ + execFileSync: mocks.execFileSync, + execSync: mocks.execSync, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + readFile: mocks.readFile, + writeFile: mocks.writeFile, + }; +}); + +vi.mock("./args", () => ({ getArgs: mocks.getArgs })); + +vi.mock("./detect", () => ({ + detectPackageManager: mocks.detectPackageManager, +})); + +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: 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.unstubAllEnvs(); + }); + + describe("publishPlatform", () => { + it("should publish to npm with package.json strategy", async () => { + const pkg = { + dir: "/path/to/pkg", + packageJson: { + name: "test-pkg", + version: "1.0.0", + }, + relativeDir: "./pkg", + }; + + await publishPlatform(pkg, [ + "npm", + { + registry: "https://registry.npmjs.org/", + strategy: "package.json", + }, + ]); + + expect(mocks.writeFile).toHaveBeenCalledWith( + "/path/to/pkg/package.json", + JSON.stringify( + { + ...pkg.packageJson, + publishConfig: { + registry: "https://registry.npmjs.org/", + }, + }, + undefined, + 2, + ), + ); + + expect(mocks.execSync).toHaveBeenCalledWith( + "pnpm publish --no-git-checks", + { stdio: "inherit" }, + ); + }); + + it("should publish to npm with .npmrc strategy", async () => { + mocks.existsSync.mockReturnValue(false); + vi.stubEnv("NPM_TOKEN", "test-token"); + + const pkg = { + dir: "/path/to/pkg", + packageJson: { + name: "@scope/test-pkg", + version: "1.0.0", + }, + relativeDir: "./pkg", + } as const; + + await publishPlatform(pkg, [ + "npm", + { + registry: "https://registry.npmjs.org/", + strategy: ".npmrc", + tokenEnvironmentKey: "NPM_TOKEN", + }, + ]); + + expect(mocks.writeFile).toHaveBeenCalledWith( + "/fake/root/.npmrc", + expect.stringContaining("test-token"), + ); + + expect(mocks.execSync).toHaveBeenCalledWith( + "pnpm publish --no-git-checks", + { stdio: "inherit" }, + ); + }); + + it("should publish to jsr", async () => { + vi.mocked(mocks.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", + }, + relativeDir: "./pkg", + }; + + const platform = "jsr"; + + await publishPlatform(pkg, platform); + + expect(mocks.writeFile).toHaveBeenCalledWith( + "/path/to/pkg/jsr.json", + expect.any(String), + ); + + expect(mocks.execSync).toHaveBeenCalledWith( + "pnpm dlx jsr publish --allow-dirty --allow-slow-types", + { stdio: "inherit" }, + ); + }); + }); +}); diff --git a/core/multipublish/src/publish.ts b/core/multipublish/src/publish.ts new file mode 100644 index 00000000..c1473827 --- /dev/null +++ b/core/multipublish/src/publish.ts @@ -0,0 +1,168 @@ +import type { Package } from "@manypkg/get-packages"; + +import { findRoot } from "@manypkg/find-root"; +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"; + +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 const npmPublishCommand = { + bun: "bun publish", + npm: "npm publish", + pnpm: "pnpm publish", + yarn: "yarn publish", +} satisfies Record, string>; + +export const jsrPublishCommand = { + bun: "bunx publish", + deno: "deno publish", + npm: "npx jsr publish", + pnpm: "pnpm dlx jsr publish", + yarn: "yarn dlx jsr publish", +} satisfies Record; + +export async function publishPlatform( + pkg: Package, + 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 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 userJsr = await jsr.loadConfig(pkg.dir); + + if (config.experimentalGenerateJSR) { + userJsr.config = jsr.transformer.parse(pkg.packageJson); + userJsr.filename = path.join(pkg.dir, util.JSR_CONFIG_FILENAME); + } + + if (!userJsr.config) { + throw new Error("failed to load userJsr config file"); + } + + if (!userJsr.filename) { + throw new Error("failed to load userJsr config filename"); + } + + jsr.updateIncludeExcludeList(userJsr.config, config); + + const jsrFile = JSON.stringify(userJsr.config, undefined, 2); + await fsp.writeFile(userJsr.filename, jsrFile); + + 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, () => { + cp.execSync( + [ + jsrPublishCommand[packageManager], + "--allow-dirty", + isDryRun && "--dry-run", + config.allowSlowTypes && "--allow-slow-types", + ] + .filter((x) => x) + .join(" "), + { stdio: "inherit" }, + ); + }); + + util.gitClean(userJsr.filename); + if (config.experimentalUpdateCatalogs) { + 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") { + throw new Error("deno is not supported for npm publish"); + } + + 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 npmrcPrefix = fs.existsSync(npmrcPath) + ? await fsp.readFile(npmrcPath, "utf8") + : ""; + + const scope = pkg.packageJson.name.split("/").at(0); + if (!scope?.startsWith("@")) { + throw new Error("scope must start with `@` symbol"); + } + + const npmrcFile = + npmrcPrefix + + "\n" + + util.npmrcTemplate({ + authToken, + registry: config.registry, + registryDomain: new URL(config.registry).host, + scope, + }); + + await fsp.writeFile(npmrcPath, npmrcFile); + break; + } + 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; + } + } + + await util.chdir(pkg.dir, () => { + cp.execSync( + [ + npmPublishCommand[packageManager], + packageManager === "pnpm" && "--no-git-checks", + isDryRun && "--dry-run", + ] + .filter((x) => x) + .join(" "), + { stdio: "inherit" }, + ); + }); + + util.gitClean(packageJsonPath); + if (config.strategy === ".npmrc") util.gitClean(npmrcPath); + break; + } + default: + throw new Error(`no implementation found for ${key}`); + } +} diff --git a/core/multipublish/src/release.test.ts b/core/multipublish/src/release.test.ts new file mode 100644 index 00000000..9dd2b3bb --- /dev/null +++ b/core/multipublish/src/release.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { changesetStatusSchema } from "./release"; + +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/release.ts b/core/multipublish/src/release.ts new file mode 100644 index 00000000..b55cc03c --- /dev/null +++ b/core/multipublish/src/release.ts @@ -0,0 +1,56 @@ +import * as cp from "node:child_process"; +import * as fsp from "node:fs/promises"; +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 ChangesetStatusSchema = z.input; +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 changesetOutput = ".multipublish.status.json"; + cp.execFileSync("changeset", ["status", `--output=${changesetOutput}`]); + + const file = await fsp.readFile(changesetOutput, "utf8"); + + gitClean(changesetOutput); + + return changesetStatusSchema.parse(JSON.parse(file)); + } + + const input = await readStdin(); + return releasesSchema.parse(input); +} diff --git a/core/multipublish/src/schema.test.ts b/core/multipublish/src/schema.test.ts new file mode 100644 index 00000000..6beb0a7f --- /dev/null +++ b/core/multipublish/src/schema.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { configSchema, platformsSchema } from "./schema"; + +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, + 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/schema.ts b/core/multipublish/src/schema.ts new file mode 100644 index 00000000..b8d75ba2 --- /dev/null +++ b/core/multipublish/src/schema.ts @@ -0,0 +1,33 @@ +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), + experimentalUpdateCatalogs: 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 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: platformsSchema, + tmpDirectory: z.string().default(".release"), + useChangesets: z.boolean().default(true), +}); diff --git a/core/multipublish/src/util.test.ts b/core/multipublish/src/util.test.ts new file mode 100644 index 00000000..7d22aebe --- /dev/null +++ b/core/multipublish/src/util.test.ts @@ -0,0 +1,92 @@ +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 { 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", () => { + 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); + }); +}); + +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); + }); +}); diff --git a/core/multipublish/src/util.ts b/core/multipublish/src/util.ts new file mode 100644 index 00000000..9430fdaa --- /dev/null +++ b/core/multipublish/src/util.ts @@ -0,0 +1,51 @@ +import dedent from "dedent"; +import * as cp from "node:child_process"; +import * as process from "node:process"; + +export const MODULE_NAME = "multipublish" as const; +export const JSR_CONFIG_FILENAME = "jsr.json" as const; + +export async function chdir( + newDir: string, + callback: () => Promise | void, +) { + const cwd = process.cwd(); + try { + process.chdir(newDir); + await callback(); + } catch (e) { + console.error(e); + } finally { + process.chdir(cwd); + } +} + +export function gitClean(filename: string) { + cp.execFileSync("git", ["clean", "-f", "--", filename], { + stdio: "inherit", + }); +} + +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 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; +} 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/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", + }, +}); diff --git a/package.json b/package.json index 57cfbd03..d0c56ae4 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", @@ -44,9 +43,10 @@ "@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/multipublish": "workspace:*", "@stephansama/prettier-plugin-handlebars": "workspace:*", "@tsconfig/recommended": "^1.0.13", "@turbo/gen": "^2.7.0", @@ -54,7 +54,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..aaafba2b 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:* @@ -198,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 @@ -219,9 +231,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) @@ -572,6 +581,52 @@ 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) + 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 + 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 + zod: + specifier: catalog:schema + version: 4.2.1 + devDependencies: + '@types/yargs': + specifier: 'catalog:' + version: 17.0.35 + es-toolkit: + specifier: 'catalog:' + version: 1.43.0 + 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)) + core/prettier-plugin-handlebars: devDependencies: prettier: @@ -5308,6 +5363,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'} @@ -5475,18 +5538,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'} @@ -5761,6 +5812,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 @@ -6868,6 +6920,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'} @@ -7589,6 +7645,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==} @@ -7831,12 +7891,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==} @@ -8621,6 +8681,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 @@ -10085,6 +10149,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==} @@ -10257,11 +10322,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'} @@ -10354,9 +10414,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==} @@ -15052,6 +15109,8 @@ snapshots: dedent@1.7.0: {} + dedent@1.7.1: {} + deep-eql@5.0.2: {} deep-extend@0.6.0: {} @@ -15207,17 +15266,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: {} @@ -17002,6 +17050,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 @@ -17032,7 +17085,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: {} @@ -17123,7 +17176,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: @@ -18046,6 +18099,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 @@ -18353,10 +18408,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: @@ -18698,7 +18753,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 @@ -19282,6 +19337,8 @@ snapshots: search-insights@2.17.3: {} + semiver@1.1.0: {} + semver@5.7.2: {} semver@6.3.1: {} @@ -20130,7 +20187,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: {} @@ -20971,8 +21028,6 @@ snapshots: yaml@2.7.1: {} - yaml@2.8.1: {} - yaml@2.8.2: {} yargs-parser@21.1.1: {} @@ -21068,8 +21123,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