Skip to content
Open
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
4 changes: 2 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2415,8 +2415,8 @@ Flags:
| Flag | Type | Description |
| --- | --- | --- |
| `-c, --chat=<value>...` | option | Chat ID to subscribe to. Defaults to all chats. |
| `--exclude-type=<chat.upserted\|chat.deleted\|message.upserted\|message.deleted>...` | option | Drop events of these types. Repeat for multiple. |
| `--include-type=<chat.upserted\|chat.deleted\|message.upserted\|message.deleted>...` | option | Only forward events of these types. Repeat for multiple. |
| `--exclude-type=<chat.upserted\|chat.deleted\|message.upserted\|message.deleted\|message.stream>...` | option | Drop events of these types. Repeat for multiple. |
| `--include-type=<chat.upserted\|chat.deleted\|message.upserted\|message.deleted\|message.stream>...` | option | Only forward events of these types. Repeat for multiple. |
| `--webhook=<value>` | option | Forward each event to this URL as a POST request (best-effort, fire-and-forget) |
| `--webhook-queue=<value>` | option | Maximum pending webhook deliveries before dropping events Default: 64 |
| `--webhook-secret=<value>` | option | HMAC-SHA256 secret. Signs payloads with X-Beeper-Signature: sha256=<hex> |
Expand Down
100 changes: 63 additions & 37 deletions packages/cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
readConfig,
readTarget,
listTargets,
pathExists,
saveTargetAuth,
updateConfig,
writeTarget,
Expand Down Expand Up @@ -102,7 +103,7 @@ export default class Setup extends BeeperCommand {
return
}
if (flags.json || !process.stdin.isTTY) {
await printData(setupSessionFoundOutput(local, setupCmd), flags.json ? 'json' : 'human')
await printData(setupSessionFoundOutput(local, setupCmd, detected.serverInstalled), flags.json ? 'json' : 'human')
return
}
printLocalDesktopPreview(local)
Expand Down Expand Up @@ -250,12 +251,14 @@ export default class Setup extends BeeperCommand {
}

