fix(desktop): repair owned dangling CLI links
lstat finds dangling links, so the provisioner skipped the command that needed repair after a bundle moved. Replace only links that name the same command in an absolute bundled payload path. Preserve live links, foreign destinations and regular files. Stage the replacement link beside the target before renaming it. Keep the payload command intact and report cleanup errors. Handle each command separately so one filesystem error does not skip later entries. Real temporary symlinks reproduce the stale-link failure. The integrated checks exercise replacement, preservation and an actual failed rename. Both desktop typechecks and scoped lint pass. No native macOS package was moved or launched.
This commit is contained in:
110
apps/desktop/electron/cli-provision.test.ts
Normal file
110
apps/desktop/electron/cli-provision.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { provisionCliLinks } from './cli-provision'
|
||||
|
||||
function fixture(): { root: string; binDir: string; source: string } {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-cli-links-'))
|
||||
const binDir = path.join(root, 'bin')
|
||||
const source = path.join(root, 'new', 'agent-payload', 'bin', 'hermes')
|
||||
|
||||
fs.mkdirSync(path.dirname(source), { recursive: true })
|
||||
fs.mkdirSync(binDir)
|
||||
fs.writeFileSync(source, 'payload command\n')
|
||||
|
||||
return { root, binDir, source }
|
||||
}
|
||||
|
||||
test('repairs owned dangling CLI links without changing foreign or live entries', context => {
|
||||
const { root, binDir, source } = fixture()
|
||||
const messages: string[] = []
|
||||
const target = path.join(binDir, 'hermes')
|
||||
|
||||
try {
|
||||
const oldSource = path.join(root, 'old', 'agent-payload', 'bin', 'hermes')
|
||||
|
||||
try {
|
||||
fs.symlinkSync(oldSource, target)
|
||||
} catch (error) {
|
||||
if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') {
|
||||
context.skip('Windows symlinks require Developer Mode or elevation')
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
provisionCliLinks({ hermes: source }, binDir, message => messages.push(message))
|
||||
assert.equal(fs.readlinkSync(target), source)
|
||||
assert.equal(fs.readFileSync(source, 'utf8'), 'payload command\n')
|
||||
assert.deepEqual(fs.readdirSync(binDir), ['hermes'])
|
||||
|
||||
const foreign = path.join(root, 'removed-other-tool', 'hermes')
|
||||
const otherName = path.join(root, 'old', 'agent-payload', 'bin', 'other')
|
||||
|
||||
for (const destination of [source, foreign, otherName, 'agent-payload/bin/hermes']) {
|
||||
fs.unlinkSync(target)
|
||||
fs.symlinkSync(destination, target)
|
||||
const original = fs.readlinkSync(target)
|
||||
|
||||
provisionCliLinks({ hermes: source }, binDir, message => messages.push(message))
|
||||
assert.equal(fs.readlinkSync(target), original)
|
||||
assert.deepEqual(fs.readdirSync(binDir), ['hermes'])
|
||||
}
|
||||
|
||||
fs.unlinkSync(target)
|
||||
fs.writeFileSync(target, 'user command\n')
|
||||
provisionCliLinks({ hermes: source }, binDir, message => messages.push(message))
|
||||
assert.equal(fs.readFileSync(target, 'utf8'), 'user command\n')
|
||||
fs.unlinkSync(target)
|
||||
provisionCliLinks({ hermes: source }, binDir, message => messages.push(message))
|
||||
assert.equal(fs.readlinkSync(target), source)
|
||||
assert.equal(messages.filter(message => message.includes('linked 1')).length, 2)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('a failed link swap preserves its source and target, then provisions later commands', context => {
|
||||
const { root, binDir, source } = fixture()
|
||||
const target = path.join(binDir, 'hermes')
|
||||
const oldSource = path.join(root, 'old', 'agent-payload', 'bin', 'hermes')
|
||||
const acpSource = path.join(path.dirname(source), 'hermes-acp')
|
||||
const messages: string[] = []
|
||||
let swaps = 0
|
||||
|
||||
try {
|
||||
try {
|
||||
fs.symlinkSync(oldSource, target)
|
||||
} catch (error) {
|
||||
if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') {
|
||||
context.skip('Windows symlinks require Developer Mode or elevation')
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
fs.writeFileSync(acpSource, 'ACP command\n')
|
||||
provisionCliLinks({ hermes: source, 'hermes-acp': acpSource }, binDir, message => messages.push(message), {
|
||||
...fs,
|
||||
renameSync: (from, to) => {
|
||||
swaps += 1
|
||||
fs.unlinkSync(from)
|
||||
fs.renameSync(from, to)
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(swaps, 1)
|
||||
assert.equal(fs.readlinkSync(target), oldSource)
|
||||
assert.equal(fs.readFileSync(source, 'utf8'), 'payload command\n')
|
||||
assert.equal(fs.readlinkSync(path.join(binDir, 'hermes-acp')), acpSource)
|
||||
assert.deepEqual(fs.readdirSync(binDir).sort(), ['hermes', 'hermes-acp'])
|
||||
assert.ok(messages.some(message => message.includes('hermes') && message.includes('ENOENT')))
|
||||
assert.ok(messages.some(message => message.includes('linked 1')))
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
89
apps/desktop/electron/cli-provision.ts
Normal file
89
apps/desktop/electron/cli-provision.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import * as fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
type ProvisionFs = Pick<typeof fs, 'mkdirSync' | 'lstatSync' | 'readlinkSync' | 'statSync' | 'symlinkSync' | 'renameSync' | 'unlinkSync'>
|
||||
|
||||
export function provisionCliLinks(
|
||||
commands: Readonly<Record<string, string>>,
|
||||
binDir: string,
|
||||
log: (message: string) => void,
|
||||
io: ProvisionFs = fs
|
||||
): void {
|
||||
try {
|
||||
io.mkdirSync(binDir, { recursive: true })
|
||||
} catch (error) {
|
||||
log(`[payload] CLI PATH provision skipped: ${String(error)}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let linked = 0
|
||||
|
||||
for (const [name, source] of Object.entries(commands)) {
|
||||
const target = path.join(binDir, name)
|
||||
|
||||
try {
|
||||
const existing = io.lstatSync(target, { throwIfNoEntry: false })
|
||||
|
||||
if (!existing) {
|
||||
io.symlinkSync(source, target)
|
||||
linked += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!existing.isSymbolicLink()) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
io.statSync(target)
|
||||
|
||||
continue
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
|
||||
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const destination = io.readlinkSync(target)
|
||||
const bin = path.dirname(destination)
|
||||
|
||||
// Only bundled CLI links have this absolute destination shape.
|
||||
if (!path.isAbsolute(destination) || path.basename(destination) !== name ||
|
||||
path.basename(bin) !== 'bin' || path.basename(path.dirname(bin)) !== 'agent-payload') {
|
||||
continue
|
||||
}
|
||||
|
||||
// Rename a staged link, never the payload command itself.
|
||||
const staged = `${target}.hermes-provision-${randomUUID()}`
|
||||
|
||||
io.symlinkSync(source, staged)
|
||||
|
||||
try {
|
||||
io.renameSync(staged, target)
|
||||
} catch (error) {
|
||||
try {
|
||||
io.unlinkSync(staged)
|
||||
} catch (cleanupError) {
|
||||
if ((cleanupError as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw new AggregateError([error, cleanupError], `${String(error)}; temporary link cleanup failed: ${String(cleanupError)}`)
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
linked += 1
|
||||
} catch (error) {
|
||||
log(`[payload] CLI PATH provision skipped for ${target}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (linked > 0) {
|
||||
log(`[payload] linked ${linked} CLI trampoline(s) into ${binDir}`)
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
} from './browser-windows'
|
||||
import { detectBundleSkew } from './bundle-skew'
|
||||
import { detectBundleSwap, readBundleSwapStamp } from './bundle-swap'
|
||||
import { provisionCliLinks } from './cli-provision'
|
||||
import { applyConnectionChange, sshQuitShouldBlock, teardownSshState } from './connection-apply'
|
||||
import {
|
||||
apiRequestRegistryConnectionId,
|
||||
@@ -279,7 +280,7 @@ import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition'
|
||||
import { listWindowsProcesses, reapPackageRootedProcesses } from './package-process-reap'
|
||||
import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-process-identity'
|
||||
import { bundledPayload, installIdForRoot, type PayloadInfo } from './payload-backend'
|
||||
import { bundledPayload, installIdForRoot } from './payload-backend'
|
||||
import { registerPetOverlayIpc } from './pet-overlay-ipc'
|
||||
import {
|
||||
pendingNotice as pendingPluginCompatNotice,
|
||||
@@ -4538,51 +4539,12 @@ function createActiveBackend(backendArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX bundled installs have no MSIX ExecutionAlias mechanism: put the
|
||||
* payload's CLI trampolines on the user's PATH by symlinking them into
|
||||
* ~/.local/bin (created on demand). First-run provision, but idempotent
|
||||
* and cheap enough to run on every bundled boot: an existing entry of any
|
||||
* kind is left alone, and every failure (no HOME, EPERM, EROFS...) is
|
||||
* silent — CLI-on-PATH must never block boot. Windows bundled installs do
|
||||
* NOT get this: the AppExecutionAlias is the mechanism there.
|
||||
*/
|
||||
function provisionPosixCliOnPath(payload: PayloadInfo): void {
|
||||
const names = Object.keys(payload.commands)
|
||||
|
||||
try {
|
||||
const binDir = path.join(os.homedir(), '.local', 'bin')
|
||||
fs.mkdirSync(binDir, { recursive: true })
|
||||
let linked = 0
|
||||
|
||||
for (const name of names) {
|
||||
const source = payload.commands[name]
|
||||
const target = path.join(binDir, name)
|
||||
|
||||
// lstat, not exists: a DANGLING symlink from a previous install (the
|
||||
// .app moved/uninstalled) is exactly the case we want to replace.
|
||||
if (fs.lstatSync(target, { throwIfNoEntry: false })) {
|
||||
continue
|
||||
}
|
||||
|
||||
fs.symlinkSync(source, target)
|
||||
linked += 1
|
||||
}
|
||||
|
||||
if (linked > 0) {
|
||||
rememberLog(`[payload] linked ${linked} CLI trampoline(s) into ${binDir}`)
|
||||
}
|
||||
} catch (error) {
|
||||
rememberLog(`[payload] CLI PATH provision skipped: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHermesBackend(backendArgs) {
|
||||
const payload = bundledPayload(process.resourcesPath)
|
||||
|
||||
if (payload) {
|
||||
if (!IS_WINDOWS) {
|
||||
provisionPosixCliOnPath(payload)
|
||||
provisionCliLinks(payload.commands, path.join(os.homedir(), '.local', 'bin'), rememberLog)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user