agent-desktop/npm/scripts/postinstall.js
Lahfir 3f322728b4
feat!: implement Playwright-grade foundation contract
Settle the Playwright-grade reliability contract in agent-desktop-core
before the Windows/Linux adapters are built, so they inherit it instead
of redesigning it. Every command now observes, waits, verifies, and
reports honestly instead of firing blindly.

Highlights: capability-supertrait split of PlatformAdapter with
not_supported() defaults; canonical role/state vocabulary with live
`is --property visible`; display enumeration (`list-displays`) and honest
`--screen` with scale factor; truthful Automation permission; `native_id`
identity spine; window-id-first resolution; serializable `LocatorQuery`
with live `find`; default-on auto-wait before every ref action; three-way
`hit_test` occlusion gate; `scroll_into_view` in core; core accessible-name
precedence; typed `ActionStep` delivery tier; `ProcessState` and
`APP_UNRESPONSIVE`; `LaunchOptions`; baseline-diff desktop signals
(`wait --event`); typed clipboard (`Text`/`Image`/`FileUrls`); mouse
modifier chords and `mouse-wheel`. Hardened through a 35-reviewer pass with
independent validation and a green live e2e gate (109/0), plus a
head-vs-main performance comparison harness.

BREAKING CHANGE: default-on auto-wait changes the timing of every
previously-untouched ref-action call (bounded 5000 ms default; `--timeout-ms 0`
restores single-shot). `ENVELOPE_VERSION` is now `2.1` (adds the
`APP_UNRESPONSIVE` code and process state in error details). FFI ABI major
is `3` (append-only struct evolution; `wait --event` is intentionally not
exposed over FFI). The legacy string clipboard API is removed in favor of
typed content. `key-down`/`key-up` fail closed until daemon-owned held input
exists. `close-app` verifies termination and the osascript fallback path is
removed. `--text` matching is subtree containment: `find --text X --first`
returns the outermost matching container.
2026-07-20 00:21:38 -07:00

323 lines
9.6 KiB
JavaScript

