diff --git a/packages/cli/README.md b/packages/cli/README.md index f1167857..3b03d09b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -431,7 +431,7 @@ Flags: | `--oauth` | boolean | Authorize the target with browser OAuth/PKCE | | `--remote=` | option | Connect to a remote Beeper Desktop or Server URL | | `--server` | boolean | Set up a local Beeper Server target | -| `--server-env=` | option | Server environment. Staging forces nightly. Default: production | +| `--server-env=` | option | Server environment (production or staging) Default: production | | `--username=` | option | Username to use if setup creates a new account | Examples: @@ -480,7 +480,7 @@ Flags: | Flag | Type | Description | | --- | --- | --- | | `--channel=` | option | Server release channel Default: stable | -| `--server-env=` | option | Server environment. Staging forces nightly. Default: production | +| `--server-env=` | option | Server environment (production or staging) Default: production | Examples: @@ -573,7 +573,7 @@ Flags: | --- | --- | --- | | `--default` | boolean | Set this target as the default after creation | | `--port=` | option | TCP port the managed Desktop will expose its API on | -| `--server-env=` | option | Server environment. Staging forces nightly. Default: production | +| `--server-env=` | option | Server environment (production or staging) Default: production | Examples: @@ -602,7 +602,7 @@ Flags: | --- | --- | --- | | `--default` | boolean | Set this target as the default after creation | | `--port=` | option | TCP port the managed Server will expose its API on | -| `--server-env=` | option | Server environment. Staging forces nightly. Default: production | +| `--server-env=` | option | Server environment (production or staging) Default: production | Examples: diff --git a/packages/cli/src/commands/install/server.ts b/packages/cli/src/commands/install/server.ts index c612a131..a647943b 100644 --- a/packages/cli/src/commands/install/server.ts +++ b/packages/cli/src/commands/install/server.ts @@ -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 { diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 5b45031b..f59baab6 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -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' }), } diff --git a/packages/cli/src/commands/targets/add/desktop.ts b/packages/cli/src/commands/targets/add/desktop.ts index 34af8af4..5796a042 100644 --- a/packages/cli/src/commands/targets/add/desktop.ts +++ b/packages/cli/src/commands/targets/add/desktop.ts @@ -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 { const { args, flags } = await this.parse(TargetsAddDesktop) diff --git a/packages/cli/src/commands/targets/add/server.ts b/packages/cli/src/commands/targets/add/server.ts index b5b10aa2..da43b5c4 100644 --- a/packages/cli/src/commands/targets/add/server.ts +++ b/packages/cli/src/commands/targets/add/server.ts @@ -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 { const { args, flags } = await this.parse(TargetsAddServer) diff --git a/packages/cli/src/lib/installations.ts b/packages/cli/src/lib/installations.ts index 1f42d5a3..b10a3f6d 100644 --- a/packages/cli/src/lib/installations.ts +++ b/packages/cli/src/lib/installations.ts @@ -80,18 +80,13 @@ 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) @@ -99,11 +94,10 @@ export function normalizeInstallRequest(options: { 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', } } @@ -116,11 +110,6 @@ export function feedURLFor(options: ReturnType): return url.toString() } -export function downloadURLFor(options: ReturnType): 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 { const response = await fetch(feedURL, { signal: AbortSignal.timeout(30_000) }) if (!response.ok) throw new Error(`Update feed returned ${response.status} ${response.statusText}`) @@ -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 }) @@ -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 { return installServer({ channel: installation.channel, serverEnv: installation.serverEnv }) } @@ -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}".`) diff --git a/packages/cli/test/cli-smoke.ts b/packages/cli/test/cli-smoke.ts index f10fb246..623f2106 100644 --- a/packages/cli/test/cli-smoke.ts +++ b/packages/cli/test/cli-smoke.ts @@ -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' @@ -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 () => [ diff --git a/packages/cli/test/installations.test.ts b/packages/cli/test/installations.test.ts index bb7787cd..c6c8a037 100644 --- a/packages/cli/test/installations.test.ts +++ b/packages/cli/test/installations.test.ts @@ -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-')) @@ -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) + }) +})