diff --git a/README.md b/README.md index 41f182a9..1b413d07 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,15 @@ For workspaces and monorepos: pnpm exec pkg-pr-new publish './packages/A' './packages/B' # or `pnpm exec pkg-pr-new publish './packages/*'` ``` +You can also pass **prebuilt tarballs** (`.tgz` or `.tar.gz`) directly, which is handy when your build pipeline already produces tarballs in a custom way: + +```sh +pnpm exec pkg-pr-new publish './artifacts/*.tgz' +``` + +> [!NOTE] +> Prebuilt tarballs are uploaded **as-is**: pkg.pr.new will not repack them. If one tarball references another tarball being published in the same call, pkg.pr.new will print a warning and that reference will not be rewritten to a pkg.pr.new URL. Repack with the resolved version yourself if you need cross-package linking. + > [!CAUTION] > In CI environments, avoid `npx`, `pnpm dlx`, `yarn dlx`, and `bunx` for this step. Install `pkg-pr-new` as a dependency and execute it from the lockfile (`npm exec`, `pnpm exec`, `yarn`, or `bun run`). diff --git a/packages/cli/index.ts b/packages/cli/index.ts index a1c62b2f..b4fd02e9 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -15,6 +15,7 @@ import { installCommands, } from "@pkg-pr-new/utils"; import { glob } from "tinyglobby"; +import { parseTarGzip, type ParsedTarFileItem } from "nanotar"; import ignore from "ignore"; import "./environments"; import { isBinaryFile } from "isbinaryfile"; @@ -123,15 +124,48 @@ const main = defineCommand({ }, }, run: async ({ args }) => { - const paths = + const rawInputs = args._.length > 0 ? await glob(args._, { expandDirectories: false, - onlyDirectories: true, + onlyFiles: false, absolute: true, }) : [process.cwd()]; + const paths: string[] = []; + const tarballPaths: string[] = []; + for (const input of rawInputs) { + let stat; + try { + stat = await fs.stat(input); + } catch { + console.warn(`Skipping ${input}: cannot stat`); + continue; + } + if (stat.isDirectory()) { + paths.push(input); + } else if ( + stat.isFile() && + (input.endsWith(".tgz") || input.endsWith(".tar.gz")) + ) { + tarballPaths.push(input); + } else { + console.warn( + `Skipping ${input}: not a directory or .tgz/.tar.gz file`, + ); + } + } + + if (paths.length > 0 && tarballPaths.length > 0) { + console.error( + "pkg-pr-new: cannot mix directory and prebuilt tarball inputs in the same publish.", + ); + process.exit(1); + } + + const isTarballMode = tarballPaths.length > 0; + const templates = await glob(args.template || [], { expandDirectories: false, onlyDirectories: true, @@ -268,6 +302,7 @@ const main = defineCommand({ const packageInfos: Array<{ packageName: string; pJson: PackageJson; + tarballPath?: string; }> = []; for (const p of paths) { @@ -286,6 +321,82 @@ const main = defineCommand({ packageInfos.push({ packageName, pJson }); } + for (const tgzPath of tarballPaths) { + let pJson: PackageJson | null; + try { + pJson = await readPackageJsonFromTarball(tgzPath); + } catch (error) { + console.warn( + `Skipping ${tgzPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + continue; + } + + if (!pJson) { + console.warn( + `Skipping ${tgzPath}: no top-level package.json found inside the tarball`, + ); + continue; + } + + if (pJson.private) { + console.warn( + `Skipping ${tgzPath}: the package is marked private`, + ); + continue; + } + + if (!pJson.name) { + throw new Error( + `"name" field in the package.json inside ${tgzPath} should be defined`, + ); + } + + packageInfos.push({ + packageName: pJson.name, + pJson, + tarballPath: tgzPath, + }); + } + + if (isTarballMode && packageInfos.length > 1) { + const allPackageNames = new Set( + packageInfos.map((info) => info.packageName), + ); + const depFields = [ + "dependencies", + "devDependencies", + "optionalDependencies", + ...(isPeerDepsEnabled ? (["peerDependencies"] as const) : []), + ] as const; + + for (const info of packageInfos) { + const siblings = new Set(); + for (const field of depFields) { + const deps = info.pJson[field]; + if (!deps) { + continue; + } + for (const depName of Object.keys(deps)) { + if ( + allPackageNames.has(depName) && + depName !== info.packageName + ) { + siblings.add(depName); + } + } + } + if (siblings.size > 0) { + const list = [...siblings].map((s) => `'${s}'`).join(", "); + console.warn( + `warning: prebuilt tarball '${info.tarballPath}' references sibling package(s) ${list} in its package.json. ` + + `Those references will NOT be rewritten to pkg.pr.new URLs because the tarball is taken as-is. ` + + `Pass the source directory instead, or repack after replacing those versions manually if you need cross-package linking.`, + ); + } + } + } + if (isCompact) { for (const { packageName } of packageInfos) { try { @@ -505,6 +616,27 @@ const main = defineCommand({ } } + for (const info of packageInfos) { + if (!info.tarballPath) { + continue; + } + const filename = path.basename(info.tarballPath); + const buffer = await fs.readFile(info.tarballPath); + const shasum = createHash("sha1").update(buffer).digest("hex"); + + shasums[info.packageName] = shasum; + + const outputPkg = outputMetadata.packages.find( + (p) => p.name === info.packageName, + )!; + outputPkg.shasum = shasum; + + const blob = new Blob([buffer], { + type: "application/octet-stream", + }); + formData.append(`package:${info.packageName}`, blob, filename); + } + const formDataPackagesSize = [...formData.entries()].reduce( (prev, [_, entry]) => prev + getFormEntrySize(entry), 0, @@ -840,3 +972,29 @@ function parsePackageJson(contents: string) { return null; } } + +async function readPackageJsonFromTarball( + tarballPath: string, +): Promise { + const compressed = await fs.readFile(tarballPath); + + let entries: ParsedTarFileItem[]; + try { + entries = await parseTarGzip(compressed, { + filter: (file) => { + const segments = file.name.split("/"); + return segments.length === 2 && segments[1] === "package.json"; + }, + }); + } catch (error) { + throw new Error( + `failed to read tarball (${error instanceof Error ? error.message : String(error)})`, + ); + } + + const entry = entries[0]; + if (!entry) { + return null; + } + return parsePackageJson(entry.text); +} diff --git a/packages/cli/package.json b/packages/cli/package.json index bc8b1a00..231ba961 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,7 +20,6 @@ "keywords": [], "author": "", "license": "MIT", - "dependencies": {}, "devDependencies": { "@actions/core": "^3.0.1", "@jsdevtools/ez-spawn": "^3.0.4", @@ -28,6 +27,7 @@ "citty": "^0.1.6", "ignore": "^7.0.5", "isbinaryfile": "5.0.2", + "nanotar": "^0.3.0", "ohash": "^1.1.4", "pkg-types": "^2.3.1", "query-registry": "^4.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2656eb31..4f789b1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,7 +171,7 @@ importers: version: link:../utils '@simulacrum/github-api-simulator': specifier: ^0.5.4 - version: 0.5.6(ajv@8.17.1)(picomatch@4.0.2)(react@18.3.1) + version: 0.5.6(ajv@8.17.1)(picomatch@4.0.4)(react@18.3.1) '@types/string-similarity': specifier: ^4.0.2 version: 4.0.2 @@ -235,6 +235,9 @@ importers: isbinaryfile: specifier: 5.0.2 version: 5.0.2 + nanotar: + specifier: ^0.3.0 + version: 0.3.0 ohash: specifier: ^1.1.4 version: 1.1.4 @@ -6303,6 +6306,9 @@ packages: nanotar@0.2.0: resolution: {integrity: sha512-9ca1h0Xjvo9bEkE4UOxgAzLV0jHKe6LMaxo37ND2DAhhAtd0j8pR1Wxz+/goMrZO8AEZTWCmyaOsFI/W5AdpCQ==} + nanotar@0.3.0: + resolution: {integrity: sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg==} + natural-compare-lite@1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} @@ -7995,9 +8001,6 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} - ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -10012,7 +10015,7 @@ snapshots: semver: 7.7.1 std-env: 3.9.0 tinyexec: 1.0.1 - ufo: 1.6.3 + ufo: 1.6.4 youch: 4.1.0-beta.6 transitivePeerDependencies: - magicast @@ -10177,7 +10180,7 @@ snapshots: '@nuxt/kit': 3.16.2(magicast@0.3.5) chalk: 5.4.1 css-tree: 3.1.0 - defu: 6.1.4 + defu: 6.1.7 esbuild: 0.24.2 fontaine: 0.5.0 h3: 1.15.1 @@ -10189,7 +10192,7 @@ snapshots: pathe: 1.1.2 sirv: 3.0.0 tinyglobby: 0.2.16 - ufo: 1.6.3 + ufo: 1.6.4 unifont: 0.1.7 unplugin: 2.2.2 unstorage: 1.16.0(db0@0.2.1)(ioredis@5.4.2) @@ -10260,7 +10263,7 @@ snapshots: scule: 1.3.0 semver: 7.7.1 std-env: 3.9.0 - ufo: 1.6.3 + ufo: 1.6.4 unctx: 2.4.1 unimport: 4.1.3 untyped: 1.5.2 @@ -10325,13 +10328,13 @@ snapshots: c12: 2.0.1(magicast@0.3.5) compatx: 0.1.8 consola: 3.4.2 - defu: 6.1.4 + defu: 6.1.7 hookable: 5.5.3 pathe: 1.1.2 pkg-types: 1.3.1 scule: 1.3.0 std-env: 3.9.0 - ufo: 1.6.3 + ufo: 1.6.4 uncrypto: 0.1.3 unimport: 3.14.5(rollup@4.29.1) untyped: 1.5.2 @@ -10484,7 +10487,7 @@ snapshots: postcss: 8.5.14 rollup-plugin-visualizer: 5.14.0(rollup@4.29.1) std-env: 3.9.0 - ufo: 1.6.3 + ufo: 1.6.4 unenv: 1.10.0 unplugin: 2.2.2 vite: 6.2.5(@types/node@20.17.10)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.1) @@ -11329,12 +11332,12 @@ snapshots: fflate: 0.7.4 string.prototype.codepointat: 0.2.1 - '@simulacrum/foundation-simulator@0.4.0(ajv@8.17.1)(picomatch@4.0.2)(react@18.3.1)': + '@simulacrum/foundation-simulator@0.4.0(ajv@8.17.1)(picomatch@4.0.4)(react@18.3.1)': dependencies: ajv-formats: 3.0.1(ajv@8.17.1) cors: 2.8.5 express: 4.21.2 - fdir: 6.4.3(picomatch@4.0.2) + fdir: 6.4.3(picomatch@4.0.4) http-proxy-middleware: 3.0.3 lodash: 4.17.21 openapi-backend: 5.11.1 @@ -11351,10 +11354,10 @@ snapshots: - redux - supports-color - '@simulacrum/github-api-simulator@0.5.6(ajv@8.17.1)(picomatch@4.0.2)(react@18.3.1)': + '@simulacrum/github-api-simulator@0.5.6(ajv@8.17.1)(picomatch@4.0.4)(react@18.3.1)': dependencies: '@faker-js/faker': 9.6.0 - '@simulacrum/foundation-simulator': 0.4.0(ajv@8.17.1)(picomatch@4.0.2)(react@18.3.1) + '@simulacrum/foundation-simulator': 0.4.0(ajv@8.17.1)(picomatch@4.0.4)(react@18.3.1) assert-ts: 0.3.4 graphql: 16.10.0 graphql-yoga: 5.13.2(graphql@16.10.0) @@ -11785,7 +11788,7 @@ snapshots: dependencies: '@unhead/schema': 1.11.14 '@unhead/shared': 1.11.14 - defu: 6.1.4 + defu: 6.1.7 hookable: 5.5.3 unhead: 1.11.14 vue: 3.5.13(typescript@5.7.2) @@ -14043,9 +14046,9 @@ snapshots: optionalDependencies: picomatch: 4.0.2 - fdir@6.4.3(picomatch@4.0.2): + fdir@6.4.3(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.2 + picomatch: 4.0.4 fdir@6.5.0(picomatch@4.0.4): optionalDependencies: @@ -14391,7 +14394,7 @@ snapshots: iron-webcrypto: 1.2.1 node-mock-http: 1.0.0 radix3: 1.1.2(patch_hash=35eb325322f6de1aa3fc5c4c45acfc4e268f7512c5c235316b1c760dbb94b2cf) - ufo: 1.6.3 + ufo: 1.6.4 uncrypto: 0.1.3 h3@1.15.11: @@ -15718,6 +15721,8 @@ snapshots: nanotar@0.2.0: {} + nanotar@0.3.0: {} + natural-compare-lite@1.4.0: {} natural-compare@1.4.0: {} @@ -15794,7 +15799,7 @@ snapshots: serve-placeholder: 2.0.2 serve-static: 1.16.2 std-env: 3.8.0 - ufo: 1.6.3 + ufo: 1.6.4 uncrypto: 0.1.3 unctx: 2.4.1 unenv: 1.10.0 @@ -16124,7 +16129,7 @@ snapshots: pathe: 2.0.3 pkg-types: 1.3.1 tinyexec: 0.3.2 - ufo: 1.6.3 + ufo: 1.6.4 nypm@0.6.0: dependencies: @@ -16194,7 +16199,7 @@ snapshots: dependencies: destr: 2.0.3 node-fetch-native: 1.6.4 - ufo: 1.6.3 + ufo: 1.6.4 ofetch@1.5.1: dependencies: @@ -16944,7 +16949,7 @@ snapshots: '@vueuse/core': 12.2.0(typescript@5.7.2) '@vueuse/shared': 12.2.0(typescript@5.7.2) aria-hidden: 1.2.4 - defu: 6.1.4 + defu: 6.1.7 ohash: 1.1.4 uncrypto: 0.1.3 vue: 3.5.13(typescript@5.7.2) @@ -17872,8 +17877,6 @@ snapshots: ufo@1.6.1: {} - ufo@1.6.3: {} - ufo@1.6.4: {} ultrahtml@1.5.3: {} @@ -18490,7 +18493,7 @@ snapshots: vue-bundle-renderer@2.1.1: dependencies: - ufo: 1.6.3 + ufo: 1.6.4 vue-demi@0.14.10(vue@3.5.13): dependencies: