Files
warp-engine-client/src/infrastructure/files/StoreFileSystem.ts
T
mr.zeroandClaude Opus 5 06f3f2a3b1
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
The store engine moves into the client, and Python goes with it
Reading the catalog, choosing the release that fits this machine, unpacking it,
writing the menu entry and remembering what went where all happen in process now.
There is no interpreter to find, no child process, and no JSON-lines protocol
between the two halves — `PythonEngineProcessRunner`, the runtime locator, the two
engine mappers and the version negotiation are all gone, and with them the one
unchecked cast this codebase had (engine stdout to a typed event).

What that buys a person: on Windows and on a fresh Mac the app simply works. It
used to look for `python3`, `python` and `py -3` and draw a link to python.org
where none answered.

What lands on disk is unchanged, deliberately. `config.json` and `state.json` keep
the shell engine's snake_case shape, its `<scope>:<name>` keys and its file modes,
so a machine whose library was installed by the CLI keeps it — verified against the
Python engine on the same catalog: the same 13-title listing with zero field
differences, byte-identical payloads, identical modes and an identical Info.plist,
and a re-sync over a Python-installed home that writes nothing. Remove, prune,
prune-suppression on a named sync and the v1 state migration were each exercised.

Three things worth knowing about the new code:

  - the zip reader is ~150 lines over `node:zlib`, because Node has none and this
    application has no runtime dependencies. It restores the executable bit from
    each entry's external attributes, without which nothing installed can start,
    and it refuses zip64, unknown compression and paths that escape the
    destination rather than guessing;

  - `SUPPORTED_WARP_ENGINE_VERSIONS` names the engine versions this client is
    written against, checked against the `WarpEngine-Version` header every
    response carries. `selectCatalogDialect` switches over that list exhaustively,
    so adding a version fails the build — type checker and linter both — until
    somebody says what its catalog reads like. An absent header is read as the
    oldest version, which is what an engine before 0.4.0 is;

  - refresh and the language picker are icons at the foot of the side menu now,
    both named for a tooltip and a screen reader, the picker still a real
    `<select>` under its glyph.

The repository is free of Python as well: the Makefile, the CI check and the
release script read package.json and the forge's JSON with Node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:36:25 +02:00

195 lines
7.0 KiB
TypeScript

import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
/**
* The mode an atomic write lands on when the caller names none.
*
* The shell engine's writes went through `mkstemp`, which creates at 0600, and its
* `state.json` and box art carry that mode on every machine this store has ever run
* on. Matching it keeps "the same files, in the same shape" literally true — and for
* a state file that records what is installed under someone's home directory, the
* more private of the two defaults is the better one anyway.
*/
const PRIVATE_FILE_MODE = 0o600
/**
* The filesystem rules a store writes by.
*
* Three of them are safety rather than taste, and they are the reason this is one
* class instead of scattered `fs` calls:
*
* - **nothing is written in place.** A store interrupted mid-sync would otherwise
* leave a half-written menu entry, which is worse than an old one;
* - **every delete is guarded by `within`.** A store may only remove files from
* the subtree it owns, never from the user's own library;
* - **only empty directories are pruned.** One surprise file is enough to keep a
* directory.
*/
export class StoreFileSystem {
public constructor (
private readonly tag: string,
private readonly log: (line: string) => void
) {}
public readJson (filePath: string): unknown {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
} catch (error: unknown) {
if (isMissingFile(error)) return null
this.log(`warning: cannot read ${filePath}: ${describe(error)}`)
return null
}
}
public writeJson (filePath: string, data: unknown): void {
this.writeAtomic(filePath, Buffer.from(`${JSON.stringify(data, null, 2)}\n`, 'utf8'))
}
/** Write bytes via a temp file in the same directory, then rename over the target. */
public writeAtomic (filePath: string, blob: Buffer, mode: number = PRIVATE_FILE_MODE): string {
const directory = path.dirname(filePath)
fs.mkdirSync(directory, { recursive: true })
const temporary = path.join(directory, `.${this.tag}-${process.pid.toString(36)}-${counter()}.tmp`)
try {
fs.writeFileSync(temporary, blob, { mode })
// The mode is set again: `writeFileSync` applies the umask to it, and a launcher
// that is not executable is not a launcher.
fs.chmodSync(temporary, mode)
fs.renameSync(temporary, filePath)
} catch (error: unknown) {
fs.rmSync(temporary, { force: true })
throw error
}
return filePath
}
/**
* Remove a file, a symlink or a directory tree — but only inside `root`.
*
* Returns whether anything went. A symlink is unlinked rather than followed: on
* macOS a menu entry may be a link to the archive's own `.app`, and removing the
* store's entry must not touch what it points at.
*/
public removeWithin (target: string, root: string): boolean {
if (!within(target, root)) {
this.log(`warning: refusing to delete ${target} (outside ${root})`)
return false
}
const stats = statOrNull(target)
if (stats === null) return false
if (stats.isDirectory() && !stats.isSymbolicLink()) {
fs.rmSync(target, { recursive: true, force: true })
} else {
fs.rmSync(target, { force: true })
}
return true
}
/**
* Remove those of `directories` that are now empty, deepest first.
*
* A store that has uninstalled everything should not leave its folders behind.
*/
public pruneEmptyDirectories (directories: readonly string[], root: string): void {
const ordered = [...new Set(directories.filter((entry: string): boolean => entry.length > 0)
.map((entry: string): string => path.resolve(entry)))]
.sort((left: string, right: string): number => depth(right) - depth(left))
for (const directory of ordered) {
if (!within(directory, root)) {
this.log(`warning: refusing to remove ${directory} (outside ${root})`)
continue
}
const stats = statOrNull(directory)
if (stats?.isDirectory() !== true) continue
if (fs.readdirSync(directory).length > 0) continue
try {
fs.rmdirSync(directory)
} catch {
// A directory that will not go is not a failure worth reporting: the next
// sync finds it again, and an uninstall has already done its real work.
}
}
}
public temporaryPath (directory: string, label: string, suffix: string): string {
return path.join(directory, `.${this.tag}-${label}${suffix}`)
}
}
/**
* True when `target` is `root` or sits inside it.
*
* Every delete a store performs is guarded by this.
*/
export function within (target: string, root: string): boolean {
if (target.length === 0 || root.length === 0) return false
const resolvedTarget = path.resolve(target)
const resolvedRoot = path.resolve(root)
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + path.sep)
}
/** `$XDG_DATA_HOME|~/.local/share/applications` or a plain `~`-path, resolved. */
export function expandPathSpecification (specification: string | null): string | null {
if (specification === null || specification.length === 0) return null
let remaining = specification
if (remaining.startsWith('$')) {
const [variable, fallback] = splitOnce(remaining.slice(1), '|')
// `$XDG_DATA_HOME|~/.local/share/applications` — the tail after the fallback's
// own prefix is what gets appended to the variable.
const [name, tail] = splitOnce(variable, '/')
const value = process.env[name]
if (value !== undefined && value.length > 0) {
const base = expandHome(value)
return path.resolve(tail.length > 0 ? path.join(base, tail) : base)
}
remaining = fallback
}
if (remaining.length === 0) return null
return path.resolve(expandVariables(expandHome(remaining)))
}
export function expandHome (value: string): string {
return value === '~' || value.startsWith(`~${path.sep}`) || value.startsWith('~/')
? path.join(os.homedir(), value.slice(2))
: value
}
function expandVariables (value: string): string {
return value.replace(/\$(\w+)|\$\{(\w+)\}/g, (match: string, bare: string | undefined, braced: string | undefined): string =>
process.env[bare ?? braced ?? ''] ?? match)
}
function splitOnce (value: string, separator: string): readonly [string, string] {
const index = value.indexOf(separator)
return index < 0 ? [value, ''] : [value.slice(0, index), value.slice(index + separator.length)]
}
function statOrNull (target: string): fs.Stats | null {
try {
return fs.lstatSync(target)
} catch {
return null
}
}
function depth (value: string): number {
return value.split(path.sep).length
}
function isMissingFile (error: unknown): boolean {
return typeof error === 'object' && error !== null &&
(error as { readonly code?: unknown }).code === 'ENOENT'
}
export function describe (error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
let sequence = 0
function counter (): string {
sequence += 1
return sequence.toString(36)
}