From b9b4a4e22fcc119ed789157e32a4c637056e7d26 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:20:39 +0000 Subject: [PATCH 1/5] fix(cli): launch desktop directly on Linux --- packages/cli/src/lib/installations.ts | 7 ++ packages/cli/src/lib/profiles.ts | 35 ++++-- packages/cli/test/profiles-linux.test.ts | 129 +++++++++++++++++++++++ 3 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 packages/cli/test/profiles-linux.test.ts 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..0adb4f8c 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,25 @@ 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() + } 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 +109,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/test/profiles-linux.test.ts b/packages/cli/test/profiles-linux.test.ts new file mode 100644 index 00000000..962384c7 --- /dev/null +++ b/packages/cli/test/profiles-linux.test.ts @@ -0,0 +1,129 @@ +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 { + 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 +} From 2daf757f2426a64f68744e1ccec2cc75217f7247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 25 Sep 2026 15:52:05 +0000 Subject: [PATCH 2/5] Run Linux Desktop profile tests only on Linux and reject unsupported platforms The suite drives launchDesktopApp and findDesktopAppPath through their Linux branches; on the macOS CI runner it spawned the real open command and failed. launchDesktopApp now throws on platforms it cannot launch instead of reporting a start that never happened. --- packages/cli/src/lib/profiles.ts | 2 ++ packages/cli/test/profiles-linux.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/profiles.ts b/packages/cli/src/lib/profiles.ts index 0adb4f8c..f84895a9 100644 --- a/packages/cli/src/lib/profiles.ts +++ b/packages/cli/src/lib/profiles.ts @@ -73,6 +73,8 @@ export async function launchDesktopApp(target?: Target): Promise<{ id: string; s } 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() } } diff --git a/packages/cli/test/profiles-linux.test.ts b/packages/cli/test/profiles-linux.test.ts index 962384c7..f58136ac 100644 --- a/packages/cli/test/profiles-linux.test.ts +++ b/packages/cli/test/profiles-linux.test.ts @@ -27,7 +27,7 @@ afterEach(() => { restoreEnvironment('BEEPER_TEST_OPEN_CAPTURE', originalOpenCapture) }) -describe('Linux Desktop profiles', () => { +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') From 59c077d1cd681d05c25c9868e2c7f50c86d77209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 25 Sep 2026 15:56:18 +0000 Subject: [PATCH 3/5] Find a self-run Desktop AppImage through its desktop entry When run from an AppImage, Beeper Desktop writes ~/.local/share/applications/Beeper.desktop with Exec pointing at that AppImage. Read it so an AppImage kept outside the managed install directory is still found. --- packages/cli/src/lib/profiles.ts | 8 ++++++++ packages/cli/test/profiles-linux.test.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/cli/src/lib/profiles.ts b/packages/cli/src/lib/profiles.ts index f84895a9..1800d0a2 100644 --- a/packages/cli/src/lib/profiles.ts +++ b/packages/cli/src/lib/profiles.ts @@ -117,6 +117,8 @@ export async function findDesktopAppPath(): Promise { 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 } @@ -125,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 index f58136ac..7b18f6c3 100644 --- a/packages/cli/test/profiles-linux.test.ts +++ b/packages/cli/test/profiles-linux.test.ts @@ -7,6 +7,7 @@ import { desktopInstallDir, writeInstallations, type Installation } from '../src 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 @@ -17,11 +18,13 @@ 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) @@ -82,6 +85,18 @@ describe.if(process.platform === 'linux')('Linux Desktop profiles', () => { 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 { From 3057de1d450b0deb24729243501228249d1df1aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 25 Sep 2026 15:56:18 +0000 Subject: [PATCH 4/5] Stage downloads next to their destination Renaming from the OS temp directory fails with EXDEV when it is on another filesystem, as with the tmpfs /tmp on Fedora. --- packages/cli/src/lib/installations.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/lib/installations.ts b/packages/cli/src/lib/installations.ts index 8938747b..70ac9133 100644 --- a/packages/cli/src/lib/installations.ts +++ b/packages/cli/src/lib/installations.ts @@ -1,6 +1,5 @@ import { createWriteStream } from 'node:fs' import { chmod, cp, mkdir, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' import { basename, dirname, extname, join } from 'node:path' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' @@ -216,7 +215,7 @@ export async function downloadArtifact(url: string, destinationDir: string): Pro if (!response.ok || !response.body) throw new Error(`Download returned ${response.status} ${response.statusText}`) const filename = filenameFromResponse(response) ?? (basename(new URL(response.url).pathname) || `beeper-download-${Date.now()}`) const finalPath = join(destinationDir, filename) - const tmpPath = join(tmpdir(), `${filename}.${process.pid}.${Date.now()}.tmp`) + const tmpPath = join(destinationDir, `${filename}.${process.pid}.${Date.now()}.tmp`) await writeResponseToFile(response, tmpPath) await rename(tmpPath, finalPath) return finalPath From 90e2ebc3ebae1da4c1bc247cc1ef0726851403d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 25 Sep 2026 17:07:24 +0000 Subject: [PATCH 5/5] Revert duplicate download staging change #35 lands the same fix with tests; keep this PR to the launch changes. --- packages/cli/src/lib/installations.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/installations.ts b/packages/cli/src/lib/installations.ts index 70ac9133..8938747b 100644 --- a/packages/cli/src/lib/installations.ts +++ b/packages/cli/src/lib/installations.ts @@ -1,5 +1,6 @@ import { createWriteStream } from 'node:fs' import { chmod, cp, mkdir, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { basename, dirname, extname, join } from 'node:path' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' @@ -215,7 +216,7 @@ export async function downloadArtifact(url: string, destinationDir: string): Pro if (!response.ok || !response.body) throw new Error(`Download returned ${response.status} ${response.statusText}`) const filename = filenameFromResponse(response) ?? (basename(new URL(response.url).pathname) || `beeper-download-${Date.now()}`) const finalPath = join(destinationDir, filename) - const tmpPath = join(destinationDir, `${filename}.${process.pid}.${Date.now()}.tmp`) + const tmpPath = join(tmpdir(), `${filename}.${process.pid}.${Date.now()}.tmp`) await writeResponseToFile(response, tmpPath) await rename(tmpPath, finalPath) return finalPath