mirror of
https://gh-proxy.org/https://github.com/klinsmaya/workdsh.git
synced 2026-11-10 02:17:22 +08:00
fix(ci): align beta runtime variant sources
This commit is contained in:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user