Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 31 additions & 15 deletions packages/dsh-desktop-market-installer/generations/installer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`)
Expand Down
151 changes: 126 additions & 25 deletions packages/dsh-desktop-market-installer/pnpm-runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = /^(?<name>(?:@[A-Za-z0-9-~][A-Za-z0-9._~-]*\/)?[A-Za-z0-9-~][A-Za-z0-9._~-]*)@git\+ssh:\/\/git@github\.com\/(?<owner>[A-Za-z0-9_.-]+)\/(?<repo>[A-Za-z0-9_.-]+)\.git#(?<sha>[0-9a-f]{40})(?<subpath>&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)
Expand All @@ -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
Expand All @@ -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`
Expand All @@ -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
}
}
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
87 changes: 69 additions & 18 deletions src/main/state/legacy-preset-migration.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const existing = new Set<string>()
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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Loading
Loading