diff --git a/packages/dsh-desktop-market-installer/generations/projection.d.ts b/packages/dsh-desktop-market-installer/generations/projection.d.ts index 2ca85c9a..84f973ee 100644 --- a/packages/dsh-desktop-market-installer/generations/projection.d.ts +++ b/packages/dsh-desktop-market-installer/generations/projection.d.ts @@ -4,4 +4,13 @@ export interface ProjectionResult { bundles: string[] } +export interface PublishedGenerationManifest { + plugins: string[] + bundles: string[] +} + export function projectGenerations(dshHome: string, profile?: string): Promise +export function publishGenerationManifest( + 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 5a2b962d..62418719 100644 --- a/packages/dsh-desktop-market-installer/generations/projection.mjs +++ b/packages/dsh-desktop-market-installer/generations/projection.mjs @@ -24,10 +24,10 @@ import { resolveEnabledGenerations } from './registry.mjs' * contract — agree with what is actually linked. * * Generation plugins appear in `dependencies` at their installed version so - * dsh-market can present them as ordinary installed releases. pnpm is kept on - * the immutable local generation by a root override under `pnpm.overrides`; - * the internal `link:` path therefore never leaks into the market-facing - * dependency map. + * dsh-market can present them as ordinary installed releases. A root override + * records the immutable local source, but Desktop's pnpm runner temporarily + * removes both generated fields before every Profile package operation. pnpm + * therefore never owns the projected node_modules path. * * The projection is derived, never authored. Losing it costs a reprojection, * not a repair. @@ -220,6 +220,44 @@ async function ensureDirLink(linkPath, target) { * in-box bundles are left untouched. */ export async function projectGenerations(dshHome, profile = 'web') { + const { dir, manifestState, enabled, targets, linkSpecs } = + await prepareGenerationProjection(dshHome, profile) + const modulesDir = join(dir, 'node_modules') + await mkdir(modulesDir, { recursive: true }) + + const linked = [] + const projected = new Map() + for (const [pluginName, generation] of enabled) { + const target = targets.get(pluginName) + if (target === undefined) throw new Error(`Enabled generation target was not prevalidated: ${pluginName}`) + await ensureDirLink(join(modulesDir, pluginName), target) + linked.push(pluginName) + projected.set(pluginName, generation) + } + + const unlinked = await pruneStaleGenerationLinks(modulesDir, projected) + const bundles = await syncProfileManifest(dir, projected, linkSpecs, manifestState) + + return { linked, unlinked, bundles } +} + +/** + * Publish the desired generation set for inventory and the next launch without + * touching the active Profile's node_modules. Market operations run inside the + * live Harness, so replacing even a junction there recreates the Windows + * rename conflict generations are meant to avoid. The cold-start projector + * materializes these links after Harness has stopped. + */ +export async function publishGenerationManifest(dshHome, profile = 'web') { + const { dir, manifestState, enabled, linkSpecs } = + await prepareGenerationProjection(dshHome, profile) + const bundles = await syncProfileManifest(dir, enabled, linkSpecs, manifestState, { + syncBundles: false + }) + return { plugins: [...enabled.keys()], bundles } +} + +async function prepareGenerationProjection(dshHome, profile) { const dir = profileDir(dshHome, profile) // Validate the authoritative Profile manifest before touching links. A // malformed or temporarily unreadable existing file must never be mistaken @@ -230,27 +268,15 @@ export async function projectGenerations(dshHome, profile = 'web') { for (const [pluginName, generation] of enabled) { targets.set(pluginName, await validateEnabledGenerationTarget(pluginName, generation)) } - const modulesDir = join(dir, 'node_modules') - await mkdir(modulesDir, { recursive: true }) - - const linked = [] const linkSpecs = new Map() - const projected = new Map() for (const [pluginName, generation] of enabled) { const target = targets.get(pluginName) if (target === undefined) throw new Error(`Enabled generation target was not prevalidated: ${pluginName}`) - await ensureDirLink(join(modulesDir, pluginName), target) - linked.push(pluginName) - projected.set(pluginName, generation) // This path belongs only to pnpm's root override. The market reads the // ordinary dependency version written below and never sees it. linkSpecs.set(pluginName, `link:${relative(dir, target).split('\\').join('/')}`) } - - const unlinked = await pruneStaleGenerationLinks(modulesDir, projected) - const bundles = await syncProfileManifest(dir, projected, linkSpecs, manifestState) - - return { linked, unlinked, bundles } + return { dir, manifestState, enabled, targets, linkSpecs } } /** @@ -295,7 +321,13 @@ async function pruneStaleGenerationLinks(modulesDir, enabled) { * Desktop owns so a disabled generation can be removed after a crash without * guessing whether an ordinary version dependency belongs to the user. */ -async function syncProfileManifest(dir, enabled, linkSpecs, manifestState) { +async function syncProfileManifest( + dir, + enabled, + linkSpecs, + manifestState, + { syncBundles = true } = {} +) { const manifestPath = join(dir, 'package.json') const { current, manifest } = manifestState @@ -355,15 +387,16 @@ async function syncProfileManifest(dir, enabled, linkSpecs, manifestState) { // left out of `bundles` makes the consistency check report "installed and // declares a bundle, but is not composed", which the app surfaces as a // restart prompt on every launch. - const declaredBundles = (manifest.dsh?.profile?.bundles ?? []).filter((name) => - IN_BOX_BUNDLES.has(name) - ) - for (const name of Object.keys(dependencies)) { - if (declaredBundles.includes(name) || enabled.has(name)) continue - if (await declaresBundle(join(dir, 'node_modules', name))) declaredBundles.push(name) - } - const pluginNames = [...enabled.keys()].sort() - const bundles = [...declaredBundles, ...pluginNames] + let bundles = [...(manifest.dsh?.profile?.bundles ?? [])] + if (syncBundles) { + const declaredBundles = bundles.filter((name) => IN_BOX_BUNDLES.has(name)) + for (const name of Object.keys(dependencies)) { + if (declaredBundles.includes(name) || enabled.has(name)) continue + if (await declaresBundle(join(dir, 'node_modules', name))) declaredBundles.push(name) + } + const pluginNames = [...enabled.keys()].sort() + bundles = [...declaredBundles, ...pluginNames] + } const desktop = { ...(manifest.dsh?.desktop ?? {}) diff --git a/packages/dsh-desktop-market-installer/index.js b/packages/dsh-desktop-market-installer/index.js index 7db2fc3e..c03a65b0 100644 --- a/packages/dsh-desktop-market-installer/index.js +++ b/packages/dsh-desktop-market-installer/index.js @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url' import { PassThrough } from 'node:stream' import { installGeneration } from './generations/installer.mjs' -import { projectGenerations } from './generations/projection.mjs' +import { publishGenerationManifest } from './generations/projection.mjs' import { disableGeneration, listGenerations, @@ -223,6 +223,9 @@ export async function ensurePnpmShim(home = dshHome()) { // has to say so rather than leaving a stale shim to be mistaken for a fresh // one. const runnerPath = await stagePnpmRunner(directory) + if (runnerPath === undefined && hasGenerationProjection(home)) { + throw new Error('The generation-aware pnpm runner is unavailable; refusing to mutate the projected Profile.') + } const pnpmCommand = runnerPath === undefined ? [pnpmEntry] : [runnerPath, pnpmEntry] process.stdout.write( runnerPath === undefined @@ -405,6 +408,16 @@ export function projectedGenerationRemoval(args, home = dshHome()) { } } +function hasGenerationProjection(home) { + try { + const manifest = JSON.parse(readFileSync(join(profileDirectory(home), 'package.json'), 'utf8')) + const plugins = manifest.dsh?.desktop?.generationProjection?.plugins + return typeof plugins === 'object' && plugins !== null && Object.keys(plugins).length > 0 + } catch { + return false + } +} + export function createDesktopPnpmService(options) { const { binDirectory, @@ -457,8 +470,8 @@ 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. On Windows that is the whole fix — the shared tree's in-place - * package replacement is the operation pnpm cannot do there. + * replaced. Only manifest inventory is published while Harness is live; the + * node_modules junction changes on the next cold start. */ const runExternalMarketPluginInstall = (args, invokingDir, signal) => { validatePluginOperation(args, invokingDir) @@ -491,9 +504,9 @@ export function createDesktopPnpmService(options) { return generation === undefined || generation.pluginName !== install.generation.pluginName }) await writeDesired(home, [...kept, install.generation.id]) - const projection = await projectGenerations(home) - write(`enabled: ${projection.linked.join(', ')}`) - write(`bundles: ${JSON.stringify(projection.bundles)}`) + const published = await publishGenerationManifest(home) + write(`staged for next restart: ${published.plugins.join(', ')}`) + write(`bundles: ${JSON.stringify(published.bundles)}`) return { exitCode: 0 } }) ) @@ -517,10 +530,10 @@ export function createDesktopPnpmService(options) { const handle = asHandle(async ({ write, isCancelled }) => withRegistryLock(home, async () => { if (isCancelled()) return { exitCode: 1, message: 'The package operation was aborted.' } - write(`Disabling ${generationRemoval} generation…`) + write(`Disabling ${generationRemoval} generation for the next restart…`) await disableGeneration(home, generationRemoval) - const projection = await projectGenerations(home) - write(`enabled: ${projection.linked.join(', ')}`) + const published = await publishGenerationManifest(home) + write(`staged for next restart: ${published.plugins.join(', ')}`) return { exitCode: 0 } }) ) diff --git a/packages/dsh-desktop-market-installer/pnpm-runner.d.mts b/packages/dsh-desktop-market-installer/pnpm-runner.d.mts new file mode 100644 index 00000000..5bf43546 --- /dev/null +++ b/packages/dsh-desktop-market-installer/pnpm-runner.d.mts @@ -0,0 +1,8 @@ +export interface SuspendedGenerationProjection { + plugins: string[] + restore: () => Promise +} + +export function suspendGenerationProjectionForPnpm( + profileDirectory: string +): Promise diff --git a/packages/dsh-desktop-market-installer/pnpm-runner.mjs b/packages/dsh-desktop-market-installer/pnpm-runner.mjs index cf88a9d9..6423c2e6 100644 --- a/packages/dsh-desktop-market-installer/pnpm-runner.mjs +++ b/packages/dsh-desktop-market-installer/pnpm-runner.mjs @@ -11,6 +11,10 @@ * * EPERM: operation not permitted, rename '…\argparse_tmp_19856_4' -> '…\argparse' * + * Generation projection paths are removed from pnpm's manifest view before + * this recovery is considered, so only shared-tree packages can reach the + * replacement path below. + * * Two recoveries, in order of how little they disturb: retry once (a scanner's * handle is gone within a second), then move the blocked target aside and * retry (a rename of the directory itself succeeds where replacing its @@ -22,7 +26,7 @@ */ import { spawn } from 'node:child_process' import { existsSync, watch } from 'node:fs' -import { readdir, rename } from 'node:fs/promises' +import { readFile, readdir, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -88,6 +92,132 @@ function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } +const PROJECTION_VERSION = 1 +const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/iu + +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function writeJsonAtomically(path, value) { + const temporary = `${path}.${process.pid}.${Date.now()}.pnpm-projection.tmp` + try { + await writeFile(temporary, `${JSON.stringify(value, undefined, 2)}\n`, 'utf8') + await rename(temporary, path) + } finally { + await rm(temporary, { force: true }).catch(() => undefined) + } +} + +/** + * Keep generation-owned Profile entries outside pnpm's mutable dependency set. + * + * The persistent manifest exposes installed versions to dsh-market, but the + * package roots themselves are junctions owned by the cold-start projector. + * Letting pnpm see the same names gives two writers one node_modules path and + * makes pnpm attempt `_tmp_* -> ` while Harness is live. + * + * The ownership marker survives the temporary manifest so a crash is safe: + * cold start can always derive the visible fields again from desired.json. + */ +export async function suspendGenerationProjectionForPnpm(profileDirectory) { + const manifestPath = join(profileDirectory, 'package.json') + let text + try { + text = await readFile(manifestPath, 'utf8') + } catch (error) { + if (error?.code === 'ENOENT') return { plugins: [], restore: async () => undefined } + throw error + } + + let manifest + try { + manifest = JSON.parse(text) + } catch (error) { + throw new Error(`Profile manifest is invalid before pnpm projection isolation: ${errorText(error)}`) + } + if (!isRecord(manifest)) { + throw new Error('Profile manifest root is invalid before pnpm projection isolation.') + } + + const projection = manifest.dsh?.desktop?.generationProjection + const projectedPlugins = projection?.version === PROJECTION_VERSION && isRecord(projection.plugins) + ? Object.keys(projection.plugins).filter((name) => PACKAGE_NAME_PATTERN.test(name)) + : [] + if (projectedPlugins.length === 0) { + return { plugins: [], restore: async () => undefined } + } + + const dependencies = manifest.dependencies === undefined ? {} : manifest.dependencies + const pnpm = manifest.pnpm === undefined ? {} : manifest.pnpm + const overrides = pnpm.overrides === undefined ? {} : pnpm.overrides + if (!isRecord(dependencies) || !isRecord(pnpm) || !isRecord(overrides)) { + throw new Error('Profile dependency fields are invalid before pnpm projection isolation.') + } + + const owned = new Map(projectedPlugins.map((name) => [name, { + dependency: Object.hasOwn(dependencies, name) + ? { present: true, value: dependencies[name] } + : { present: false }, + override: Object.hasOwn(overrides, name) + ? { present: true, value: overrides[name] } + : { present: false } + }])) + let changed = false + for (const name of projectedPlugins) { + if (Object.hasOwn(dependencies, name)) { + delete dependencies[name] + changed = true + } + if (Object.hasOwn(overrides, name)) { + delete overrides[name] + changed = true + } + } + if (!changed) return { plugins: [], restore: async () => undefined } + + manifest.dependencies = dependencies + if (Object.keys(overrides).length > 0) pnpm.overrides = overrides + else delete pnpm.overrides + if (Object.keys(pnpm).length > 0) manifest.pnpm = pnpm + else delete manifest.pnpm + await writeJsonAtomically(manifestPath, manifest) + + let restored = false + return { + plugins: projectedPlugins, + restore: async () => { + if (restored) return + const currentText = await readFile(manifestPath, 'utf8') + let current + try { + current = JSON.parse(currentText) + } catch (error) { + throw new Error(`Profile manifest is invalid after pnpm projection isolation: ${errorText(error)}`) + } + if (!isRecord(current)) { + throw new Error('Profile manifest root is invalid after pnpm projection isolation.') + } + const currentDependencies = isRecord(current.dependencies) ? current.dependencies : {} + const currentPnpm = isRecord(current.pnpm) ? current.pnpm : {} + const currentOverrides = isRecord(currentPnpm.overrides) ? currentPnpm.overrides : {} + for (const [name, state] of owned) { + if (state.dependency.present) currentDependencies[name] = state.dependency.value + else delete currentDependencies[name] + if (state.override.present) currentOverrides[name] = state.override.value + else delete currentOverrides[name] + } + current.dependencies = currentDependencies + if (Object.keys(currentOverrides).length > 0) currentPnpm.overrides = currentOverrides + else delete currentPnpm.overrides + if (Object.keys(currentPnpm).length > 0) current.pnpm = currentPnpm + else delete current.pnpm + await writeJsonAtomically(manifestPath, current) + restored = true + } + } +} + /** * Run pnpm once, mirroring its streams to this process while keeping a copy * for failure classification. @@ -253,7 +383,7 @@ function killTree(child) { * Run pnpm, recovering from a Windows locked rename. Returns the exit code of * the run that decided the outcome. */ -export async function runWithLockRecovery(executable, args, options = {}) { +async function runWithLockRecoveryUnisolated(executable, args, options = {}) { const { spawnProcess = spawn, moveAside = rename, @@ -339,6 +469,28 @@ export async function runWithLockRecovery(executable, args, options = {}) { return third } +/** + * Run pnpm with generation projection names removed from its manifest view, + * then restore the market-facing fields regardless of pnpm's outcome. + */ +export async function runWithLockRecovery(executable, args, options = {}) { + const profileDirectory = options.profileDirectory ?? process.cwd() + const isolateProjection = options.isolateProjection ?? suspendGenerationProjectionForPnpm + const report = options.report ?? ((message) => process.stderr.write(`${MARKER} ${message}\n`)) + const isolation = await isolateProjection(profileDirectory) + if (isolation.plugins.length > 0) { + report(`excluded ${isolation.plugins.length} generation projection(s) from pnpm`) + } + try { + return await runWithLockRecoveryUnisolated(executable, args, options) + } finally { + await isolation.restore() + if (isolation.plugins.length > 0) { + report(`restored ${isolation.plugins.length} generation projection(s) after pnpm`) + } + } +} + /** The `_tmp__` staging name pnpm leaves beside its destination. */ const STAGING_PATTERN = /^(?.+)_tmp_\d+_\d+$/u diff --git a/src/main/runtime/profile-plugin-command.ts b/src/main/runtime/profile-plugin-command.ts index d976476a..b41ae248 100644 --- a/src/main/runtime/profile-plugin-command.ts +++ b/src/main/runtime/profile-plugin-command.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' -import { chmod, mkdir, readdir, stat, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' import { delimiter, dirname, join } from 'node:path' import { resolveEnvironmentPath } from './harness-runtime' @@ -103,6 +103,11 @@ export async function ensureProfilePnpmShim(options: ProfilePluginCommandOptions const directory = join(options.dshHome, '.desktop-bin') await mkdir(directory, { recursive: true }) const command = buildPnpmShimCommand(options) + if (command.length === 1 && await profileHasGenerationProjection(options.dshHome)) { + throw new Error( + 'The generation-aware pnpm runner is unavailable; refusing to mutate the projected Profile.' + ) + } if (process.platform === 'win32') { await writeFile( @@ -139,6 +144,20 @@ export async function ensureProfilePnpmShim(options: ProfilePluginCommandOptions return directory } +async function profileHasGenerationProjection(dshHome: string): Promise { + try { + const manifest = JSON.parse( + await readFile(join(dshHome, 'profiles', PROFILE, 'package.json'), 'utf8') + ) as { + dsh?: { desktop?: { generationProjection?: { plugins?: unknown } } } + } + const plugins = manifest.dsh?.desktop?.generationProjection?.plugins + return typeof plugins === 'object' && plugins !== null && Object.keys(plugins).length > 0 + } catch { + return false + } +} + export function buildProfilePluginCommandEnvironment( environment: NodeJS.ProcessEnv, shimDirectory: string, diff --git a/src/main/state/generation-migration.ts b/src/main/state/generation-migration.ts index 2abdb6c0..fc653f8b 100644 --- a/src/main/state/generation-migration.ts +++ b/src/main/state/generation-migration.ts @@ -679,9 +679,9 @@ export async function migrateProfileToGenerations(deps: MigrationDeps): Promise< await snapshotProfile(dshHome, note, previousDesired, plan.fingerprint) // Trim the manifest to the shared-tree packages and drop the lockfile, then // let projection add the generations back as visible version deps, private - // pnpm overrides, bundles, and symlinks — all before the rebuild, so - // `pnpm install` sees the final manifest and its `.install-complete` - // fingerprint matches. + // pnpm overrides, bundles, and symlinks. The Desktop pnpm runner hides the + // generation-owned fields during the shared-tree rebuild, then restores + // them so `.install-complete` sees the final market-facing manifest. await rewriteManifest(dshHome) const existingDesired = await readDesired(dshHome) await writeDesired(dshHome, [...new Set([...existingDesired, ...generationIds])]) diff --git a/test/generation-boundary.test.js b/test/generation-boundary.test.js index f098776b..3f0f834a 100644 --- a/test/generation-boundary.test.js +++ b/test/generation-boundary.test.js @@ -1,9 +1,10 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { lstat, mkdir, mkdtemp, readFile, readlink, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { createDesktopPnpmService } from '../packages/dsh-desktop-market-installer/index.js' import { installGeneration } from '../packages/dsh-desktop-market-installer/generations/installer.mjs' +import { projectGenerations } from '../packages/dsh-desktop-market-installer/generations/projection.mjs' import { listGenerations, readDesired @@ -84,7 +85,7 @@ describe('the market install boundary', () => { expect(typeof svc.runExternalMarketPluginInstall).toBe('function') }) - it('installs a generation, points desired at it, and reprojects', async () => { + it('installs a generation and defers its node_modules projection until cold start', async () => { const home = await freshHome() const svc = service(home, stubGenerationInstall('demo-plugin', '9.9.9')) @@ -97,16 +98,25 @@ describe('the market install boundary', () => { expect(result.exitCode).toBe(0) expect(result.stdout).toContain('isolated generation') - expect(result.stdout).toContain('enabled: demo-plugin') + expect(result.stdout).toContain('staged for next restart: demo-plugin') const desired = await readDesired(home) expect(desired).toHaveLength(1) expect(desired[0]).toMatch(/^demo-plugin\+9\.9\.9\+/u) const manifest = JSON.parse(await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8')) - expect(manifest.dsh.profile.bundles).toContain('demo-plugin') + expect(manifest.dsh.profile.bundles).not.toContain('demo-plugin') 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' }) + + await projectGenerations(home) + expect((await lstat(link)).isSymbolicLink()).toBe(true) + const activeManifest = JSON.parse( + await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8') + ) + expect(activeManifest.dsh.profile.bundles).toContain('demo-plugin') }) it('routes a market removal through desired.json instead of the shared profile CLI', async () => { @@ -118,17 +128,56 @@ describe('the market install boundary', () => { join(home, 'profiles', 'web') ) ) + await projectGenerations(home) + const link = join(home, 'profiles', 'web', 'node_modules', 'demo-plugin') + const activeTarget = await readlink(link) const result = await drainHandle( svc.runPlugin(['remove', '--workspace-root', 'demo-plugin'], join(home, 'profiles', 'web')) ) expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('Disabling demo-plugin generation') + expect(result.stdout).toContain('Disabling demo-plugin generation for the next restart') expect(await readDesired(home)).toEqual([]) const manifest = JSON.parse(await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8')) expect(manifest.dependencies['demo-plugin']).toBeUndefined() expect(manifest.pnpm?.overrides?.['demo-plugin']).toBeUndefined() + expect(manifest.dsh.profile.bundles).toContain('demo-plugin') + expect(await readlink(link)).toBe(activeTarget) + + await projectGenerations(home) + await expect(lstat(link)).rejects.toMatchObject({ code: 'ENOENT' }) + const inactiveManifest = JSON.parse( + await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8') + ) + expect(inactiveManifest.dsh.profile.bundles).not.toContain('demo-plugin') + }) + + it('keeps the active generation link unchanged until an update reaches cold start', async () => { + const home = await freshHome() + await drainHandle( + service(home, stubGenerationInstall('widget', '1.0.0')).runExternalMarketPluginInstall( + ['add', 'widget@1.0.0'], + join(home, 'profiles', 'web') + ) + ) + await projectGenerations(home) + const link = join(home, 'profiles', 'web', 'node_modules', 'widget') + const firstTarget = await readlink(link) + + await drainHandle( + service(home, stubGenerationInstall('widget', '2.0.0')).runExternalMarketPluginInstall( + ['add', 'widget@2.0.0'], + join(home, 'profiles', 'web') + ) + ) + + expect(await readlink(link)).toBe(firstTarget) + const staged = JSON.parse(await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8')) + expect(staged.dependencies.widget).toBe('2.0.0') + + await projectGenerations(home) + expect(await readlink(link)).not.toBe(firstTarget) }) it('replaces an earlier generation of the same plugin', async () => { diff --git a/test/generation-projection.test.ts b/test/generation-projection.test.ts index 3655a59b..c10178b1 100644 --- a/test/generation-projection.test.ts +++ b/test/generation-projection.test.ts @@ -4,9 +4,11 @@ import { lstat, mkdir, mkdtemp, readFile, readlink, rm, writeFile } from 'node:f import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { afterEach, describe, expect, it, vi } from 'vitest' import { projectGenerations } from '../packages/dsh-desktop-market-installer/generations/projection' +import { suspendGenerationProjectionForPnpm } from '../packages/dsh-desktop-market-installer/pnpm-runner.mjs' import { ensureRegistryDirectories, registryLayout, @@ -41,6 +43,9 @@ vi.mock('node:fs/promises', async (importOriginal) => { describe('generation projection onto the app-boot contract', () => { const execFileAsync = promisify(execFile) const pnpmEntry = join(dirname(createRequire(import.meta.url).resolve('pnpm')), 'bin', 'pnpm.cjs') + const pnpmRunner = fileURLToPath( + new URL('../packages/dsh-desktop-market-installer/pnpm-runner.mjs', import.meta.url) + ) const homes: string[] = [] async function freshHome(): Promise { @@ -178,7 +183,7 @@ describe('generation projection onto the app-boot contract', () => { expect(projected.pnpm.overrides.unrelated).toBe('3.0.0') }) - it('keeps pnpm installs on the local generation while the manifest exposes a version', async () => { + it('keeps projected generations outside pnpm while the manifest exposes a version', async () => { const home = await freshHome() await ensureRegistryDirectories(home) const dir = join(home, 'profiles', 'web') @@ -190,18 +195,48 @@ describe('generation projection onto the app-boot contract', () => { await fakeGeneration(home, 'a+1+x', 'plugin-a', '1.0.0') await writeDesired(home, ['a+1+x']) await projectGenerations(home) + const link = join(dir, 'node_modules', 'plugin-a') + const targetBefore = await readlink(link) - await execFileAsync( + const result = await execFileAsync( process.execPath, - [pnpmEntry, 'install', '--ignore-scripts', '--no-frozen-lockfile', '--offline'], + [pnpmRunner, pnpmEntry, 'install', '--ignore-scripts', '--no-frozen-lockfile', '--offline'], { cwd: dir } ) + expect(result.stderr).toContain('excluded 1 generation projection(s) from pnpm') + expect(result.stderr).toContain('restored 1 generation projection(s) after pnpm') const manifest = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) expect(manifest.dependencies['plugin-a']).toBe('1.0.0') - const installed = JSON.parse(await readFile(join(dir, 'node_modules', 'plugin-a', 'package.json'), 'utf8')) + expect(await readlink(link)).toBe(targetBefore) + const installed = JSON.parse(await readFile(join(link, 'package.json'), 'utf8')) expect(installed.version).toBe('1.0.0') - expect(await readFile(join(dir, 'pnpm-lock.yaml'), 'utf8')).toContain('link:../.generations/live/a+1+x') + expect(await readFile(join(dir, 'pnpm-lock.yaml'), 'utf8')).not.toContain('plugin-a') + }) + + it('reconstructs market-facing fields at cold start after an interrupted pnpm run', async () => { + const home = await freshHome() + await ensureRegistryDirectories(home) + const dir = join(home, 'profiles', 'web') + await mkdir(dir, { recursive: true }) + await writeFile( + join(dir, 'package.json'), + JSON.stringify({ name: 'dsh-profile-web', dependencies: {}, dsh: { profile: { bundles: [] } } }) + ) + await fakeGeneration(home, 'a+1+x', 'plugin-a', '1.0.0') + await writeDesired(home, ['a+1+x']) + await projectGenerations(home) + + const isolation = await suspendGenerationProjectionForPnpm(dir) + expect(isolation.plugins).toEqual(['plugin-a']) + const interrupted = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) + expect(interrupted.dependencies['plugin-a']).toBeUndefined() + expect(interrupted.dsh.desktop.generationProjection.plugins).toHaveProperty('plugin-a') + + await projectGenerations(home) + const repaired = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) + expect(repaired.dependencies['plugin-a']).toBe('1.0.0') + expect(repaired.pnpm.overrides['plugin-a']).toMatch(/^link:/u) }) it('never touches a real pnpm-managed directory in node_modules', async () => { diff --git a/test/pnpm-runner.test.js b/test/pnpm-runner.test.js index f49ec11d..a1742c07 100644 --- a/test/pnpm-runner.test.js +++ b/test/pnpm-runner.test.js @@ -1,4 +1,6 @@ import { EventEmitter } from 'node:events' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { @@ -7,7 +9,8 @@ import { blockedTargets, lockedRenameTarget, runWithLockRecovery, - sidelinePath + sidelinePath, + suspendGenerationProjectionForPnpm } from '../packages/dsh-desktop-market-installer/pnpm-runner.mjs' const WINDOWS_LOCK_FAILURE = [ @@ -43,6 +46,105 @@ function fakePnpm(runs) { } describe('packaged pnpm runner', () => { + 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') + try { + await writeFile(manifestPath, JSON.stringify({ + name: 'dsh-profile-web', + dependencies: { + 'generation-plugin': '1.0.0', + 'shared-plugin': '2.0.0' + }, + pnpm: { + overrides: { + 'generation-plugin': 'link:../.generations/live/generation+1+x/node_modules/generation-plugin', + 'shared-plugin': '2.0.1' + } + }, + dsh: { + desktop: { + generationProjection: { + version: 1, + plugins: { + 'generation-plugin': { + generationId: 'generation+1+x', + visibleVersion: '1.0.0' + } + } + } + } + } + })) + + const isolation = await suspendGenerationProjectionForPnpm(profile) + expect(isolation.plugins).toEqual(['generation-plugin']) + const suspended = JSON.parse(await readFile(manifestPath, 'utf8')) + 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') + + suspended.dependencies['new-shared-plugin'] = '3.0.0' + await writeFile(manifestPath, JSON.stringify(suspended)) + await isolation.restore() + await isolation.restore() + + const restored = JSON.parse(await readFile(manifestPath, 'utf8')) + expect(restored.dependencies).toEqual({ + 'shared-plugin': '2.0.0', + 'new-shared-plugin': '3.0.0', + 'generation-plugin': '1.0.0' + }) + expect(restored.pnpm.overrides).toEqual({ + 'shared-plugin': '2.0.1', + 'generation-plugin': 'link:../.generations/live/generation+1+x/node_modules/generation-plugin' + }) + } finally { + await rm(profile, { recursive: true, force: true }) + } + }) + + 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') + try { + await writeFile(manifestPath, JSON.stringify({ + dependencies: { 'generation-plugin': '1.0.0' }, + pnpm: { + overrides: { + 'generation-plugin': 'link:../.generations/live/generation+1+x/node_modules/generation-plugin' + } + }, + dsh: { + desktop: { + generationProjection: { + version: 1, + plugins: { 'generation-plugin': { visibleVersion: '1.0.0' } } + } + } + } + })) + const { spawnProcess, calls } = fakePnpm([ + { code: 1, output: 'ERR_PNPM_NO_MATCHING_VERSION' } + ]) + + const result = await runWithLockRecovery('/node', ['/pnpm.cjs', 'install'], { + profileDirectory: profile, + spawnProcess, + wait: async () => undefined, + report: () => undefined + }) + + expect(result.code).toBe(1) + expect(calls).toHaveLength(1) + const restored = JSON.parse(await readFile(manifestPath, 'utf8')) + expect(restored.dependencies['generation-plugin']).toBe('1.0.0') + expect(restored.pnpm.overrides['generation-plugin']).toMatch(/^link:/u) + } finally { + await rm(profile, { recursive: true, force: true }) + } + }) + it('recognizes a Windows locked rename inside a profile', () => { expect(lockedRenameTarget(WINDOWS_LOCK_FAILURE)).toBe(BLOCKED_TARGET) expect( diff --git a/test/profile-plugin-command.test.ts b/test/profile-plugin-command.test.ts index 574f4dd7..d14a978b 100644 --- a/test/profile-plugin-command.test.ts +++ b/test/profile-plugin-command.test.ts @@ -6,6 +6,7 @@ import { buildProfilePluginCommandEnvironment, buildProfilePluginRemoveArguments, diagnosticLine, + ensureProfilePnpmShim, removeProfilePluginWithDsh } from '../src/main/runtime/profile-plugin-command' @@ -127,6 +128,36 @@ describe('profile pnpm shim and failure reporting', () => { ).toEqual([existingRunnerPath, '/app/pnpm.cjs']) }) + it('fails closed when a projected profile would bypass the generation-aware runner', async () => { + const home = join(__dirname, '.temp-profile-plugin-command-runner-test') + try { + await mkdir(join(home, 'profiles', 'web'), { recursive: true }) + await writeFile( + join(home, 'profiles', 'web', 'package.json'), + JSON.stringify({ + dsh: { + desktop: { + generationProjection: { + version: 1, + plugins: { 'generation-plugin': { visibleVersion: '1.0.0' } } + } + } + } + }) + ) + + await expect(ensureProfilePnpmShim({ + dshHome: home, + dshEntryPath: '/app/dsh/bin.js', + nodeExecutablePath: '/app/node', + pnpmEntryPath: '/app/pnpm.cjs', + pnpmRunnerPath: '/app/missing-runner.mjs' + })).rejects.toThrow(/generation-aware pnpm runner is unavailable/u) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + it('reports the failure that names a cause, not dsh’s wrapper line', () => { // dsh always ends with "pnpm failed in profile directory …", which names // nothing — reporting that turns every failure into a dead end.