diff --git a/packages/dsh-desktop-market-installer/generations/installer.mjs b/packages/dsh-desktop-market-installer/generations/installer.mjs index 3653a16f5..3dea86322 100644 --- a/packages/dsh-desktop-market-installer/generations/installer.mjs +++ b/packages/dsh-desktop-market-installer/generations/installer.mjs @@ -4,6 +4,7 @@ import { existsSync } from 'node:fs' import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, unlink, writeFile } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, relative } from 'node:path' import semver from 'semver' +import { isMap, isScalar, parseDocument } from 'yaml' import { hideConsoleImportArguments } from '../windows-hide-console.mjs' import { ensureRegistryDirectories, generationId, writeGenerationMeta } from './registry.mjs' @@ -121,26 +122,41 @@ function safeBuildApprovalKey(key) { */ export function generationBuildApprovals(workspaceYaml) { if (typeof workspaceYaml !== 'string' || workspaceYaml === '') return [] - const blockPattern = /allowBuilds:[ \t]*\r?\n((?:[ \t]+[^\r\n]*\r?\n?)*)/gu + let document + try { + document = parseDocument(workspaceYaml) + } catch { + return [] + } + if (document.errors.every((error) => error.code === 'BAD_SCALAR_START')) { + document.errors.length = 0 + } + if (document.errors.length > 0 || !isMap(document.contents)) return [] 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) - } + for (const pair of document.contents.items) { + if (!isScalar(pair.key) || pair.key.value !== 'allowBuilds' || !isMap(pair.value)) continue + for (const entry of pair.value.items) { + if (!isScalar(entry.key) || !isScalar(entry.value) || entry.value.value !== true) continue + const key = String(entry.key.value) if (safeBuildApprovalKey(key)) approved.add(key) } } return [...approved] } +function readWorkspaceAutoInstallPeers(workspaceYaml) { + if (typeof workspaceYaml !== 'string' || workspaceYaml === '') return undefined + let document + try { + document = parseDocument(workspaceYaml) + } catch { + return undefined + } + if (document.errors.length > 0 || !isMap(document.contents)) return undefined + const value = document.contents.get('autoInstallPeers') + return typeof value === 'boolean' ? value : undefined +} + /** * pnpm 10 matches a git prepare approval against its normalized, commit-pinned * SSH resolution id. dsh-market deliberately records stable HTTPS/codeload @@ -314,8 +330,8 @@ export async function installGeneration(options) { if (error.code === 'ENOENT') return '' throw error }) - const policy = /^autoInstallPeers:[ \t]*(true|false)[ \t]*(?:#.*)?\r?$/m.exec(workspace) - if (policy) options = { ...options, autoInstallPeers: policy[1] === 'true' } + const policy = readWorkspaceAutoInstallPeers(workspace) + if (policy !== undefined) options = { ...options, autoInstallPeers: policy } } const { dshHome, pluginSpec, onTrace } = options const trace = (line) => onTrace?.(`generation-install: ${line}`) diff --git a/packages/dsh-desktop-market-installer/pnpm-runner.mjs b/packages/dsh-desktop-market-installer/pnpm-runner.mjs index b8a237829..5ded589e2 100644 --- a/packages/dsh-desktop-market-installer/pnpm-runner.mjs +++ b/packages/dsh-desktop-market-installer/pnpm-runner.mjs @@ -27,6 +27,7 @@ import { spawn } from 'node:child_process' import { existsSync, realpathSync, watch } from 'node:fs' import { readFile, readdir, rename, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { hideConsoleImportArguments } from './windows-hide-console.mjs' @@ -96,7 +97,6 @@ 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) @@ -122,21 +122,106 @@ async function writeTextAtomically(path, value) { } } -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) +let cachedYamlModule + +function loadYamlModule(profileDirectory) { + if (cachedYamlModule !== undefined) return cachedYamlModule + const anchors = [ + import.meta.url, + process.env.DSH_DESKTOP_PNPM_RUNNER, + process.env.DSH_DESKTOP_PNPM_ENTRY, + process.argv[2], + profileDirectory ? join(profileDirectory, 'package.json') : undefined, + join(process.cwd(), 'package.json') + ] + for (const anchor of anchors) { + if (typeof anchor !== 'string' || anchor.length === 0) continue + try { + const require = createRequire(anchor) + cachedYamlModule = require('yaml') + return cachedYamlModule + } catch { + // Try the next resolution anchor when running from a staged .desktop-bin copy. + } + } + throw new Error('Could not resolve the yaml package for pnpm lockfile projection isolation.') +} + +/** + * Remove generation-owned direct dependencies from pnpm's lockfile view. + * + * pnpm derives the roots it owns from an importer's dependency mappings. The + * package and snapshot sections are merely their transitive resolution, so + * removing the importer mappings is both sufficient and lets pnpm prune the + * stale legacy records as part of the operation it is already performing. + */ +export function removeProjectedLockfileImporters(text, pluginNames, profileDirectory) { + const names = new Set(pluginNames) + if (names.size === 0) return text + const { isMap, isScalar, parseDocument } = loadYamlModule(profileDirectory) + let document + try { + document = parseDocument(text) + } catch { + return text + } + if (document.errors.length > 0 || !isMap(document.contents)) { + return text + } + + const importers = document.contents.get('importers', true) + if (!isMap(importers)) return text + + let changed = false + for (const importerPair of importers.items) { + const importer = importerPair.value + if (!isMap(importer)) continue + + for (let index = importer.items.length - 1; index >= 0; index -= 1) { + const entry = importer.items[index] + const key = isScalar(entry?.key) ? String(entry.key.value) : undefined + if (key !== undefined && names.has(key)) { + importer.items.splice(index, 1) + changed = true + continue + } + if (!isMap(entry?.value)) continue + for (const name of names) { + if (entry.value.delete(name)) { + changed = true + } } - values.set(key, match[2] === 'true') + } + } + + if (!changed) return text + const serialized = String(document) + return text.includes('\r\n') ? serialized.replace(/\r?\n/gu, '\r\n') : serialized +} + +async function removeGenerationLockfileEntries(profileDirectory, pluginNames) { + const lockfilePath = join(profileDirectory, 'pnpm-lock.yaml') + let text + try { + text = await readFile(lockfilePath, 'utf8') + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } + const updated = removeProjectedLockfileImporters(text, pluginNames, profileDirectory) + if (updated === text) return false + await writeTextAtomically(lockfilePath, updated) + return true +} + +function buildApprovalValues(document, isMap, isScalar) { + const values = new Map() + for (const pair of document.contents.items) { + if (!isScalar(pair.key) || pair.key.value !== 'allowBuilds' || !isMap(pair.value)) continue + for (const entry of pair.value.items) { + if (!isScalar(entry.key) || !isScalar(entry.value)) continue + if (typeof entry.value.value !== 'boolean') continue + values.set(String(entry.key.value), entry.value.value) } } return values @@ -156,13 +241,26 @@ export function gitPrepareApprovalKey(output) { * 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) { +export function mergeApprovedGitPrepareKey(workspaceYaml, output, profileDirectory) { 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) + const { isMap, isScalar, parseDocument } = loadYamlModule(profileDirectory) + let document + try { + document = parseDocument(workspaceYaml) + } catch { + return { workspaceYaml, key: undefined } + } + if (document.errors.every((error) => error.code === 'BAD_SCALAR_START')) { + document.errors.length = 0 + } + if (document.errors.length > 0 || !isMap(document.contents)) { + return { workspaceYaml, key: undefined } + } + const values = buildApprovalValues(document, isMap, isScalar) 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` @@ -171,14 +269,16 @@ export function mergeApprovedGitPrepareKey(workspaceYaml, output) { 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 + const allowBuilds = document.contents.get('allowBuilds', true) + if (!isMap(allowBuilds)) return { workspaceYaml, key: undefined } + const keyNode = document.createNode(exact) + keyNode.type = 'QUOTE_DOUBLE' + allowBuilds.set(keyNode, document.createNode(true)) + const serialized = String(document) return { - workspaceYaml: `${workspaceYaml.slice(0, end)}${insertion}${workspaceYaml.slice(end)}`, + workspaceYaml: workspaceYaml.includes('\r\n') + ? serialized.replace(/\r?\n/gu, '\r\n') + : serialized, key: exact } } @@ -192,7 +292,7 @@ async function approveGitPrepareRetry(profileDirectory, output) { if (error?.code === 'ENOENT') return undefined throw error } - const merged = mergeApprovedGitPrepareKey(workspaceYaml, output) + const merged = mergeApprovedGitPrepareKey(workspaceYaml, output, profileDirectory) if (merged.key === undefined) return undefined if (merged.workspaceYaml !== workspaceYaml) { await writeTextAtomically(workspacePath, merged.workspaceYaml) @@ -273,6 +373,7 @@ export async function suspendGenerationProjectionForPnpm(profileDirectory) { if (Object.keys(pnpm).length > 0) manifest.pnpm = pnpm else delete manifest.pnpm await writeJsonAtomically(manifestPath, manifest) + await removeGenerationLockfileEntries(profileDirectory, projectedPlugins) let restored = false return { diff --git a/src/main/state/legacy-preset-migration.ts b/src/main/state/legacy-preset-migration.ts index 87ca4512e..8eb208ada 100644 --- a/src/main/state/legacy-preset-migration.ts +++ b/src/main/state/legacy-preset-migration.ts @@ -1,13 +1,37 @@ import { constants } from 'node:fs' import { cp, copyFile, mkdir, readFile, readdir, rename, rm, stat } from 'node:fs/promises' import { join } from 'node:path' -import { isSeq, parse, parseDocument } from 'yaml' +import { isMap, isScalar, isSeq, parse, parseDocument, type YAMLMap } from 'yaml' import { writeTextAtomically } from './persona-prefix-migration' const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/ const START = '# dsh-desktop legacy presets begin' const END = '# dsh-desktop legacy presets end' const BUILT_IN_PRESETS = new Set(['standard', 'ptc', 'minimal', 'cordis']) +const YAML_OPTIONS = { + customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value: string) => value }] +} + +function collectExistingPresetIds(text: string): Set { + const existing = new Set() + const document = parseDocument(text, YAML_OPTIONS) + if (document.errors.length > 0 || !isSeq(document.contents)) return existing + const recordPresetId = (value: unknown): void => { + if (typeof value !== 'string' || !value.startsWith('preset-')) return + const id = value.slice('preset-'.length) + if (PRESET_ID.test(id)) existing.add(id) + } + for (const row of document.contents.items) { + if (!isMap(row)) continue + recordPresetId(row.get('id')) + const insert = row.get('insert', true) + if (!isSeq(insert)) continue + for (const entry of insert.items) { + if (isMap(entry)) recordPresetId(entry.get('id')) + } + } + return existing +} function yamlString(value: string): string { return JSON.stringify(value) @@ -81,9 +105,7 @@ export async function migrateLegacyAgentPresets(dshHome: string, note: (line: st } // A converted row belongs to the new Profile from now on. Preserve edits // made through the new editor; only append legacy IDs not yet present. - const existingIds = new Set( - [...base.matchAll(/^(?:- id:| - id:) preset-([a-z0-9-]+)\s*$/gmu)].map((match) => match[1]) - ) + const existingIds = collectExistingPresetIds(base) const pending = sections.filter(({ id }) => !existingIds.has(id)) if (pending.length === 0) return const generated = start === -1 ? '' : base.slice(start + START.length, end).trim() @@ -152,21 +174,50 @@ export async function disableMigratedLegacyPreset( } if (blockStart === -1 || blockEnd === -1 || blockEnd < blockStart) return false + const blockText = lines.slice(blockStart + 1, blockEnd).join('\n') + const document = parseDocument(blockText, YAML_OPTIONS) + const contents = document.contents + if (document.errors.length > 0 || !isSeq(contents)) return false + const wanted = `preset-${presetId}` - for (let index = blockStart; index <= blockEnd; index++) { - const match = /^(\s*)- id: (\S+)\s*$/.exec(lines[index] as string) - if (!match || match[2] !== wanted) continue - const fieldIndent = `${match[1]} ` - // The entry's own fields run until the next row at the entry indent. - for (let scan = index + 1; scan <= blockEnd; scan++) { - const later = lines[scan] as string - if (later && !later.startsWith(fieldIndent) && later.trimStart().startsWith('- ')) break - if (later.startsWith(`${fieldIndent}disabled:`)) return false + let target: YAMLMap | undefined + for (const row of contents.items) { + if (!isMap(row)) continue + const rowId: unknown = row.get('id') + if (rowId === wanted) { + target = row + break + } + const insert = row.get('insert', true) + if (!isSeq(insert)) continue + for (const entry of insert.items) { + if (!isMap(entry)) continue + const entryId: unknown = entry.get('id') + if (entryId === wanted) { + target = entry + break + } } - lines.splice(index + 1, 0, `${fieldIndent}disabled: true`) - await writeTextAtomically(profilePatch, lines.join('\n')) - note(`[desktop] disabled the migrated legacy preset row for "${presetId}"; a plugin registers it at runtime (#814)`) - return true + if (target !== undefined) break } - return false + + if (target === undefined || target.has('disabled')) return false + const idPair = target.items.find((pair) => isScalar(pair.key) && pair.key.value === 'id') + const keyOffset = isScalar(idPair?.key) ? idPair.key.range?.[0] : undefined + const valueEnd = isScalar(idPair?.value) ? idPair.value.range?.[1] : undefined + if (keyOffset === undefined || valueEnd === undefined) return false + + const lineStart = blockText.lastIndexOf('\n', keyOffset - 1) + 1 + const fieldIndent = ' '.repeat(Math.max(0, keyOffset - lineStart)) + const lineEndIndex = blockText.indexOf('\n', valueEnd) + const insertOffset = lineEndIndex === -1 ? blockText.length : lineEndIndex + const updatedBlock = `${blockText.slice(0, insertOffset)}\n${fieldIndent}disabled: true${blockText.slice(insertOffset)}` + const updatedLines = [ + ...lines.slice(0, blockStart + 1), + updatedBlock, + ...lines.slice(blockEnd) + ] + await writeTextAtomically(profilePatch, updatedLines.join('\n')) + note(`[desktop] disabled the migrated legacy preset row for "${presetId}"; a plugin registers it at runtime (#814)`) + return true } diff --git a/src/main/state/plugin-disable.ts b/src/main/state/plugin-disable.ts index cfb721935..92a56e848 100644 --- a/src/main/state/plugin-disable.ts +++ b/src/main/state/plugin-disable.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' -import { isSeq, parse, parseDocument } from 'yaml' +import { isMap, isScalar, isSeq, parse, parseDocument } from 'yaml' import { forgetPlugin, readMarketState, @@ -42,25 +42,10 @@ interface PluginPatchRows { foreignDisables: string[] } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -function rowBlockPattern(rowId: string, disabled: boolean): RegExp { - return new RegExp( - `^- id: ['"]?${escapeRegExp(rowId)}['"]?\\r?\\n disabled: ${disabled ? 'true' : 'false'}[ \\t]*(?:\\r?\\n|$)`, - 'm' - ) -} - function rowBlock(rowId: string, disabled: boolean): string { return `- id: ${rowId}\n disabled: ${disabled ? 'true' : 'false'}\n` } -function withoutComments(text: string): string { - return text.replace(/^[ \t]*#.*$/gm, '').trim() -} - /** * Append one top-level entry to a patch layer, as dsh-market's appendPatchEntry * does: an empty or comment-only file takes the entry as is, the template's @@ -69,23 +54,30 @@ function withoutComments(text: string): string { */ function appendPatchEntry(text: string, block: string): { text: string } | { error: string } { if (text.trim() === '') return { text: block } - const content = withoutComments(text) const terminated = (value: string): string => (value.endsWith('\n') ? value : `${value}\n`) - if (content === '') return { text: `${terminated(text)}${block}` } - if (content === '[]' || content === '[ ]') { - return { text: `${terminated(text.replace(/^[ \t]*\[[ \t]*\][ \t]*(?:#.*)?(?:\r?\n|$)/m, '# []\n'))}${block}` } + const document = parseDocument(text) + if (document.errors.length > 0) { + return { error: 'the patch layer is not a valid entry list; fix the YAML first' } } - const lastLine = text.split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line !== '' && !line.startsWith('#')) - .pop() ?? '' - if (/^[[{]/.test(lastLine)) { - return { error: 'the patch layer ends in a top-level flow structure; tidy it into an entry list first' } + if (document.contents === null) { + return { text: `${terminated(text)}${block}` } } - const document = parseDocument(text) - if (document.errors.length > 0 || !isSeq(document.contents)) { + if (!isSeq(document.contents)) { return { error: 'the patch layer is not a valid entry list; fix the YAML first' } } + if (document.contents.flow) { + const range = document.contents.range + if (document.contents.items.length === 0 && range !== undefined) { + const replaced = `${text.slice(0, range[0])}# []${text.slice(range[1])}` + return { text: `${terminated(replaced)}${block}` } + } + return { error: 'the patch layer ends in a top-level flow structure; tidy it into an entry list first' } + } + for (const item of document.contents.items) { + if (isMap(item) && item.flow) { + return { error: 'the patch layer ends in a top-level flow structure; tidy it into an entry list first' } + } + } return { text: `${terminated(text)}${block}` } } @@ -101,11 +93,26 @@ export function disablePatchRows( let next = text for (const rowId of rowIds) { if (!ROW_ID.test(rowId)) return { error: `row id ${rowId} cannot be written to the patch layer` } - if (rowBlockPattern(rowId, true).test(next)) continue - const forced = rowBlockPattern(rowId, false) - if (forced.test(next)) { - next = next.replace(forced, rowBlock(rowId, true)) - continue + const document = parseDocument(next) + if (document.errors.length === 0 && isSeq(document.contents) && !document.contents.flow) { + const existing = document.contents.items.find((item) => { + if (!isMap(item) || item.flow || item.items.length !== 2) return false + const id: unknown = item.get('id') + const disabled: unknown = item.get('disabled') + return id === rowId && typeof disabled === 'boolean' + }) + if (isMap(existing)) { + const disabled: unknown = existing.get('disabled') + if (disabled === true) continue + const disabledPair = existing.items.find( + (pair) => isScalar(pair.key) && pair.key.value === 'disabled' + ) + const valueRange = isScalar(disabledPair?.value) ? disabledPair.value.range : undefined + if (valueRange !== undefined) { + next = `${next.slice(0, valueRange[0])}true${next.slice(valueRange[1])}` + continue + } + } } const appended = appendPatchEntry(next, rowBlock(rowId, true)) if ('error' in appended) return appended @@ -121,11 +128,21 @@ export function enablePatchRows(text: string, rowIds: readonly string[]): { text /** Row ids the user patch layer switches off, scanned like dsh-market's readUserPatchState. */ export function patchLayerDisabledRows(text: string): string[] { + let document + try { + document = parseDocument(text) + } catch { + return [] + } + if (document.errors.length > 0 || !isSeq(document.contents)) return [] const disabled: string[] = [] - const lines = text.split(/\r?\n/) - for (const [index, line] of lines.entries()) { - const row = /^- id: ['"]?([A-Za-z0-9_.-]+)['"]?\s*$/.exec(line) - if (row && /^ {2}disabled: true\s*$/.test(lines[index + 1] ?? '')) disabled.push(row[1]!) + for (const item of document.contents.items) { + if (!isMap(item)) continue + const id: unknown = item.get('id') + const isDisabled: unknown = item.get('disabled') + if (typeof id === 'string' && ROW_ID.test(id) && isDisabled === true) { + disabled.push(id) + } } return disabled } diff --git a/src/main/state/profile-store.ts b/src/main/state/profile-store.ts index 53d5132d0..2202a3d3f 100644 --- a/src/main/state/profile-store.ts +++ b/src/main/state/profile-store.ts @@ -1,5 +1,6 @@ import { readFile, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' +import { parse } from 'yaml' import { profilePackageJsonPath } from './plugin-recovery' /** @@ -18,15 +19,26 @@ import { profilePackageJsonPath } from './plugin-recovery' * store there keeps the two accounts of the same fact from drifting apart. */ +function parseModulesMetadata(raw: string): Record | undefined { + for (const candidate of [raw, `{\n${raw}\n}`]) { + try { + const parsed: unknown = parse(candidate) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch { + // Fall back to wrapping comma-terminated JSON-in-YAML fragments. + } + } + return undefined +} + /** The store `node_modules` was linked from, as pnpm recorded it. */ export async function recordedStoreDir(profileDirectory: string): Promise { try { const raw = await readFile(join(profileDirectory, 'node_modules', '.modules.yaml'), 'utf8') - // pnpm writes this file as JSON-in-YAML, so the value arrives quoted and - // comma-terminated; older layouts write it bare. - const quoted = /^\s*"?storeDir"?\s*:\s*"([^"]+)"/mu.exec(raw) - const bare = /^\s*"?storeDir"?\s*:\s*([^"\s][^,\n]*?)\s*,?\s*$/mu.exec(raw) - const value = (quoted?.[1] ?? bare?.[1])?.trim() + const metadata = parseModulesMetadata(raw) + const value = typeof metadata?.storeDir === 'string' ? metadata.storeDir.trim() : '' return value ? value : undefined } catch { return undefined diff --git a/test/pnpm-runner.test.js b/test/pnpm-runner.test.js index 88e3cc26b..0dd508f07 100644 --- a/test/pnpm-runner.test.js +++ b/test/pnpm-runner.test.js @@ -11,6 +11,7 @@ import { gitPrepareApprovalKey, lockedRenameTarget, mergeApprovedGitPrepareKey, + removeProjectedLockfileImporters, runWithLockRecovery, sidelinePath, suspendGenerationProjectionForPnpm @@ -169,6 +170,7 @@ describe('packaged pnpm runner', () => { } } })) + await writeFile(join(profile, 'pnpm-lock.yaml'), `lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n generation-plugin:\n specifier: 1.0.0\n version: 1.0.0\n shared-plugin:\n specifier: 2.0.0\n version: 2.0.0\n\npackages:\n\n generation-plugin@1.0.0:\n resolution: {integrity: sha512-example}\n`) const isolation = await suspendGenerationProjectionForPnpm(profile) expect(isolation.plugins).toEqual(['generation-plugin']) @@ -176,6 +178,10 @@ describe('packaged pnpm runner', () => { expect(suspended.dependencies).toEqual({ 'shared-plugin': '2.0.0' }) expect(suspended.pnpm.overrides).toEqual({ 'shared-plugin': '2.0.1' }) expect(suspended.dsh.desktop.generationProjection.plugins).toHaveProperty('generation-plugin') + const suspendedLockfile = await readFile(join(profile, 'pnpm-lock.yaml'), 'utf8') + expect(suspendedLockfile).not.toContain('specifier: 1.0.0') + expect(suspendedLockfile).toContain('shared-plugin') + expect(suspendedLockfile).toContain('generation-plugin@1.0.0') suspended.dependencies['new-shared-plugin'] = '3.0.0' await writeFile(manifestPath, JSON.stringify(suspended)) @@ -197,6 +203,43 @@ describe('packaged pnpm runner', () => { } }) + it('prunes projected importer entries through the YAML AST while preserving CRLF and packages', () => { + const lockfile = [ + "lockfileVersion: '9.0'", + '', + 'importers:', + ' .:', + ' dependencies:', + " '@scope/generation-plugin': { specifier: 1.0.0, version: 1.0.0 }", + ' shared-plugin:', + ' specifier: 2.0.0', + ' version: 2.0.0', + ' optionalDependencies:', + ' optional-generation-plugin:', + ' specifier: ^3.0.0', + ' version: 3.0.0', + '', + 'packages:', + " '@scope/generation-plugin@1.0.0':", + ' resolution: {integrity: sha512-example}', + '' + ].join('\r\n') + + const updated = removeProjectedLockfileImporters(lockfile, [ + '@scope/generation-plugin', + 'optional-generation-plugin' + ]) + expect(updated).toContain('\r\n') + expect(updated).not.toContain('specifier: 1.0.0') + expect(updated).not.toContain('optional-generation-plugin') + expect(updated).toContain('shared-plugin:') + expect(updated).toContain("'@scope/generation-plugin@1.0.0':") + + expect(removeProjectedLockfileImporters(lockfile, ['absent-plugin'])).toBe(lockfile) + const malformed = 'importers:\n .:\n dependencies: [\n' + expect(removeProjectedLockfileImporters(malformed, ['generation-plugin'])).toBe(malformed) + }) + it('restores generation-owned fields after pnpm fails', async () => { const profile = await mkdtemp(join(tmpdir(), 'dsh-pnpm-projection-failure-')) const manifestPath = join(profile, 'package.json')