#!/usr/bin/env node
const {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
renameSync,
symlinkSync,
unlinkSync,
writeFileSync,
} = require('fs');
const { dirname, isAbsolute, join } = require('path');
const { platform, arch } = require('os');
const { execFileSync } = require('child_process');
const { createHash } = require('crypto');
const projectRoot = join(__dirname, '..');
const binDir = join(projectRoot, 'bin');
const packageJson = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
const version = packageJson.version;
const GITHUB_REPO = 'lahfir/agent-desktop';
const MACOS_HELPER_NAME = 'agent-desktop-macos-helper';
const MACOS_HELPER_PATH_ENV = 'AGENT_DESKTOP_MACOS_HELPER_PATH';
const TARGET_MAP = {
'darwin-arm64': 'aarch64-apple-darwin',
'darwin-x64': 'x86_64-apple-darwin',
'linux-x64': 'x86_64-unknown-linux-gnu',
'linux-arm64': 'aarch64-unknown-linux-gnu',
'win32-x64': 'x86_64-pc-windows-msvc',
};
const BINARY_NAME_MAP = {
'darwin-arm64': 'agent-desktop-darwin-arm64',
'darwin-x64': 'agent-desktop-darwin-x64',
'linux-x64': 'agent-desktop-linux-x64',
'linux-arm64': 'agent-desktop-linux-arm64',
'win32-x64': 'agent-desktop-win32-x64.exe',
};
const SUPPORTED_PLATFORMS = ['darwin'];
function log(msg) {
process.stderr.write(`agent-desktop: ${msg}\n`);
}
function trashRecoverably(path, trashCommand = 'trash') {
try {
execFileSync(trashCommand, [path], { stdio: 'pipe', timeout: 30000 });
} catch (err) {
if (!existsSync(path)) return;
const reason = err.code === 'ENOENT'
? `trash command is unavailable: ${trashCommand}`
: `trash exited with status ${err.status ?? 'unknown'}`;
log(`Could not move cleanup artifact to Trash; retained at ${path}: ${reason}`);
}
}
function getPlatformKey() {
return `${platform()}-${arch()}`;
}
function download(url, dest) {
const tmpDest = dest + '.tmp';
try {
execFileSync('curl', ['-fsSL', '--retry', '3', '--retry-delay', '2', '-o', tmpDest, url], {
stdio: 'pipe',
timeout: 60000,
});
renameSync(tmpDest, dest);
} catch (err) {
try { unlinkSync(tmpDest); } catch {}
throw new Error(`Failed to download ${url}: ${err.message}`);
}
}
function verifyChecksum(filePath, expectedHash) {
return sha256(filePath) === expectedHash.toLowerCase();
}
function sha256(filePath) {
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
}
function checksumFor(contents, fileName) {
for (const line of contents.split('\n')) {
const match = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
if (match && match[2] === fileName) {
return match[1].toLowerCase();
}
}
throw new Error(`Checksum entry missing for ${fileName}`);
}
function installExecutable(source, destination) {
const temporary = `${destination}.install-${process.pid}`;
try {
writeFileSync(temporary, readFileSync(source), { mode: 0o755 });
chmodSync(temporary, 0o755);
if (sha256(source) !== sha256(temporary)) {
throw new Error(`Executable copy verification failed for ${destination}`);
}
renameSync(temporary, destination);
} finally {
try { unlinkSync(temporary); } catch {}
}
}
function customHelperPath(customBinaryPath) {
const override = process.env[MACOS_HELPER_PATH_ENV];
if (override) {
if (!isAbsolute(override)) {
throw new Error(`${MACOS_HELPER_PATH_ENV} must be an absolute path`);
}
return override;
}
return join(dirname(customBinaryPath), MACOS_HELPER_NAME);
}
function validateArchive(tarballPath) {
const listing = execFileSync('tar', ['-tzf', tarballPath], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 30000,
})
.split('\n')
.filter(Boolean)
.sort();
const expected = ['agent-desktop', MACOS_HELPER_NAME].sort();
if (JSON.stringify(listing) !== JSON.stringify(expected)) {
throw new Error(`Release archive has unexpected entries: ${listing.join(', ')}`);
}
}
function installArchive(tarballPath, binaryPath, helperPath, trashCommand = 'trash') {
validateArchive(tarballPath);
const staging = mkdtempSync(join(binDir, '.extract-'));
try {
execFileSync('tar', ['-xzf', tarballPath, '-C', staging], {
stdio: 'pipe',
timeout: 30000,
});
const entries = readdirSync(staging).sort();
const expected = ['agent-desktop', MACOS_HELPER_NAME].sort();
if (JSON.stringify(entries) !== JSON.stringify(expected)) {
throw new Error(`Extracted archive has unexpected entries: ${entries.join(', ')}`);
}
const extractedBinary = join(staging, 'agent-desktop');
const extractedHelper = join(staging, MACOS_HELPER_NAME);
if (!lstatSync(extractedBinary).isFile() || !lstatSync(extractedHelper).isFile()) {
throw new Error('Release archive executables must be regular files');
}
installExecutable(extractedHelper, helperPath);
installExecutable(extractedBinary, binaryPath);
} finally {
trashRecoverably(staging, trashCommand);
}
}
function fixGlobalInstallBin() {
if (platform() === 'win32') return;
let npmBinDir;
try {
const prefix = execFileSync('npm', ['prefix', '-g'], { encoding: 'utf8', timeout: 5000 }).trim();
npmBinDir = join(prefix, 'bin');
} catch {
return;
}
const symlinkPath = join(npmBinDir, 'agent-desktop');
const platformKey = getPlatformKey();
const binaryName = BINARY_NAME_MAP[platformKey];
if (!binaryName) return;
const binaryPath = join(binDir, binaryName);
try {
const stat = lstatSync(symlinkPath);
if (!stat.isSymbolicLink()) return;
} catch {
return;
}
try {
unlinkSync(symlinkPath);
symlinkSync(binaryPath, symlinkPath);
log('Optimized: symlink points to native binary (zero overhead)');
} catch (err) {
log(`Could not optimize symlink: ${err.message}`);
}
}
function promptSkillInstall() {
const platformSkill = {
darwin: 'agent-desktop-macos',
win32: 'agent-desktop-windows',
linux: 'agent-desktop-linux',
}[platform()];
log('');
log('Claude Code skills available for agent-desktop!');
log('Install with:');
log(' claude mcp add-skill lahfir/agent-desktop');
if (platformSkill) {
log(` claude mcp add-skill lahfir/${platformSkill}`);
}
log('');
}
function main() {
if (process.env.AGENT_DESKTOP_SKIP_DOWNLOAD === '1') {
log('Skipping binary download (AGENT_DESKTOP_SKIP_DOWNLOAD=1)');
return;
}
const platformKey = getPlatformKey();
const target = TARGET_MAP[platformKey];
const binaryName = BINARY_NAME_MAP[platformKey];
if (!SUPPORTED_PLATFORMS.includes(platform()) || !target || !binaryName) {
log('agent-desktop currently supports macOS only.');
log('Windows and Linux support is coming in Phase 2.');
log(`See: https://github.com/${GITHUB_REPO}`);
return;
}
const binaryPath = join(binDir, binaryName);
const helperPath = join(binDir, MACOS_HELPER_NAME);
if (!existsSync(binDir)) {
mkdirSync(binDir, { recursive: true });
}
if (process.env.AGENT_DESKTOP_BINARY_PATH) {
const customPath = process.env.AGENT_DESKTOP_BINARY_PATH;
try {
if (!existsSync(customPath) || !lstatSync(customPath).isFile()) {
throw new Error(`binary is not a regular file: ${customPath}`);
}
const sourceHelper = customHelperPath(customPath);
if (!existsSync(sourceHelper) || !lstatSync(sourceHelper).isFile()) {
throw new Error(`macOS helper not found at ${sourceHelper}`);
}
installExecutable(sourceHelper, helperPath);
installExecutable(customPath, binaryPath);
log(`Using binary from AGENT_DESKTOP_BINARY_PATH: ${customPath}`);
fixGlobalInstallBin();
promptSkillInstall();
} catch (err) {
log(`Failed to install from AGENT_DESKTOP_BINARY_PATH: ${err.message}`);
process.exitCode = 1;
}
return;
}
if (existsSync(binaryPath) && existsSync(helperPath)) {
chmodSync(binaryPath, 0o755);
chmodSync(helperPath, 0o755);
log(`Native executables ready: ${binaryName}, ${MACOS_HELPER_NAME}`);
fixGlobalInstallBin();
promptSkillInstall();
return;
}
const tarball = `agent-desktop-v${version}-${target}.tar.gz`;
const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${version}`;
const tarballUrl = `${baseUrl}/${tarball}`;
const checksumsUrl = `${baseUrl}/checksums.txt`;
const tarballPath = join(binDir, tarball);
const checksumsPath = join(binDir, 'checksums.txt');
log(`Downloading native binary for ${platformKey}...`);
try {
download(tarballUrl, tarballPath);
download(checksumsUrl, checksumsPath);
const checksums = readFileSync(checksumsPath, 'utf8');
const expectedHash = checksumFor(checksums, tarball);
if (!verifyChecksum(tarballPath, expectedHash)) {
throw new Error('Checksum verification failed');
}
unlinkSync(checksumsPath);
log('Checksum verified');
installArchive(tarballPath, binaryPath, helperPath);
unlinkSync(tarballPath);
log(`Installed native executables: ${binaryName}, ${MACOS_HELPER_NAME}`);
} catch (err) {
log(`Could not download native binary: ${err.message}`);
log('');
log('Download manually from:');
log(` ${tarballUrl}`);
log(`Then place agent-desktop at: ${binaryPath}`);
log(`And ${MACOS_HELPER_NAME} at: ${helperPath}`);
try { if (existsSync(tarballPath)) unlinkSync(tarballPath); } catch {}
try { if (existsSync(checksumsPath)) unlinkSync(checksumsPath); } catch {}
process.exitCode = 1;
return;
}
fixGlobalInstallBin();
promptSkillInstall();
}
if (require.main === module) {
main();
}
module.exports = {
checksumFor,
customHelperPath,
installArchive,
trashRecoverably,
validateArchive,
};