Skip to content
Merged
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
11 changes: 11 additions & 0 deletions packages/dsh-desktop-market-installer/generations/installer.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -31,6 +34,14 @@ export function installGeneration(
options: GenerationInstallOptions
): Promise<GenerationInstallResult>

export function generationBuildApprovals(workspaceYaml: string): string[]

export function pinnedGitBuildApproval(
pluginName: string,
pluginSpec: string,
approvals: string[]
): string | undefined

export function verifyGenerationPeers(
dshHome: string,
generation: Generation
Expand Down
127 changes: 123 additions & 4 deletions packages/dsh-desktop-market-installer/generations/installer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:(?<owner>[A-Za-z0-9_.-]+)\/(?<repo>[A-Za-z0-9_.-]+)#(?<sha>[0-9a-f]{40})(?<subpath>&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))
}
Expand All @@ -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) => {
Expand All @@ -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)
Expand Down Expand Up @@ -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, '+'))
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export function publishGenerationManifest(
dshHome: string,
profile?: string
): Promise<PublishedGenerationManifest>
export function exposeMissingGenerationLinks(dshHome: string, profile?: string): Promise<string[]>
39 changes: 39 additions & 0 deletions packages/dsh-desktop-market-installer/generations/projection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions packages/dsh-desktop-market-installer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
Expand Down
Loading
Loading