diff --git a/packages/cli/README.md b/packages/cli/README.md index f1167857..da2da219 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -718,6 +718,12 @@ Arguments: | --- | --- | --- | | `name` | no | Target name. Defaults to the selected target. | +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--headless` | boolean | Repair and launch an existing Desktop profile in a headless Linux session | + Examples: ```sh diff --git a/packages/cli/src/commands/targets/start.ts b/packages/cli/src/commands/targets/start.ts index e51ca9b8..4489f906 100644 --- a/packages/cli/src/commands/targets/start.ts +++ b/packages/cli/src/commands/targets/start.ts @@ -1,4 +1,4 @@ -import { Args } from '@oclif/core' +import { Args, Flags } from '@oclif/core' import { BeeperCommand, ensureWritable } from '../../lib/command.js' import { customTargetID, readTarget, resolveTarget } from '../../lib/targets.js' import { launchDesktopApp, startProfile } from '../../lib/profiles.js' @@ -7,13 +7,17 @@ import { printSuccess } from '../../lib/output.js' export default class TargetsStart extends BeeperCommand { static override summary = 'Start a local Server target or open Beeper Desktop' static override args = { name: Args.string({ required: false, description: 'Target name. Defaults to the selected target.' }) } + static override flags = { + headless: Flags.boolean({ default: false, description: 'Repair and launch an existing Desktop profile in a headless Linux session' }), + } async run(): Promise { const { args, flags } = await this.parse(TargetsStart) ensureWritable(flags) const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] }) if (!target) throw new Error(`Unknown Beeper target "${args.name}".`) if (target.type === 'desktop' && target.id !== customTargetID) { - const result = await launchDesktopApp(target.managed ? target : undefined) + const launchTarget = flags.headless ? { ...target, headless: true } : target.managed ? target : undefined + const result = await launchDesktopApp(launchTarget) await printSuccess({ message: 'Opened Beeper Desktop', detail: target.baseURL, data: { target, result } }, flags.json ? 'json' : 'human') return } diff --git a/packages/cli/src/lib/installations.ts b/packages/cli/src/lib/installations.ts index 19abe049..8938747b 100644 --- a/packages/cli/src/lib/installations.ts +++ b/packages/cli/src/lib/installations.ts @@ -273,6 +273,13 @@ async function extractDesktopArtifact(artifactPath: string, destinationDir: stri return finalPath } + if (process.platform === 'linux') { + const finalPath = join(destinationDir, basename(artifactPath)) + if (artifactPath !== finalPath) await rename(artifactPath, finalPath) + await chmod(finalPath, 0o755) + return finalPath + } + return artifactPath } diff --git a/packages/cli/src/lib/profiles.ts b/packages/cli/src/lib/profiles.ts index 6599bcad..c623323b 100644 --- a/packages/cli/src/lib/profiles.ts +++ b/packages/cli/src/lib/profiles.ts @@ -1,12 +1,13 @@ import { spawn } from 'node:child_process' import { execFile } from 'node:child_process' import { closeSync, openSync } from 'node:fs' -import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' +import { Database } from 'bun:sqlite' import { beeperDir, type Target } from './targets.js' -import { readInstallations } from './installations.js' +import { desktopInstallDir, readInstallations } from './installations.js' import { usageError } from './errors.js' const execFileAsync = promisify(execFile) @@ -54,11 +55,11 @@ export async function startProfile(target: Target): Promise { const installations = await readInstallations().catch(() => ({ desktop: undefined })) - const appPath = installations.desktop?.path ?? await findDesktopAppPath() - const args = appPath ? ['-n', appPath, '--args'] : ['-n', '-a', 'Beeper', '--args'] - args.push('--no-enforce-app-location') - if (target?.port) args.push(`--pas-port=${target.port}`) - if (target?.serverEnv) args.push(`--server-env=${target.serverEnv}`) + const appPath = installations.desktop?.path && await isBeeperDesktopApp(installations.desktop.path) + ? installations.desktop.path + : await findDesktopAppPath() + const headlessDataDir = target?.headless ? target.dataDir ?? defaultDesktopDataDir(target.profile) : undefined + const startupState = headlessDataDir ? await ensureStartupThreadIDs(headlessDataDir) : undefined const env = target?.dataDir ? { ...process.env, @@ -67,10 +68,103 @@ export async function launchDesktopApp(target?: Target): Promise<{ id: string; s BEEPER_USER_DATA_DIR: target.dataDir, } : process.env - spawn('open', args, { detached: true, stdio: 'ignore', env }).unref() + + let child = spawnDesktopProcess(appPath, target, env) + if (process.platform === 'linux' && headlessDataDir && (startupState === 'missing-database' || startupState === 'missing-schema')) { + const state = await waitForStartupThreadIDs(headlessDataDir, 15_000) + if (state === 'repaired') { + await stopDesktopProcess(child) + child = spawnDesktopProcess(appPath, target, env) + } else if (state !== 'valid') { + await stopDesktopProcess(child) + throw new Error('Desktop profile database did not become ready for headless startup repair. Try again after Desktop finishes initializing the profile.') + } + } + child.unref() return { id: target?.id ?? 'desktop', startedAt: new Date().toISOString() } } +function spawnDesktopProcess(appPath: string | undefined, target: Target | undefined, env: NodeJS.ProcessEnv): ReturnType { + if (process.platform === 'darwin') { + const args = appPath ? ['-n', appPath, '--args'] : ['-n', '-a', 'Beeper', '--args'] + args.push(...desktopLaunchArgs(target)) + return spawn('open', args, { detached: true, stdio: 'ignore', env }) + } + if (process.platform === 'linux' || process.platform === 'win32') { + if (!appPath) throw new Error('Beeper Desktop was not found. Install Beeper Desktop and try again.') + return spawn(appPath, desktopLaunchArgs(target), { detached: true, stdio: 'ignore', env }) + } + throw new Error(`Beeper Desktop launch is not supported on ${process.platform}.`) +} + +function desktopLaunchArgs(target?: Target): string[] { + const args = ['--no-enforce-app-location'] + if (target?.port) args.push(`--pas-port=${target.port}`) + if (target?.serverEnv) args.push(`--server-env=${target.serverEnv}`) + if (target?.headless) args.push('--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader') + return args +} + +type StartupThreadIDsState = 'missing-database' | 'missing-schema' | 'valid' | 'repaired' + +async function ensureStartupThreadIDs(dataDir: string): Promise { + const dbPath = join(dataDir, 'index.db') + if (!await pathExists(dbPath)) return 'missing-database' + const db = new Database(dbPath) + try { + const table = db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'key_values' LIMIT 1").get() + if (!table) return 'missing-schema' + const row = db.query("SELECT value FROM key_values WHERE key = 'startupThreadIDs' LIMIT 1").get() as { value?: string } | null + if (row?.value) { + try { + if (Array.isArray(JSON.parse(row.value))) return 'valid' + } catch { + // Replace malformed JSON with the same empty-list fallback used for a missing row. + } + } + db.run(`INSERT INTO key_values (key, value) VALUES ('startupThreadIDs', '[]') + ON CONFLICT(key) DO UPDATE SET value = excluded.value`) + return 'repaired' + } finally { + db.close() + } +} + +async function waitForStartupThreadIDs(dataDir: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + const state = await ensureStartupThreadIDs(dataDir) + if (state === 'valid' || state === 'repaired') return state + } catch { + // Desktop can hold a short SQLite write lock while it creates the profile schema. + } + await Bun.sleep(50) + } + return undefined +} + +async function stopDesktopProcess(child: ReturnType): Promise { + if (!child.pid) return + const processGroup = -child.pid + try { + process.kill(processGroup, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return + throw error + } + for (let attempt = 0; attempt < 20; attempt++) { + await Bun.sleep(100) + try { + process.kill(processGroup, 0) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return + throw error + } + } + process.kill(processGroup, 'SIGKILL') +} + export async function findDesktopAppPath(): Promise { const installations = await readInstallations().catch(() => ({ desktop: undefined })) if (installations.desktop?.path && await isBeeperDesktopApp(installations.desktop.path)) return installations.desktop.path @@ -96,6 +190,12 @@ export async function findDesktopAppPath(): Promise { } if (process.platform === 'linux') { + const installedDirEntries = await readdir(desktopInstallDir()).catch(() => []) + for (const entry of installedDirEntries.sort()) { + if (!entry.toLowerCase().includes('beeper')) continue + const path = join(desktopInstallDir(), entry) + if (await isBeeperDesktopApp(path)) return path + } for (const path of ['/usr/bin/beeper', '/usr/local/bin/beeper']) { if (await pathExists(path)) return path } diff --git a/packages/cli/src/lib/targets.ts b/packages/cli/src/lib/targets.ts index e16730c5..db406431 100644 --- a/packages/cli/src/lib/targets.ts +++ b/packages/cli/src/lib/targets.ts @@ -31,6 +31,7 @@ export type Target = { } serverEnv?: string port?: number + headless?: boolean } export type ManagedTargetType = 'desktop' | 'server' diff --git a/packages/cli/test/headless-desktop-launch.test.ts b/packages/cli/test/headless-desktop-launch.test.ts new file mode 100644 index 00000000..2dda87f8 --- /dev/null +++ b/packages/cli/test/headless-desktop-launch.test.ts @@ -0,0 +1,179 @@ +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' +import { launchDesktopApp } from '../src/lib/profiles.js' +import { type Target } from '../src/lib/targets.js' + +let root: string +let oldConfigDir: string | undefined +let oldCapture: string | undefined +let oldPath: string | undefined + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'beeper-headless-launch-')) + oldConfigDir = process.env.BEEPER_CLI_CONFIG_DIR + oldCapture = process.env.BEEPER_LAUNCH_CAPTURE + oldPath = process.env.PATH + process.env.BEEPER_CLI_CONFIG_DIR = join(root, 'config') + process.env.BEEPER_LAUNCH_CAPTURE = join(root, 'launch.txt') + + const binDir = join(root, 'bin') + mkdirSync(binDir, { recursive: true }) + const captureScript = '#!/bin/sh\nprintf "%s\\n" "$@" > "$BEEPER_LAUNCH_CAPTURE"\n' + for (const name of ['open', 'beeper-desktop']) { + const path = join(binDir, name) + writeFileSync(path, captureScript) + chmodSync(path, 0o755) + } + process.env.PATH = `${binDir}:${oldPath}` + + mkdirSync(process.env.BEEPER_CLI_CONFIG_DIR, { recursive: true }) + writeFileSync( + join(process.env.BEEPER_CLI_CONFIG_DIR, 'installations.json'), + JSON.stringify({ + desktop: { path: join(binDir, 'beeper-desktop') }, + }), + ) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) + restoreEnv('BEEPER_CLI_CONFIG_DIR', oldConfigDir) + restoreEnv('BEEPER_LAUNCH_CAPTURE', oldCapture) + restoreEnv('PATH', oldPath) +}) + +describe('headless Desktop launch', () => { + it('seeds a missing startupThreadIDs value before launch', async () => { + const target = headlessTarget() + mkdirSync(target.dataDir!, { recursive: true }) + withDatabase(target, (db) => db.run('CREATE TABLE key_values (key TEXT PRIMARY KEY, value TEXT NOT NULL)')) + + await launchDesktopApp(target) + + const value = readStartupThreadIDs(target) + expect(value).toBe('[]') + }) + + it('repairs a non-array startupThreadIDs value before launch', async () => { + const target = headlessTarget() + mkdirSync(target.dataDir!, { recursive: true }) + withDatabase(target, (db) => { + db.run('CREATE TABLE key_values (key TEXT PRIMARY KEY, value TEXT NOT NULL)') + db.run("INSERT INTO key_values VALUES ('startupThreadIDs', 'null')") + }) + + await launchDesktopApp(target) + + const value = readStartupThreadIDs(target) + expect(value).toBe('[]') + }) + + it('preserves an existing startupThreadIDs array', async () => { + const target = headlessTarget() + mkdirSync(target.dataDir!, { recursive: true }) + withDatabase(target, (db) => { + db.run('CREATE TABLE key_values (key TEXT PRIMARY KEY, value TEXT NOT NULL)') + db.run(`INSERT INTO key_values VALUES ('startupThreadIDs', '["thread-1"]')`) + }) + + await launchDesktopApp(target) + + const value = readStartupThreadIDs(target) + expect(value).toBe('["thread-1"]') + }) + + it('repairs and restarts once when the first launch creates a fresh database', async () => { + const target = headlessTarget() + const appPath = join(root, 'bin', 'fresh-headless-desktop') + writeFileSync(appPath, `#!/usr/bin/env bun +import { appendFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { Database } from 'bun:sqlite' + +mkdirSync(process.env.BEEPER_USER_DATA_DIR, { recursive: true }) +const db = new Database(join(process.env.BEEPER_USER_DATA_DIR, 'index.db')) +db.run('CREATE TABLE IF NOT EXISTS key_values (key TEXT PRIMARY KEY, value TEXT NOT NULL)') +const row = db.query("SELECT value FROM key_values WHERE key = 'startupThreadIDs'").get() +appendFileSync(process.env.BEEPER_LAUNCH_CAPTURE, (row?.value ?? 'missing') + '\\n') +db.close() +if (!row?.value) await Bun.sleep(30_000) +`) + chmodSync(appPath, 0o755) + writeFileSync(join(process.env.BEEPER_CLI_CONFIG_DIR!, 'installations.json'), JSON.stringify({ desktop: { path: appPath } })) + + await launchDesktopApp(target) + + expect(await waitForCapture(process.env.BEEPER_LAUNCH_CAPTURE!, 2)).toEqual(['missing', '[]']) + expect(readStartupThreadIDs(target)).toBe('[]') + }) + + it('uses SwiftShader flags instead of disabling GPU access', async () => { + const target = headlessTarget() + mkdirSync(target.dataDir!, { recursive: true }) + withDatabase(target, db => { + db.run('CREATE TABLE key_values (key TEXT PRIMARY KEY, value TEXT NOT NULL)') + db.run("INSERT INTO key_values VALUES ('startupThreadIDs', '[]')") + }) + + await launchDesktopApp(target) + const args = await waitForCapture(process.env.BEEPER_LAUNCH_CAPTURE!) + + expect(args).toContain('--use-gl=angle') + expect(args).toContain('--use-angle=swiftshader') + expect(args).toContain('--enable-unsafe-swiftshader') + expect(args).not.toContain('--disable-gpu') + expect(args).not.toContain('--disable-software-rasterizer') + }) +}) + +function headlessTarget(): Target & { headless: true } { + return { + id: 'headless', + type: 'desktop', + baseURL: 'http://127.0.0.1:23374', + managed: true, + dataDir: join(root, 'profile'), + profile: 'headless', + port: 23_374, + serverEnv: 'production', + headless: true, + } +} + +function withDatabase(target: Target, callback: (db: Database) => void): void { + const db = new Database(join(target.dataDir!, 'index.db')) + try { + callback(db) + } finally { + db.close() + } +} + +function readStartupThreadIDs(target: Target): string { + let value = '' + withDatabase(target, (db) => { + value = (db.query("SELECT value FROM key_values WHERE key = 'startupThreadIDs'").get() as { value: string }).value + }) + return value +} + +async function waitForCapture(path: string, minimumLines = 1): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + try { + const lines = readFileSync(path, 'utf8').trim().split('\n') + if (lines.length >= minimumLines) return lines + } catch { + // The detached process has not created its capture file yet. + } + await Bun.sleep(10) + } + throw new Error('Desktop launch was not captured') +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) delete process.env[key] + else process.env[key] = value +} diff --git a/packages/cli/test/profiles-linux.test.ts b/packages/cli/test/profiles-linux.test.ts new file mode 100644 index 00000000..b3343617 --- /dev/null +++ b/packages/cli/test/profiles-linux.test.ts @@ -0,0 +1,131 @@ +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { desktopInstallDir, writeInstallations, type Installation } from '../src/lib/installations.js' +import { findDesktopAppPath, launchDesktopApp } from '../src/lib/profiles.js' + +const originalPath = process.env.PATH +const originalConfigDir = process.env.BEEPER_CLI_CONFIG_DIR +const originalCapture = process.env.BEEPER_TEST_CAPTURE +const originalOpenCapture = process.env.BEEPER_TEST_OPEN_CAPTURE + +let tempDir: string + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'beeper-cli-linux-profile-')) + process.env.BEEPER_CLI_CONFIG_DIR = join(tempDir, 'config') + process.env.PATH = join(tempDir, 'bin') + ':' + (originalPath ?? '') +}) + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + restoreEnvironment('PATH', originalPath) + restoreEnvironment('BEEPER_CLI_CONFIG_DIR', originalConfigDir) + restoreEnvironment('BEEPER_TEST_CAPTURE', originalCapture) + restoreEnvironment('BEEPER_TEST_OPEN_CAPTURE', originalOpenCapture) +}) + +describe('Linux Desktop profiles', () => { + it('launches an installed executable directly with the profile environment and arguments', async () => { + const appPath = join(tempDir, 'Beeper') + const capturePath = join(tempDir, 'app-capture') + const openCapturePath = join(tempDir, 'open-capture') + const openPath = join(tempDir, 'bin', 'open') + const dataDir = join(tempDir, 'profile-data') + await mkdir(dirname(appPath), { recursive: true }) + await mkdir(dirname(openPath), { recursive: true }) + await writeCaptureScript(appPath) + await writeOpenScript(openPath) + process.env.BEEPER_TEST_CAPTURE = capturePath + process.env.BEEPER_TEST_OPEN_CAPTURE = openCapturePath + const installation: Installation = { + kind: 'desktop', + channel: 'stable', + serverEnv: 'production', + bundleID: 'com.automattic.beeper.desktop', + path: appPath, + feedURL: 'https://api.beeper.com/desktop/update-feed.json', + downloadURL: 'https://api.beeper.com/desktop/download/linux/x64/stable/com.automattic.beeper.desktop', + installedAt: '2026-08-09T00:00:00.000Z', + updatedAt: '2026-08-09T00:00:00.000Z', + } + await writeInstallations({ desktop: installation }) + + await launchDesktopApp({ + id: 'linux-profile', + type: 'desktop', + baseURL: 'http://127.0.0.1:23456', + managed: true, + profile: 'linux-profile', + dataDir, + port: 23456, + serverEnv: 'staging', + }) + + const capture = await waitForFile(capturePath) + expect(capture).toContain('profile=linux-profile\n') + expect(capture).toContain(`dataDir=${dataDir}\n`) + expect(capture).toContain('multiple=true\n') + expect(capture).toContain('arg=--no-enforce-app-location\n') + expect(capture).toContain('arg=--pas-port=23456\n') + expect(capture).toContain('arg=--server-env=staging\n') + expect(await fileExists(openCapturePath)).toBe(false) + }) + + it('discovers an AppImage in the managed Desktop installation directory', async () => { + const appPath = join(desktopInstallDir(), 'Beeper-1.2.3.AppImage') + await mkdir(dirname(appPath), { recursive: true }) + await writeFile(appPath, '#!/bin/sh\n') + await chmod(appPath, 0o755) + + expect(await findDesktopAppPath()).toBe(appPath) + }) +}) + +async function writeCaptureScript(path: string): Promise { + await writeFile(path, `#!/bin/sh +{ + printf 'profile=%s\\n' "$BEEPER_PROFILE" + printf 'dataDir=%s\\n' "$BEEPER_USER_DATA_DIR" + printf 'multiple=%s\\n' "$ALLOW_MULTIPLE_INSTANCES" + for arg in "$@"; do printf 'arg=%s\\n' "$arg"; done +} > "$BEEPER_TEST_CAPTURE" +`) + await chmod(path, 0o755) +} + +async function writeOpenScript(path: string): Promise { + await writeFile(path, `#!/bin/sh +printf 'open\\n' > "$BEEPER_TEST_OPEN_CAPTURE" +`) + await chmod(path, 0o755) +} + +async function waitForFile(path: string): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + try { + const content = await readFile(path, 'utf8') + if (content.includes('arg=--server-env=staging\n')) return content + } catch { + // The detached process has not created its capture file yet. + } + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error(`Timed out waiting for ${path}`) +} + +async function fileExists(path: string): Promise { + try { + await readFile(path) + return true + } catch { + return false + } +} + +function restoreEnvironment(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name] + else process.env[name] = value +}