diff --git a/packages/dsh-desktop-market-installer/generations/installer.d.ts b/packages/dsh-desktop-market-installer/generations/installer.d.ts index fe36cf2e..84c2e472 100644 --- a/packages/dsh-desktop-market-installer/generations/installer.d.ts +++ b/packages/dsh-desktop-market-installer/generations/installer.d.ts @@ -3,6 +3,7 @@ import type { Generation } from './registry' export interface GenerationInstallOptions { dshHome: string + profile?: string pluginSpec: string /** Package name expected after installing a non-registry or aliased spec. */ expectedPluginName?: string @@ -12,6 +13,8 @@ export interface GenerationInstallOptions { sourceDirectory?: string nodeExecutablePath: string pnpmEntryPath: string + /** Hard ceiling for the pnpm subprocess; defaults to 12 minutes. */ + installTimeoutMs?: number spawnProcess?: unknown environment?: NodeJS.ProcessEnv onTrace?: (line: string) => void @@ -31,6 +34,14 @@ export function installGeneration( options: GenerationInstallOptions ): Promise +export function generationBuildApprovals(workspaceYaml: string): string[] + +export function pinnedGitBuildApproval( + pluginName: string, + pluginSpec: string, + approvals: string[] +): string | undefined + export function verifyGenerationPeers( dshHome: string, generation: Generation diff --git a/packages/dsh-desktop-market-installer/generations/installer.mjs b/packages/dsh-desktop-market-installer/generations/installer.mjs index 452a6603..f40c725d 100644 --- a/packages/dsh-desktop-market-installer/generations/installer.mjs +++ b/packages/dsh-desktop-market-installer/generations/installer.mjs @@ -30,6 +30,13 @@ import { ensureRegistryDirectories, generationId, writeGenerationMeta } from './ /** Packages the host is the sole owner of; a generation must never carry its own copy. */ const HOST_SINGLETON_PATTERNS = [/^react$/u, /^react-dom$/u, /^@deepseek-ai\//u] +const PACKAGE_NAME_PATTERN = /^(?:@[A-Za-z0-9-~][A-Za-z0-9._~-]*\/)?[A-Za-z0-9-~][A-Za-z0-9._~-]*$/u +const GIT_ALLOW_BUILD_PATTERN = /^[A-Za-z0-9@/_.-]+@git\+https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/u +const CODELOAD_ALLOW_BUILD_PATTERN = /^[A-Za-z0-9@/_.-]+@https:\/\/codeload\.github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/tar\.gz\/[0-9a-f]{40}$/u +const PINNED_GITHUB_TARGET_PATTERN = /^github:(?[A-Za-z0-9_.-]+)\/(?[A-Za-z0-9_.-]+)#(?[0-9a-f]{40})(?&path:\/(?:(?!\.\.?\/)[A-Za-z0-9_.-]+\/)*(?!\.\.?$)[A-Za-z0-9_.-]+)?$/u +const PINNED_GIT_APPROVAL_PATTERN = /@git\+ssh:\/\/git@github\.com\//u +const GENERATION_INSTALL_TIMEOUT_MS = 12 * 60 * 1000 + function isHostSingleton(name) { return HOST_SINGLETON_PATTERNS.some((pattern) => pattern.test(name)) } @@ -38,6 +45,89 @@ function installationClosureDir(dshHome) { return join(dshHome, 'profiles', 'node_modules') } +function safeBuildApprovalKey(key) { + return PACKAGE_NAME_PATTERN.test(key) || + GIT_ALLOW_BUILD_PATTERN.test(key) || + CODELOAD_ALLOW_BUILD_PATTERN.test(key) +} + +/** + * Read only explicit, safe `allowBuilds: ...: true` entries from a Profile + * workspace file. Generation installs run in a separate pnpm workspace, so + * an approval written by dsh-market has to cross that boundary deliberately. + * No other Profile workspace setting is inherited: patchedDependencies and + * relative package globs would be invalid inside the immutable staging tree. + */ +export function generationBuildApprovals(workspaceYaml) { + if (typeof workspaceYaml !== 'string' || workspaceYaml === '') return [] + const blockPattern = /allowBuilds:[ \t]*\r?\n((?:[ \t]+[^\r\n]*\r?\n?)*)/gu + const approved = new Set() + for (const block of workspaceYaml.matchAll(blockPattern)) { + for (const line of block[1].split(/\r?\n/u)) { + const match = /^[ \t]+(\S.*?)\s*:\s*(true|false)\s*$/u.exec(line) + if (match === null || match[2] !== 'true') continue + let key = match[1] + if ( + key.length >= 2 && + ((key.startsWith("'") && key.endsWith("'")) || + (key.startsWith('"') && key.endsWith('"'))) + ) { + key = key.slice(1, -1) + } + if (safeBuildApprovalKey(key)) approved.add(key) + } + } + return [...approved] +} + +/** + * pnpm 10 matches a git prepare approval against its normalized, commit-pinned + * SSH resolution id. dsh-market deliberately records stable HTTPS/codeload + * identities instead, so derive the narrower runtime key only when the same + * package and repository were already approved by the user. + */ +export function pinnedGitBuildApproval(pluginName, pluginSpec, approvals) { + if (!PACKAGE_NAME_PATTERN.test(pluginName)) return undefined + const target = PINNED_GITHUB_TARGET_PATTERN.exec(pluginSpec) + if (target?.groups === undefined) return undefined + const { owner, repo, sha, subpath = '' } = target.groups + const stable = `${pluginName}@git+https://github.com/${owner}/${repo}.git` + const codeload = `${pluginName}@https://codeload.github.com/${owner}/${repo}/tar.gz/${sha}` + if (!approvals.includes(stable) && !approvals.includes(codeload)) return undefined + return `${pluginName}@git+ssh://git@github.com/${owner}/${repo}.git#${sha}${subpath}` +} + +async function stageBuildApprovals( + dshHome, + stagingDir, + profile = 'web', + pluginName, + pluginSpec +) { + const source = join(dshHome, 'profiles', profile, 'pnpm-workspace.yaml') + let yaml + try { + yaml = await readFile(source, 'utf8') + } catch (error) { + if (error?.code === 'ENOENT') return [] + throw error + } + const approvals = generationBuildApprovals(yaml) + const pinned = pinnedGitBuildApproval(pluginName, pluginSpec, approvals) + const stagedApprovals = pinned === undefined ? approvals : [...approvals, pinned] + if (stagedApprovals.length === 0) return [] + const lines = [ + 'packages:', + ' - .', + '', + 'allowBuilds:', + ...stagedApprovals.map((key) => ` ${JSON.stringify(key)}: true`), + '' + ] + await writeFile(join(stagingDir, 'pnpm-workspace.yaml'), lines.join('\n'), 'utf8') + return stagedApprovals +} + async function defaultRunInstall(options, stagingDir) { const spawnProcess = options.spawnProcess ?? spawn return new Promise((resolve) => { @@ -63,9 +153,12 @@ async function defaultRunInstall(options, stagingDir) { } child.stdout?.on('data', collect) child.stderr?.on('data', collect) - // A generation install that needs a timeout has already failed the point of - // the exercise; record it rather than waiting the full ceiling out. - const timer = setTimeout(() => child.kill('SIGKILL'), 5 * 60 * 1000) + // Source monorepos can legitimately spend several minutes in prepare, but + // they still stay below the host's fifteen-minute operation ceiling. + const timer = setTimeout( + () => child.kill('SIGKILL'), + options.installTimeoutMs ?? GENERATION_INSTALL_TIMEOUT_MS + ) options.registerChild?.(child) child.once('close', (code) => { clearTimeout(timer) @@ -130,6 +223,16 @@ export async function installGeneration(options) { const cleanupStaging = () => rm(stagingDir, { recursive: true, force: true }).catch(() => undefined) try { + const approvals = await stageBuildApprovals( + dshHome, + stagingDir, + options.profile ?? 'web', + pluginName, + pluginSpec + ) + if (approvals.length > 0) { + trace(`forwarded ${approvals.length} approved build-script key(s) into staging`) + } let installSpec = pluginSpec if (options.sourceDirectory !== undefined) { const sourceCopy = join(stagingDir, 'source', pluginName.replace(/^@/u, '').replace(/[/\\]/gu, '+')) @@ -138,7 +241,23 @@ export async function installGeneration(options) { installSpec = `file:${sourceCopy}` } trace(`installing ${options.sourceSpec ?? pluginSpec} into staging`) - const runInstall = options.runInstall ?? ((dir) => defaultRunInstall({ ...options, pluginSpec: installSpec }, dir)) + // A git subpackage can declare a pnpm version different from its workspace + // root (the dsh-web remote UI currently does). Once this exact source has + // been approved, let pnpm follow its own documented compatibility path + // instead of failing before the authorized prepare script can run. + const approvedGitPrepare = approvals.some((key) => PINNED_GIT_APPROVAL_PATTERN.test(key)) + const installEnvironment = approvedGitPrepare + ? { + ...(options.environment ?? process.env), + npm_config_pm_on_fail: 'ignore', + PNPM_CONFIG_PM_ON_FAIL: 'ignore' + } + : options.environment + const runInstall = options.runInstall ?? ((dir) => defaultRunInstall({ + ...options, + environment: installEnvironment, + pluginSpec: installSpec + }, dir)) const started = Date.now() const { code, output } = await runInstall(stagingDir) if (code !== 0) { diff --git a/packages/dsh-desktop-market-installer/generations/projection.d.ts b/packages/dsh-desktop-market-installer/generations/projection.d.ts index 84f973ee..05aff11a 100644 --- a/packages/dsh-desktop-market-installer/generations/projection.d.ts +++ b/packages/dsh-desktop-market-installer/generations/projection.d.ts @@ -14,3 +14,4 @@ export function publishGenerationManifest( dshHome: string, profile?: string ): Promise +export function exposeMissingGenerationLinks(dshHome: string, profile?: string): Promise diff --git a/packages/dsh-desktop-market-installer/generations/projection.mjs b/packages/dsh-desktop-market-installer/generations/projection.mjs index 62418719..846586c5 100644 --- a/packages/dsh-desktop-market-installer/generations/projection.mjs +++ b/packages/dsh-desktop-market-installer/generations/projection.mjs @@ -257,6 +257,45 @@ export async function publishGenerationManifest(dshHome, profile = 'web') { return { plugins: [...enabled.keys()], bundles } } +/** + * Expose only generation links whose Profile path does not exist yet. + * + * dsh-market validates a successful add against node_modules before it + * returns control to the user. A brand-new path has no Windows replacement + * conflict, so it is safe to create for that validation. An existing path is + * never touched here: updates keep running from the old generation until the + * next cold start replaces the link. + */ +export async function exposeMissingGenerationLinks(dshHome, profile = 'web') { + const { dir, manifestState, enabled, targets } = await prepareGenerationProjection(dshHome, profile) + const modulesDir = join(dir, 'node_modules') + const linked = [] + for (const [pluginName] of enabled) { + const linkPath = join(modulesDir, pluginName) + const target = targets.get(pluginName) + if (target === undefined) throw new Error(`Enabled generation target was not prevalidated: ${pluginName}`) + try { + const info = await lstat(linkPath) + if (!info.isSymbolicLink()) continue + const current = await readlink(linkPath).catch(() => '') + const currentTarget = current === '' ? '' : resolve(dirname(linkPath), current) + if (currentTarget === target) continue + const activeBundles = manifestState.manifest.dsh?.profile?.bundles ?? [] + // A generation link left by a rejected/uninstalled pre-restart add is + // safe to replace on retry: it is neither composed nor desired. Never + // use this path for an active bundle or for a link another owner wrote. + if (activeBundles.includes(pluginName) || !currentTarget.includes(GENERATION_LINK_MARKER)) { + continue + } + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + await ensureDirLink(linkPath, target) + linked.push(pluginName) + } + return linked +} + async function prepareGenerationProjection(dshHome, profile) { const dir = profileDir(dshHome, profile) // Validate the authoritative Profile manifest before touching links. A diff --git a/packages/dsh-desktop-market-installer/index.js b/packages/dsh-desktop-market-installer/index.js index c03a65b0..a9dd794f 100644 --- a/packages/dsh-desktop-market-installer/index.js +++ b/packages/dsh-desktop-market-installer/index.js @@ -9,7 +9,10 @@ import { fileURLToPath } from 'node:url' import { PassThrough } from 'node:stream' import { installGeneration } from './generations/installer.mjs' -import { publishGenerationManifest } from './generations/projection.mjs' +import { + exposeMissingGenerationLinks, + publishGenerationManifest +} from './generations/projection.mjs' import { disableGeneration, listGenerations, @@ -470,8 +473,9 @@ export function createDesktopPnpmService(options) { * * The plugin is installed as its own immutable generation rather than into * the shared hoisted tree: a fresh directory, promoted by one rename, never - * replaced. Only manifest inventory is published while Harness is live; the - * node_modules junction changes on the next cold start. + * replaced. Only a missing link may be created while Harness is live so the + * market can validate a new install; an existing node_modules junction and + * the bundle composition change only on the next cold start. */ const runExternalMarketPluginInstall = (args, invokingDir, signal) => { validatePluginOperation(args, invokingDir) @@ -504,7 +508,12 @@ export function createDesktopPnpmService(options) { return generation === undefined || generation.pluginName !== install.generation.pluginName }) await writeDesired(home, [...kept, install.generation.id]) + // dsh-market validates a clean add against node_modules immediately. + // Creating a missing path cannot hit Windows' replace-existing rename; + // existing links (updates) remain untouched until cold start. + const exposed = await exposeMissingGenerationLinks(home) const published = await publishGenerationManifest(home) + if (exposed.length > 0) write(`available for validation: ${exposed.join(', ')}`) write(`staged for next restart: ${published.plugins.join(', ')}`) write(`bundles: ${JSON.stringify(published.bundles)}`) return { exitCode: 0 } diff --git a/packages/dsh-desktop-market-installer/pnpm-runner.mjs b/packages/dsh-desktop-market-installer/pnpm-runner.mjs index 6423c2e6..56b09c2d 100644 --- a/packages/dsh-desktop-market-installer/pnpm-runner.mjs +++ b/packages/dsh-desktop-market-installer/pnpm-runner.mjs @@ -94,6 +94,8 @@ function delay(milliseconds) { const PROJECTION_VERSION = 1 const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/iu +const GIT_PREPARE_KEY_PATTERN = /^(?(?:@[A-Za-z0-9-~][A-Za-z0-9._~-]*\/)?[A-Za-z0-9-~][A-Za-z0-9._~-]*)@git\+ssh:\/\/git@github\.com\/(?[A-Za-z0-9_.-]+)\/(?[A-Za-z0-9_.-]+)\.git#(?[0-9a-f]{40})(?&path:\/(?:(?!\.\.?\/)[A-Za-z0-9_.-]+\/)*(?!\.\.?$)[A-Za-z0-9_.-]+)?$/u +const ALLOW_BUILDS_BLOCK_PATTERN = /allowBuilds:[ \t]*\r?\n((?:[ \t]+[^\r\n]*\r?\n?)*)/gu function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -109,6 +111,94 @@ async function writeJsonAtomically(path, value) { } } +async function writeTextAtomically(path, value) { + const temporary = `${path}.${process.pid}.${Date.now()}.pnpm-policy.tmp` + try { + await writeFile(temporary, value, 'utf8') + await rename(temporary, path) + } finally { + await rm(temporary, { force: true }).catch(() => undefined) + } +} + +function buildApprovalValues(workspaceYaml) { + const values = new Map() + for (const block of workspaceYaml.matchAll(ALLOW_BUILDS_BLOCK_PATTERN)) { + for (const line of block[1].split(/\r?\n/u)) { + const match = /^[ \t]+(\S.*?)\s*:\s*(true|false)\s*$/u.exec(line) + if (match === null) continue + let key = match[1] + if ( + key.length >= 2 && + ((key.startsWith("'") && key.endsWith("'")) || + (key.startsWith('"') && key.endsWith('"'))) + ) { + key = key.slice(1, -1) + } + values.set(key, match[2] === 'true') + } + } + return values +} + +/** The exact git resolution id pnpm 10 names in its own prepare rejection. */ +export function gitPrepareApprovalKey(output) { + if (!output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) return undefined + const hint = /onlyBuiltDependencies:[ \t]*\r?\n[ \t]*-[ \t]*["']([^"'\r\n]+)["']/u.exec(output) + if (hint === null || !GIT_PREPARE_KEY_PATTERN.test(hint[1])) return undefined + return hint[1] +} + +/** + * Add pnpm's commit/subpath-specific key only when dsh-market has already + * recorded an approval for the same package and GitHub repository. This turns + * the user's existing decision into the spelling bundled pnpm 10 consumes; + * it never broadens an approval to another source. + */ +export function mergeApprovedGitPrepareKey(workspaceYaml, output) { + const exact = gitPrepareApprovalKey(output) + if (exact === undefined) return { workspaceYaml, key: undefined } + const parsed = GIT_PREPARE_KEY_PATTERN.exec(exact) + if (parsed?.groups === undefined) return { workspaceYaml, key: undefined } + const { name, owner, repo, sha } = parsed.groups + const values = buildApprovalValues(workspaceYaml) + if (values.get(exact) === false) return { workspaceYaml, key: undefined } + if (values.get(exact) === true) return { workspaceYaml, key: exact } + const stable = `${name}@git+https://github.com/${owner}/${repo}.git` + const codeload = `${name}@https://codeload.github.com/${owner}/${repo}/tar.gz/${sha}` + if (values.get(stable) !== true && values.get(codeload) !== true) { + return { workspaceYaml, key: undefined } + } + + const block = ALLOW_BUILDS_BLOCK_PATTERN.exec(workspaceYaml) + ALLOW_BUILDS_BLOCK_PATTERN.lastIndex = 0 + if (block === null || block.index === undefined) return { workspaceYaml, key: undefined } + const eol = workspaceYaml.includes('\r\n') ? '\r\n' : '\n' + const insertion = `${block[0].endsWith('\n') ? '' : eol} ${JSON.stringify(exact)}: true${eol}` + const end = block.index + block[0].length + return { + workspaceYaml: `${workspaceYaml.slice(0, end)}${insertion}${workspaceYaml.slice(end)}`, + key: exact + } +} + +async function approveGitPrepareRetry(profileDirectory, output) { + const workspacePath = join(profileDirectory, 'pnpm-workspace.yaml') + let workspaceYaml + try { + workspaceYaml = await readFile(workspacePath, 'utf8') + } catch (error) { + if (error?.code === 'ENOENT') return undefined + throw error + } + const merged = mergeApprovedGitPrepareKey(workspaceYaml, output) + if (merged.key === undefined) return undefined + if (merged.workspaceYaml !== workspaceYaml) { + await writeTextAtomically(workspacePath, merged.workspaceYaml) + } + return merged.key +} + /** * Keep generation-owned Profile entries outside pnpm's mutable dependency set. * @@ -244,7 +334,8 @@ function runPnpm(executable, args, options = {}) { return new Promise((resolve, reject) => { const child = spawnProcess(executable, args, { stdio: ['inherit', 'pipe', 'pipe'], - windowsHide: true + windowsHide: true, + env: options.environment ?? process.env }) let output = '' let idle @@ -406,9 +497,11 @@ async function runWithLockRecoveryUnisolated(executable, args, options = {}) { report = (message) => process.stderr.write(`${MARKER} ${message}\n`) } = options + let runEnvironment = options.environment ?? process.env const run = () => runPnpm(executable, args, { spawnProcess, + environment: runEnvironment, idleTimeoutMs, killGraceMs, stallAfterFailureMs, @@ -417,7 +510,23 @@ async function runWithLockRecoveryUnisolated(executable, args, options = {}) { report }) - const first = await run() + let first = await run() + if (first.code !== 0) { + try { + const key = await approveGitPrepareRetry(profileDirectory, first.output) + if (key !== undefined) { + report(`mapped the approved Git build to pnpm's pinned key; retrying ${key}`) + runEnvironment = { + ...runEnvironment, + npm_config_pm_on_fail: 'ignore', + PNPM_CONFIG_PM_ON_FAIL: 'ignore' + } + first = await run() + } + } catch (error) { + report(`could not map the approved Git build (${errorText(error)})`) + } + } const blocked = first.code === 0 ? undefined : lockedRenameTarget(first.output) // Whether the run exited on its own or had to be stopped says nothing about // whether the blocked rename can be recovered — and a run that names its diff --git a/test/generation-boundary.test.js b/test/generation-boundary.test.js index 3f0f834a..3c7b36f7 100644 --- a/test/generation-boundary.test.js +++ b/test/generation-boundary.test.js @@ -85,7 +85,7 @@ describe('the market install boundary', () => { expect(typeof svc.runExternalMarketPluginInstall).toBe('function') }) - it('installs a generation and defers its node_modules projection until cold start', async () => { + it('exposes a new generation for market validation and defers bundle activation until cold start', async () => { const home = await freshHome() const svc = service(home, stubGenerationInstall('demo-plugin', '9.9.9')) @@ -109,10 +109,12 @@ describe('the market install boundary', () => { expect(manifest.dependencies['demo-plugin']).toBe('9.9.9') expect(manifest.pnpm.overrides['demo-plugin']).toMatch(/^link:/u) const link = join(home, 'profiles', 'web', 'node_modules', 'demo-plugin') - await expect(lstat(link)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await lstat(link)).isSymbolicLink()).toBe(true) + const validationTarget = await readlink(link) await projectGenerations(home) expect((await lstat(link)).isSymbolicLink()).toBe(true) + expect(await readlink(link)).toBe(validationTarget) const activeManifest = JSON.parse( await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8') ) @@ -153,6 +155,35 @@ describe('the market install boundary', () => { expect(inactiveManifest.dsh.profile.bundles).not.toContain('demo-plugin') }) + it('replaces a stale validation-only link when a rejected install is retried', async () => { + const home = await freshHome() + const firstService = service(home, stubGenerationInstall('broken-plugin', '1.0.0')) + await drainHandle( + firstService.runExternalMarketPluginInstall( + ['add', 'broken-plugin@1.0.0'], + join(home, 'profiles', 'web') + ) + ) + const link = join(home, 'profiles', 'web', 'node_modules', 'broken-plugin') + expect((await lstat(link)).isSymbolicLink()).toBe(true) + const rejectedTarget = await readlink(link) + + await drainHandle( + firstService.runPlugin(['remove', 'broken-plugin'], join(home, 'profiles', 'web')) + ) + expect(await readlink(link)).toBe(rejectedTarget) + + await drainHandle( + service(home, stubGenerationInstall('broken-plugin', '2.0.0')).runExternalMarketPluginInstall( + ['add', 'broken-plugin@2.0.0'], + join(home, 'profiles', 'web') + ) + ) + + expect(await readlink(link)).not.toBe(rejectedTarget) + expect((await readDesired(home))[0]).toMatch(/^broken-plugin\+2\.0\.0\+/u) + }) + it('keeps the active generation link unchanged until an update reaches cold start', async () => { const home = await freshHome() await drainHandle( diff --git a/test/generation-installer.test.ts b/test/generation-installer.test.ts index 0b8b637e..c47ac9f8 100644 --- a/test/generation-installer.test.ts +++ b/test/generation-installer.test.ts @@ -3,7 +3,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { + generationBuildApprovals, installGeneration, + pinnedGitBuildApproval, verifyGenerationPeers } from '../packages/dsh-desktop-market-installer/generations/installer' import { listGenerations, registryLayout } from '../packages/dsh-desktop-market-installer/generations/registry' @@ -37,6 +39,90 @@ describe('the generation installer', () => { } } + it('accepts only explicit safe build approvals from the Profile workspace', () => { + const sha = 'a'.repeat(40) + expect(generationBuildApprovals([ + 'packages:', + ' - .', + 'allowBuilds:', + ' cloudflared: true', + ' ignored-package: false', + " '@linxin666/dsh-remote-web-ui@git+https://github.com/zhu1090093659/dsh-web.git': true", + ` "@linxin666/dsh-remote-web-ui@https://codeload.github.com/zhu1090093659/dsh-web/tar.gz/${sha}": true`, + ' ../../outside: true', + ' arbitrary key: true', + ' placeholder: set this to true or false', + '' + ].join('\r\n'))).toEqual([ + 'cloudflared', + '@linxin666/dsh-remote-web-ui@git+https://github.com/zhu1090093659/dsh-web.git', + `@linxin666/dsh-remote-web-ui@https://codeload.github.com/zhu1090093659/dsh-web/tar.gz/${sha}` + ]) + }) + + it('derives pnpm 10 git prepare keys only from a matching repository approval', () => { + const sha = 'a'.repeat(40) + const spec = `github:zhu1090093659/dsh-web#${sha}&path:/packages/dsh-remote-web-ui` + const stable = '@linxin666/dsh-remote-web-ui@git+https://github.com/zhu1090093659/dsh-web.git' + const exact = `@linxin666/dsh-remote-web-ui@git+ssh://git@github.com/zhu1090093659/dsh-web.git#${sha}&path:/packages/dsh-remote-web-ui` + + expect(pinnedGitBuildApproval('@linxin666/dsh-remote-web-ui', spec, [stable])).toBe(exact) + expect(pinnedGitBuildApproval('@linxin666/dsh-remote-web-ui', spec, [ + '@linxin666/dsh-remote-web-ui' + ])).toBeUndefined() + expect(pinnedGitBuildApproval('@linxin666/dsh-remote-web-ui', spec, [ + '@linxin666/dsh-remote-web-ui@git+https://github.com/other/repo.git' + ])).toBeUndefined() + }) + + it('forwards Profile build approvals into the isolated pnpm staging workspace', async () => { + const home = await freshHome() + const profile = join(home, 'profiles', 'web') + await mkdir(profile, { recursive: true }) + await writeFile( + join(profile, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - .', + 'allowBuilds:', + ' cloudflared: true', + " '@linxin666/dsh-remote-web-ui@git+https://github.com/zhu1090093659/dsh-web.git': true", + ' esbuild: false', + 'patchedDependencies:', + ' unsafe: ./outside.patch', + '' + ].join('\n') + ) + + const result = await installGeneration({ + dshHome: home, + pluginSpec: 'github:zhu1090093659/dsh-web#path:/packages/dsh-remote-web-ui', + expectedPluginName: '@linxin666/dsh-remote-web-ui', + nodeExecutablePath: 'node', + pnpmEntryPath: 'pnpm', + runInstall: stubInstall(async (staging) => { + const stagedPolicy = await readFile(join(staging, 'pnpm-workspace.yaml'), 'utf8') + expect(stagedPolicy).toContain('"cloudflared": true') + expect(stagedPolicy).toContain( + '"@linxin666/dsh-remote-web-ui@git+https://github.com/zhu1090093659/dsh-web.git": true' + ) + expect(stagedPolicy).not.toContain('git+ssh://') + expect(stagedPolicy).not.toContain('esbuild') + expect(stagedPolicy).not.toContain('patchedDependencies') + + const pkg = join(staging, 'node_modules', '@linxin666', 'dsh-remote-web-ui') + await mkdir(pkg, { recursive: true }) + await writeFile( + join(pkg, 'package.json'), + JSON.stringify({ name: '@linxin666/dsh-remote-web-ui', version: '0.3.11' }) + ) + await writeFile(join(staging, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') + }) + }) + + expect(result.ok).toBe(true) + }) + it('promotes a clean install into a generation and records its metadata', async () => { const home = await freshHome() const result = await installGeneration({ diff --git a/test/pnpm-runner.test.js b/test/pnpm-runner.test.js index a1742c07..b1744918 100644 --- a/test/pnpm-runner.test.js +++ b/test/pnpm-runner.test.js @@ -7,7 +7,9 @@ import { MARKER, SIDELINE_MARKER, blockedTargets, + gitPrepareApprovalKey, lockedRenameTarget, + mergeApprovedGitPrepareKey, runWithLockRecovery, sidelinePath, suspendGenerationProjectionForPnpm @@ -22,11 +24,19 @@ const WINDOWS_LOCK_FAILURE = [ const BLOCKED_TARGET = 'C:\\Users\\u\\AppData\\Roaming\\dsh-desktop-dev\\harness\\profiles\\web\\node_modules\\argparse' +const GIT_SHA = 'c36e0d9992d31a81972e8eebe5208eab7d2e7ed3' +const GIT_EXACT_KEY = `@linxin666/dsh-remote-web-ui@git+ssh://git@github.com/zhu1090093659/dsh-web.git#${GIT_SHA}&path:/packages/dsh-remote-web-ui` +const GIT_PREPARE_FAILURE = [ + 'ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED Failed to prepare git-hosted package', + 'onlyBuiltDependencies:', + ` - "${GIT_EXACT_KEY}"` +].join('\n') + function fakePnpm(runs) { const calls = [] - const spawnProcess = (executable, args) => { + const spawnProcess = (executable, args, options) => { const run = runs[calls.length] ?? { code: 0, output: '' } - calls.push({ executable, args }) + calls.push({ executable, args, options }) const child = new EventEmitter() child.stdout = new EventEmitter() child.stderr = new EventEmitter() @@ -46,6 +56,66 @@ function fakePnpm(runs) { } describe('packaged pnpm runner', () => { + it('extracts only a safe exact key from pnpm git prepare failures', () => { + expect(gitPrepareApprovalKey(GIT_PREPARE_FAILURE)).toBe(GIT_EXACT_KEY) + expect(gitPrepareApprovalKey(GIT_PREPARE_FAILURE.replace('github.com/', 'evil.example/'))) + .toBeUndefined() + expect(gitPrepareApprovalKey(`onlyBuiltDependencies:\n - "${GIT_EXACT_KEY}"`)) + .toBeUndefined() + }) + + it('maps an approved repository to pnpm 10s pinned git key', () => { + const yaml = [ + 'packages:', + ' - .', + 'allowBuilds:', + " '@linxin666/dsh-remote-web-ui': true", + ' @linxin666/dsh-remote-web-ui@git+https://github.com/zhu1090093659/dsh-web.git: true', + '' + ].join('\r\n') + const merged = mergeApprovedGitPrepareKey(yaml, GIT_PREPARE_FAILURE) + expect(merged.key).toBe(GIT_EXACT_KEY) + expect(merged.workspaceYaml).toContain(` "${GIT_EXACT_KEY}": true\r\n`) + + const unrelated = yaml.replace('zhu1090093659/dsh-web', 'other/repo') + expect(mergeApprovedGitPrepareKey(unrelated, GIT_PREPARE_FAILURE).key).toBeUndefined() + }) + + it('automatically retries a git prepare after the repository was approved', async () => { + const profile = await mkdtemp(join(tmpdir(), 'dsh-pnpm-git-approval-')) + const workspacePath = join(profile, 'pnpm-workspace.yaml') + try { + await writeFile(workspacePath, [ + 'packages:', + ' - .', + 'allowBuilds:', + ' @linxin666/dsh-remote-web-ui@git+https://github.com/zhu1090093659/dsh-web.git: true', + '' + ].join('\n')) + const { spawnProcess, calls } = fakePnpm([ + { code: 1, output: GIT_PREPARE_FAILURE }, + { code: 0, output: '' } + ]) + const lines = [] + + const result = await runWithLockRecovery('/node', ['/pnpm.cjs', 'add', 'github:x/y'], { + profileDirectory: profile, + spawnProcess, + wait: async () => undefined, + report: (line) => lines.push(line) + }) + + expect(result.code).toBe(0) + expect(calls).toHaveLength(2) + expect(calls[0].options.env.npm_config_pm_on_fail).toBeUndefined() + expect(calls[1].options.env.npm_config_pm_on_fail).toBe('ignore') + expect(await readFile(workspacePath, 'utf8')).toContain(`"${GIT_EXACT_KEY}": true`) + expect(lines.join('\n')).toContain('mapped the approved Git build') + } finally { + await rm(profile, { recursive: true, force: true }) + } + }) + it('temporarily removes only generation-owned dependency fields and restores them', async () => { const profile = await mkdtemp(join(tmpdir(), 'dsh-pnpm-projection-')) const manifestPath = join(profile, 'package.json')