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
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,13 @@ export interface ProjectionResult {
bundles: string[]
}

export interface PublishedGenerationManifest {
plugins: string[]
bundles: string[]
}

export function projectGenerations(dshHome: string, profile?: string): Promise<ProjectionResult>
export function publishGenerationManifest(
dshHome: string,
profile?: string
): Promise<PublishedGenerationManifest>
87 changes: 60 additions & 27 deletions packages/dsh-desktop-market-installer/generations/projection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 }
}

/**
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 ?? {})
Expand Down
31 changes: 22 additions & 9 deletions packages/dsh-desktop-market-installer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
})
)
Expand All @@ -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 }
})
)
Expand Down
8 changes: 8 additions & 0 deletions packages/dsh-desktop-market-installer/pnpm-runner.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface SuspendedGenerationProjection {
plugins: string[]
restore: () => Promise<void>
}

export function suspendGenerationProjectionForPnpm(
profileDirectory: string
): Promise<SuspendedGenerationProjection>
Loading
Loading