Skip to content
Merged
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
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
45 changes: 37 additions & 8 deletions packages/cli/src/lib/profiles.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -54,11 +54,9 @@ 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 env = target?.dataDir
? {
...process.env,
Expand All @@ -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<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 +111,14 @@ 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
}
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
}
Expand All @@ -104,6 +127,12 @@ export async function findDesktopAppPath(): Promise<string | undefined> {
return undefined
}

// Beeper Desktop writes this entry on Linux launch, with Exec pointing at the running AppImage.
async function readDesktopEntryExec(path: string): Promise<string | undefined> {
const entry = await readFile(path, 'utf8').catch(() => '')
return entry.match(/^Exec=(.+?)(?:\s+%[A-Za-z])*\s*$/m)?.[1]
}

async function isBeeperDesktopApp(path: string): Promise<boolean> {
if (!await pathExists(path)) return false
if (process.platform !== 'darwin') return true
Expand Down
144 changes: 144 additions & 0 deletions packages/cli/test/profiles-linux.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
await writeFile(path, `#!/bin/sh
printf 'open\\n' > "$BEEPER_TEST_OPEN_CAPTURE"
`)
await chmod(path, 0o755)
}

async function waitForFile(path: string): Promise<string> {
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<boolean> {
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
}
Loading