From 98b3221eecd88ea7914d0a1e24626fc7bebf8a78 Mon Sep 17 00:00:00 2001 From: Pine Date: Sun, 16 Aug 2026 16:11:22 +0800 Subject: [PATCH] feat(plugin): add registry name handling and release age exclusion for pnpm workspace --- apps/cli/src/plugin.ts | 55 ++++++++++++++++++- apps/cli/tests/plugin.spec.ts | 49 +++++++++++++++++ packages/host/plugin-inventory/src/index.ts | 8 ++- packages/host/plugin-inventory/src/install.ts | 41 +++++++++++--- .../plugin-inventory/tests/install.spec.ts | 54 ++++++------------ 5 files changed, 158 insertions(+), 49 deletions(-) create mode 100644 apps/cli/tests/plugin.spec.ts diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index 602029e903..a19ae9828e 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -11,8 +11,9 @@ */ import { spawnSync } from 'node:child_process' -import { existsSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' +import { dump, load } from 'js-yaml' import { DEFAULT_PROFILE_BUNDLES, initProfile, @@ -26,6 +27,52 @@ import { INSTALL_ANCHOR } from './profile-boot.ts' const NAME = 'dsh' +/** + * The bare package name when `spec` is a registry specifier, else undefined — + * the same shape the in-app install uses to decide registry participation. + * Everything pnpm treats as a non-registry source (paths, `file:`/`link:`/ + * `github:`/`git+`, tarballs, http(s) repo URLs, `.git` markers) is not a + * registry spec. + * @param spec - the pnpm specifier verbatim. + */ +export function registryNameOf(spec: string): string | undefined { + const trimmed = spec.trim() + if (trimmed.length === 0) return undefined + if (/^(?:file:|link:|github:|gitlab:|bitbucket:|git\+|git@)/.test(trimmed)) return undefined + if (/^(?:\.{1,2}|~|[/\\])/.test(trimmed) || /^[a-zA-Z]:[\\/]/.test(trimmed)) return undefined + if (/\.(?:tgz|tar\.gz)(?:[?#]|$)/.test(trimmed)) return undefined + if (/^https?:\/\//.test(trimmed) && trimmed.replace(/^https?:\/\//, '').split('/').length > 1) return undefined + if (/\.git(?:[#@]|$)/.test(trimmed)) return undefined + return trimmed.replace(/@[^/@]+$/, '') +} + +/** + * Exempt registry package names from pnpm's `minimumReleaseAge` check by writing + * the `minimumReleaseAgeExclude` setting into the profile's `pnpm-workspace.yaml` + * (honored by pnpm ≥10.16; an older pnpm ignores the key rather than aborting, + * unlike the `--minimum-release-age-exclude` CLI flag). This mirrors the in-app + * install so an external `dsh plugin add ` gets the same latest-version + * behavior as the desktop's install, not a stale age-blocked fallback. + * @param profileDir - the profile directory. + * @param names - the registry package names to exempt. + */ +export function writeReleaseAgeExclude(profileDir: string, names: readonly string[]): void { + const workspacePath = join(profileDir, 'pnpm-workspace.yaml') + let doc: Record + try { + const parsed = load(readFileSync(workspacePath, 'utf8')) + doc = parsed !== null && typeof parsed === 'object' + ? parsed as Record + : {} + } catch { + doc = {} + } + const excluded = new Set((doc.minimumReleaseAgeExclude ?? []) as string[]) + for (const name of names) excluded.add(name) + if (excluded.size > 0) doc.minimumReleaseAgeExclude = [...excluded] + writeFileSync(workspacePath, dump(doc)) +} + /** * Rewrite relative filesystem specs against the user's invoking directory. * pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin` @@ -66,6 +113,12 @@ export function runPlugin(profile: string, args: readonly string[]): number { // its .cmd shim, which spawn() refuses without a shell since the // CVE-2024-27980 hardening; the vendored path runs node against pnpm.cjs. const anchored = args.map(argument => anchorPathSpec(argument, process.cwd())) + // An external `add ` gets the same minimum-release-age exemption + // the in-app install applies, so a just-published plugin installs at latest. + if (args[0] === 'add') { + const registryNames = args.slice(1).map(registryNameOf).filter((name): name is string => name !== undefined) + if (registryNames.length > 0) writeReleaseAgeExclude(dir, registryNames) + } const vendored = resolvePnpm(process.execPath) const result = vendored === undefined ? spawnSync('pnpm', anchored, { cwd: dir, stdio: 'inherit', shell: process.platform === 'win32' }) diff --git a/apps/cli/tests/plugin.spec.ts b/apps/cli/tests/plugin.spec.ts new file mode 100644 index 0000000000..68592ce901 --- /dev/null +++ b/apps/cli/tests/plugin.spec.ts @@ -0,0 +1,49 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { load } from 'js-yaml' +import { registryNameOf, writeReleaseAgeExclude } from '../src/plugin.ts' + +const dirs: string[] = [] +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-cli-plugin-')) + dirs.push(dir) + return dir +} +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) + +describe('registryNameOf', () => { + it('returns the bare name for a registry specifier', () => { + expect(registryNameOf('dsh-theme-plugin')).toBe('dsh-theme-plugin') + expect(registryNameOf('dsh-theme-plugin@latest')).toBe('dsh-theme-plugin') + expect(registryNameOf('@scope/pkg@1.2.0')).toBe('@scope/pkg') + }) + + it('returns undefined for non-registry sources', () => { + expect(registryNameOf('github:user/repo')).toBeUndefined() + expect(registryNameOf('git+https://github.com/user/repo.git')).toBeUndefined() + expect(registryNameOf('file:../plugin')).toBeUndefined() + expect(registryNameOf('./plugin')).toBeUndefined() + expect(registryNameOf('https://example.com/p.tgz')).toBeUndefined() + }) +}) + +describe('writeReleaseAgeExclude', () => { + it('writes the exclusion into pnpm-workspace.yaml, preserving existing settings', () => { + const dir = tempDir() + writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages:\n - .\nallowBuilds:\n node-pty: true\n') + writeReleaseAgeExclude(dir, ['dsh-theme-plugin']) + writeReleaseAgeExclude(dir, ['@scope/pkg']) + const doc = load(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')) as Record + expect(doc.minimumReleaseAgeExclude).toEqual(['dsh-theme-plugin', '@scope/pkg']) + expect((doc.allowBuilds as Record)['node-pty']).toBe(true) + }) + + it('creates the file when absent', () => { + const dir = tempDir() + writeReleaseAgeExclude(dir, ['x']) + const doc = load(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')) as Record + expect(doc.minimumReleaseAgeExclude).toEqual(['x']) + }) +}) diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index 80b6731b23..ee2a67592c 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -17,6 +17,7 @@ import { runPnpmRemove, uninstallBundle, writeAllowBuilds, + writeReleaseAgeExclude, } from './install.ts' import { fetchMarketplaceCatalog, @@ -253,8 +254,12 @@ export class PluginInventoryGateway extends TypertRemoteService { writeAllowBuilds(profileDir, spec.consentBuilds) } // A registry-name spec participates in the registry fallback loop and the - // minimum-release-age exemption; a git, tarball, or path spec runs once. + // minimum-release-age exemption; a git, tarball, or path spec runs once. The + // release-age exemption is written into pnpm-workspace.yaml (not passed as a + // CLI flag) so an older pnpm ignores the setting instead of aborting on an + // unknown option. const registryName = registryPackageName(spec.spec) + if (registryName !== undefined) writeReleaseAgeExclude(profileDir, [registryName]) const result = registryName === undefined ? runPnpmInstall({ binName: 'dsh', @@ -273,7 +278,6 @@ export class PluginInventoryGateway extends TypertRemoteService { pnpmCjs: pnpm.pnpmCjs, spec: spec.spec, before, - minimumReleaseAgeExclude: registryName, }) if (result.pendingBuilds !== undefined) { return { ok: true, restartRequired: false, pendingBuilds: result.pendingBuilds } diff --git a/packages/host/plugin-inventory/src/install.ts b/packages/host/plugin-inventory/src/install.ts index f82080aac7..8d132ab582 100644 --- a/packages/host/plugin-inventory/src/install.ts +++ b/packages/host/plugin-inventory/src/install.ts @@ -150,8 +150,6 @@ export interface PnpmInstallOptions { readonly before: ProfileManifest /** An npm registry to install from (`pnpm add --registry`); defaults to pnpm's configured one. */ readonly registry?: string - /** A bare registry package name to exempt from pnpm's minimum-release-age check. */ - readonly minimumReleaseAgeExclude?: string } /** @@ -164,15 +162,9 @@ export interface PnpmInstallOptions { * @returns `pendingBuilds` when pnpm blocked build scripts, else an empty result. */ export function runPnpmInstall(options: PnpmInstallOptions): PnpmAddResult { - const { binName, profileDir, installAnchor, nodeBin, pnpmCjs, spec, before, registry, minimumReleaseAgeExclude } = options + const { binName, profileDir, installAnchor, nodeBin, pnpmCjs, spec, before, registry } = options const args = ['add', spec] if (registry !== undefined) args.push('--registry', registry) - // `--minimum-release-age-exclude` is understood only by pnpm ≥10.7. It is safe - // with the vendored pnpm (pinned 11.7); a PATH-pnpm fallback in a development - // checkout may be older and reject the option, so skip it there. - if (minimumReleaseAgeExclude !== undefined && nodeBin !== undefined) { - args.push(`--minimum-release-age-exclude=${minimumReleaseAgeExclude}`) - } const result = nodeBin === undefined ? spawn(pnpmCjs, args, profileDir) : spawn(nodeBin, [pnpmCjs, ...args], profileDir) if (result.exitCode !== 0) { const pendingBuilds = parseBlockedBuilds(result.output) @@ -264,6 +256,37 @@ export function writeAllowBuilds(profileDir: string, names: readonly string[]): writeFileSync(workspacePath, dump(doc)) } +/** + * Exempt the given registry package names from pnpm's `minimumReleaseAge` check + * by writing the `minimumReleaseAgeExclude` setting into the profile's + * `pnpm-workspace.yaml`. Writing the config directly is the robust way to beat + * the release-age check: the setting is honored by pnpm ≥10.16 (the documented + * mechanism, which pnpm reads only from `pnpm-workspace.yaml`, not `.npmrc`), + * and — unlike the `--minimum-release-age-exclude` CLI flag — an unknown-key pnpm + * ignores it instead of aborting with `Unknown option`. This lets a plugin + * published minutes ago install at its latest version on whichever pnpm the + * runtime resolves. Existing workspace settings are preserved and the exclusion + * list is merged. + * @param profileDir - the writable profile directory. + * @param names - the registry package names to exempt from the release-age check. + */ +export function writeReleaseAgeExclude(profileDir: string, names: readonly string[]): void { + const workspacePath = join(profileDir, 'pnpm-workspace.yaml') + let doc: Record + try { + const parsed = load(readFileSync(workspacePath, 'utf8')) + doc = parsed !== null && typeof parsed === 'object' + ? parsed as Record + : { ...PROFILE_WORKSPACE_BASE } + } catch { + doc = { ...PROFILE_WORKSPACE_BASE } + } + const excluded = new Set((doc.minimumReleaseAgeExclude ?? []) as string[]) + for (const name of names) excluded.add(name) + if (excluded.size > 0) doc.minimumReleaseAgeExclude = [...excluded] + writeFileSync(workspacePath, dump(doc)) +} + /** Options for removing one plugin dependency. */ export interface PnpmRemoveOptions { readonly binName: string diff --git a/packages/host/plugin-inventory/tests/install.spec.ts b/packages/host/plugin-inventory/tests/install.spec.ts index 9f0d37fb30..7f270c0c97 100644 --- a/packages/host/plugin-inventory/tests/install.spec.ts +++ b/packages/host/plugin-inventory/tests/install.spec.ts @@ -1,11 +1,13 @@ -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { load } from 'js-yaml' import { readProfileManifest } from '@deepseek-ai/dsh-app-boot' import { composeOfflineBundle, INSTALL_REGISTRIES, parseBlockedBuilds, registryPackageName, resolvePnpm, resolvePnpmCommand, runPnpmInstall, writeAllowBuilds, + writeReleaseAgeExclude, runPnpmInstallWithRegistries, runPnpmRemove, uninstallBundle, } from '../src/install.ts' @@ -67,24 +69,6 @@ function makeBlockedBuildPnpm(dir: string): string { return file } -/** - * A self-executable fake pnpm (shebang + executable bit) that records its argv - * and exits 0 — simulates a `pnpm` command invoked directly off PATH, without a - * `node` prefix (as the PATH-pnpm fallback does). - */ -function makeExecutablePnpm(dir: string): string { - const file = join(dir, 'pnpm-path') - writeFileSync(file, [ - '#!/usr/bin/env node', - "const fs = require('fs')", - 'const args = process.argv.slice(2)', - 'fs.writeFileSync(process.env.RECORD, JSON.stringify(args))', - 'process.exit(0)', - ].join('\n')) - chmodSync(file, 0o755) - return file -} - /** * A fake pnpm that, for `remove `, drops the named dependency from the * profile's package.json (as pnpm does) and exits 0. @@ -171,7 +155,7 @@ describe('runPnpmInstall', () => { } }) - it('passes minimum-release-age-exclude for a registry name', () => { + it('never passes a release-age CLI flag (the exemption is a workspace setting, not a flag)', () => { const dir = makeProfile() const record = join(dir, 'record.json') const pnpm = makeRecordingPnpm(dir) @@ -181,32 +165,28 @@ describe('runPnpmInstall', () => { runPnpmInstall({ binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'), nodeBin: process.execPath, pnpmCjs: pnpm, spec: 'x', - before: readProfileManifest('dsh', dir), minimumReleaseAgeExclude: 'x', + before: readProfileManifest('dsh', dir), }) const args = JSON.parse(readFileSync(record, 'utf8')) as string[] - expect(args).toContain('--minimum-release-age-exclude=x') + expect(args).toEqual(['add', 'x']) + expect(args.some(arg => arg.includes('minimum-release-age'))).toBe(false) } finally { delete process.env.RECORD delete process.env.EXIT } }) - it('omits minimum-release-age-exclude for a PATH-pnpm fallback (may be an older pnpm)', () => { + it('writes the minimum-release-age exemption into pnpm-workspace.yaml, preserving existing settings', () => { const dir = makeProfile() - const record = join(dir, 'record.json') - const pnpm = makeExecutablePnpm(dir) - process.env.RECORD = record - try { - runPnpmInstall({ - binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'), - nodeBin: undefined, pnpmCjs: pnpm, spec: 'x', - before: readProfileManifest('dsh', dir), minimumReleaseAgeExclude: 'x', - }) - const args = JSON.parse(readFileSync(record, 'utf8')) as string[] - expect(args).toEqual(['add', 'x']) - } finally { - delete process.env.RECORD - } + const workspacePath = join(dir, 'pnpm-workspace.yaml') + writeFileSync(workspacePath, 'autoInstallPeers: false\nallowBuilds:\n node-pty: true\n') + writeReleaseAgeExclude(dir, ['dsh-theme-plugin']) + writeReleaseAgeExclude(dir, ['@scope/pkg']) + const doc = JSON.parse(JSON.stringify(load(readFileSync(workspacePath, 'utf8')))) as Record + expect(doc.minimumReleaseAgeExclude).toEqual(['dsh-theme-plugin', '@scope/pkg']) + // Existing settings survive. + expect((doc.allowBuilds as Record).node_pty ?? (doc.allowBuilds as Record)['node-pty']).toBe(true) + expect(doc.autoInstallPeers).toBe(false) }) it('returns pendingBuilds when pnpm blocks build scripts', () => {