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..1800d0a2 100644 --- a/packages/cli/src/lib/profiles.ts +++ b/packages/cli/src/lib/profiles.ts @@ -1,12 +1,12 @@ 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 { 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 +54,9 @@ 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 env = target?.dataDir ? { ...process.env, @@ -67,10 +65,27 @@ 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() + + if (process.platform === 'darwin') { + const args = appPath ? ['-n', appPath, '--args'] : ['-n', '-a', 'Beeper', '--args'] + args.push(...desktopLaunchArgs(target)) + spawn('open', args, { detached: true, stdio: 'ignore', env }).unref() + } else if (process.platform === 'linux' || process.platform === 'win32') { + if (!appPath) throw new Error('Beeper Desktop was not found. Install Beeper Desktop and try again.') + spawn(appPath, desktopLaunchArgs(target), { detached: true, stdio: 'ignore', env }).unref() + } else { + throw new Error(`Beeper Desktop launch is not supported on ${process.platform}.`) + } return { id: target?.id ?? 'desktop', startedAt: new Date().toISOString() } } +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}`) + return args +} + 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 +111,14 @@ 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 + } + const registeredPath = await readDesktopEntryExec(join(process.env.HOME ?? homedir(), '.local', 'share', 'applications', 'Beeper.desktop')) + if (registeredPath && await pathExists(registeredPath)) return registeredPath for (const path of ['/usr/bin/beeper', '/usr/local/bin/beeper']) { if (await pathExists(path)) return path } @@ -104,6 +127,12 @@ export async function findDesktopAppPath(): Promise { return undefined } +// Beeper Desktop writes this entry on Linux launch, with Exec pointing at the running AppImage. +async function readDesktopEntryExec(path: string): Promise { + const entry = await readFile(path, 'utf8').catch(() => '') + return entry.match(/^Exec=(.+?)(?:\s+%[A-Za-z])*\s*$/m)?.[1] +} + async function isBeeperDesktopApp(path: string): Promise { if (!await pathExists(path)) return false if (process.platform !== 'darwin') return true diff --git a/packages/cli/test/profiles-linux.test.ts b/packages/cli/test/profiles-linux.test.ts new file mode 100644 index 00000000..7b18f6c3 --- /dev/null +++ b/packages/cli/test/profiles-linux.test.ts @@ -0,0 +1,144 @@ +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 originalHome = process.env.HOME +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 ?? '') + process.env.HOME = join(tempDir, 'home') +}) + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + restoreEnvironment('PATH', originalPath) + restoreEnvironment('HOME', originalHome) + restoreEnvironment('BEEPER_CLI_CONFIG_DIR', originalConfigDir) + restoreEnvironment('BEEPER_TEST_CAPTURE', originalCapture) + restoreEnvironment('BEEPER_TEST_OPEN_CAPTURE', originalOpenCapture) +}) + +describe.if(process.platform === 'linux')('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) + }) + + it('discovers the AppImage that Desktop registered in its desktop entry', async () => { + const appPath = join(tempDir, 'My Apps', 'Beeper-4.3.152-x86_64.AppImage') + const entryPath = join(tempDir, 'home', '.local', 'share', 'applications', 'Beeper.desktop') + await mkdir(dirname(appPath), { recursive: true }) + await mkdir(dirname(entryPath), { recursive: true }) + await writeFile(appPath, '#!/bin/sh\n') + await chmod(appPath, 0o755) + await writeFile(entryPath, `[Desktop Entry]\nName=Beeper\nExec=${appPath} %u\nType=Application\n`) + + 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 { + return await readFile(path, 'utf8') + } catch { + 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 +}