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
8 changes: 4 additions & 4 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ Flags:
| `--oauth` | boolean | Authorize the target with browser OAuth/PKCE |
| `--remote=<value>` | option | Connect to a remote Beeper Desktop or Server URL |
| `--server` | boolean | Set up a local Beeper Server target |
| `--server-env=<production\|staging>` | option | Server environment. Staging forces nightly. Default: production |
| `--server-env=<production\|staging>` | option | Server environment (production or staging) Default: production |
| `--username=<value>` | option | Username to use if setup creates a new account |

Examples:
Expand Down Expand Up @@ -480,7 +480,7 @@ Flags:
| Flag | Type | Description |
| --- | --- | --- |
| `--channel=<stable\|nightly>` | option | Server release channel Default: stable |
| `--server-env=<production\|staging>` | option | Server environment. Staging forces nightly. Default: production |
| `--server-env=<production\|staging>` | option | Server environment (production or staging) Default: production |

Examples:

Expand Down Expand Up @@ -573,7 +573,7 @@ Flags:
| --- | --- | --- |
| `--default` | boolean | Set this target as the default after creation |
| `--port=<value>` | option | TCP port the managed Desktop will expose its API on |
| `--server-env=<production\|staging>` | option | Server environment. Staging forces nightly. Default: production |
| `--server-env=<production\|staging>` | option | Server environment (production or staging) Default: production |

Examples:

Expand Down Expand Up @@ -602,7 +602,7 @@ Flags:
| --- | --- | --- |
| `--default` | boolean | Set this target as the default after creation |
| `--port=<value>` | option | TCP port the managed Server will expose its API on |
| `--server-env=<production\|staging>` | option | Server environment. Staging forces nightly. Default: production |
| `--server-env=<production\|staging>` | option | Server environment (production or staging) Default: production |

Examples:

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/install/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default class SetupInstallServer extends BeeperCommand {
static override summary = 'Install Beeper Server locally'
static override flags = {
channel: Flags.string({ options: ['stable', 'nightly'], default: 'stable', description: 'Server release channel' }),
'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment. Staging forces nightly.' }),
'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment (production or staging)' }),
}

