fix(desktop): run updater state.db pre-flight through the installation launcher
PM-managed checkouts retire the in-tree `venv` once a generation is committed, so `findPythonForRoot` returns null there and the desktop Update button refused every hand-off with: state.db pre-flight failed: Python not found. Update cancelled before backend shutdown. Update the selected installation with its hermes update command, then retry. The pre-flight was the only remaining update-path consumer of the checkout-venv resolution — the source check (`readSourceUpdate`) and the hand-off script (`scripts/desktop-update/runtime.ps1`) already resolve the installation launcher for managed checkouts. `preflightStateDb` now accepts the launcher and runs the emergency snapshot through it (`--run-module hermes_cli.backup_sqlite`, mirroring the update check), keeping the legacy `python -I -S <script>` shape for pre-PM installs and the update check's fail-closed ComSpec wrapping for a `.cmd` launcher. The composition resolves the launcher off the same `pm` marker as `readSourceUpdate` and surfaces a repair message before the backend stops when it is missing. Test: apps/desktop/electron/updater/state-db-preflight.test.ts gains a managed case that drives the launcher shape end-to-end (shim launcher -> snapshot published); the legacy-path cases are unchanged.
This commit is contained in:
@@ -523,6 +523,7 @@ import {
|
||||
} from './updater'
|
||||
import {
|
||||
observeUpdaterHandoff,
|
||||
resolveInstallationLauncher,
|
||||
resolveStagedUpdaterBinary,
|
||||
resolveVenvDir,
|
||||
spawnUpdaterProcess,
|
||||
@@ -3552,8 +3553,26 @@ function resolveCheckoutUpdateStrategy(): UpdaterStrategy {
|
||||
return
|
||||
}
|
||||
|
||||
// PM-managed checkouts carry no venv of their own: the installation
|
||||
// launcher owns interpreter and generation selection there — same
|
||||
// contract as readSourceUpdate and the hand-off script.
|
||||
const managed: boolean = directoryExists(path.join(root, 'pm'))
|
||||
const launcher: string | null = managed
|
||||
? resolveInstallationLauncher(root, IS_WINDOWS, HERMES_HOME)
|
||||
: null
|
||||
|
||||
if (managed && !launcher) {
|
||||
const message =
|
||||
`state.db pre-flight failed: the installation launcher under ${root} is missing. ` +
|
||||
'Update cancelled before backend shutdown. Repair this installation before retrying.'
|
||||
|
||||
log(`[updates] ${message}`)
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
preflightStateDb({
|
||||
python: await findPythonForRoot(root),
|
||||
python: managed ? null : await findPythonForRoot(root),
|
||||
launcher,
|
||||
script: path.join(root, 'hermes_cli', 'backup_sqlite.py'),
|
||||
home,
|
||||
log
|
||||
|
||||
@@ -87,6 +87,62 @@ with sqlite3.connect(sys.argv[1]) as c:
|
||||
}
|
||||
})
|
||||
|
||||
test('a managed installation runs the snapshot through the installation launcher', (): void => {
|
||||
const home: string = fs.mkdtempSync(path.join(os.tmpdir(), 'managed-preflight-'))
|
||||
const shims: string = fs.mkdtempSync(path.join(os.tmpdir(), 'launcher-shim-'))
|
||||
const python: string = process.env.HERMES_PYTHON || 'python3'
|
||||
const script: string = fileURLToPath(new URL('../../../../hermes_cli/backup_sqlite.py', import.meta.url))
|
||||
|
||||
// Stand-in for the installation launcher under `.hermes/bin`: it must accept
|
||||
// exactly what the runtime passes it — `--run-module hermes_cli.backup_sqlite
|
||||
// <home>` — and publish the snapshot like the real launcher does.
|
||||
const shim: string = path.join(shims, process.platform === 'win32' ? 'hermes.cmd' : 'hermes')
|
||||
fs.writeFileSync(
|
||||
shim,
|
||||
process.platform === 'win32'
|
||||
? `@echo off\r\n"${python}" -I -S "${script}" %3\r\n`
|
||||
: `#!/bin/sh\nexec "${python}" -I -S "${script}" "$3"\n`
|
||||
)
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(shim, 0o755)
|
||||
}
|
||||
|
||||
const logs: string[] = []
|
||||
|
||||
try {
|
||||
const created = spawnSync(
|
||||
python,
|
||||
[
|
||||
'-I',
|
||||
'-S',
|
||||
'-c',
|
||||
"import sqlite3, sys; c = sqlite3.connect(sys.argv[1]); c.execute('CREATE TABLE t (x)'); c.commit(); c.close()",
|
||||
path.join(home, 'state.db')
|
||||
],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
|
||||
assert.equal(created.status, 0, created.stderr)
|
||||
|
||||
preflightStateDb({
|
||||
python: null,
|
||||
launcher: shim,
|
||||
script,
|
||||
home,
|
||||
log: (message: string): void => {
|
||||
logs.push(message)
|
||||
}
|
||||
})
|
||||
|
||||
const backups: string[] = fs.readdirSync(home).filter((name: string): boolean => name.endsWith('.bak'))
|
||||
assert.equal(backups.length, 1, logs.join('\n'))
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
fs.rmSync(shims, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('an older selected checkout without the snapshot helper refuses before backend stop', (): void => {
|
||||
const oldRoot: string = fs.mkdtempSync(path.join(os.tmpdir(), 'old-preflight-'))
|
||||
let stopped = false
|
||||
|
||||
@@ -7,19 +7,49 @@ interface StateDbPreflight {
|
||||
script: string
|
||||
home: string
|
||||
log: (message: string) => void
|
||||
/**
|
||||
* The installation launcher of a PM-managed checkout (`.hermes/bin/hermes`).
|
||||
* A managed checkout carries no venv of its own — the launcher owns
|
||||
* interpreter and generation selection there — so the snapshot runs through
|
||||
* it exactly like the update check does (`readSourceUpdate`).
|
||||
*/
|
||||
launcher?: string | null
|
||||
}
|
||||
|
||||
// Synchronous by design: the caller must not stop the backend before the snapshot.
|
||||
export function preflightStateDb({ python, script, home, log }: StateDbPreflight): void {
|
||||
export function preflightStateDb({ python, script, home, log, launcher = null }: StateDbPreflight): void {
|
||||
try {
|
||||
if (!python) {
|
||||
const command: string | null = launcher ?? python
|
||||
|
||||
if (!command) {
|
||||
throw new Error('Python not found')
|
||||
}
|
||||
|
||||
const args: string[] = launcher
|
||||
? ['--run-module', 'hermes_cli.backup_sqlite', home]
|
||||
: ['-I', '-S', script, home]
|
||||
|
||||
// Node refuses direct .cmd execFile; an older published launcher can still
|
||||
// be one. Same fail-closed guard as the update check: shell:true would
|
||||
// interpolate untrusted paths, so keep cmd.exe's one unavoidable parse
|
||||
// closed instead.
|
||||
const viaCmd: boolean = process.platform === 'win32' && /\.cmd$/i.test(command)
|
||||
|
||||
if (viaCmd && [command, ...args].some((value: string): boolean => /["%&|<>^\r\n]/.test(value))) {
|
||||
throw new Error('The pre-flight snapshot contains an unsafe Windows command argument.')
|
||||
}
|
||||
|
||||
const result: string = execFileSync(
|
||||
python,
|
||||
['-I', '-S', script, home],
|
||||
hiddenWindowsChildOptions({ encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
viaCmd ? (process.env.ComSpec ?? 'cmd.exe') : command,
|
||||
viaCmd
|
||||
? ['/d', '/v:off', '/s', '/c', `""${command}" ${args.map((arg: string): string => `"${arg}"`).join(' ')}"`]
|
||||
: args,
|
||||
hiddenWindowsChildOptions({
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsVerbatimArguments: viaCmd
|
||||
})
|
||||
)
|
||||
|
||||
log(`[updates] state.db pre-flight: ${result.trim()}`)
|
||||
|
||||
Reference in New Issue
Block a user