Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/commands/targets/start.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<void> {
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
}
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/lib/installations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
116 changes: 108 additions & 8 deletions packages/cli/src/lib/profiles.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -54,11 +55,11 @@ export async function startProfile(target: Target): Promise<ProfileRun | { id: s

export async function launchDesktopApp(target?: Target): Promise<{ id: string; startedAt: string }> {
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,
Expand All @@ -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<typeof spawn> {
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<StartupThreadIDsState> {
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<StartupThreadIDsState | undefined> {
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<typeof spawn>): Promise<void> {
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<string | undefined> {
const installations = await readInstallations().catch(() => ({ desktop: undefined }))
if (installations.desktop?.path && await isBeeperDesktopApp(installations.desktop.path)) return installations.desktop.path
Expand All @@ -96,6 +190,12 @@ export async function findDesktopAppPath(): Promise<string | undefined> {
}

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
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/lib/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type Target = {
}
serverEnv?: string
port?: number
headless?: boolean
}

export type ManagedTargetType = 'desktop' | 'server'
Expand Down
179 changes: 179 additions & 0 deletions packages/cli/test/headless-desktop-launch.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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
}
Loading