async run(): Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default class Setup extends BeeperCommand {
desktop: Flags.boolean({ default: false, description: 'Set up a local Beeper Desktop target' }),
install: Flags.boolean({ default: false, description: 'Allow installing missing managed runtime' }),
channel: Flags.string({ options: ['stable', 'nightly'], default: 'stable', description: 'Install release channel' }),
'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment. Staging forces nightly.' }),
'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment (production or staging)' }),
email: Flags.string({ description: 'Sign in with an email address' }),
username: Flags.string({ description: 'Username to use if setup creates a new account' }),
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/targets/add/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default class TargetsAddDesktop extends BeeperCommand {
static override flags = {
port: Flags.integer({ description: 'TCP port the managed Desktop will expose its API on' }),
default: Flags.boolean({ default: false, description: 'Set this target as the default after creation' }),
'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment. Staging forces nightly.' }),
'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment (production or staging)' }),
}
async run(): Promise<void> {
const { args, flags } = await this.parse(TargetsAddDesktop)
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/targets/add/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default class TargetsAddServer extends BeeperCommand {
static override flags = {
port: Flags.integer({ description: 'TCP port the managed Server will expose its API on' }),
default: Flags.boolean({ default: false, description: 'Set this target as the default after creation' }),
'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment. Staging forces nightly.' }),
'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment (production or staging)' }),
}
async run(): Promise<void> {
const { args, flags } = await this.parse(TargetsAddServer)
Expand Down
54 changes: 31 additions & 23 deletions packages/cli/src/lib/installations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,30 +80,24 @@ export function normalizeInstallRequest(options: {
kind: InstallKind
channel: InstallChannel
serverEnv: ServerEnv
platform: 'macos' | 'windows' | 'linux'
feedPlatform: 'darwin' | 'win32' | 'linux'
arch: 'x64' | 'arm64'
bundleID: string
apiBaseURL: string
} {
// TODO: switch Server installs back to production once the production download
// endpoint returns a beeper-server artifact instead of the Desktop app bundle.
const serverEnv = options.kind === 'server' ? 'staging' : normalizeServerEnv(options.serverEnv)
let channel = options.channel ?? 'stable'
if (serverEnv === 'staging') channel = 'nightly'
const platform = normalizeDownloadPlatform(options.platform ?? process.platform)
const serverEnv = normalizeServerEnv(options.serverEnv)
const channel = options.channel ?? 'stable'
const feedPlatform = normalizeFeedPlatform(options.platform ?? process.platform)
const arch = normalizeArch(options.arch ?? process.arch)
const bundleID = bundleIDFor(options.kind, channel)
return {
kind: options.kind,
channel,
serverEnv,
platform,
feedPlatform,
arch,
bundleID,
apiBaseURL: options.kind === 'server' || serverEnv === 'staging' ? 'https://api.beeper-staging.com' : 'https://api.beeper.com',
apiBaseURL: serverEnv === 'staging' ? 'https://api.beeper-staging.com' : 'https://api.beeper.com',
}
}

Expand All @@ -116,11 +110,6 @@ export function feedURLFor(options: ReturnType<typeof normalizeInstallRequest>):
return url.toString()
}

export function downloadURLFor(options: ReturnType<typeof normalizeInstallRequest>): string {
const channelSegment = options.serverEnv === 'staging' && options.kind === 'server' ? 'stable' : options.channel
return `${options.apiBaseURL}/desktop/download/${options.platform}/${options.arch}/${channelSegment}/${options.bundleID}`
}

export async function fetchFeed(feedURL: string): Promise<FeedInfo> {
const response = await fetch(feedURL, { signal: AbortSignal.timeout(30_000) })
if (!response.ok) throw new Error(`Update feed returned ${response.status} ${response.statusText}`)
Expand Down Expand Up @@ -180,8 +169,18 @@ export async function installServer(options: { channel?: InstallChannel; serverE
if (process.platform === 'win32') throw new Error('Beeper Server install is not available on Windows.')
const request = normalizeInstallRequest({ kind: 'server', channel: options.channel, serverEnv: options.serverEnv })
const feedURL = feedURLFor(request)
const downloadURL = downloadURLFor(request)
const feed = await fetchFeed(feedURL).catch(() => ({ raw: undefined, version: undefined }))
let feed: FeedInfo
try {
feed = await fetchFeed(feedURL)
} catch (error) {
const reason = error instanceof Error ? ` ${error.message}` : ''
throw new Error(`Beeper Server ${request.channel} artifact is unavailable from the ${request.serverEnv} update feed; refusing to install a different channel.${reason}`)
}
const downloadURL = feed.url
if (!downloadURL) {
throw new Error(`Beeper Server ${request.channel} update feed did not include an artifact URL; refusing to install a different channel.`)
}
assertServerArtifactChannel(downloadURL, request.channel)
const version = feed.version ?? 'unknown'
const stageDir = join(serverInstallRoot(), `${request.channel}-${version}-${Date.now()}`)
await mkdir(stageDir, { recursive: true })
Expand All @@ -205,6 +204,22 @@ export async function installServer(options: { channel?: InstallChannel; serverE
})
}

function assertServerArtifactChannel(downloadURL: string, channel: InstallChannel): void {
let filename: string
try {
filename = decodeURIComponent(basename(new URL(downloadURL).pathname)).toLowerCase()
} catch {
throw new Error(`Beeper Server ${channel} update feed returned an invalid artifact URL; refusing to install it.`)
}
if (!filename.startsWith('beeper-server-')) {
throw new Error(`Beeper Server ${channel} update feed returned a non-Server artifact; refusing to install it.`)
}
const artifactChannel: InstallChannel = filename.includes('nightly') ? 'nightly' : 'stable'
if (artifactChannel !== channel) {
throw new Error(`Beeper Server ${channel} update feed returned a ${artifactChannel} artifact; refusing to install a different channel.`)
}
}

export async function updateServerInstallation(installation: Installation): Promise<Installation> {
return installServer({ channel: installation.channel, serverEnv: installation.serverEnv })
}
Expand Down Expand Up @@ -336,13 +351,6 @@ function normalizeServerEnv(value?: string): ServerEnv {
throw new Error(`Unsupported server env "${value}". Expected production or staging.`)
}

function normalizeDownloadPlatform(platform: NodeJS.Platform): 'macos' | 'windows' | 'linux' {
if (platform === 'darwin') return 'macos'
if (platform === 'win32') return 'windows'
if (platform === 'linux') return 'linux'
throw new Error(`Unsupported platform "${platform}".`)
}

function normalizeFeedPlatform(platform: NodeJS.Platform): 'darwin' | 'win32' | 'linux' {
if (platform === 'darwin' || platform === 'win32' || platform === 'linux') return platform
throw new Error(`Unsupported platform "${platform}".`)
Expand Down
10 changes: 0 additions & 10 deletions packages/cli/test/cli-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { commandManifest } from '../dist/lib/manifest.js'
import { resolveAccountID, resolveAccountIDs, resolveChatID } from '../dist/lib/resolve.js'
import { downloadURLFor, feedURLFor, normalizeInstallRequest } from '../dist/lib/installations.js'

const root = fileURLToPath(new URL('..', import.meta.url))
const configDir = '/tmp/beeper-cli-test'
Expand Down Expand Up @@ -250,15 +249,6 @@ assert.equal(rpcLine.id, 1)
assert.equal(rpcLine.ok, true)
assert.match(rpcLine.stdout, /"success": true/)

const stagingServerRequest = normalizeInstallRequest({ kind: 'server', serverEnv: 'staging', channel: 'stable', platform: 'darwin', arch: 'arm64' })
assert.equal(stagingServerRequest.channel, 'nightly')
assert.equal(stagingServerRequest.bundleID, 'com.automattic.beeper.server.nightly')
assert.equal(feedURLFor(stagingServerRequest), 'https://api.beeper-staging.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server.nightly&platform=darwin&channel=nightly&arch=arm64')
assert.equal(downloadURLFor(stagingServerRequest), 'https://api.beeper-staging.com/desktop/download/macos/arm64/stable/com.automattic.beeper.server.nightly')

const desktopNightlyRequest = normalizeInstallRequest({ kind: 'desktop', channel: 'nightly', platform: 'darwin', arch: 'arm64' })
assert.equal(downloadURLFor(desktopNightlyRequest), 'https://api.beeper.com/desktop/download/macos/arm64/nightly/com.automattic.beeper.desktop.nightly')

const fakeClient = {
accounts: {
list: async () => [
Expand Down
66 changes: 64 additions & 2 deletions packages/cli/test/installations.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { expect, it } from 'bun:test'
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'
import { createServer, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { downloadArtifact } from '../src/lib/installations.js'
import { downloadArtifact, feedURLFor, installServer, normalizeInstallRequest } from '../src/lib/installations.js'

const originalFetch = globalThis.fetch

afterEach(() => {
globalThis.fetch = originalFetch
})

it('installs a complete download without staging it in the system temp directory', async () => {
const destination = await mkdtemp(join(tmpdir(), 'beeper-download-'))
Expand Down Expand Up @@ -50,3 +56,59 @@ it('removes the partial file when a download is interrupted', async () => {
await rm(destination, { recursive: true, force: true })
}
})

describe('server installation artifact selection', () => {
it('uses the production stable Server artifact by default', () => {
const request = normalizeInstallRequest({ kind: 'server', platform: 'linux', arch: 'x64' })

expect(request.channel).toBe('stable')
expect(request.serverEnv).toBe('production')
expect(request.bundleID).toBe('com.automattic.beeper.server')
expect(request.apiBaseURL).toBe('https://api.beeper.com')
expect(feedURLFor(request)).toBe('https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server&platform=linux&channel=stable&arch=x64')
})

it('keeps staging stable when staging is explicitly selected', () => {
const request = normalizeInstallRequest({ kind: 'server', serverEnv: 'staging', channel: 'stable', platform: 'linux', arch: 'x64' })

expect(request.channel).toBe('stable')
expect(request.serverEnv).toBe('staging')
expect(request.bundleID).toBe('com.automattic.beeper.server')
expect(request.apiBaseURL).toBe('https://api.beeper-staging.com')
})

it('keeps nightly explicit instead of deriving it from the environment', () => {
const request = normalizeInstallRequest({ kind: 'server', channel: 'nightly', platform: 'linux', arch: 'x64' })

expect(request.channel).toBe('nightly')
expect(request.serverEnv).toBe('production')
expect(request.bundleID).toBe('com.automattic.beeper.server.nightly')
expect(request.apiBaseURL).toBe('https://api.beeper.com')
})

it('fails closed when the selected Server feed has no artifact URL', async () => {
let calls = 0
globalThis.fetch = async () => {
calls += 1
return calls === 1
? new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })
: new Response('unexpected download', { status: 404, statusText: 'Not Found' })
}

await expect(installServer()).rejects.toThrow('Beeper Server stable update feed did not include an artifact URL; refusing to install a different channel.')
expect(calls).toBe(1)
})

it('fails closed when the stable feed returns a nightly artifact', async () => {
let calls = 0
globalThis.fetch = async () => {
calls += 1
return calls === 1
? Response.json({ url: 'https://downloads.beeper.com/beeper-server-nightly-4.3.23-linux-x64.tar.gz' })
: new Response('unexpected download', { status: 404, statusText: 'Not Found' })
}

await expect(installServer()).rejects.toThrow('Beeper Server stable update feed returned a nightly artifact; refusing to install a different channel.')
expect(calls).toBe(1)
})
})
Loading