fix(ci): align beta runtime variant sources

This commit is contained in:
xipian-air
2026-09-05 14:57:34 +08:00
parent bfc19760a3
commit 5c0e9d0ae2
3 changed files with 440 additions and 221 deletions
@@ -1,17 +1,17 @@
/** App-local command environments available to desktop Host plugins. */
import { randomUUID } from 'node:crypto'
import { createHash, randomUUID } from 'node:crypto'
import {
chmodSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
unlinkSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { basename, dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { join, win32 as windowsPath } from 'node:path'
import { PNPM_IGNORE_MINIMUM_RELEASE_AGE } from './pnpm-policy.ts'
import { assertDesktopProfileName } from './profile-manager.ts'
@@ -23,7 +23,9 @@ const ELECTRON_HEADERS_URL = 'https://electronjs.org/headers'
const DIRECTORY_MODE = 0o700
const EXECUTABLE_FILE_MODE = 0o700
const PRIVATE_FILE_MODE = 0o600
const TEMPORARY_ID = /^\d+\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
const GENERATION_SCHEMA_VERSION = 1
const GENERATION_NAME = /^(?<hash>[0-9a-f]{64})-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
const MANIFEST_NAME = '.generation.json'
/** Inputs used to install one app-local pnpm command environment. */
export interface DesktopPnpmRuntimeOptions {
@@ -31,7 +33,7 @@ export interface DesktopPnpmRuntimeOptions {
platform: NodeJS.Platform
/** Electron executable reused in RunAsNode mode. */
appExecutable: string
/** Physical packaged pnpm JavaScript entry. */
/** Packaged pnpm JavaScript entry executable by Electron RunAsNode, including an ASAR logical path. */
pnpmBinPath: string
/** Electron version used when pnpm installs native dependencies. */
electronVersion: string
@@ -106,16 +108,6 @@ function escapeBatchSetValue(value: string): string {
return value.replaceAll('%', '%%')
}
/** Return one lstat result, preserving every failure except absence. */
function lstatOptional(filename: string): ReturnType<typeof lstatSync> | undefined {
try {
return lstatSync(filename)
} catch (cause) {
if ((cause as NodeJS.ErrnoException).code === 'ENOENT') return undefined
throw cause
}
}
/** Create one owner-only real directory and reject a pre-existing alternate file type. */
function preparePrivateDirectory(directory: string): void {
mkdirSync(directory, { recursive: true, mode: DIRECTORY_MODE })
@@ -126,77 +118,221 @@ function preparePrivateDirectory(directory: string): void {
chmodSync(directory, DIRECTORY_MODE)
}
/** Reject command-directory entries not owned by this runtime generation. */
function assertOwnedDirectoryEntries(directory: string, allowed: readonly string[]): void {
const unexpected = readdirSync(directory).filter(entry => !allowed.includes(entry))
if (unexpected.length > 0) {
throw new Error(
`dsh-plugin-desktop: command runtime directory contains unexpected entries: ${unexpected.join(', ')}`,
)
type RuntimeGenerationKind = 'dsh' | 'pnpm'
interface RuntimeArtifact {
relativePath: string
contents: string
mode: number
}
interface RuntimeGenerationManifest {
schemaVersion: typeof GENERATION_SCHEMA_VERSION
kind: RuntimeGenerationKind
platform: NodeJS.Platform
contentHash: string
files: Array<{
path: string
sha256: string
mode: number
}>
}
interface RuntimeGenerationPlan {
kind: RuntimeGenerationKind
platform: NodeJS.Platform
contentHash: string
directories: string[]
artifacts: RuntimeArtifact[]
}
interface RuntimeGeneration {
root: string
obsoletePathDirectories: string[]
}
/** Hash deterministic generated content without depending on its eventual directory name. */
function sha256(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('hex')
}
/** Create a content-addressed plan and its self-describing ownership marker. */
function runtimeGenerationPlan(
kind: RuntimeGenerationKind,
platform: NodeJS.Platform,
directories: readonly string[],
artifacts: readonly RuntimeArtifact[],
): RuntimeGenerationPlan {
const orderedDirectories = [...directories].sort()
const orderedArtifacts = [...artifacts].sort((left, right) => left.relativePath.localeCompare(right.relativePath))
const contentHash = sha256(JSON.stringify({
schemaVersion: GENERATION_SCHEMA_VERSION,
kind,
platform,
directories: orderedDirectories,
files: orderedArtifacts.map(artifact => ({
path: artifact.relativePath,
contents: artifact.contents,
mode: artifact.mode,
})),
}))
const manifest: RuntimeGenerationManifest = {
schemaVersion: GENERATION_SCHEMA_VERSION,
kind,
platform,
contentHash,
files: orderedArtifacts.map(artifact => ({
path: artifact.relativePath,
sha256: sha256(artifact.contents),
mode: artifact.mode,
})),
}
return {
kind,
platform,
contentHash,
directories: orderedDirectories,
artifacts: [
{ relativePath: MANIFEST_NAME, contents: `${JSON.stringify(manifest, undefined, 2)}\n`, mode: PRIVATE_FILE_MODE },
...orderedArtifacts,
],
}
}
/** Remove one stray command entry without recursively deleting unknown data. */
function removeUnexpectedEntry(directory: string, entry: string): void {
const filename = join(directory, entry)
if (lstatSync(filename).isDirectory()) {
throw new Error(
`dsh-plugin-desktop: command runtime contains an unexpected directory: ${entry}`,
)
}
process.stderr.write(
`dsh-plugin-desktop: removing unexpected command runtime entry ${JSON.stringify(entry)}\n`,
)
unlinkSync(filename)
function pathSegments(relativePath: string): string[] {
return relativePath.split('/')
}
/** Recover an app-owned command directory to this generation's exact contents. */
function reconcileOwnedDirectoryEntries(directory: string, allowed: readonly string[]): void {
for (const entry of readdirSync(directory)) {
if (!allowed.includes(entry)) removeUnexpectedEntry(directory, entry)
}
function generationPath(root: string, relativePath: string): string {
return join(root, ...pathSegments(relativePath))
}
/** Remove only stale atomic-write files generated for one exact target name. */
function removeStaleTemporaryFiles(directory: string, targetName: string): void {
const prefix = `.${targetName}.`
const suffix = '.tmp'
for (const entry of readdirSync(directory)) {
if (!entry.startsWith(prefix) || !entry.endsWith(suffix)) continue
const identity = entry.slice(prefix.length, -suffix.length)
if (!TEMPORARY_ID.test(identity)) continue
const filename = join(directory, entry)
const stat = lstatSync(filename)
if (!stat.isFile() && !stat.isSymbolicLink()) {
throw new Error(`dsh-plugin-desktop: command runtime stale temporary path is not a file: ${filename}`)
/** Inspect a generation tree without following symlinks. */
function generationTree(root: string): { directories: string[]; files: string[] } | undefined {
try {
const rootStat = lstatSync(root)
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return undefined
const directories: string[] = []
const files: string[] = []
const visit = (directory: string, prefix: string): boolean => {
for (const entry of readdirSync(directory)) {
const relativePath = prefix.length === 0 ? entry : `${prefix}/${entry}`
const stat = lstatSync(join(directory, entry))
if (stat.isSymbolicLink()) return false
if (stat.isDirectory()) {
directories.push(relativePath)
if (!visit(join(directory, entry), relativePath)) return false
continue
}
if (!stat.isFile()) return false
files.push(relativePath)
}
return true
}
unlinkSync(filename)
if (!visit(root, '')) return undefined
return { directories: directories.sort(), files: files.sort() }
} catch {
return undefined
}
}
/** Remove a temporary file while preserving every failure except absence. */
function unlinkTemporaryFile(filename: string): void {
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
/** Verify exact content before making an existing immutable generation executable via PATH. */
function reusableGeneration(root: string, plan: RuntimeGenerationPlan): boolean {
const tree = generationTree(root)
if (tree === undefined) return false
const expectedFiles = plan.artifacts.map(artifact => artifact.relativePath).sort()
if (!sameStrings(tree.directories, plan.directories) || !sameStrings(tree.files, expectedFiles)) return false
try {
unlinkSync(filename)
for (const artifact of plan.artifacts) {
const filename = generationPath(root, artifact.relativePath)
const stat = lstatSync(filename)
if (!stat.isFile() || stat.isSymbolicLink()) return false
if (process.platform !== 'win32' && (stat.mode & 0o777) !== artifact.mode) return false
if (readFileSync(filename, 'utf8') !== artifact.contents) return false
}
return true
} catch {
return false
}
}
function reportCleanupFailure(filename: string, cause: unknown): void {
const code = (cause as NodeJS.ErrnoException).code
try {
process.stderr.write(
`dsh-plugin-desktop: deferred command runtime cleanup for ${JSON.stringify(filename)}`
+ `${code === undefined ? '' : ` (${code})`}\n`,
)
} catch {
// Diagnostics must not turn best-effort generation cleanup into a startup failure.
}
}
/** Materialize a new generation privately, then publish it under a never-reused name. */
function createRuntimeGeneration(generationsDir: string, plan: RuntimeGenerationPlan): string {
const temporaryRoot = join(generationsDir, `.staging.${process.pid}.${randomUUID()}`)
mkdirSync(temporaryRoot, { mode: DIRECTORY_MODE })
chmodSync(temporaryRoot, DIRECTORY_MODE)
try {
for (const relativePath of [...plan.directories].sort((left, right) => {
return pathSegments(left).length - pathSegments(right).length || left.localeCompare(right)
})) {
const directory = generationPath(temporaryRoot, relativePath)
mkdirSync(directory, { mode: DIRECTORY_MODE })
chmodSync(directory, DIRECTORY_MODE)
}
for (const artifact of plan.artifacts) {
const filename = generationPath(temporaryRoot, artifact.relativePath)
writeFileSync(filename, artifact.contents, { encoding: 'utf8', flag: 'wx', mode: artifact.mode })
chmodSync(filename, artifact.mode)
}
const root = join(generationsDir, `${plan.contentHash}-${randomUUID()}`)
renameSync(temporaryRoot, root)
return root
} catch (cause) {
if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause
try {
rmSync(temporaryRoot, { recursive: true, force: true, maxRetries: 0 })
} catch (cleanupCause) {
reportCleanupFailure(temporaryRoot, cleanupCause)
}
throw cause
}
}
/** Atomically replace one regular app-owned file without accepting a symlink target. */
function replacePrivateFile(filename: string, contents: string, mode: number): void {
const existing = lstatOptional(filename)
if (existing !== undefined && (!existing.isFile() || existing.isSymbolicLink())) {
throw new Error(`dsh-plugin-desktop: command runtime file is not a regular file: ${filename}`)
}
const temporary = join(dirname(filename), `.${basename(filename)}.${process.pid}.${randomUUID()}.tmp`)
try {
writeFileSync(temporary, contents, { encoding: 'utf8', flag: 'wx', mode })
chmodSync(temporary, mode)
renameSync(temporary, filename)
} finally {
unlinkTemporaryFile(temporary)
/** Reuse an exact immutable generation or publish a clean sibling when any candidate is tainted. */
function installRuntimeGeneration(stateDir: string, plan: RuntimeGenerationPlan): RuntimeGeneration {
preparePrivateDirectory(stateDir)
const generationsDir = join(stateDir, 'generations')
preparePrivateDirectory(generationsDir)
const entries = readdirSync(generationsDir)
let root: string | undefined
for (const entry of entries.sort()) {
const match = GENERATION_NAME.exec(entry)
if (match?.groups?.hash !== plan.contentHash) continue
const candidate = join(generationsDir, entry)
if (reusableGeneration(candidate, plan)) {
root = candidate
break
}
}
root ??= createRuntimeGeneration(generationsDir, plan)
const obsoletePathDirectories = [
join(stateDir, 'bin'),
join(stateDir, 'private', 'node-bin'),
...entries.flatMap(entry => [
join(generationsDir, entry, 'bin'),
join(generationsDir, entry, 'private', 'node-bin'),
]),
].filter(directory => {
return directory !== join(root, 'bin') && directory !== join(root, 'private', 'node-bin')
})
// Published generations may still back child processes after the desktop lifecycle advances.
// Keep them immutable and isolate them from PATH; safe garbage collection requires explicit leases.
return { root, obsoletePathDirectories }
}
/** Module preloaded into RunAsNode children before their requested entry. */
@@ -210,65 +346,68 @@ function clearEnvironmentModule(): string {
}
/** Build the private POSIX Node command used only by pnpm lifecycle scripts. */
function posixNodeShim(appExecutable: string, clearEnvironmentUrl: string): string {
function posixNodeShim(appExecutable: string): string {
return [
'#!/bin/sh',
`${RUN_AS_NODE}=1 exec ${quoteSh(appExecutable)} --import ${quoteSh(clearEnvironmentUrl)} "$@"`,
'runtime_node_bin_dir=${0%/*}',
'runtime_private_dir=${runtime_node_bin_dir%/*}',
'runtime_clear_environment="$runtime_private_dir/clear-env.cjs"',
`${RUN_AS_NODE}=1 exec ${quoteSh(appExecutable)} --require "$runtime_clear_environment" "$@"`,
'',
].join('\n')
}
/** Build the public POSIX pnpm command. */
function posixPnpmShim(
options: DesktopPnpmRuntimeOptions,
nodeBinDir: string,
nodeShimPath: string,
clearEnvironmentUrl: string,
): string {
function posixPnpmShim(options: DesktopPnpmRuntimeOptions): string {
return [
'#!/bin/sh',
'runtime_public_dir=${0%/*}',
'runtime_root_dir=${runtime_public_dir%/*}',
'runtime_private_dir="$runtime_root_dir/private"',
'runtime_node_bin_dir="$runtime_private_dir/node-bin"',
'runtime_node_shim="$runtime_node_bin_dir/node"',
'runtime_clear_environment="$runtime_private_dir/clear-env.cjs"',
[
`PATH=${quoteSh(nodeBinDir)}:"\${PATH:-}"`,
`NODE=${quoteSh(nodeShimPath)}`,
'PATH="$runtime_node_bin_dir:${PATH:-}"',
'NODE="$runtime_node_shim"',
`${RUN_AS_NODE}=1`,
'npm_config_runtime=electron',
`npm_config_target=${quoteSh(options.electronVersion)}`,
`npm_config_disturl=${quoteSh(ELECTRON_HEADERS_URL)}`,
`exec ${quoteSh(options.appExecutable)} --import ${quoteSh(clearEnvironmentUrl)} ${quoteSh(options.pnpmBinPath)} ${PNPM_IGNORE_MINIMUM_RELEASE_AGE} "$@"`,
`exec ${quoteSh(options.appExecutable)} --require "$runtime_clear_environment" ${quoteSh(options.pnpmBinPath)} ${PNPM_IGNORE_MINIMUM_RELEASE_AGE} "$@"`,
].join(' '),
'',
].join('\n')
}
/** Build the private Windows Node command used only by pnpm lifecycle scripts. */
function windowsNodeShim(appExecutable: string, clearEnvironmentUrl: string): string {
function windowsNodeShim(appExecutable: string): string {
return [
'@echo off',
'setlocal DisableDelayedExpansion',
'set "DSH_RUNTIME_PRIVATE=%~dp0.."',
`set "${RUN_AS_NODE}=1"`,
`${quoteBatchWord(appExecutable)} --import ${quoteBatchWord(clearEnvironmentUrl)} %*`,
`${quoteBatchWord(appExecutable)} --require "%DSH_RUNTIME_PRIVATE%\\clear-env.cjs" %*`,
'exit /b %errorlevel%',
'',
].join('\r\n')
}
/** Build the public Windows pnpm command. */
function windowsPnpmShim(
options: DesktopPnpmRuntimeOptions,
nodeBinDir: string,
nodeShimPath: string,
clearEnvironmentUrl: string,
): string {
function windowsPnpmShim(options: DesktopPnpmRuntimeOptions): string {
return [
'@echo off',
'setlocal DisableDelayedExpansion',
`set "PATH=${escapeBatchSetValue(nodeBinDir)};%PATH%"`,
`set "NODE=${escapeBatchSetValue(nodeShimPath)}"`,
'set "DSH_RUNTIME_ROOT=%~dp0.."',
'set "DSH_RUNTIME_PRIVATE=%DSH_RUNTIME_ROOT%\\private"',
'set "DSH_RUNTIME_NODE_BIN=%DSH_RUNTIME_PRIVATE%\\node-bin"',
'set "PATH=%DSH_RUNTIME_NODE_BIN%;%PATH%"',
'set "NODE=%DSH_RUNTIME_NODE_BIN%\\node.cmd"',
`set "${RUN_AS_NODE}=1"`,
'set "npm_config_runtime=electron"',
`set "npm_config_target=${escapeBatchSetValue(options.electronVersion)}"`,
`set "npm_config_disturl=${ELECTRON_HEADERS_URL}"`,
`${quoteBatchWord(options.appExecutable)} --import ${quoteBatchWord(clearEnvironmentUrl)} ${quoteBatchWord(options.pnpmBinPath)} ${PNPM_IGNORE_MINIMUM_RELEASE_AGE} %*`,
`${quoteBatchWord(options.appExecutable)} --require "%DSH_RUNTIME_PRIVATE%\\clear-env.cjs" ${quoteBatchWord(options.pnpmBinPath)} ${PNPM_IGNORE_MINIMUM_RELEASE_AGE} %*`,
'exit /b %errorlevel%',
'',
].join('\r\n')
@@ -305,7 +444,13 @@ function normalizedPathComponent(component: string, platform: NodeJS.Platform):
const unquoted = platform === 'win32' && component.startsWith('"') && component.endsWith('"')
? component.slice(1, -1)
: component
return platform === 'win32' ? unquoted.toLowerCase() : unquoted
if (platform !== 'win32' || unquoted.length === 0) return unquoted
const normalized = windowsPath.normalize(unquoted)
const root = windowsPath.parse(normalized).root
const withoutTrailingSeparators = normalized.length > root.length
? normalized.replace(/[\\/]+$/u, '')
: normalized
return withoutTrailingSeparators.toLowerCase()
}
/** Remove every occurrence of one directory from a PATH value. */
@@ -323,18 +468,21 @@ function installPathDirectory(
environment: NodeJS.ProcessEnv,
directory: string,
platform: NodeJS.Platform,
obsoleteDirectories: readonly string[] = [],
): () => void {
const original = pathEntries(environment, platform)
const current = original.find(entry => entry.value !== undefined)
const currentValue = current?.value ?? ''
const key = current?.key ?? PATH
const delimiter = platform === 'win32' ? ';' : ':'
const inheritedValue = withoutPathDirectory(currentValue, directory, platform)
const firstComponent = currentValue.split(delimiter)[0] ?? ''
if (normalizedPathComponent(firstComponent, platform) === normalizedPathComponent(directory, platform)) {
const inheritedValue = [directory, ...obsoleteDirectories].reduce(
(value, candidate) => withoutPathDirectory(value, candidate, platform),
currentValue,
)
const installedValue = inheritedValue.length === 0 ? directory : `${directory}${delimiter}${inheritedValue}`
if (installedValue === currentValue) {
return () => {}
}
const installedValue = inheritedValue.length === 0 ? directory : `${directory}${delimiter}${inheritedValue}`
for (const entry of original) delete environment[entry.key]
environment[key] = installedValue
@@ -368,18 +516,24 @@ export function installDesktopDshRuntime(options: DesktopDshRuntimeOptions): Des
['state directory', options.stateDir],
] as const) assertScriptValue(label, value)
const pathDir = join(options.stateDir, 'bin')
preparePrivateDirectory(options.stateDir)
preparePrivateDirectory(pathDir)
removeStaleTemporaryFiles(pathDir, 'dsh.cmd')
assertOwnedDirectoryEntries(pathDir, ['dsh.cmd'])
const plan = runtimeGenerationPlan('dsh', options.platform, ['bin'], [{
relativePath: 'bin/dsh.cmd',
contents: windowsDshShim(options),
mode: PRIVATE_FILE_MODE,
}])
const generation = installRuntimeGeneration(options.stateDir, plan)
const pathDir = join(generation.root, 'bin')
const dshShimPath = join(pathDir, 'dsh.cmd')
replacePrivateFile(dshShimPath, windowsDshShim(options), PRIVATE_FILE_MODE)
return {
pathDir,
dshShimPath,
dispose: installPathDirectory(options.environment ?? process.env, pathDir, options.platform),
dispose: installPathDirectory(
options.environment ?? process.env,
pathDir,
options.platform,
generation.obsoletePathDirectories,
),
}
}
@@ -399,41 +553,38 @@ export function installDesktopPnpmRuntime(options: DesktopPnpmRuntimeOptions): D
['state directory', options.stateDir],
] as const) assertScriptValue(label, value)
const pathDir = join(options.stateDir, 'bin')
const privateDir = join(options.stateDir, 'private')
const nodeBinDir = join(privateDir, 'node-bin')
preparePrivateDirectory(options.stateDir)
preparePrivateDirectory(pathDir)
preparePrivateDirectory(privateDir)
preparePrivateDirectory(nodeBinDir)
const windows = options.platform === 'win32'
const pnpmShimName = windows ? 'pnpm.cmd' : 'pnpm'
const nodeShimName = windows ? 'node.cmd' : 'node'
removeStaleTemporaryFiles(pathDir, pnpmShimName)
removeStaleTemporaryFiles(nodeBinDir, nodeShimName)
removeStaleTemporaryFiles(privateDir, 'clear-env.mjs')
reconcileOwnedDirectoryEntries(pathDir, [pnpmShimName])
reconcileOwnedDirectoryEntries(nodeBinDir, [nodeShimName])
const plan = runtimeGenerationPlan(
'pnpm',
options.platform,
['bin', 'private', 'private/node-bin'],
[
{
relativePath: 'private/clear-env.cjs',
contents: clearEnvironmentModule(),
mode: PRIVATE_FILE_MODE,
},
{
relativePath: `private/node-bin/${nodeShimName}`,
contents: windows ? windowsNodeShim(options.appExecutable) : posixNodeShim(options.appExecutable),
mode: windows ? PRIVATE_FILE_MODE : EXECUTABLE_FILE_MODE,
},
{
relativePath: `bin/${pnpmShimName}`,
contents: windows ? windowsPnpmShim(options) : posixPnpmShim(options),
mode: windows ? PRIVATE_FILE_MODE : EXECUTABLE_FILE_MODE,
},
],
)
const generation = installRuntimeGeneration(options.stateDir, plan)
const pathDir = join(generation.root, 'bin')
const privateDir = join(generation.root, 'private')
const nodeBinDir = join(privateDir, 'node-bin')
const pnpmShimPath = join(pathDir, pnpmShimName)
const nodeShimPath = join(nodeBinDir, nodeShimName)
const clearEnvironmentPath = join(privateDir, 'clear-env.mjs')
replacePrivateFile(clearEnvironmentPath, clearEnvironmentModule(), PRIVATE_FILE_MODE)
const clearEnvironmentUrl = pathToFileURL(clearEnvironmentPath).href
replacePrivateFile(
nodeShimPath,
windows
? windowsNodeShim(options.appExecutable, clearEnvironmentUrl)
: posixNodeShim(options.appExecutable, clearEnvironmentUrl),
windows ? PRIVATE_FILE_MODE : EXECUTABLE_FILE_MODE,
)
replacePrivateFile(
pnpmShimPath,
windows
? windowsPnpmShim(options, nodeBinDir, nodeShimPath, clearEnvironmentUrl)
: posixPnpmShim(options, nodeBinDir, nodeShimPath, clearEnvironmentUrl),
windows ? PRIVATE_FILE_MODE : EXECUTABLE_FILE_MODE,
)
const clearEnvironmentPath = join(privateDir, 'clear-env.cjs')
return {
pathDir,
@@ -441,6 +592,11 @@ export function installDesktopPnpmRuntime(options: DesktopPnpmRuntimeOptions): D
nodeBinDir,
nodeShimPath,
clearEnvironmentPath,
dispose: installPathDirectory(options.environment ?? process.env, pathDir, options.platform),
dispose: installPathDirectory(
options.environment ?? process.env,
pathDir,
options.platform,
generation.obsoletePathDirectories,
),
}
}
@@ -260,7 +260,8 @@ function normalizedUpdate(
})
}
function sameSettings(
/** Return whether two normalized Setup views have identical persisted leaves. */
export function sameDesktopSetupWizardSettings(
current: DesktopSetupWizardSettings,
next: DesktopSetupWizardSettings,
): boolean {
@@ -341,7 +342,7 @@ export async function updateDesktopSetupWizardSettings(
// Explicit default leaves do not need to be materialized. Apart from making
// Setup idempotent, this lets an unchanged first-run choice proceed while an
// unrelated or orphaned settings writer lock exists.
if (sameSettings(projectSettings(loadSettingsDocument(path).root), next)) return next
if (sameDesktopSetupWizardSettings(projectSettings(loadSettingsDocument(path).root), next)) return next
ensureDocumentDirectory(path)
const loaded = loadSettingsDocument(path)
// Refuse to cover an invalid known value, including the inactive platform's
@@ -1,5 +1,7 @@
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
@@ -9,8 +11,9 @@ import {
symlinkSync,
writeFileSync,
} from 'node:fs'
import { syncBuiltinESMExports } from 'node:module'
import { tmpdir } from 'node:os'
import { delimiter as pathDelimiter, join } from 'node:path'
import { basename, delimiter as pathDelimiter, dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
@@ -68,6 +71,12 @@ describe('desktop Host pnpm runtime', () => {
expect(readdirSync(installation.pathDir)).toEqual(['pnpm'])
expect(readdirSync(installation.nodeBinDir)).toEqual(['node'])
const generationRoot = dirname(installation.pathDir)
const manifest = JSON.parse(readFileSync(join(generationRoot, '.generation.json'), 'utf8')) as {
contentHash: string
}
expect(readdirSync(generationRoot).sort()).toEqual(['.generation.json', 'bin', 'private'])
expect(basename(generationRoot)).toMatch(new RegExp(`^${manifest.contentHash}-`))
if (process.platform !== 'win32') {
expect(lstatSync(stateDir).mode & 0o777).toBe(0o700)
expect(lstatSync(installation.pathDir).mode & 0o777).toBe(0o700)
@@ -77,20 +86,20 @@ describe('desktop Host pnpm runtime', () => {
expect(lstatSync(installation.clearEnvironmentPath).mode & 0o777).toBe(0o600)
}
const clearEnvironmentUrl = pathToFileURL(installation.clearEnvironmentPath).href
const pnpm = readFileSync(installation.pnpmShimPath, 'utf8')
expect(pnpm).toContain(`PATH='${installation.nodeBinDir}':"\${PATH:-}"`)
expect(pnpm).toContain(`NODE='${installation.nodeShimPath}'`)
expect(pnpm).toContain('runtime_root_dir=${runtime_public_dir%/*}')
expect(pnpm).toContain('PATH="$runtime_node_bin_dir:${PATH:-}"')
expect(pnpm).toContain('NODE="$runtime_node_shim"')
expect(pnpm).toContain('ELECTRON_RUN_AS_NODE=1 npm_config_runtime=electron')
expect(pnpm).toContain("npm_config_target='43.4.0'")
expect(pnpm).toContain("npm_config_disturl='https://electronjs.org/headers'")
expect(pnpm.match(/--config\.minimumReleaseAge=0/gu)).toHaveLength(1)
expect(pnpm).toContain('--config.minimumReleaseAge=0 "$@"')
expect(pnpm).toContain(`--import '${clearEnvironmentUrl}'`)
expect(pnpm.indexOf(`--import '${clearEnvironmentUrl}'`)).toBeLessThan(pnpm.indexOf('pnpm/bin/pnpm.mjs'))
expect(pnpm).toContain('--require "$runtime_clear_environment"')
expect(pnpm.indexOf('--require "$runtime_clear_environment"')).toBeLessThan(pnpm.indexOf('pnpm/bin/pnpm.mjs'))
const node = readFileSync(installation.nodeShimPath, 'utf8')
expect(node).toContain(`ELECTRON_RUN_AS_NODE=1 exec`)
expect(node).toContain(`--import '${clearEnvironmentUrl}' "$@"`)
expect(node).toContain('--require "$runtime_clear_environment" "$@"')
expect(node).not.toContain('npm_config_')
expect(readFileSync(installation.clearEnvironmentPath, 'utf8')).toContain(
"name.toUpperCase() === 'ELECTRON_RUN_AS_NODE'",
@@ -178,7 +187,7 @@ describe('desktop Host pnpm runtime', () => {
const command = process.platform === 'win32'
? process.env.ComSpec ?? 'cmd.exe'
: installation.pnpmShimPath
: 'pnpm'
const args = process.platform === 'win32'
? ['/d', '/s', '/c', `""${installation.pnpmShimPath}" "${captureOutput}""`]
: [captureOutput]
@@ -217,21 +226,22 @@ describe('desktop Host pnpm runtime', () => {
expect(readdirSync(installation.pathDir)).toEqual(['pnpm.cmd'])
expect(readdirSync(installation.nodeBinDir)).toEqual(['node.cmd'])
const clearEnvironmentUrl = pathToFileURL(installation.clearEnvironmentPath).href
const escapedClearEnvironmentUrl = clearEnvironmentUrl.replaceAll('%', '%%')
const pnpm = readFileSync(installation.pnpmShimPath, 'utf8')
expect(pnpm).toContain(`set "PATH=${installation.nodeBinDir};%PATH%"`)
expect(pnpm).toContain(`set "NODE=${installation.nodeShimPath}"`)
expect(pnpm).toContain('set "DSH_RUNTIME_ROOT=%~dp0.."')
expect(pnpm).toContain('set "PATH=%DSH_RUNTIME_NODE_BIN%;%PATH%"')
expect(pnpm).toContain('set "NODE=%DSH_RUNTIME_NODE_BIN%\\node.cmd"')
expect(pnpm).toContain('set "ELECTRON_RUN_AS_NODE=1"')
expect(pnpm).toContain('set "npm_config_runtime=electron"')
expect(pnpm).toContain('set "npm_config_target=43.4.0"')
expect(pnpm).toContain(`--import "${escapedClearEnvironmentUrl}"`)
expect(pnpm.indexOf(`--import "${escapedClearEnvironmentUrl}"`)).toBeLessThan(pnpm.indexOf('pnpm\\bin\\pnpm.mjs'))
expect(pnpm).toContain('--require "%DSH_RUNTIME_PRIVATE%\\clear-env.cjs"')
expect(pnpm.indexOf('--require "%DSH_RUNTIME_PRIVATE%\\clear-env.cjs"')).toBeLessThan(
pnpm.indexOf('pnpm\\bin\\pnpm.mjs'),
)
expect(pnpm.match(/--config\.minimumReleaseAge=0/gu)).toHaveLength(1)
expect(pnpm).toContain('--config.minimumReleaseAge=0 %*')
const node = readFileSync(installation.nodeShimPath, 'utf8')
expect(node).toContain('set "ELECTRON_RUN_AS_NODE=1"')
expect(node).toContain(`--import "${escapedClearEnvironmentUrl}" %*`)
expect(node).toContain('--require "%DSH_RUNTIME_PRIVATE%\\clear-env.cjs" %*')
expect(node).not.toContain('npm_config_')
expect(environment).toEqual({
@@ -261,6 +271,28 @@ describe('desktop Host pnpm runtime', () => {
expect(environment).toEqual({ PATH: `${laterPath}${pathDelimiter}${originalPath}` })
})
it('normalizes Windows separators and trailing separators when replacing legacy PATH entries', () => {
const root = temporaryDirectory()
const stateDir = join(root, 'runtime-commands')
const environment: NodeJS.ProcessEnv = { Path: 'C:\\Windows' }
const first = installDesktopPnpmRuntime(options(stateDir, 'win32', environment))
first.dispose()
const legacyPathDir = join(stateDir, 'bin')
mkdirSync(legacyPathDir, { recursive: true })
writeFileSync(join(legacyPathDir, 'bsk.exe'), 'legacy unknown command')
const legacyVariant = `${legacyPathDir.replaceAll('/', '\\')}\\`
const activeVariant = `${first.pathDir.replaceAll('/', '\\')}\\`
const systemPath = 'C:/Windows/System32/'
environment.Path = `"${legacyVariant}";"${activeVariant}";${systemPath}`
const second = installDesktopPnpmRuntime(options(stateDir, 'win32', environment))
expect(second.pathDir).toBe(first.pathDir)
expect(environment.Path).toBe(`${second.pathDir};${systemPath}`)
second.dispose()
expect(environment.Path).toBe(`"${legacyVariant}";"${activeVariant}";${systemPath}`)
})
it.runIf(process.platform === 'win32')('reclaims PATH precedence from an inherited Desktop terminal shim', () => {
const root = temporaryDirectory()
const stateDir = join(root, 'runtime')
@@ -306,17 +338,24 @@ describe('desktop Host pnpm runtime', () => {
expect(environment).toEqual(original)
})
it('does not duplicate or remove a runtime already first on PATH', () => {
it('reuses an exact immutable generation without rewriting it', () => {
const stateDir = join(temporaryDirectory(), 'runtime')
const pathDir = join(stateDir, 'bin')
const platform = process.platform === 'win32' ? 'win32' : 'linux'
const originalPath = process.platform === 'win32' ? 'C:\\Windows' : '/usr/bin'
const environment: NodeJS.ProcessEnv = { PATH: `${pathDir}${pathDelimiter}${originalPath}` }
const installation = installDesktopPnpmRuntime(options(stateDir, platform, environment))
const environment: NodeJS.ProcessEnv = { PATH: originalPath }
const first = installDesktopPnpmRuntime(options(stateDir, platform, environment))
const firstStat = lstatSync(first.pnpmShimPath)
first.dispose()
environment.PATH = `${first.pathDir}${pathDelimiter}${originalPath}`
expect(environment.PATH).toBe(`${pathDir}${pathDelimiter}${originalPath}`)
installation.dispose()
expect(environment.PATH).toBe(`${pathDir}${pathDelimiter}${originalPath}`)
const second = installDesktopPnpmRuntime(options(stateDir, platform, environment))
expect(second.pathDir).toBe(first.pathDir)
expect(lstatSync(second.pnpmShimPath).ino).toBe(firstStat.ino)
expect(lstatSync(second.pnpmShimPath).mtimeMs).toBe(firstStat.mtimeMs)
expect(environment.PATH).toBe(`${first.pathDir}${pathDelimiter}${originalPath}`)
second.dispose()
expect(environment.PATH).toBe(`${first.pathDir}${pathDelimiter}${originalPath}`)
})
it('rejects symlinked state directories before changing PATH', () => {
@@ -332,89 +371,86 @@ describe('desktop Host pnpm runtime', () => {
expect(environment).toEqual({ PATH: '/usr/bin' })
})
it('rejects a symlinked generated file before changing PATH', () => {
it('publishes a clean sibling instead of trusting a contaminated generation', () => {
const root = temporaryDirectory()
const stateDir = join(root, 'runtime')
const pathDir = join(stateDir, 'bin')
const privateDir = join(stateDir, 'private')
const nodeBinDir = join(privateDir, 'node-bin')
mkdirSync(pathDir, { recursive: true })
mkdirSync(nodeBinDir, { recursive: true })
const environment: NodeJS.ProcessEnv = { PATH: '/usr/bin' }
const first = installDesktopPnpmRuntime(options(stateDir, 'linux', environment))
first.dispose()
const target = join(root, 'outside')
writeFileSync(target, 'outside')
symlinkSync(target, join(pathDir, 'pnpm'))
const environment: NodeJS.ProcessEnv = { PATH: '/usr/bin' }
rmSync(first.pnpmShimPath)
symlinkSync(target, first.pnpmShimPath)
const unknownExecutable = join(first.pathDir, 'bsk.exe')
writeFileSync(unknownExecutable, 'locked or foreign')
environment.PATH = `${first.pathDir}:/usr/bin`
expect(() => installDesktopPnpmRuntime(options(stateDir, 'linux', environment)))
.toThrow('not a regular file')
const second = installDesktopPnpmRuntime(options(stateDir, 'linux', environment))
expect(second.pathDir).not.toBe(first.pathDir)
expect(readdirSync(second.pathDir)).toEqual(['pnpm'])
expect(environment.PATH).toBe(`${second.pathDir}:/usr/bin`)
expect(readFileSync(unknownExecutable, 'utf8')).toBe('locked or foreign')
expect(readFileSync(target, 'utf8')).toBe('outside')
expect(environment).toEqual({ PATH: '/usr/bin' })
second.dispose()
})
it('recovers from stray command files instead of failing startup', () => {
it('leaves legacy unknown files untouched and excludes their directories from PATH', () => {
const root = temporaryDirectory()
const stateDir = join(root, 'runtime')
const pathDir = join(stateDir, 'bin')
const nodeBinDir = join(stateDir, 'private', 'node-bin')
mkdirSync(pathDir, { recursive: true })
mkdirSync(nodeBinDir, { recursive: true })
writeFileSync(join(pathDir, 'dsh'), 'stray')
writeFileSync(join(nodeBinDir, 'dsh'), 'stray')
const environment: NodeJS.ProcessEnv = { PATH: '/usr/bin' }
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const legacyPathDir = join(stateDir, 'bin')
const legacyNodeBinDir = join(stateDir, 'private', 'node-bin')
mkdirSync(legacyPathDir, { recursive: true })
mkdirSync(legacyNodeBinDir, { recursive: true })
const publicUnknown = join(legacyPathDir, 'bsk.exe')
const privateUnknown = join(legacyNodeBinDir, 'bsk.exe')
writeFileSync(publicUnknown, 'locked public command')
writeFileSync(privateUnknown, 'locked private command')
const environment: NodeJS.ProcessEnv = {
PATH: `${legacyPathDir}:${legacyNodeBinDir}:/usr/bin`,
}
const installation = installDesktopPnpmRuntime(options(stateDir, 'linux', environment))
expect(readdirSync(pathDir)).toEqual(['pnpm'])
expect(readdirSync(nodeBinDir)).toEqual(['node'])
expect(environment.PATH).toBe(`${pathDir}:/usr/bin`)
expect(stderr).toHaveBeenCalledTimes(2)
expect(installation.pathDir).not.toBe(legacyPathDir)
expect(environment.PATH).toBe(`${installation.pathDir}:/usr/bin`)
expect(readFileSync(publicUnknown, 'utf8')).toBe('locked public command')
expect(readFileSync(privateUnknown, 'utf8')).toBe('locked private command')
installation.dispose()
})
it('removes stray symlinks without touching their targets', () => {
it('does not attempt to delete a published generation that could still be locked', () => {
const root = temporaryDirectory()
const stateDir = join(root, 'runtime')
const pathDir = join(stateDir, 'bin')
mkdirSync(pathDir, { recursive: true })
const target = join(root, 'outside')
writeFileSync(target, 'outside')
symlinkSync(target, join(pathDir, 'dsh'))
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const installation = installDesktopPnpmRuntime(options(stateDir, 'linux', { PATH: '/usr/bin' }))
expect(readdirSync(pathDir)).toEqual(['pnpm'])
expect(readFileSync(target, 'utf8')).toBe('outside')
expect(stderr).toHaveBeenCalledOnce()
installation.dispose()
})
it('refuses an unexpected directory in the public command directory', () => {
const root = temporaryDirectory()
const stateDir = join(root, 'runtime')
const pathDir = join(stateDir, 'bin')
mkdirSync(join(pathDir, 'dsh'), { recursive: true })
const environment: NodeJS.ProcessEnv = { PATH: '/usr/bin' }
const first = installDesktopPnpmRuntime(options(stateDir, 'linux', environment))
const firstRoot = dirname(first.pathDir)
first.dispose()
const originalRmSync = fs.rmSync
const rm = vi.spyOn(fs, 'rmSync').mockImplementation((filename, rmOptions) => {
if (filename === firstRoot) {
throw Object.assign(new Error('locked'), { code: 'EPERM' })
}
return originalRmSync(filename, rmOptions)
})
syncBuiltinESMExports()
expect(() => installDesktopPnpmRuntime(options(stateDir, 'linux', environment)))
.toThrow('contains an unexpected directory: dsh')
expect(environment).toEqual({ PATH: '/usr/bin' })
})
let second: ReturnType<typeof installDesktopPnpmRuntime> | undefined
try {
second = installDesktopPnpmRuntime({
...options(stateDir, 'linux', environment),
electronVersion: '44.0.0',
})
} finally {
rm.mockRestore()
syncBuiltinESMExports()
}
it('removes only strictly named stale atomic files before validating commands', () => {
const root = temporaryDirectory()
const stateDir = join(root, 'runtime')
const pathDir = join(stateDir, 'bin')
mkdirSync(pathDir, { recursive: true })
const stale = join(pathDir, '.pnpm.123.123e4567-e89b-12d3-a456-426614174000.tmp')
writeFileSync(stale, 'partial')
const environment: NodeJS.ProcessEnv = { PATH: '/usr/bin' }
const installation = installDesktopPnpmRuntime(options(stateDir, 'linux', environment))
expect(readdirSync(pathDir)).toEqual(['pnpm'])
installation.dispose()
expect(second).toBeDefined()
expect(environment.PATH).toBe(`${second?.pathDir}:/usr/bin`)
expect(existsSync(firstRoot)).toBe(true)
expect(rm.mock.calls.some(([filename]) => filename === firstRoot)).toBe(false)
second?.dispose()
})
it('fails loud for unsupported platforms and unsafe generated values', () => {
@@ -429,6 +465,32 @@ describe('desktop Host pnpm runtime', () => {
})
describe('desktop Host dsh runtime', () => {
it('content-addresses Windows DSH shims and reuses an exact generation', () => {
const root = temporaryDirectory()
const stateDir = join(root, 'runtime')
const environment: NodeJS.ProcessEnv = { Path: 'C:\\Windows' }
const runtimeOptions = {
platform: 'win32' as const,
appExecutable: 'C:\\Program Files\\DSH Desktop\\DSH Desktop.exe',
dshBootstrapPath: 'C:\\Program Files\\DSH Desktop\\resources\\app.asar\\desktop-cli.js',
profileName: 'web',
homeDir: 'C:\\Users\\tester\\.dsh',
stateDir,
environment,
}
const first = installDesktopDshRuntime(runtimeOptions)
const firstRoot = dirname(first.pathDir)
first.dispose()
const second = installDesktopDshRuntime(runtimeOptions)
expect(second.pathDir).toBe(first.pathDir)
expect(readdirSync(second.pathDir)).toEqual(['dsh.cmd'])
expect(readdirSync(firstRoot).sort()).toEqual(['.generation.json', 'bin'])
expect(readFileSync(second.dshShimPath, 'utf8')).toContain('set "DSH_DESKTOP_DEFAULT_PROFILE=web"')
second.dispose()
})
it.runIf(process.platform === 'win32')('makes the active profile available to Host plugin child processes', () => {
const root = temporaryDirectory()
const stateDir = join(root, 'runtime')