private async setupFromChoice(flags: SetupFlags): Promise<void> {
const serverInstalled = await isServerInstalled()
process.stdout.write('No usable Beeper Desktop session was found on this device.\n\n')
process.stdout.write('How do you want to connect Beeper CLI?\n\n')
process.stdout.write(' 1. Install Beeper Desktop\n')
process.stdout.write(' 2. Install local Beeper Server\n')
process.stdout.write(` 2. ${serverInstalled ? 'Use installed local Beeper Server' : 'Install local Beeper Server'}\n`)
process.stdout.write(' 3. Connect with Desktop API on another device\n\n')
const choice = await promptChoice('Choose [1]: ', ['1', '2', '3'], '1')
const defaultChoice = serverInstalled ? '2' : '1'
const choice = await promptChoice(`Choose [${defaultChoice}]: `, ['1', '2', '3'], defaultChoice)
if (choice === '1') {
if (!await promptYesNoDefaultYes('Install Beeper Desktop stable from beeper.com?')) return
await installWithCopy('desktop', { ...flags, channel: 'stable' })
Expand All @@ -264,8 +267,10 @@ export default class Setup extends BeeperCommand {
return
}
if (choice === '2') {
if (!await promptYesNoDefaultYes('Install local Beeper Server stable from beeper.com?')) return
await installWithCopy('server', { ...flags, channel: 'stable', 'server-env': 'production' })
if (!serverInstalled) {
if (!await promptYesNoDefaultYes('Install local Beeper Server stable from beeper.com?')) return
await installWithCopy('server', { ...flags, channel: 'stable', 'server-env': 'production' })
}
await this.setupManaged('server', { ...flags, install: false, server: true, channel: 'stable' })
return
}
Expand All @@ -275,12 +280,13 @@ export default class Setup extends BeeperCommand {
}

private async handleBrokenCurrentTarget(target: Target, readiness: Readiness, flags: SetupFlags): Promise<boolean> {
const serverInstalled = await isServerInstalled()
process.stdout.write(`Beeper CLI is set up for ${target.name ?? target.id}, but it is not reachable.\n\n`)
if (readiness.message) process.stdout.write(`${readiness.message}\n\n`)
process.stdout.write('What do you want to do?\n\n')
process.stdout.write(` 1. Retry ${target.name ?? target.id}\n`)
process.stdout.write(' 2. Use Beeper Desktop on this device\n')
process.stdout.write(' 3. Install local Beeper Server\n')
process.stdout.write(` 3. ${serverInstalled ? 'Use installed local Beeper Server' : 'Install local Beeper Server'}\n`)
process.stdout.write(' 4. Connect with Desktop API on another device\n\n')
const choice = await promptChoice('Choose [1]: ', ['1', '2', '3', '4'], '1')
if (choice === '1') return false
Expand All @@ -290,8 +296,10 @@ export default class Setup extends BeeperCommand {
return true
}
if (choice === '3') {
if (!await promptYesNoDefaultYes('Install local Beeper Server stable from beeper.com?')) return true
await installWithCopy('server', { ...flags, channel: 'stable', 'server-env': 'production' })
if (!serverInstalled) {
if (!await promptYesNoDefaultYes('Install local Beeper Server stable from beeper.com?')) return true
await installWithCopy('server', { ...flags, channel: 'stable', 'server-env': 'production' })
}
await this.setupManaged('server', { ...flags, install: false, server: true, channel: 'stable' })
return true
}
Expand Down Expand Up @@ -336,11 +344,11 @@ type PreparedLocalDesktopSetup = {
}

type DesktopSetupDetection =
| { kind: 'session-found'; local: PreparedLocalDesktopSetup }
| { kind: 'installed-not-running' }
| { kind: 'running-signed-out'; readiness?: Readiness }
| { kind: 'session-unreadable'; reason: string; readiness?: Readiness }
| { kind: 'not-installed' }
| { kind: 'session-found'; local: PreparedLocalDesktopSetup; serverInstalled: boolean }
| { kind: 'installed-not-running'; serverInstalled: boolean }
| { kind: 'running-signed-out'; readiness?: Readiness; serverInstalled: boolean }
| { kind: 'session-unreadable'; reason: string; readiness?: Readiness; serverInstalled: boolean }
| { kind: 'not-installed'; serverInstalled: boolean }

async function setupTarget(flags: SetupFlags): Promise<Target> {
if (flags['base-url']) return { id: customTargetID, type: 'desktop', baseURL: flags['base-url'] }
Expand Down Expand Up @@ -397,29 +405,33 @@ async function prepareLocalDesktopSetup(target: Target, flags: SetupFlags): Prom

async function detectDesktopSetup(target: Target, flags: SetupFlags): Promise<DesktopSetupDetection> {
printProgress(flags, 'Checking Beeper Desktop')
const appInstalled = await isDesktopAppInstalled()
const installations = await readInstallations().catch((): Awaited<ReturnType<typeof readInstallations>> => ({}))
const serverInstalled = await isServerInstalled(installations)
const appInstalled = Boolean(installations.desktop?.path || await findDesktopAppPath())
printProgress(flags, 'Reading local Desktop session')
const local = await prepareLocalDesktopSetup(target, flags).catch(error => ({ error }))
if (!('error' in local)) return { kind: 'session-found', local }
if (!('error' in local)) return { kind: 'session-found', local, serverInstalled }

printProgress(flags, 'Checking Desktop readiness')
const desktop = await findLocalDesktop({ baseURL: target.baseURL, scan: target.id === builtInDesktopTargetID, timeoutMs: 500 }).catch(() => undefined)
if (desktop) {
const readiness = await evaluateReadiness({ baseURL: desktop.baseURL, target: target.id, token: false })
if (readiness.state === 'needs-login') return { kind: 'running-signed-out', readiness }
if (readiness.state === 'needs-login') return { kind: 'running-signed-out', readiness, serverInstalled }
return {
kind: 'session-unreadable',
reason: local.error instanceof Error ? local.error.message : String(local.error),
readiness,
serverInstalled,
}
}

return appInstalled ? { kind: 'installed-not-running' } : { kind: 'not-installed' }
return appInstalled ? { kind: 'installed-not-running', serverInstalled } : { kind: 'not-installed', serverInstalled }
}

async function isDesktopAppInstalled(): Promise<boolean> {
const installations = await readInstallations().catch((): Awaited<ReturnType<typeof readInstallations>> => ({}))
return Boolean(installations.desktop?.path || await findDesktopAppPath())
async function isServerInstalled(installations?: Awaited<ReturnType<typeof readInstallations>>): Promise<boolean> {
if (process.env.BEEPER_SERVER_BIN) return true
const installation = installations ?? await readInstallations().catch((): Awaited<ReturnType<typeof readInstallations>> => ({}))
return Boolean(installation.server?.path && await pathExists(installation.server.path))
}

async function commitLocalDesktopSetup(prepared: PreparedLocalDesktopSetup): Promise<SetupResult> {
Expand Down Expand Up @@ -498,7 +510,13 @@ function printLocalDesktopPreview(prepared: PreparedLocalDesktopSetup): void {
process.stdout.write('\n')
}

function setupSessionFoundOutput(local: PreparedLocalDesktopSetup, setupCmd: string): Record<string, unknown> {
function setupSessionFoundOutput(local: PreparedLocalDesktopSetup, setupCmd: string, serverInstalled: boolean): Record<string, unknown> {
const availableActions = [
action('use-desktop-session', `${setupCmd} --local`),
action('desktop-oauth', `${setupCmd} --oauth`),
action('connect-remote', 'beeper setup --remote <url>'),
]
if (serverInstalled) availableActions.push(installedServerAction(true))
return {
state: local.readiness.state === 'ready' ? 'desktop-ready' : 'desktop-session-found',
message: local.readiness.state === 'ready'
Expand All @@ -508,11 +526,7 @@ function setupSessionFoundOutput(local: PreparedLocalDesktopSetup, setupCmd: str
readiness: local.readiness,
localDesktop: localDesktopPreview(local),
recommendedAction: action('use-desktop-session', `${setupCmd} --local`),
availableActions: [
action('use-desktop-session', `${setupCmd} --local`),
action('desktop-oauth', `${setupCmd} --oauth`),
action('connect-remote', 'beeper setup --remote <url>'),
],
availableActions,
}
}

Expand Down Expand Up @@ -611,6 +625,7 @@ function printNextSteps(): void {

function setupStateOutput(detected: Exclude<DesktopSetupDetection, { kind: 'session-found' }>, target: Target): Record<string, unknown> {
if (detected.kind === 'installed-not-running') {
const serverAction = installedServerAction(detected.serverInstalled)
return setupActionEnvelope({
state: 'desktop-installed-not-running',
message: 'Beeper Desktop is installed but not running.',
Expand All @@ -619,50 +634,61 @@ function setupStateOutput(detected: Exclude<DesktopSetupDetection, { kind: 'sess
availableActions: [
action('launch-desktop', 'beeper setup --desktop --yes'),
action('connect-remote', 'beeper setup --remote <url>'),
action('install-server', 'beeper setup --server --install --yes'),
serverAction,
],
})
}
if (detected.kind === 'running-signed-out') {
const availableActions = [
action('open-desktop', 'beeper setup --desktop --yes'),
action('connect-remote', 'beeper setup --remote <url>'),
]
if (detected.serverInstalled) availableActions.push(installedServerAction(true))
return setupActionEnvelope({
state: 'desktop-running-signed-out',
message: 'Beeper Desktop is running but not signed in.',
target,
readiness: detected.readiness,
recommendedAction: action('open-desktop', 'beeper setup --desktop --yes'),
availableActions: [
action('open-desktop', 'beeper setup --desktop --yes'),
action('connect-remote', 'beeper setup --remote <url>'),
],
availableActions,
})
}
if (detected.kind === 'session-unreadable') {
const availableActions = [
action('desktop-oauth', 'beeper setup --oauth --yes'),
action('connect-remote', 'beeper setup --remote <url>'),
]
if (detected.serverInstalled) availableActions.push(installedServerAction(true))
return setupActionEnvelope({
state: 'desktop-running-session-unreadable',
message: 'Beeper Desktop is running, but CLI could not read the local session.',
target,
readiness: detected.readiness,
detail: detected.reason,
recommendedAction: action('desktop-oauth', 'beeper setup --oauth --yes'),
availableActions: [
action('desktop-oauth', 'beeper setup --oauth --yes'),
action('connect-remote', 'beeper setup --remote <url>'),
],
availableActions,
})
}
const serverAction = installedServerAction(detected.serverInstalled)
return setupActionEnvelope({
state: 'desktop-not-installed',
message: 'No Beeper Desktop installation was found on this device.',
target,
recommendedAction: action('install-desktop', 'beeper setup --desktop --install --yes'),
recommendedAction: detected.serverInstalled ? serverAction : action('install-desktop', 'beeper setup --desktop --install --yes'),
availableActions: [
action('install-desktop', 'beeper setup --desktop --install --yes'),
action('install-server', 'beeper setup --server --install --yes'),
serverAction,
action('connect-remote', 'beeper setup --remote <url>'),
],
})
}

function installedServerAction(installed: boolean): { id: string; command: string } {
return installed
? action('use-installed-server', 'beeper setup --server --yes')
: action('install-server', 'beeper setup --server --install --yes')
}

function currentTargetBrokenOutput(target: Target, readiness: Readiness): Record<string, unknown> {
return {
state: 'current-target-unreachable',
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export default class Watch extends BeeperCommand {
static override flags = {
chat: Flags.string({ char: 'c', multiple: true, description: 'Chat ID to subscribe to. Defaults to all chats.' }),
json: Flags.boolean({ default: false, description: 'Print raw JSON, one event per line' }),
'include-type': Flags.string({ multiple: true, options: ['chat.upserted', 'chat.deleted', 'message.upserted', 'message.deleted'], description: 'Only forward events of these types. Repeat for multiple.' }),
'exclude-type': Flags.string({ multiple: true, options: ['chat.upserted', 'chat.deleted', 'message.upserted', 'message.deleted'], description: 'Drop events of these types. Repeat for multiple.' }),
'include-type': Flags.string({ multiple: true, options: ['chat.upserted', 'chat.deleted', 'message.upserted', 'message.deleted', 'message.stream'], description: 'Only forward events of these types. Repeat for multiple.' }),
'exclude-type': Flags.string({ multiple: true, options: ['chat.upserted', 'chat.deleted', 'message.upserted', 'message.deleted', 'message.stream'], description: 'Drop events of these types. Repeat for multiple.' }),
webhook: Flags.string({ description: 'Forward each event to this URL as a POST request (best-effort, fire-and-forget)' }),
'webhook-secret': Flags.string({ description: 'HMAC-SHA256 secret. Signs payloads with X-Beeper-Signature: sha256=<hex>' }),
'webhook-queue': Flags.integer({ default: 64, description: 'Maximum pending webhook deliveries before dropping events' }),
Expand Down
27 changes: 26 additions & 1 deletion packages/cli/test/cli-smoke.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { existsSync, readdirSync, rmSync } from 'node:fs'
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { commandManifest } from '../dist/lib/manifest.js'
Expand Down Expand Up @@ -235,6 +235,31 @@ envelope = JSON.parse(result.stderr)
assert.equal(envelope.success, false)
assert.match(envelope.error, /Unknown Beeper target/)

rmSync(configDir, { recursive: true, force: true })
const fakeServerPath = join(configDir, 'bin', 'beeper-server')
mkdirSync(join(configDir, 'bin'), { recursive: true })
writeFileSync(fakeServerPath, '#!/bin/sh\n', { mode: 0o755 })
writeFileSync(join(configDir, 'installations.json'), `${JSON.stringify({
server: {
kind: 'server',
channel: 'stable',
serverEnv: 'production',
bundleID: 'com.automattic.beeper.server',
version: 'test',
path: fakeServerPath,
feedURL: 'https://example.invalid/feed',
downloadURL: 'https://example.invalid/download',
installedAt: '2026-05-18T00:00:00.000Z',
updatedAt: '2026-05-18T00:00:00.000Z',
},
}, null, 2)}\n`)
result = run('setup', '--json')
assert.equal(result.status, 0, result.stderr)
envelope = JSON.parse(result.stdout)
assert.equal(envelope.success, true)
assert(envelope.data.availableActions.some(action => action.id === 'use-installed-server' && action.command === 'beeper setup --server --yes'))
assert(!envelope.data.availableActions.some(action => action.id === 'install-server'), 'setup must not offer to reinstall an already installed Server')

const rpcResult = spawnSync(process.execPath, ['./bin/dev.js', 'rpc'], {
cwd: root,
encoding: 'utf8',
Expand Down
Loading