From 395c7ebf28f51c140deeb5775ec1fdbbc3dbd848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 04:38:40 +0200 Subject: [PATCH 01/20] trip --- .github/workflows/publish-release.yml | 32 +- package.json | 3 +- packages/cli-plugin-cloudflare/package.json | 4 +- .../src/lib/cloudflared.ts | 4 +- packages/cli/bin/binary-bootstrap.js | 2 +- packages/cli/bin/cli.js | 11 + packages/cli/bin/run.js | 123 +++++- packages/cli/package.json | 5 +- packages/cli/scripts/build-binaries.ts | 5 +- packages/cli/src/commands.generated.ts | 368 +++++++++--------- packages/cli/src/commands/accounts/add.ts | 34 +- .../cli/src/commands/auth/email/response.ts | 29 ++ packages/cli/src/commands/auth/email/start.ts | 19 + packages/cli/src/commands/contacts/list.ts | 4 +- packages/cli/src/commands/setup.ts | 26 +- packages/cli/src/commands/update.ts | 4 +- packages/cli/src/lib/installations.ts | 6 +- packages/cli/src/lib/matrix-direct.ts | 4 +- packages/cli/src/lib/runner.ts | 34 +- packages/cli/src/lib/setup-login.ts | 79 ++++ packages/cli/test/e2e-staging.ts | 95 ++--- scripts/publish-packages.ts | 140 +++++++ 22 files changed, 724 insertions(+), 307 deletions(-) create mode 100644 packages/cli/bin/cli.js create mode 100644 packages/cli/src/commands/auth/email/response.ts create mode 100644 packages/cli/src/commands/auth/email/start.ts create mode 100644 packages/cli/src/lib/setup-login.ts create mode 100644 scripts/publish-packages.ts diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 7f7b1b6b..ea95c281 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -38,25 +38,13 @@ jobs: gh release create "${tag}" --title "${tag}" --generate-notes --verify-tag fi - # Binary/Homebrew release assets are intentionally disabled until binary - # releases are ready to ship. Re-enable these steps with the package scripts - # already kept in packages/cli/package.json: - # - # - name: Build Homebrew archive - # run: bun run --filter beeper-cli pack:homebrew - # - # - name: Publish GitHub release assets - # env: - # GH_TOKEN: ${{ github.token }} - # run: | - # set -euo pipefail - # tag="${GITHUB_REF_NAME}" - # if ! gh release view "${tag}" >/dev/null 2>&1; then - # gh release create "${tag}" --title "${tag}" --generate-notes --verify-tag - # fi - # gh release upload "${tag}" packages/cli/dist/bin/beeper-* packages/cli/dist/bin/binaries.json packages/cli/dist/release/*.tar.gz packages/cli/dist/release/homebrew.json --clobber - # - # - name: Publish Homebrew formula - # env: - # HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} - # run: bun scripts/publish-homebrew-formula.ts + - name: Build binary release assets + run: bun run --filter beeper-cli binary + + - name: Publish GitHub release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + gh release upload "${tag}" packages/cli/dist/bin/beeper-* packages/cli/dist/bin/binaries.json --clobber diff --git a/package.json b/package.json index bfe5690f..306405ee 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "desktop-api-cli-monorepo", + "name": "@beeper/cli", "private": true, "type": "module", "packageManager": "bun@1.3.10", @@ -16,6 +16,7 @@ "changeset": "changeset", "lint": "eslint eslint.config.mjs packages/cli-plugin-cloudflare/src packages/cli-plugin-cloudflare/test", "pack:packages": "mkdir -p .packs && (cd packages/cli && bun pm pack --destination ../../.packs) && (cd packages/cli-plugin-cloudflare && bun pm pack --destination ../../.packs)", + "publish:packages": "bun scripts/publish-packages.ts", "release": "bun run check && bun changeset publish", "test": "bun run --workspaces --sequential test", "typecheck": "bun run --filter beeper-cli build && bun run --workspaces --sequential typecheck", diff --git a/packages/cli-plugin-cloudflare/package.json b/packages/cli-plugin-cloudflare/package.json index 0f95289c..0f51e2d3 100644 --- a/packages/cli-plugin-cloudflare/package.json +++ b/packages/cli-plugin-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@beeper/cli-plugin-cloudflare", - "version": "0.0.0", + "version": "0.6.0", "description": "Cloudflare Tunnel commands for Beeper CLI", "license": "MIT", "type": "module", @@ -34,7 +34,7 @@ }, "dependencies": { "@oclif/core": "^4.11.2", - "beeper-cli": "workspace:*" + "beeper-cli": "^0.6.0" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/packages/cli-plugin-cloudflare/src/lib/cloudflared.ts b/packages/cli-plugin-cloudflare/src/lib/cloudflared.ts index 342ab218..c9e98b8e 100644 --- a/packages/cli-plugin-cloudflare/src/lib/cloudflared.ts +++ b/packages/cli-plugin-cloudflare/src/lib/cloudflared.ts @@ -1,4 +1,4 @@ -import { access, chmod, mkdir, rename, rm } from 'node:fs/promises' +import { access, chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises' import { arch, platform } from 'node:os' import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -193,7 +193,7 @@ function downloadURL(system = platform(), cpu = arch()): string { async function downloadFile(url: string, to: string): Promise { const response = await fetch(url, { redirect: 'follow' }) if (!response.ok || !response.body) throw new Error(`Could not download ${url}: ${response.status} ${response.statusText}`) - await Bun.write(to, response) + await writeFile(to, Buffer.from(await response.arrayBuffer())) } export function findTunnelURL(data: string, domain = cloudflaredDomain()): string | undefined { diff --git a/packages/cli/bin/binary-bootstrap.js b/packages/cli/bin/binary-bootstrap.js index 4910a07f..72cff777 100644 --- a/packages/cli/bin/binary-bootstrap.js +++ b/packages/cli/bin/binary-bootstrap.js @@ -11,7 +11,7 @@ void (async () => { const payloadHash = createHash('sha256').update(archive).digest('hex').slice(0, 16) const cacheRoot = process.env.BEEPER_CLI_BINARY_CACHE_DIR || join(homedir(), '.cache', 'beeper-cli', 'binary') const payloadRoot = join(cacheRoot, payloadHash) - const entrypoint = join(payloadRoot, 'bin', 'run.js') + const entrypoint = join(payloadRoot, 'bin', 'cli.js') if (!existsSync(entrypoint)) { const tempArchive = join(tmpdir(), `beeper-cli-${payloadHash}.tar.gz`) diff --git a/packages/cli/bin/cli.js b/packages/cli/bin/cli.js new file mode 100644 index 00000000..d05ebe6e --- /dev/null +++ b/packages/cli/bin/cli.js @@ -0,0 +1,11 @@ +#!/usr/bin/env bun +import { execute } from '@oclif/core' +import { renderStartupLogo } from './logo.js' + +void (async () => { + if (process.argv.slice(2).length === 0 && process.env.BEEPER_NO_LOGO !== '1') { + process.stdout.write(`${renderStartupLogo()}\n\n`) + } + + await execute({ dir: import.meta.url }) +})() diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js index d05ebe6e..434300d5 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -1,11 +1,118 @@ -#!/usr/bin/env bun -import { execute } from '@oclif/core' -import { renderStartupLogo } from './logo.js' +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { createWriteStream, existsSync } from 'node:fs' +import { chmod, mkdir, readFile, rename, rm } from 'node:fs/promises' +import { get } from 'node:https' +import { homedir, tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' -void (async () => { - if (process.argv.slice(2).length === 0 && process.env.BEEPER_NO_LOGO !== '1') { - process.stdout.write(`${renderStartupLogo()}\n\n`) +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))) +const pkg = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) +const version = pkg.version +const platform = normalizePlatform(process.platform) +const arch = normalizeArch(process.arch) +const extension = platform === 'windows' ? '.exe' : '' +const executableName = `beeper-${platform}-${arch}${extension}` +const releaseTag = process.env.BEEPER_CLI_RELEASE_TAG || `v${version}` +const releaseBaseURL = (process.env.BEEPER_CLI_RELEASE_BASE_URL || `https://github.com/beeper/desktop-api-cli/releases/download/${releaseTag}`).replace(/\/$/, '') +const cacheRoot = process.env.BEEPER_CLI_BINARY_CACHE_DIR || join(homedir(), '.cache', 'beeper-cli') +const cacheDir = join(cacheRoot, version, `${platform}-${arch}`) +const cachedExecutable = join(cacheDir, platform === 'windows' ? 'beeper.exe' : 'beeper') + +try { + const executable = await ensureExecutable() + const child = spawn(executable, process.argv.slice(2), { + env: process.env, + stdio: 'inherit', + }) + child.once('exit', (code, signal) => { + if (signal) process.kill(process.pid, signal) + process.exit(code ?? 1) + }) + child.once('error', error => { + console.error(`beeper-cli: failed to start downloaded binary: ${error.message}`) + process.exit(1) + }) +} catch (error) { + console.error(`beeper-cli: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +} + +async function ensureExecutable() { + if (existsSync(cachedExecutable)) return cachedExecutable + + await mkdir(cacheDir, { recursive: true }) + const tmpPath = join(tmpdir(), `${executableName}.${process.pid}.${Date.now()}.download`) + const url = `${releaseBaseURL}/${executableName}` + console.error(`beeper-cli: downloading ${url}`) + await download(url, tmpPath) + + const expectedHash = await fetchExpectedHash().catch(() => undefined) + if (expectedHash) { + const actualHash = await sha256(tmpPath) + if (actualHash !== expectedHash) { + await rm(tmpPath, { force: true }) + throw new Error(`downloaded binary checksum mismatch for ${executableName}`) + } + } else if (process.env.BEEPER_CLI_REQUIRE_CHECKSUM === '1') { + await rm(tmpPath, { force: true }) + throw new Error(`no checksum found for ${executableName}`) + } + + if (platform !== 'windows') await chmod(tmpPath, 0o755) + await rename(tmpPath, cachedExecutable) + return cachedExecutable +} + +function normalizePlatform(value) { + if (value === 'darwin') return 'darwin' + if (value === 'linux') return 'linux' + if (value === 'win32') return 'windows' + throw new Error(`unsupported platform: ${value}`) +} + +function normalizeArch(value) { + if (value === 'x64') return 'x64' + if (value === 'arm64') return 'arm64' + throw new Error(`unsupported architecture: ${value}`) +} + +async function fetchExpectedHash() { + const manifestURL = `${releaseBaseURL}/binaries.json` + const manifestPath = join(tmpdir(), `beeper-cli-binaries-${version}-${process.pid}-${Date.now()}.json`) + try { + await download(manifestURL, manifestPath, { quiet: true }) + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + return manifest.artifacts?.find(artifact => artifact.file === executableName)?.sha256 + } finally { + await rm(manifestPath, { force: true }) } +} + +async function download(url, destination, options = {}) { + await mkdir(dirname(destination), { recursive: true }) + await new Promise((resolve, reject) => { + const request = get(url, response => { + if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + response.resume() + download(new URL(response.headers.location, url).toString(), destination, options).then(resolve, reject) + return + } + if (response.statusCode !== 200) { + response.resume() + reject(new Error(`download failed for ${basename(url)}: HTTP ${response.statusCode}`)) + return + } + pipeline(response, createWriteStream(destination)).then(resolve, reject) + }) + request.once('error', reject) + request.setTimeout(120_000, () => request.destroy(new Error(`download timed out: ${url}`))) + }) +} - await execute({ dir: import.meta.url }) -})() +async function sha256(path) { + return createHash('sha256').update(await readFile(path)).digest('hex') +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 379d7226..0d2f9d96 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -5,7 +5,7 @@ "license": "MIT", "type": "module", "bin": { - "beeper": "./bin/run.js" + "beeper": "bin/run.js" }, "exports": { "./plugin-sdk": { @@ -27,6 +27,7 @@ "check:readme": "bun run build && bun scripts/generate-readme.ts --check", "clean": "rm -rf dist", "dev": "bun ./bin/dev.js", + "dev:shim": "node ./bin/run.js", "e2e:staging": "bun run build && bun test/e2e-staging.ts", "pack:homebrew": "bun run binary && bun scripts/build-homebrew-archive.ts", "readme": "bun run build && bun scripts/generate-readme.ts", @@ -53,7 +54,7 @@ "scope": "beeper", "pluginPrefix": "plugin", "jitPlugins": { - "@beeper/cli-plugin-cloudflare": "^0.0.0" + "@beeper/cli-plugin-cloudflare": "^0.6.0" }, "plugins": [ "@oclif/plugin-autocomplete", diff --git a/packages/cli/scripts/build-binaries.ts b/packages/cli/scripts/build-binaries.ts index c9a22948..d0f6c3a8 100644 --- a/packages/cli/scripts/build-binaries.ts +++ b/packages/cli/scripts/build-binaries.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { createHash } from 'node:crypto' import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -57,7 +58,7 @@ async function hashFile(path) { } async function buildPayload() { - const workDir = await mkdtemp(join('/private/tmp', 'beeper-cli-payload-')) + const workDir = await mkdtemp(join(tmpdir(), 'beeper-cli-payload-')) try { await cp(join(root, 'package.json'), join(workDir, 'package.json')) await cp(join(root, 'bin'), join(workDir, 'bin'), { recursive: true }) @@ -79,7 +80,7 @@ async function buildPayload() { async function run(command, args, options = {}) { const child = Bun.spawn([command, ...args], { cwd: options.cwd || root, - env: { ...process.env, TMPDIR: '/private/tmp' }, + env: process.env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', diff --git a/packages/cli/src/commands.generated.ts b/packages/cli/src/commands.generated.ts index ccbf0678..8cbaf8eb 100644 --- a/packages/cli/src/commands.generated.ts +++ b/packages/cli/src/commands.generated.ts @@ -6,97 +6,99 @@ import Command4 from './commands/accounts/use.js' import Command5 from './commands/api/get.js' import Command6 from './commands/api/post.js' import Command7 from './commands/api/request.js' -import Command8 from './commands/auth/logout.js' -import Command9 from './commands/auth/status.js' -import Command10 from './commands/autocomplete.js' -import Command11 from './commands/bridges/list.js' -import Command12 from './commands/bridges/show.js' -import Command13 from './commands/chats/archive.js' -import Command14 from './commands/chats/avatar.js' -import Command15 from './commands/chats/description.js' -import Command16 from './commands/chats/disappear.js' -import Command17 from './commands/chats/draft.js' -import Command18 from './commands/chats/focus.js' -import Command19 from './commands/chats/list.js' -import Command20 from './commands/chats/mark-read.js' -import Command21 from './commands/chats/mark-unread.js' -import Command22 from './commands/chats/mute.js' -import Command23 from './commands/chats/notify-anyway.js' -import Command24 from './commands/chats/pin.js' -import Command25 from './commands/chats/priority.js' -import Command26 from './commands/chats/remind.js' -import Command27 from './commands/chats/rename.js' -import Command28 from './commands/chats/search.js' -import Command29 from './commands/chats/show.js' -import Command30 from './commands/chats/start.js' -import Command31 from './commands/chats/unarchive.js' -import Command32 from './commands/chats/unmute.js' -import Command33 from './commands/chats/unpin.js' -import Command34 from './commands/chats/unremind.js' -import Command35 from './commands/completion.js' -import Command36 from './commands/config/get.js' -import Command37 from './commands/config/path.js' -import Command38 from './commands/config/reset.js' -import Command39 from './commands/config/set.js' -import Command40 from './commands/contacts/list.js' -import Command41 from './commands/contacts/search.js' -import Command42 from './commands/contacts/show.js' -import Command43 from './commands/docs.js' -import Command44 from './commands/doctor.js' -import Command45 from './commands/export.js' -import Command46 from './commands/install/desktop.js' -import Command47 from './commands/install/server.js' -import Command48 from './commands/man.js' -import Command49 from './commands/media/download.js' -import Command50 from './commands/messages/context.js' -import Command51 from './commands/messages/delete.js' -import Command52 from './commands/messages/edit.js' -import Command53 from './commands/messages/export.js' -import Command54 from './commands/messages/list.js' -import Command55 from './commands/messages/search.js' -import Command56 from './commands/messages/show.js' -import Command57 from './commands/plugins.js' -import Command58 from './commands/plugins/available.js' -import Command59 from './commands/presence.js' -import Command60 from './commands/rpc.js' -import Command61 from './commands/send/file.js' -import Command62 from './commands/send/react.js' -import Command63 from './commands/send/sticker.js' -import Command64 from './commands/send/text.js' -import Command65 from './commands/send/unreact.js' -import Command66 from './commands/send/voice.js' -import Command67 from './commands/setup.js' -import Command68 from './commands/status.js' -import Command69 from './commands/targets/add/desktop.js' -import Command70 from './commands/targets/add/remote.js' -import Command71 from './commands/targets/add/server.js' -import Command72 from './commands/targets/disable.js' -import Command73 from './commands/targets/enable.js' -import Command74 from './commands/targets/list.js' -import Command75 from './commands/targets/logs.js' -import Command76 from './commands/targets/remove.js' -import Command77 from './commands/targets/restart.js' -import Command78 from './commands/targets/show.js' -import Command79 from './commands/targets/start.js' -import Command80 from './commands/targets/status.js' -import Command81 from './commands/targets/stop.js' -import Command82 from './commands/targets/use.js' -import Command83 from './commands/update.js' -import Command84 from './commands/verify.js' -import Command85 from './commands/verify/approve.js' -import Command86 from './commands/verify/cancel.js' -import Command87 from './commands/verify/list.js' -import Command88 from './commands/verify/qr-confirm.js' -import Command89 from './commands/verify/qr-scan.js' -import Command90 from './commands/verify/recovery-key.js' -import Command91 from './commands/verify/reset-recovery-key.js' -import Command92 from './commands/verify/sas.js' -import Command93 from './commands/verify/sas-confirm.js' -import Command94 from './commands/verify/show.js' -import Command95 from './commands/verify/start.js' -import Command96 from './commands/verify/status.js' -import Command97 from './commands/version.js' -import Command98 from './commands/watch.js' +import Command8 from './commands/auth/email/response.js' +import Command9 from './commands/auth/email/start.js' +import Command10 from './commands/auth/logout.js' +import Command11 from './commands/auth/status.js' +import Command12 from './commands/autocomplete.js' +import Command13 from './commands/bridges/list.js' +import Command14 from './commands/bridges/show.js' +import Command15 from './commands/chats/archive.js' +import Command16 from './commands/chats/avatar.js' +import Command17 from './commands/chats/description.js' +import Command18 from './commands/chats/disappear.js' +import Command19 from './commands/chats/draft.js' +import Command20 from './commands/chats/focus.js' +import Command21 from './commands/chats/list.js' +import Command22 from './commands/chats/mark-read.js' +import Command23 from './commands/chats/mark-unread.js' +import Command24 from './commands/chats/mute.js' +import Command25 from './commands/chats/notify-anyway.js' +import Command26 from './commands/chats/pin.js' +import Command27 from './commands/chats/priority.js' +import Command28 from './commands/chats/remind.js' +import Command29 from './commands/chats/rename.js' +import Command30 from './commands/chats/search.js' +import Command31 from './commands/chats/show.js' +import Command32 from './commands/chats/start.js' +import Command33 from './commands/chats/unarchive.js' +import Command34 from './commands/chats/unmute.js' +import Command35 from './commands/chats/unpin.js' +import Command36 from './commands/chats/unremind.js' +import Command37 from './commands/completion.js' +import Command38 from './commands/config/get.js' +import Command39 from './commands/config/path.js' +import Command40 from './commands/config/reset.js' +import Command41 from './commands/config/set.js' +import Command42 from './commands/contacts/list.js' +import Command43 from './commands/contacts/search.js' +import Command44 from './commands/contacts/show.js' +import Command45 from './commands/docs.js' +import Command46 from './commands/doctor.js' +import Command47 from './commands/export.js' +import Command48 from './commands/install/desktop.js' +import Command49 from './commands/install/server.js' +import Command50 from './commands/man.js' +import Command51 from './commands/media/download.js' +import Command52 from './commands/messages/context.js' +import Command53 from './commands/messages/delete.js' +import Command54 from './commands/messages/edit.js' +import Command55 from './commands/messages/export.js' +import Command56 from './commands/messages/list.js' +import Command57 from './commands/messages/search.js' +import Command58 from './commands/messages/show.js' +import Command59 from './commands/plugins.js' +import Command60 from './commands/plugins/available.js' +import Command61 from './commands/presence.js' +import Command62 from './commands/rpc.js' +import Command63 from './commands/send/file.js' +import Command64 from './commands/send/react.js' +import Command65 from './commands/send/sticker.js' +import Command66 from './commands/send/text.js' +import Command67 from './commands/send/unreact.js' +import Command68 from './commands/send/voice.js' +import Command69 from './commands/setup.js' +import Command70 from './commands/status.js' +import Command71 from './commands/targets/add/desktop.js' +import Command72 from './commands/targets/add/remote.js' +import Command73 from './commands/targets/add/server.js' +import Command74 from './commands/targets/disable.js' +import Command75 from './commands/targets/enable.js' +import Command76 from './commands/targets/list.js' +import Command77 from './commands/targets/logs.js' +import Command78 from './commands/targets/remove.js' +import Command79 from './commands/targets/restart.js' +import Command80 from './commands/targets/show.js' +import Command81 from './commands/targets/start.js' +import Command82 from './commands/targets/status.js' +import Command83 from './commands/targets/stop.js' +import Command84 from './commands/targets/use.js' +import Command85 from './commands/update.js' +import Command86 from './commands/verify.js' +import Command87 from './commands/verify/approve.js' +import Command88 from './commands/verify/cancel.js' +import Command89 from './commands/verify/list.js' +import Command90 from './commands/verify/qr-confirm.js' +import Command91 from './commands/verify/qr-scan.js' +import Command92 from './commands/verify/recovery-key.js' +import Command93 from './commands/verify/reset-recovery-key.js' +import Command94 from './commands/verify/sas.js' +import Command95 from './commands/verify/sas-confirm.js' +import Command96 from './commands/verify/show.js' +import Command97 from './commands/verify/start.js' +import Command98 from './commands/verify/status.js' +import Command99 from './commands/version.js' +import Command100 from './commands/watch.js' export const commands = { 'accounts:add': Command0, @@ -107,95 +109,97 @@ export const commands = { 'api:get': Command5, 'api:post': Command6, 'api:request': Command7, - 'auth:logout': Command8, - 'auth:status': Command9, - 'autocomplete': Command10, - 'bridges:list': Command11, - 'bridges:show': Command12, - 'chats:archive': Command13, - 'chats:avatar': Command14, - 'chats:description': Command15, - 'chats:disappear': Command16, - 'chats:draft': Command17, - 'chats:focus': Command18, - 'chats:list': Command19, - 'chats:mark-read': Command20, - 'chats:mark-unread': Command21, - 'chats:mute': Command22, - 'chats:notify-anyway': Command23, - 'chats:pin': Command24, - 'chats:priority': Command25, - 'chats:remind': Command26, - 'chats:rename': Command27, - 'chats:search': Command28, - 'chats:show': Command29, - 'chats:start': Command30, - 'chats:unarchive': Command31, - 'chats:unmute': Command32, - 'chats:unpin': Command33, - 'chats:unremind': Command34, - 'completion': Command35, - 'config:get': Command36, - 'config:path': Command37, - 'config:reset': Command38, - 'config:set': Command39, - 'contacts:list': Command40, - 'contacts:search': Command41, - 'contacts:show': Command42, - 'docs': Command43, - 'doctor': Command44, - 'export': Command45, - 'install:desktop': Command46, - 'install:server': Command47, - 'man': Command48, - 'media:download': Command49, - 'messages:context': Command50, - 'messages:delete': Command51, - 'messages:edit': Command52, - 'messages:export': Command53, - 'messages:list': Command54, - 'messages:search': Command55, - 'messages:show': Command56, - 'plugins': Command57, - 'plugins:available': Command58, - 'presence': Command59, - 'rpc': Command60, - 'send:file': Command61, - 'send:react': Command62, - 'send:sticker': Command63, - 'send:text': Command64, - 'send:unreact': Command65, - 'send:voice': Command66, - 'setup': Command67, - 'status': Command68, - 'targets:add:desktop': Command69, - 'targets:add:remote': Command70, - 'targets:add:server': Command71, - 'targets:disable': Command72, - 'targets:enable': Command73, - 'targets:list': Command74, - 'targets:logs': Command75, - 'targets:remove': Command76, - 'targets:restart': Command77, - 'targets:show': Command78, - 'targets:start': Command79, - 'targets:status': Command80, - 'targets:stop': Command81, - 'targets:use': Command82, - 'update': Command83, - 'verify': Command84, - 'verify:approve': Command85, - 'verify:cancel': Command86, - 'verify:list': Command87, - 'verify:qr-confirm': Command88, - 'verify:qr-scan': Command89, - 'verify:recovery-key': Command90, - 'verify:reset-recovery-key': Command91, - 'verify:sas': Command92, - 'verify:sas-confirm': Command93, - 'verify:show': Command94, - 'verify:start': Command95, - 'verify:status': Command96, - 'version': Command97, - 'watch': Command98, + 'auth:email:response': Command8, + 'auth:email:start': Command9, + 'auth:logout': Command10, + 'auth:status': Command11, + 'autocomplete': Command12, + 'bridges:list': Command13, + 'bridges:show': Command14, + 'chats:archive': Command15, + 'chats:avatar': Command16, + 'chats:description': Command17, + 'chats:disappear': Command18, + 'chats:draft': Command19, + 'chats:focus': Command20, + 'chats:list': Command21, + 'chats:mark-read': Command22, + 'chats:mark-unread': Command23, + 'chats:mute': Command24, + 'chats:notify-anyway': Command25, + 'chats:pin': Command26, + 'chats:priority': Command27, + 'chats:remind': Command28, + 'chats:rename': Command29, + 'chats:search': Command30, + 'chats:show': Command31, + 'chats:start': Command32, + 'chats:unarchive': Command33, + 'chats:unmute': Command34, + 'chats:unpin': Command35, + 'chats:unremind': Command36, + 'completion': Command37, + 'config:get': Command38, + 'config:path': Command39, + 'config:reset': Command40, + 'config:set': Command41, + 'contacts:list': Command42, + 'contacts:search': Command43, + 'contacts:show': Command44, + 'docs': Command45, + 'doctor': Command46, + 'export': Command47, + 'install:desktop': Command48, + 'install:server': Command49, + 'man': Command50, + 'media:download': Command51, + 'messages:context': Command52, + 'messages:delete': Command53, + 'messages:edit': Command54, + 'messages:export': Command55, + 'messages:list': Command56, + 'messages:search': Command57, + 'messages:show': Command58, + 'plugins': Command59, + 'plugins:available': Command60, + 'presence': Command61, + 'rpc': Command62, + 'send:file': Command63, + 'send:react': Command64, + 'send:sticker': Command65, + 'send:text': Command66, + 'send:unreact': Command67, + 'send:voice': Command68, + 'setup': Command69, + 'status': Command70, + 'targets:add:desktop': Command71, + 'targets:add:remote': Command72, + 'targets:add:server': Command73, + 'targets:disable': Command74, + 'targets:enable': Command75, + 'targets:list': Command76, + 'targets:logs': Command77, + 'targets:remove': Command78, + 'targets:restart': Command79, + 'targets:show': Command80, + 'targets:start': Command81, + 'targets:status': Command82, + 'targets:stop': Command83, + 'targets:use': Command84, + 'update': Command85, + 'verify': Command86, + 'verify:approve': Command87, + 'verify:cancel': Command88, + 'verify:list': Command89, + 'verify:qr-confirm': Command90, + 'verify:qr-scan': Command91, + 'verify:recovery-key': Command92, + 'verify:reset-recovery-key': Command93, + 'verify:sas': Command94, + 'verify:sas-confirm': Command95, + 'verify:show': Command96, + 'verify:start': Command97, + 'verify:status': Command98, + 'version': Command99, + 'watch': Command100, } diff --git a/packages/cli/src/commands/accounts/add.ts b/packages/cli/src/commands/accounts/add.ts index 2f216b74..43e3215e 100644 --- a/packages/cli/src/commands/accounts/add.ts +++ b/packages/cli/src/commands/accounts/add.ts @@ -38,9 +38,12 @@ export default class AccountsAdd extends BeeperCommand { await printData(bridges, 'json') return } - - printAvailableAccounts(bridges.items) - return + if (flags.guided && !flags['non-interactive'] && process.stdin.isTTY) { + args.bridge = await chooseAccountType(bridges.items) + } else { + printAvailableAccounts(bridges.items) + return + } } const bridges = await client.bridges.list() @@ -81,6 +84,31 @@ export default class AccountsAdd extends BeeperCommand { } } +async function chooseAccountType(items: AccountType[]): Promise { + const available = items.filter(item => item.status === 'available') + if (!available.length) throw new Error('No available bridges to connect.') + + process.stdout.write('Choose a bridge to connect an account:\n') + available.forEach((account, index) => { + const multiple = account.supportsMultipleAccounts ? 'multiple allowed' : 'single account' + process.stdout.write(` ${index + 1}. ${account.displayName} (${account.id}) - ${multiple}\n`) + }) + + const rl = createInterface({ input, output }) + try { + for (;;) { + const answer = (await rl.question('Select a bridge: ')).trim() + const selected = Number.parseInt(answer, 10) + if (Number.isInteger(selected) && selected >= 1 && selected <= available.length) return available[selected - 1]!.id + const byID = available.find(account => account.id === answer) + if (byID) return byID.id + process.stdout.write('Choose one of the listed bridges.\n') + } + } finally { + rl.close() + } +} + function printAvailableAccounts(items: AccountType[]): void { const sections: Array<[string, AccountType[]]> = [ ['On-Device Accounts', items.filter(item => item.provider === 'local')], diff --git a/packages/cli/src/commands/auth/email/response.ts b/packages/cli/src/commands/auth/email/response.ts new file mode 100644 index 00000000..1a517ecb --- /dev/null +++ b/packages/cli/src/commands/auth/email/response.ts @@ -0,0 +1,29 @@ +import { Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../../lib/command.js' +import { resolveTarget } from '../../../lib/targets.js' +import { finishEmailSetup } from '../../../lib/setup-login.js' +import { printData } from '../../../lib/output.js' + +export default class AuthEmailResponse extends BeeperCommand { + static override summary = 'Finish email sign-in with a verification code' + static override flags = { + code: Flags.string({ required: true, description: 'Email verification code' }), + 'setup-request-id': Flags.string({ required: true, description: 'Setup request ID from auth email start' }), + username: Flags.string({ description: 'Username to use if setup creates a new account' }), + yes: Flags.boolean({ default: false, description: 'Accept required registration prompts non-interactively' }), + } + + async run(): Promise { + const { flags } = await this.parse(AuthEmailResponse) + ensureWritable(flags) + const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] }) + const data = await finishEmailSetup(target, { + code: flags.code, + json: flags.json, + setupRequestID: flags['setup-request-id'], + username: flags.username, + yes: flags.yes, + }) + await printData(data, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/auth/email/start.ts b/packages/cli/src/commands/auth/email/start.ts new file mode 100644 index 00000000..0baca3db --- /dev/null +++ b/packages/cli/src/commands/auth/email/start.ts @@ -0,0 +1,19 @@ +import { Flags } from '@oclif/core' +import { BeeperCommand } from '../../../lib/command.js' +import { resolveTarget } from '../../../lib/targets.js' +import { startEmailSetup } from '../../../lib/setup-login.js' +import { printData } from '../../../lib/output.js' + +export default class AuthEmailStart extends BeeperCommand { + static override summary = 'Start email sign-in for a target' + static override flags = { + email: Flags.string({ required: true, description: 'Email address to sign in with' }), + } + + async run(): Promise { + const { flags } = await this.parse(AuthEmailStart) + const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] }) + const data = await startEmailSetup(target, flags.email) + await printData(data, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/contacts/list.ts b/packages/cli/src/commands/contacts/list.ts index 684c314e..9fac2b25 100644 --- a/packages/cli/src/commands/contacts/list.ts +++ b/packages/cli/src/commands/contacts/list.ts @@ -3,7 +3,7 @@ import { BeeperCommand } from '../../lib/command.js' import { createClient } from '../../lib/client.js' import { apiCopy, cliCopy } from '../../lib/copy.js' import { collectPage, printIDs, printList } from '../../lib/output.js' -import { resolveAccountIDs } from '../../lib/resolve.js' +import { listAccountIDs, resolveAccountIDs } from '../../lib/resolve.js' import { withInkSpinner as withSpinner } from '../../lib/ink/spinner.js' export default class ContactsList extends BeeperCommand { @@ -21,7 +21,7 @@ export default class ContactsList extends BeeperCommand { async run(): Promise { const { flags } = await this.parse(ContactsList) const client = await createClient(flags) - const accountIDs = (await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }))! + const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) ?? await listAccountIDs(client) const useSpinner = !flags.json && !flags.ids const load = async (): Promise>> => { const collected: Array> = [] diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 16105cce..ca1f39a5 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -7,6 +7,7 @@ import { installDesktop, installServer, type InstallChannel } from '../lib/insta import { connectedAccountSummary, findLocalDesktopSession, type LocalDesktopSession } from '../lib/local-desktop.js' import { loginWithPKCE } from '../lib/oauth.js' import { launchDesktopApp, startProfile } from '../lib/profiles.js' +import { interactiveEmailSetup } from '../lib/setup-login.js' import { builtInDesktopTargetID, createProfileTarget, @@ -32,6 +33,8 @@ export default class Setup extends BeeperCommand { 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.' }), + email: Flags.string({ description: 'Sign in with an email address' }), + username: Flags.string({ description: 'Username to use if setup creates a new account' }), } static override examples = [ @@ -45,8 +48,8 @@ export default class Setup extends BeeperCommand { async run(): Promise { const { flags } = await this.parse(Setup) ensureWritable(flags) - const modeCount = [flags.local, flags.oauth, Boolean(flags.remote), flags.server, flags.desktop].filter(Boolean).length - if (modeCount > 1) throw new Error('Specify at most one of --local, --oauth, --remote, --server, or --desktop') + const modeCount = [flags.local, flags.oauth, Boolean(flags.email), Boolean(flags.remote), flags.server, flags.desktop].filter(Boolean).length + if (modeCount > 1) throw new Error('Specify at most one of --local, --oauth, --email, --remote, --server, or --desktop') if (flags.events) writeEvent('setup_step', { step: 'start', target: flags.target }) if (flags.remote) { @@ -71,6 +74,10 @@ export default class Setup extends BeeperCommand { await this.setupOAuth(target, flags) return } + if (flags.email) { + await this.setupEmail(target, flags) + return + } await this.setupDefault(target, flags) } @@ -138,6 +145,11 @@ export default class Setup extends BeeperCommand { await this.printSetupResult(result, flags) } + private async setupEmail(target: Target, flags: SetupFlags): Promise { + const result = await setupEmailTarget(target, flags) + await this.printSetupResult(result, flags) + } + private async setupRemote(flags: SetupFlags): Promise { const name = flags.target ?? remoteName(flags.remote!) const target: Target = { @@ -194,10 +206,12 @@ type SetupFlags = { json?: boolean local?: boolean oauth?: boolean + email?: string remote?: string server?: boolean 'server-env'?: string target?: string + username?: string yes?: boolean } @@ -311,6 +325,14 @@ async function setupOAuthTarget(target: Target, flags: SetupFlags, source?: Auth return { accounts, authSource, readiness, target: publicTarget({ ...target, auth }) } } +async function setupEmailTarget(target: Target, flags: SetupFlags): Promise { + if (flags.events) writeEvent('setup_step', { step: 'email', target: target.id }) + const email = flags.email + if (!email) throw new Error('Email setup requires --email.') + if (flags.json || !process.stdin.isTTY) throw new Error('Email setup prompts for the verification code. For automation, use `beeper auth email start` and `beeper auth email response`.') + return interactiveEmailSetup(target, { email, username: flags.username, yes: flags.yes, json: flags.json }) +} + function publicTarget(target: Target): Omit & { auth?: { source?: AuthSource; tokenType?: 'Bearer' } } { const { auth, ...rest } = target return { ...rest, auth: auth ? { source: auth.source, tokenType: auth.tokenType } : undefined } diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 5da632cc..173ee9ec 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -128,10 +128,10 @@ function upgradeAction(method: CLIInstallMethod): string { case 'brew': return 'Update with: brew upgrade beeper/tap/beeper-cli' case 'npm-global': - return 'Update with: bun install -g beeper-cli@latest' + return 'Update with: npm install -g beeper-cli@latest' case 'git': return `Update with: git -C ${method.path.split('/packages/')[0]} pull && bun run --filter beeper-cli build` default: - return 'Update with: brew upgrade beeper/tap/beeper-cli OR bun install -g beeper-cli@latest' + return 'Update with: brew upgrade beeper/tap/beeper-cli OR npm install -g beeper-cli@latest' } } diff --git a/packages/cli/src/lib/installations.ts b/packages/cli/src/lib/installations.ts index 8e05cdcb..7a7ead7a 100644 --- a/packages/cli/src/lib/installations.ts +++ b/packages/cli/src/lib/installations.ts @@ -213,7 +213,7 @@ export async function downloadArtifact(url: string, destinationDir: string): Pro 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`) - await Bun.write(tmpPath, response) + await writeResponseToFile(response, tmpPath) await rename(tmpPath, finalPath) return finalPath } @@ -360,6 +360,10 @@ function stringField(value: unknown, fields: string[]): string | undefined { return undefined } +async function writeResponseToFile(response: Response, path: string): Promise { + await writeFile(path, Buffer.from(await response.arrayBuffer())) +} + function filenameFromResponse(response: Response): string | undefined { const contentDisposition = response.headers.get('content-disposition') const match = contentDisposition?.match(/filename="?([^";]+)"?/i) diff --git a/packages/cli/src/lib/matrix-direct.ts b/packages/cli/src/lib/matrix-direct.ts index 232dc546..b2b349fb 100644 --- a/packages/cli/src/lib/matrix-direct.ts +++ b/packages/cli/src/lib/matrix-direct.ts @@ -1,4 +1,4 @@ -import { appRequest } from './app-api.js' +import { getAppState } from './app-state.js' import { resolveTarget } from './targets.js' type MatrixFlags = { @@ -12,7 +12,7 @@ export async function matrixContext(flags: MatrixFlags): Promise const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] }) const token = process.env.BEEPER_ACCESS_TOKEN || target.auth?.accessToken if (!token) throw new Error('Matrix fallback requires stored target auth or BEEPER_ACCESS_TOKEN.') - const state = await appRequest<{ matrix?: { homeserver?: string } }>('GET', '/v1/app/setup', { + const state = await getAppState({ baseURL: flags['base-url'], target: flags.target, token, diff --git a/packages/cli/src/lib/runner.ts b/packages/cli/src/lib/runner.ts index b029e778..9ffc8a79 100644 --- a/packages/cli/src/lib/runner.ts +++ b/packages/cli/src/lib/runner.ts @@ -1,3 +1,5 @@ +import { spawn } from 'node:child_process' + export type RunResult = { code: number | null signal: NodeJS.Signals | null @@ -6,22 +8,32 @@ export type RunResult = { } export async function runCli(args: string[], options: { inherit?: boolean } = {}): Promise { - const child = Bun.spawn([process.execPath, process.argv[1]!, ...args], { + const child = spawn(process.execPath, [process.argv[1]!, ...args], { env: process.env, - stdin: options.inherit ? 'inherit' : 'ignore', - stdout: options.inherit ? 'inherit' : 'pipe', - stderr: options.inherit ? 'inherit' : 'pipe', + stdio: [options.inherit ? 'inherit' : 'ignore', options.inherit ? 'inherit' : 'pipe', options.inherit ? 'inherit' : 'pipe'], + }) + + const waitForExit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({ code, signal })) }) if (options.inherit) { - const code = await child.exited - return { code, signal: child.signalCode as NodeJS.Signals | null, stdout: '', stderr: '' } + const { code, signal } = await waitForExit + return { code, signal, stdout: '', stderr: '' } } - const [stdout, stderr, code] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, + const [stdout, stderr, exit] = await Promise.all([ + streamToString(child.stdout), + streamToString(child.stderr), + waitForExit, ]) - return { code, signal: child.signalCode as NodeJS.Signals | null, stdout, stderr } + return { code: exit.code, signal: exit.signal, stdout, stderr } +} + +async function streamToString(stream: NodeJS.ReadableStream | null): Promise { + if (!stream) return '' + const chunks: Buffer[] = [] + for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + return Buffer.concat(chunks).toString('utf8') } diff --git a/packages/cli/src/lib/setup-login.ts b/packages/cli/src/lib/setup-login.ts new file mode 100644 index 00000000..f8b106d1 --- /dev/null +++ b/packages/cli/src/lib/setup-login.ts @@ -0,0 +1,79 @@ +import { BeeperDesktop } from '@beeper/desktop-api' +import { evaluateReadiness } from './app-state.js' +import { isRegistrationRequired, promptText, promptYesNoDefaultYes, type AppLoginSuccess } from './app-api.js' +import { connectedAccountSummary } from './local-desktop.js' +import { saveTargetAuth, writeTarget, type AuthSource, type Target } from './targets.js' + +export type SetupLoginResult = { + accounts: string[] + authSource?: AuthSource + readiness: Awaited> + target: Omit & { auth?: { source?: AuthSource; tokenType?: 'Bearer' } } +} + +export async function startEmailSetup(target: Target, email: string): Promise<{ setupRequestID: string }> { + const client = setupClient(target) + const start = await client.app.login.start() + await client.app.login.email({ setupRequestID: start.setupRequestID, email }) + return { setupRequestID: start.setupRequestID } +} + +export async function finishEmailSetup(target: Target, options: { + code: string + email?: string + json?: boolean + setupRequestID: string + username?: string + yes?: boolean +}): Promise { + const client = setupClient(target) + let output = await client.app.login.response({ setupRequestID: options.setupRequestID, response: options.code }) + if (isRegistrationRequired(output)) { + if ((options.json || !process.stdin.isTTY) && !options.yes) throw new Error('Registration requires --yes to accept the Beeper terms in non-interactive setup.') + const username = options.username ?? (options.json || !process.stdin.isTTY ? undefined : await promptUsername(output.usernameSuggestions)) + if (!username) throw new Error('Registration requires --username.') + if (!options.yes && !await promptYesNoDefaultYes('Accept the Beeper terms and create this account?')) throw new Error('Registration cancelled.') + output = await client.app.login.register({ + acceptTerms: true, + leadToken: output.leadToken, + setupRequestID: output.setupRequestID, + username, + }) + } + return persistSetupLogin(target, output as AppLoginSuccess) +} + +export async function interactiveEmailSetup(target: Target, options: { email: string; json?: boolean; username?: string; yes?: boolean }): Promise { + const start = await startEmailSetup(target, options.email) + const code = await promptText('Email code: ') + return finishEmailSetup(target, { ...options, code, setupRequestID: start.setupRequestID }) +} + +function setupClient(target: Target): BeeperDesktop { + return new BeeperDesktop({ baseURL: target.baseURL, accessToken: 'not-needed-for-setup', logLevel: 'warn' }) +} + +async function persistSetupLogin(target: Target, data: AppLoginSuccess): Promise { + const token = data.matrix?.accessToken + if (!token) throw new Error('Setup did not return a Matrix access token.') + const auth = { accessToken: token, source: 'manual' as AuthSource, tokenType: 'Bearer' as const } + await writeTarget(target) + await saveTargetAuth(target, auth) + const [readiness, accounts] = await Promise.all([ + evaluateReadiness({ baseURL: target.baseURL, target: target.id, token }), + connectedAccountSummary(target, auth).catch(() => []), + ]) + return { accounts, authSource: auth.source, readiness, target: publicTarget({ ...target, auth }) } +} + +function publicTarget(target: Target): Omit & { auth?: { source?: AuthSource; tokenType?: 'Bearer' } } { + const { auth, ...rest } = target + return { ...rest, auth: auth ? { source: auth.source, tokenType: auth.tokenType } : undefined } +} + +async function promptUsername(suggestions: string[] | undefined): Promise { + const fallback = suggestions?.[0] + const suffix = fallback ? ` [${fallback}]` : '' + const value = await promptText(`Username${suffix}: `) + return value || fallback || '' +} diff --git a/packages/cli/test/e2e-staging.ts b/packages/cli/test/e2e-staging.ts index 892a7b7c..870f2c59 100644 --- a/packages/cli/test/e2e-staging.ts +++ b/packages/cli/test/e2e-staging.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url' const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') await loadEnvFile(process.env.BEEPER_E2E_ENV_FILE || path.join(repoRoot, '.env.e2e')) -const cliBin = path.join(repoRoot, 'bin/run.js') +const cliBin = process.env.BEEPER_E2E_CLI_BIN || path.join(repoRoot, 'bin/dev.js') const runID = process.env.BEEPER_E2E_RUN_ID || String(Date.now()) const workDir = process.env.BEEPER_E2E_WORKDIR || path.join(tmpdir(), `beeper-cli-e2e-${runID}`) const configDir = process.env.BEEPER_E2E_CONFIG_DIR || path.join(workDir, 'cli-config') @@ -265,14 +265,17 @@ async function phaseMessaging() { async function phaseGroupMessaging(targets) { const [sender, ...receivers] = targets.filter(target => target.accessToken && target.matrix?.userID) - if (!sender || receivers.length < 2) { - recordBlock('messaging', undefined, 'group messaging needs three signed-in targets with Matrix user IDs.') + const distinctReceivers = receivers.filter((target, index, list) => + target.matrix.userID !== sender?.matrix?.userID && + list.findIndex(candidate => candidate.matrix.userID === target.matrix.userID) === index) + if (!sender || distinctReceivers.length < 2) { + recordBlock('messaging', undefined, 'group messaging needs three signed-in targets with distinct Matrix user IDs.') return } const roomName = `CLI E2E ${runID}` const createRoomBody = JSON.stringify({ - invite: receivers.slice(0, 2).map(target => target.matrix.userID), + invite: distinctReceivers.slice(0, 2).map(target => target.matrix.userID), name: roomName, preset: 'trusted_private_chat', }) @@ -290,7 +293,7 @@ async function phaseGroupMessaging(targets) { const send = runCli(sendArgs, { env: { BEEPER_ACCESS_TOKEN: sender.accessToken }, allowFailure: true }) recordCommand('messaging-group', sendArgs, send) - for (const target of [sender, ...receivers.slice(0, 2)]) { + for (const target of [sender, ...distinctReceivers.slice(0, 2)]) { const listArgs = ['messages', 'list', '--chat', chatID, '--target', target.name, '--limit', '10', '--json'] const list = runCli(listArgs, { env: { BEEPER_ACCESS_TOKEN: target.accessToken }, allowFailure: true }) recordCommand('messaging-group', listArgs, list) @@ -314,11 +317,10 @@ async function phaseApiSurface() { for (const args of [ ['api', 'request', 'GET', '/v1/info', '--target', target.name, '--no-auth', '--json'], ['api', 'request', 'GET', '/v1/spec', '--target', target.name, '--no-auth', '--json'], - ['api', 'request', 'GET', '/v1/app', '--target', target.name, '--json'], - ['api', 'request', 'GET', '/v1/app/verifications', '--target', target.name, '--json'], + ['api', 'request', 'GET', '/v1/app/setup', '--target', target.name, '--json'], + ['api', 'request', 'GET', '/v1/app/setup/verifications', '--target', target.name, '--json'], ['api', 'get', '/v1/accounts', '--target', target.name, '--json'], ['api', 'get', '/v1/chats?limit=10', '--target', target.name, '--json'], - ['api', 'get', '/v1/contacts?limit=10', '--target', target.name, '--json'], ]) { const result = runCli(args, { env, allowFailure: true }) recordCommand('api-surface', args, result) @@ -653,7 +655,8 @@ function recordLoginBlock(target, args, result) { if (target.kind === 'server' && /OAuth authorization failed|needs-login|server_error/i.test(output)) { recordBlock('login', target, 'Complete Server setup sign-in, then rerun the login/readiness phases.', [ `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/run.js targets start ${target.name} --json`, - `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/run.js api post /v1/app/setup/start --target ${target.name} --no-auth`, + `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/run.js auth email start --target ${target.name} --email ${target.email} --json`, + `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/run.js auth email response --target ${target.name} --setup-request-id "$SETUP_REQUEST_ID" --code "$QA_OTP" --username qatest --yes --json`, `BEEPER_E2E_RUN_ID=${runID} BEEPER_E2E_OTP="$QA_OTP" BEEPER_E2E_PHASES=login,readiness bun packages/cli/test/e2e-staging.ts`, ]) return @@ -662,73 +665,41 @@ function recordLoginBlock(target, args, result) { } async function loginServerViaSetupAPI(target) { - const setupApi = (path, body) => { - const args = ['api', 'post', path, '--target', target.name, '--no-auth'] - if (body !== undefined) args.push('--body', body) - args.push('--json') - return args - } - const runStep = (args) => { - const result = runCli(args, { allowFailure: true }) - recordCommand('login', args, result) - if (result.status !== 0) recordLoginBlock(target, args, result) - return result + const startArgs = ['auth', 'email', 'start', '--target', target.name, '--email', target.email, '--json'] + const start = runCli(startArgs, { allowFailure: true }) + recordCommand('login', startArgs, start) + if (start.status !== 0) { + recordLoginBlock(target, startArgs, start) + return false } - - const start = runStep(setupApi('/v1/app/setup/start')) - if (start.status !== 0) return false const setupRequestID = parseEnvelope(start.stdout)?.data?.setupRequestID if (!setupRequestID) { - recordFailure('login', target, `setup start did not return setupRequestID for ${target.name}`) + recordFailure('login', target, `auth email start did not return setupRequestID for ${target.name}`) return false } - const email = runStep(setupApi('/v1/app/setup/email', JSON.stringify({ setupRequestID, email: target.email }))) - if (email.status !== 0) return false - - const response = runStep(setupApi('/v1/app/setup/response', JSON.stringify({ setupRequestID, response: otp }))) - if (response.status !== 0) return false - - let data = parseEnvelope(response.stdout)?.data - if (data?.registrationRequired) { - const registerBody = JSON.stringify({ - acceptTerms: true, - leadToken: data.leadToken, - setupRequestID: data.setupRequestID ?? setupRequestID, - username: usernameForEmail(target.email) ?? data.usernameSuggestions?.[0], - }) - const register = runStep(setupApi('/v1/app/setup/register', registerBody)) - if (register.status !== 0) return false - data = parseEnvelope(register.stdout)?.data + const responseArgs = ['auth', 'email', 'response', '--target', target.name, '--setup-request-id', setupRequestID, '--code', otp, '--username', usernameForEmail(target.email), '--yes', '--json'] + const response = runCli(responseArgs, { allowFailure: true }) + recordCommand('login', responseArgs, response) + if (response.status !== 0) { + recordLoginBlock(target, responseArgs, response) + return false } - const token = data?.matrix?.accessToken + const body = parseEnvelope(response.stdout)?.data + const token = await loadTargetAccessToken(target) if (!token) { - recordFailure('login', target, `setup API did not return a Matrix access token for ${target.name}`) + recordFailure('login', target, `setup did not persist a Matrix access token for ${target.name}`) return false } - target.matrix = { - deviceID: data.matrix.deviceID, - homeserver: data.matrix.homeserver, - userID: data.matrix.userID, - } - await saveTargetAuth(target, { - accessToken: token, - source: 'manual', - tokenType: 'Bearer', - }) + target.accessToken = token + target.matrix = body?.readiness?.app?.matrix return true } -async function saveTargetAuth(target, auth) { - const targetPath = path.join(configDir, 'targets', `${target.name}.json`) - const current = JSON.parse(await readFile(targetPath, 'utf8')) - await writeFile(targetPath, `${JSON.stringify({ ...current, auth }, null, 2)}\n`, { mode: 0o600 }) -} - function usernameForEmail(email) { const digits = email.match(/\+(\d+)@/)?.[1] - return digits ? `qatest${digits}` : undefined + return digits ? `qatest${digits}` : `qatest${Date.now()}` } async function waitForInfo(target) { @@ -795,8 +766,8 @@ function isCoveredByCliSurface(pathname) { return [ '/v1/info', '/v1/spec', - '/v1/app', - '/v1/app/verifications', + '/v1/app/setup', + '/v1/app/setup/verifications', '/v1/accounts', '/v1/chats', '/v1/contacts', diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts new file mode 100644 index 00000000..c04b34da --- /dev/null +++ b/scripts/publish-packages.ts @@ -0,0 +1,140 @@ +#!/usr/bin/env bun +import { existsSync } from "node:fs"; +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const root = process.cwd(); +const args = Bun.argv.slice(2); + +const flags = new Set(args.filter((arg) => arg.startsWith("--"))); +const positional = args.filter((arg) => !arg.startsWith("--")); + +const dryRun = flags.has("--dry-run"); +const skipChecks = flags.has("--skip-checks"); +const skipExisting = flags.has("--skip-existing"); + +const usage = `Usage: bun run publish:packages [version] [--dry-run] [--skip-checks] [--skip-existing] + +Publishes: + - beeper-cli + - @beeper/cli-plugin-* + +All publishable packages are updated to the same version before publishing. +`; + +if (flags.has("--help") || flags.has("-h")) { + console.log(usage); + process.exit(0); +} + +let version = positional[0]; +if (!version) { + version = prompt("Version to publish (for beeper-cli and @beeper/cli-plugin-*):")?.trim(); +} + +if (!version || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z-.]+)?$/.test(version)) { + console.error(`Invalid semver version: ${version ?? ""}`); + console.error(usage); + process.exit(1); +} + +const readJson = async (path: string) => JSON.parse(await readFile(path, "utf8")); +const writeJson = async (path: string, value: unknown) => { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +}; + +const run = async (command: string[], options: { cwd?: string; allowFailure?: boolean } = {}) => { + console.log(`$ ${command.join(" ")}`); + const proc = Bun.spawn(command, { + cwd: options.cwd ?? root, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const code = await proc.exited; + if (code !== 0 && !options.allowFailure) { + throw new Error(`Command failed (${code}): ${command.join(" ")}`); + } + return code; +}; + +const packagesDir = join(root, "packages"); +const packageDirs = (await readdir(packagesDir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(packagesDir, entry.name)); + +const packageJsonPaths = packageDirs + .map((dir) => join(dir, "package.json")) + .filter((path) => existsSync(path)); + +const packages = await Promise.all( + packageJsonPaths.map(async (path) => ({ path, dir: path.replace(/\/package\.json$/, ""), json: await readJson(path) })), +); + +const publishable = packages.filter( + (pkg) => pkg.json.name === "beeper-cli" || /^@beeper\/cli-plugin-/.test(pkg.json.name), +); + +if (publishable.length === 0) { + throw new Error("No publishable packages found."); +} + +const publishableNames = new Set(publishable.map((pkg) => pkg.json.name)); +const pluginNames = [...publishableNames].filter((name) => name.startsWith("@beeper/cli-plugin-")); + +for (const pkg of publishable) { + pkg.json.version = version; + + for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] as const) { + const deps = pkg.json[section]; + if (!deps) continue; + for (const depName of Object.keys(deps)) { + if (publishableNames.has(depName)) deps[depName] = `^${version}`; + } + } + + if (pkg.json.name === "beeper-cli") { + pkg.json.bin ??= {}; + if (pkg.json.bin.beeper === "./bin/run.js") pkg.json.bin.beeper = "bin/run.js"; + + pkg.json.oclif ??= {}; + pkg.json.oclif.jitPlugins ??= {}; + for (const pluginName of pluginNames) { + pkg.json.oclif.jitPlugins[pluginName] = `^${version}`; + } + } + + await writeJson(pkg.path, pkg.json); +} + +console.log(`Updated ${publishable.length} package.json file(s) to ${version}:`); +for (const pkg of publishable) console.log(` - ${pkg.json.name}@${version}`); + +await run(["bun", "install", "--lockfile-only"]); + +if (!skipChecks) { + await run(["bun", "run", "check"]); +} else { + console.warn("Skipping checks because --skip-checks was provided."); +} + +const ordered = [ + ...publishable.filter((pkg) => pkg.json.name === "beeper-cli"), + ...publishable.filter((pkg) => pkg.json.name !== "beeper-cli").sort((a, b) => a.json.name.localeCompare(b.json.name)), +]; + +for (const pkg of ordered) { + if (skipExisting) { + const code = await run(["npm", "view", `${pkg.json.name}@${version}`, "version"], { allowFailure: true }); + if (code === 0) { + console.log(`Skipping already-published ${pkg.json.name}@${version}`); + continue; + } + } + + const command = ["npm", "publish", "--access", "public"]; + if (dryRun) command.push("--dry-run"); + await run(command, { cwd: pkg.dir }); +} + +console.log(dryRun ? "Dry run complete." : `Published ${ordered.length} package(s) at ${version}.`); From 05797478820c41cdd72f2494f039f746166bf8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 04:41:17 +0200 Subject: [PATCH 02/20] Update e2e-staging.ts --- packages/cli/test/e2e-staging.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/test/e2e-staging.ts b/packages/cli/test/e2e-staging.ts index 870f2c59..cc4ede29 100644 --- a/packages/cli/test/e2e-staging.ts +++ b/packages/cli/test/e2e-staging.ts @@ -412,6 +412,8 @@ async function phaseCliSurface() { recordCoverage('commands', args, result) if (args[0] === 'accounts' && args[1] === 'add' && args.length === 5) { recordBlock('cli-surface', target, 'accounts add without a bridge intentionally lists available account types; local-dummy covers the actual login flow.') + } else if (args[0] === 'doctor' && result.status !== 0 && parseEnvelope(result.stdout)?.data) { + report.notes.push('doctor returned non-zero because the target is not fully healthy; JSON diagnostics were still returned.') } else if (result.status !== 0) { recordFailure('cli-surface', target, `beeper ${args.join(' ')} failed with status ${result.status}`) } From b5bb45d7678dc84a4cd391b4e7dd4b563c443184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 15:34:18 +0200 Subject: [PATCH 03/20] wip --- packages/cli/README.md | 48 ++ packages/cli/src/commands/chats/show.ts | 16 +- packages/cli/src/commands/chats/start.ts | 19 +- packages/cli/src/commands/messages/list.ts | 15 +- packages/cli/src/commands/send/text.ts | 8 +- packages/cli/src/commands/setup.ts | 429 ++++++++++++++++-- .../src/commands/verify/reset-recovery-key.ts | 12 +- packages/cli/src/lib/logo.ts | 97 ++++ packages/cli/src/lib/manifest.ts | 10 + packages/cli/src/lib/matrix-direct.ts | 70 --- packages/cli/src/lib/profiles.ts | 61 ++- packages/cli/test/cli-smoke.ts | 22 +- packages/cli/test/e2e-staging.ts | 318 ++++++++++--- .../test/messages-search-validation.test.ts | 2 +- 14 files changed, 893 insertions(+), 234 deletions(-) create mode 100644 packages/cli/src/lib/logo.ts delete mode 100644 packages/cli/src/lib/matrix-direct.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 00afe8e6..5a2a2934 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -330,6 +330,8 @@ First-party optional plugins: | `targets tunnel` | Expose a local Desktop API over a public Cloudflare tunnel | | `auth status` | Show stored auth for the selected target | | `auth logout` | Clear stored authentication | +| `auth email start` | Start email sign-in for a target | +| `auth email response` | Finish email sign-in with a verification code | | `verify` | Finish setup verification or verify another device | | `verify status` | Show encryption and device-verification readiness | | `verify approve` | Approve a pending device verification request | @@ -423,12 +425,14 @@ Flags: | --- | --- | --- | | `--channel=` | option | Install release channel Default: stable | | `--desktop` | boolean | Set up a local Beeper Desktop target | +| `--email=` | option | Sign in with an email address | | `--install` | boolean | Allow installing missing managed runtime | | `--local` | boolean | Use the local Beeper Desktop session on this device | | `--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 | +| `--username=` | option | Username to use if setup creates a new account | Examples: @@ -902,6 +906,50 @@ beeper auth logout Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. +### `beeper auth email start` +Start email sign-in for a target + +```sh +beeper auth email start +``` + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--email=` | option | Email address to sign in with Required. | + +Examples: + +```sh +beeper auth email start --email you@example.com --target work --json +``` + +Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. + +### `beeper auth email response` +Finish email sign-in with a verification code + +```sh +beeper auth email response +``` + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--code=` | option | Email verification code Required. | +| `--setup-request-id=` | option | Setup request ID from auth email start Required. | +| `--username=` | option | Username to use if setup creates a new account | + +Examples: + +```sh +beeper auth email response --setup-request-id --code --target work --json +``` + +Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`. + ### `beeper verify` Finish setup verification or verify another device diff --git a/packages/cli/src/commands/chats/show.ts b/packages/cli/src/commands/chats/show.ts index 0c4f297a..12b1613c 100644 --- a/packages/cli/src/commands/chats/show.ts +++ b/packages/cli/src/commands/chats/show.ts @@ -1,7 +1,6 @@ import { Flags } from '@oclif/core' import { BeeperCommand } from '../../lib/command.js' import { createClient } from '../../lib/client.js' -import { shouldFallbackToMatrix } from '../../lib/matrix-direct.js' import { printData } from '../../lib/output.js' import { resolveChatID } from '../../lib/resolve.js' @@ -12,19 +11,6 @@ export default class ChatsShow extends BeeperCommand { const { flags } = await this.parse(ChatsShow) const client = await createClient(flags) const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - try { - await printData(await client.chats.retrieve(chatID, { maxParticipantCount: flags['max-participants'] }), flags.json ? 'json' : 'human') - } catch (error) { - if (!shouldFallbackToMatrix(chatID, error)) throw error - await printData({ - accountID: 'matrix', - id: chatID, - network: 'Beeper', - participants: { hasMore: false, items: [], total: 0 }, - title: chatID, - type: 'single', - unreadCount: 0, - }, flags.json ? 'json' : 'human') - } + await printData(await client.chats.retrieve(chatID, { maxParticipantCount: flags['max-participants'] }), flags.json ? 'json' : 'human') } } diff --git a/packages/cli/src/commands/chats/start.ts b/packages/cli/src/commands/chats/start.ts index 2eadb11c..c3d439c9 100644 --- a/packages/cli/src/commands/chats/start.ts +++ b/packages/cli/src/commands/chats/start.ts @@ -2,7 +2,6 @@ import { Args, Flags } from '@oclif/core' import { BeeperCommand, ensureWritable } from '../../lib/command.js' import { createClient } from '../../lib/client.js' import { printData } from '../../lib/output.js' -import { createMatrixDM } from '../../lib/matrix-direct.js' import { listAccountIDs, resolveAccountID, userQueryFromInput } from '../../lib/resolve.js' export default class ChatsStart extends BeeperCommand { @@ -18,23 +17,7 @@ export default class ChatsStart extends BeeperCommand { const client = await createClient(flags) const accountID = flags.account ? await resolveAccountID(client, flags.account) : await defaultAccountID(client) const user = userQueryFromInput(args.user) - try { - await printData(await client.chats.start({ accountID, user, title: flags.title } as any), flags.json ? 'json' : 'human') - } catch (error) { - if (accountID !== 'matrix' || !user.id || !/uninitialized undefined account: hungryserv|getChat/i.test(error instanceof Error ? error.message : String(error))) throw error - const room = await createMatrixDM(flags, user.id) - await printData({ - accountID: 'matrix', - chatID: room.room_id, - id: room.room_id, - network: 'Beeper', - participants: { hasMore: false, items: [], total: 0 }, - status: 'created', - title: user.id, - type: 'single', - unreadCount: 0, - }, flags.json ? 'json' : 'human') - } + await printData(await client.chats.start({ accountID, user, title: flags.title } as any), flags.json ? 'json' : 'human') } } diff --git a/packages/cli/src/commands/messages/list.ts b/packages/cli/src/commands/messages/list.ts index 2de4193c..d3798d62 100644 --- a/packages/cli/src/commands/messages/list.ts +++ b/packages/cli/src/commands/messages/list.ts @@ -1,7 +1,6 @@ import { Flags } from '@oclif/core' import { BeeperCommand } from '../../lib/command.js' import { createClient } from '../../lib/client.js' -import { listMatrixMessages, shouldFallbackToMatrix } from '../../lib/matrix-direct.js' import { collectPage, printIDs, printList } from '../../lib/output.js' import { resolveChatID } from '../../lib/resolve.js' @@ -24,14 +23,7 @@ export default class MessagesList extends BeeperCommand { const before = flags['before-cursor'] const after = flags['after-cursor'] if (before && after) throw new Error('Use only one of --before-cursor or --after-cursor') - let items: unknown[] - try { - items = await collectFiltered(client.messages.list(chatID, { cursor: before ?? after, direction: before ? 'before' : after ? 'after' : undefined }), flags.limit, flags.sender) - } catch (error) { - if (!shouldFallbackToMatrix(chatID, error)) throw error - const matrixItems = await listMatrixMessages(flags, chatID, flags.limit) - items = filterBySender(matrixItems, flags.sender) - } + let items = await collectFiltered(client.messages.list(chatID, { cursor: before ?? after, direction: before ? 'before' : after ? 'after' : undefined }), flags.limit, flags.sender) if (flags.asc) items = [...items].reverse() if (flags.ids) printIDs(items) else await printList(items, flags.json ? 'json' : 'human', { title: 'No messages yet', subtitle: 'This chat is empty.' }) @@ -48,11 +40,6 @@ async function collectFiltered(iterable: AsyncIterable, limit: number, return items } -function filterBySender(items: unknown[], sender: string | undefined): unknown[] { - if (!sender) return items - return items.filter(item => matchesSender(item, sender)) -} - export function matchesSender(item: unknown, sender: string): boolean { if (!item || typeof item !== 'object') return false const row = item as { isSender?: boolean; senderID?: string } diff --git a/packages/cli/src/commands/send/text.ts b/packages/cli/src/commands/send/text.ts index bb0cf66f..42fdacfe 100644 --- a/packages/cli/src/commands/send/text.ts +++ b/packages/cli/src/commands/send/text.ts @@ -1,7 +1,6 @@ import { Flags } from '@oclif/core' import { BeeperCommand, ensureWritable } from '../../lib/command.js' import { createClient } from '../../lib/client.js' -import { sendMatrixText, shouldFallbackToMatrix } from '../../lib/matrix-direct.js' import { printData } from '../../lib/output.js' import { resolveChatID } from '../../lib/resolve.js' import { sendMessage } from '../../lib/send-message.js' @@ -29,11 +28,6 @@ export default class SendText extends BeeperCommand { ensureWritable(flags) const client = await createClient(flags) const chatID = await resolveChatID(client, flags.to, { pick: flags.pick }) - try { - await printData(await sendMessage(client, { chatID, text: flags.message, replyTo: flags['reply-to'], mentions: flags.mention, noPreview: flags['no-preview'], wait: flags.wait, waitTimeoutMs: flags['wait-timeout'] }), flags.json ? 'json' : 'human') - } catch (error) { - if (!shouldFallbackToMatrix(chatID, error)) throw error - await printData(await sendMatrixText(flags, chatID, flags.message), flags.json ? 'json' : 'human') - } + await printData(await sendMessage(client, { chatID, text: flags.message, replyTo: flags['reply-to'], mentions: flags.mention, noPreview: flags['no-preview'], wait: flags.wait, waitTimeoutMs: flags['wait-timeout'] }), flags.json ? 'json' : 'human') } } diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index ca1f39a5..9d408cdf 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -1,19 +1,21 @@ import { Flags } from '@oclif/core' import { BeeperCommand, ensureWritable, writeEvent } from '../lib/command.js' -import { evaluateReadiness } from '../lib/app-state.js' +import { driveVerification, evaluateReadiness, type Readiness } from '../lib/app-state.js' import { ensureDesktopToken, findLocalDesktop } from '../lib/desktop-auth.js' -import { promptYesNo, promptYesNoDefaultYes } from '../lib/app-api.js' -import { installDesktop, installServer, type InstallChannel } from '../lib/installations.js' +import { promptText, promptYesNoDefaultYes } from '../lib/app-api.js' +import { installDesktop, installServer, readInstallations } from '../lib/installations.js' import { connectedAccountSummary, findLocalDesktopSession, type LocalDesktopSession } from '../lib/local-desktop.js' import { loginWithPKCE } from '../lib/oauth.js' -import { launchDesktopApp, startProfile } from '../lib/profiles.js' +import { findDesktopAppPath, launchDesktopApp, startProfile } from '../lib/profiles.js' import { interactiveEmailSetup } from '../lib/setup-login.js' +import { renderStartupLogo } from '../lib/logo.js' import { builtInDesktopTargetID, createProfileTarget, customTargetID, readConfig, readTarget, + listTargets, saveTargetAuth, updateConfig, writeTarget, @@ -42,14 +44,19 @@ export default class Setup extends BeeperCommand { 'beeper setup --oauth', 'beeper setup --remote https://my-beeper.example.com', 'beeper setup --server --install', - 'beeper setup --desktop --channel nightly', + 'beeper setup --desktop --install', ] async run(): Promise { const { flags } = await this.parse(Setup) ensureWritable(flags) - const modeCount = [flags.local, flags.oauth, Boolean(flags.email), Boolean(flags.remote), flags.server, flags.desktop].filter(Boolean).length - if (modeCount > 1) throw new Error('Specify at most one of --local, --oauth, --email, --remote, --server, or --desktop') + const targetModeCount = [Boolean(flags.remote), flags.server, flags.desktop].filter(Boolean).length + if (targetModeCount > 1) throw new Error('Specify at most one of --remote, --server, or --desktop') + const authModeCount = [flags.local, flags.oauth, Boolean(flags.email)].filter(Boolean).length + if (authModeCount > 1) throw new Error('Specify at most one of --local, --oauth, or --email') + if ((flags.local || flags.oauth) && (flags.remote || flags.server || flags.desktop)) { + throw new Error('Use --local or --oauth with an existing target, not with --remote, --server, or --desktop.') + } if (flags.events) writeEvent('setup_step', { step: 'start', target: flags.target }) if (flags.remote) { @@ -84,20 +91,18 @@ export default class Setup extends BeeperCommand { private async setupDefault(target: Target, flags: SetupFlags): Promise { const setupCmd = setupCommand(target) + printSetupHeader(flags) + printResumeBanner(target, flags) if (target.type === 'desktop') { - const local = await prepareLocalDesktopSetup(target, flags).catch(() => undefined) - if (local) { + const detected = await detectDesktopSetup(target, flags) + if (detected.kind === 'session-found') { + const local = detected.local if (flags.yes) { await this.printSetupResult(await commitLocalDesktopSetup(local), flags) return } if (flags.json || !process.stdin.isTTY) { - await printData({ - target: publicTarget(local.target), - readiness: local.readiness, - localDesktop: localDesktopPreview(local), - availableActions: [`${setupCmd} --local`, `${setupCmd} --oauth`], - }, flags.json ? 'json' : 'human') + await printData(setupSessionFoundOutput(local, setupCmd), flags.json ? 'json' : 'human') return } printLocalDesktopPreview(local) @@ -105,20 +110,57 @@ export default class Setup extends BeeperCommand { await this.printSetupResult(await commitLocalDesktopSetup(local), flags) return } + await printSuccess({ + message: local.readiness.state === 'ready' ? 'Beeper Desktop is ready' : `Setup paused: ${local.readiness.state}`, + detail: setupDetailForReadiness(local.readiness, local.target), + data: { target: publicTarget(local.target), readiness: local.readiness }, + }, 'human') + return + } else if (flags.json || !process.stdin.isTTY) { + await printData(setupStateOutput(detected, target), flags.json ? 'json' : 'human') + return + } else if (detected.kind === 'installed-not-running' && !flags.json && process.stdin.isTTY) { + printStatus('Found Beeper Desktop on this device.', 'installed, not running') + const shouldLaunch = flags.yes || await promptYesNoDefaultYes('Launch Beeper Desktop now?') + if (shouldLaunch) { + await launchAndPoll(target, setupCmd, flags) + return + } + } else if (detected.kind === 'running-signed-out' && !flags.json && process.stdin.isTTY) { + printStatus('Found Beeper Desktop on this device.', 'running, signed out') + const shouldOpen = flags.yes || await promptYesNoDefaultYes('Open Beeper Desktop so you can sign in?') + if (shouldOpen) { + await launchAndPoll(target, setupCmd, flags) + return + } + } else if (detected.kind === 'session-unreadable' && !flags.json && process.stdin.isTTY) { + printStatus('Found Beeper Desktop on this device.', 'signed in, but CLI could not read the local session') + process.stdout.write('You can still connect through Beeper Desktop.\n') + if (flags.debug) process.stdout.write(`\n${detected.reason}\n`) + process.stdout.write('\n') + const useOAuth = flags.yes || await promptYesNoDefaultYes('Connect through Beeper Desktop instead?') + if (useOAuth) { + await this.setupOAuth(target, flags) + return + } + } else if (detected.kind === 'not-installed' && !flags.json && process.stdin.isTTY) { + await this.setupFromChoice(flags) + return } } const readiness = await evaluateReadiness({ baseURL: target.baseURL, target: target.id }) + if (readiness.state === 'target-unreachable' && target.type !== 'desktop') { + if (flags.json || !process.stdin.isTTY) { + await printData(currentTargetBrokenOutput(target, readiness), flags.json ? 'json' : 'human') + return + } + if (await this.handleBrokenCurrentTarget(target, readiness, flags)) return + } if (readiness.state === 'target-unreachable' && target.type === 'desktop' && !flags.json && process.stdin.isTTY) { - const shouldLaunch = flags.yes || await promptYesNo('Beeper Desktop is not reachable. Launch it now?') + const shouldLaunch = flags.yes || await promptYesNoDefaultYes('Beeper Desktop is not reachable. Launch it now?') if (shouldLaunch) { - if (flags.events) writeEvent('setup_step', { step: 'launch', target: target.id }) - await launchDesktopApp(target) - await printSuccess({ - message: 'Launched Beeper Desktop', - detail: `Run \`${setupCmd}\` again after it finishes starting.`, - data: { target: publicTarget(target), readiness }, - }, flags.json ? 'json' : 'human') + await launchAndPoll(target, setupCmd, flags) return } } @@ -130,7 +172,7 @@ export default class Setup extends BeeperCommand { await printSuccess({ message: readiness.state === 'ready' ? 'Target ready' : `Setup paused: ${readiness.state}`, - detail: readiness.message, + detail: setupDetailForReadiness(readiness, target), data: { target: publicTarget(target), readiness }, }, 'human') } @@ -151,7 +193,12 @@ export default class Setup extends BeeperCommand { } private async setupRemote(flags: SetupFlags): Promise { - const name = flags.target ?? remoteName(flags.remote!) + const name = flags.target ?? await uniqueRemoteName(flags.remote!) + if (!flags.json && process.stdin.isTTY) { + process.stdout.write('Connecting to Desktop API on another device.\n\n') + process.stdout.write(`Name: ${name}\n`) + process.stdout.write(`URL: ${flags.remote!}\n\n`) + } const target: Target = { id: name, name, @@ -161,15 +208,15 @@ export default class Setup extends BeeperCommand { } await writeTarget(target) if (!flags.target) await updateConfig(config => ({ ...config, defaultTarget: config.defaultTarget ?? target.id })) - const result = await setupOAuthTarget(target, flags, 'remote-oauth') + const result = flags.email ? await setupEmailTarget(target, flags) : await setupOAuthTarget(target, flags, 'remote-oauth') await this.printSetupResult(result, flags) } private async setupManaged(type: 'desktop' | 'server', flags: SetupFlags): Promise { if (flags.install) { if ((flags.json || !process.stdin.isTTY) && !flags.yes) throw new Error('Install requires --install --yes in non-interactive mode.') - if (type === 'desktop') await installDesktop({ channel: flags.channel as InstallChannel, serverEnv: flags['server-env'] }) - else await installServer({ channel: flags.channel as InstallChannel, serverEnv: flags['server-env'] }) + if (type === 'desktop') await installWithCopy('desktop', flags) + else await installWithCopy('server', flags) } const id = flags.target ?? type const target = await readTarget(id) ?? await createProfileTarget(type, id, { serverEnv: flags['server-env'], port: undefined }) @@ -178,11 +225,16 @@ export default class Setup extends BeeperCommand { if (type === 'desktop') return undefined throw error }) + if (flags.email) { + await this.setupEmail(target, flags) + return + } const readiness = await evaluateReadiness({ baseURL: target.baseURL, target: target.id }) await printData({ target: publicTarget(target), readiness }, flags.json ? 'json' : 'human') } private async printSetupResult(result: SetupResult, flags: SetupFlags): Promise { + result = await maybeDriveOnboarding(result, flags) if (flags.json || !process.stdin.isTTY) { await printData(result, flags.json ? 'json' : 'human') return @@ -191,15 +243,69 @@ export default class Setup extends BeeperCommand { message: result.readiness.state === 'ready' ? `Connected to ${result.target.name ?? result.target.id}` : `Connected; setup paused: ${result.readiness.state}`, - detail: result.accounts.length ? `Connected accounts: ${result.accounts.join(', ')}` : result.readiness.message, + detail: setupResultDetail(result), data: result, }, 'human') + if (result.readiness.state === 'ready') printNextSteps() + } + + private async setupFromChoice(flags: SetupFlags): Promise { + 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(' 3. Connect with Desktop API on another device\n\n') + const choice = await promptChoice('Choose [1]: ', ['1', '2', '3'], '1') + if (choice === '1') { + if (!await promptYesNoDefaultYes('Install Beeper Desktop stable from beeper.com?')) return + await installWithCopy('desktop', { ...flags, channel: 'stable' }) + const target = await setupTarget({ ...flags, desktop: true }) + await launchAndPoll(target, setupCommand(target), flags) + 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' }) + await this.setupManaged('server', { ...flags, install: false, server: true, channel: 'stable' }) + return + } + const url = await promptText('Desktop API URL: ') + if (!url) throw new Error('Remote URL is required.') + await this.setupRemote({ ...flags, remote: url }) + } + + private async handleBrokenCurrentTarget(target: Target, readiness: Readiness, flags: SetupFlags): Promise { + 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(' 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 + if (choice === '2') { + const desktop = await defaultDesktopTarget() + await this.setupDefault(desktop, { ...flags, target: desktop.id }) + 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' }) + await this.setupManaged('server', { ...flags, install: false, server: true, channel: 'stable' }) + return true + } + const url = await promptText('Desktop API URL: ') + if (!url) throw new Error('Remote URL is required.') + await this.setupRemote({ ...flags, remote: url }) + return true } } type SetupFlags = { 'base-url'?: string channel?: string + debug?: boolean desktop?: boolean events?: boolean install?: boolean @@ -224,11 +330,18 @@ type SetupResult = { type PreparedLocalDesktopSetup = { accounts: string[] - readiness: Awaited> + readiness: Readiness session: LocalDesktopSession target: Target } +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' } + async function setupTarget(flags: SetupFlags): Promise { if (flags['base-url']) return { id: customTargetID, type: 'desktop', baseURL: flags['base-url'] } if (flags.target) { @@ -243,6 +356,10 @@ async function setupTarget(flags: SetupFlags): Promise { } const desktop = await readTarget(builtInDesktopTargetID) if (desktop) return desktop + return defaultDesktopTarget() +} + +async function defaultDesktopTarget(): Promise { const detected = await findLocalDesktop({ scan: true, timeoutMs: 300 }).catch(() => undefined) const target: Target = { id: builtInDesktopTargetID, @@ -280,6 +397,33 @@ async function prepareLocalDesktopSetup(target: Target, flags: SetupFlags): Prom return { accounts, readiness, session, target: resolvedTarget } } +async function detectDesktopSetup(target: Target, flags: SetupFlags): Promise { + printProgress(flags, 'Checking Beeper Desktop') + const appInstalled = await isDesktopAppInstalled() + printProgress(flags, 'Reading local Desktop session') + const local = await prepareLocalDesktopSetup(target, flags).catch(error => ({ error })) + if (!('error' in local)) return { kind: 'session-found', local } + + 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 } + return { + kind: 'session-unreadable', + reason: local.error instanceof Error ? local.error.message : String(local.error), + readiness, + } + } + + return appInstalled ? { kind: 'installed-not-running' } : { kind: 'not-installed' } +} + +async function isDesktopAppInstalled(): Promise { + const installations = await readInstallations().catch((): Awaited> => ({})) + return Boolean(installations.desktop?.path || await findDesktopAppPath()) +} + async function commitLocalDesktopSetup(prepared: PreparedLocalDesktopSetup): Promise { await writeTarget(prepared.target) await saveTargetAuth(prepared.target, prepared.session.auth) @@ -350,11 +494,236 @@ function localDesktopPreview(prepared: PreparedLocalDesktopSetup): Record { + return { + state: local.readiness.state === 'ready' ? 'desktop-ready' : 'desktop-session-found', + message: local.readiness.state === 'ready' + ? 'Beeper Desktop is signed in and ready.' + : 'Beeper Desktop is signed in, but setup is not finished.', + target: publicTarget(local.target), + 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 '), + ], + } +} + +function printSetupHeader(flags: SetupFlags): void { + if (flags.json || !process.stdin.isTTY || process.env.BEEPER_QUIET === '1') return + process.stdout.write(`${renderStartupLogo()}\n\n`) + process.stdout.write('Setup\n\n') +} + +function printResumeBanner(target: Target, flags: SetupFlags): void { + if (flags.json || !process.stdin.isTTY || process.env.BEEPER_QUIET === '1') return + if (target.id !== builtInDesktopTargetID || flags.target) process.stdout.write(`Continuing setup for ${target.name ?? target.id}.\n\n`) +} + +function printStatus(title: string, status: string): void { + process.stdout.write(`${title}\n\n`) + process.stdout.write(`Status: ${status}\n\n`) +} + +function printProgress(flags: SetupFlags, message: string): void { + if (flags.json || !process.stdin.isTTY || process.env.BEEPER_QUIET === '1') return + process.stdout.write(`${message}...\n`) +} + +async function promptChoice(label: string, allowed: string[], fallback: string): Promise { + const value = await promptText(label) + const normalized = value || fallback + if (!allowed.includes(normalized)) throw new Error(`Choose one of: ${allowed.join(', ')}`) + return normalized +} + +async function launchAndPoll(target: Target, setupCmd: string, flags: SetupFlags): Promise { + if (flags.events) writeEvent('setup_step', { step: 'launch', target: target.id }) + if (!flags.json && process.stdin.isTTY) process.stdout.write('Opening Beeper Desktop...\n') + await launchDesktopApp(target) + const readiness = await pollReadiness(target, 10_000) + const detail = readiness.state === 'target-unreachable' + ? `Run \`${setupCmd}\` again after Beeper Desktop finishes starting.` + : setupDetailForReadiness(readiness, target) + await printSuccess({ + message: 'Launched Beeper Desktop', + detail, + data: { target: publicTarget(target), readiness }, + }, flags.json ? 'json' : 'human') + if (!flags.json && process.stdin.isTTY && readiness.state === 'target-unreachable') { + process.stdout.write('\nNext:\n') + process.stdout.write(` ${setupCmd}\n`) + process.stdout.write(' beeper doctor\n') + } +} + +async function pollReadiness(target: Target, timeoutMs: number): Promise { + const started = Date.now() + let readiness = await evaluateReadiness({ baseURL: target.baseURL, target: target.id, token: false }) + while (readiness.state === 'target-unreachable' && Date.now() - started < timeoutMs) { + await new Promise(resolve => setTimeout(resolve, 500)) + readiness = await evaluateReadiness({ baseURL: target.baseURL, target: target.id, token: false }) + } + return readiness +} + +async function maybeDriveOnboarding(result: SetupResult, flags: SetupFlags): Promise { + if (flags.json || !process.stdin.isTTY) return result + if (result.readiness.state !== 'needs-verification' && result.readiness.state !== 'verification-in-progress') return result + process.stdout.write('Continuing verification...\n\n') + await driveVerification({ baseURL: result.target.baseURL, target: result.target.id, yes: flags.yes }) + return { + ...result, + readiness: await evaluateReadiness({ baseURL: result.target.baseURL, target: result.target.id }), + target: result.target, + } +} + +async function installWithCopy(type: 'desktop' | 'server', flags: SetupFlags): Promise { + const label = type === 'desktop' ? 'Beeper Desktop' : 'local Beeper Server' + if (!flags.json && process.stdin.isTTY) process.stdout.write(`Installing ${label} stable from beeper.com...\n`) + if (type === 'desktop') await installDesktop({ channel: 'stable', serverEnv: flags['server-env'] }) + else await installServer({ channel: 'stable', serverEnv: 'production' }) + if (!flags.json && process.stdin.isTTY) process.stdout.write(`Installed ${label} stable.\n\n`) +} + +function setupResultDetail(result: SetupResult): string | undefined { + const detail = setupDetailForReadiness(result.readiness, result.target) + if (result.accounts.length && detail) return `Connected accounts: ${result.accounts.join(', ')}\n${detail}` + if (result.accounts.length) return `Connected accounts: ${result.accounts.join(', ')}` + return detail +} + +function printNextSteps(): void { + process.stdout.write('\nNext:\n') + process.stdout.write(' beeper chats list\n') + process.stdout.write(' beeper send text --to "hello"\n') +} + +function setupStateOutput(detected: Exclude, target: Target): Record { + if (detected.kind === 'installed-not-running') { + return setupActionEnvelope({ + state: 'desktop-installed-not-running', + message: 'Beeper Desktop is installed but not running.', + target, + recommendedAction: action('launch-desktop', 'beeper setup --desktop --yes'), + availableActions: [ + action('launch-desktop', 'beeper setup --desktop --yes'), + action('connect-remote', 'beeper setup --remote '), + action('install-server', 'beeper setup --server --install --yes'), + ], + }) + } + if (detected.kind === 'running-signed-out') { + 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 '), + ], + }) + } + if (detected.kind === 'session-unreadable') { + 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 '), + ], + }) + } + 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'), + availableActions: [ + action('install-desktop', 'beeper setup --desktop --install --yes'), + action('install-server', 'beeper setup --server --install --yes'), + action('connect-remote', 'beeper setup --remote '), + ], + }) +} + +function currentTargetBrokenOutput(target: Target, readiness: Readiness): Record { + return { + state: 'current-target-unreachable', + message: `Beeper CLI is set up for ${target.name ?? target.id}, but it is not reachable.`, + target: publicTarget(target), + readiness, + recommendedAction: action('retry-current', `beeper setup -t ${target.id}`), + availableActions: [ + action('retry-current', `beeper setup -t ${target.id}`), + action('use-desktop', 'beeper setup --desktop'), + action('install-server', 'beeper setup --server --install --yes'), + action('connect-remote', 'beeper setup --remote '), + ], + } +} + +function setupActionEnvelope(options: { + state: string + message: string + target: Target + detail?: string + readiness?: Readiness + recommendedAction: ReturnType + availableActions: Array> +}): Record { + return { + state: options.state, + message: options.message, + detail: options.detail, + target: publicTarget(options.target), + readiness: options.readiness, + recommendedAction: options.recommendedAction, + availableActions: options.availableActions, + } +} + +function action(id: string, command: string): { id: string; command: string } { + return { id, command } +} + +function setupDetailForReadiness(readiness: Readiness, target: Pick): string | undefined { + if (readiness.state === 'needs-login') return 'Sign in to Beeper Desktop, then run `beeper setup` again.' + if (readiness.state === 'needs-verification' || readiness.state === 'verification-in-progress') return 'Continue verification to finish setup.' + if (readiness.state === 'needs-recovery-key' || readiness.state === 'needs-secrets') return `Run \`beeper verify recovery-key${target.id === builtInDesktopTargetID ? '' : ` -t ${target.id}`}\`.` + if (readiness.state === 'needs-cross-signing-setup') return `Run \`beeper verify reset-recovery-key${target.id === builtInDesktopTargetID ? '' : ` -t ${target.id}`}\`.` + if (readiness.state === 'needs-first-sync' || readiness.state === 'initializing') return 'Beeper is still syncing. You can rerun `beeper setup` at any time.' + return readiness.message +} + +async function uniqueRemoteName(url: string): Promise { + const base = remoteName(url) + const targets = await listTargets() + const ids = new Set(targets.map(target => target.id)) + if (!ids.has(base)) return base + for (let index = 2; index < 100; index += 1) { + const id = `${base}-${index}` + if (!ids.has(id)) return id + } + return `remote-${Date.now()}` +} + function setupCommand(target: Target): string { return target.id === builtInDesktopTargetID ? 'beeper setup' : `beeper setup -t ${target.id}` } diff --git a/packages/cli/src/commands/verify/reset-recovery-key.ts b/packages/cli/src/commands/verify/reset-recovery-key.ts index 575d32e8..8f4cb42c 100644 --- a/packages/cli/src/commands/verify/reset-recovery-key.ts +++ b/packages/cli/src/commands/verify/reset-recovery-key.ts @@ -1,12 +1,22 @@ import { BeeperCommand, ensureWritable } from '../../lib/command.js' import { createClient } from '../../lib/client.js' import { printData } from '../../lib/output.js' +import { promptYesNoDefaultYes } from '../../lib/app-api.js' export default class AuthVerifyResetRecoveryKey extends BeeperCommand { static override summary = 'Create a new encrypted-messages recovery key' async run(): Promise { const { flags } = await this.parse(AuthVerifyResetRecoveryKey) ensureWritable(flags) const client = await createClient(flags) - await printData(await client.app.login.verification.recoveryKey.reset.create({}), flags.json ? 'json' : 'human') + const reset = await client.app.login.verification.recoveryKey.reset.create({}) + if ((flags.json || !process.stdin.isTTY) && !flags.yes) { + throw new Error('Resetting the recovery key requires --yes in non-interactive mode so the new key can be confirmed.') + } + if (!flags.yes) { + process.stderr.write(`New recovery key:\n${reset.recoveryKey}\n`) + if (!await promptYesNoDefaultYes('I saved this recovery key. Use it for this account?')) throw new Error('Recovery key reset cancelled.') + } + const confirmed = await client.app.login.verification.recoveryKey.reset.confirm({ recoveryKey: reset.recoveryKey }) + await printData({ recoveryKey: reset.recoveryKey, session: confirmed.session }, flags.json ? 'json' : 'human') } } diff --git a/packages/cli/src/lib/logo.ts b/packages/cli/src/lib/logo.ts new file mode 100644 index 00000000..98287cec --- /dev/null +++ b/packages/cli/src/lib/logo.ts @@ -0,0 +1,97 @@ +const iconSource = String.raw` + @@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@ @@@@@@@ + @@@@@@ @@@@@@ + @@@@@ @@@@@ +@@@@@ @@@@@ +@@@@@ @@@@@ +@@@@@ @@@@@ +@@@@@ @@@@@ +@@@@@ @@@@ + @@@@@ @@@@@ + @@@@@@ @@@@@@ + @@@@@@@ @@@@@@@ + @@@@@@@@@@@ @@@@@@@ + @@@@@@@@@@@@@@ @@@@@@@ + @@@@@@@@@@@@@@ @@@@@@@@ + @@@@@@@@@@@@@@ @@@@@@@@@@@@@ + @@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ +` + +const wordmarkSource = String.raw` +@@@@@@@ @@@@@@@@ @@@@@@@@ @@@@@@@ @@@@@@@@ @@@@@@@ +@@ @@ @@ @@ @@ @@ @@ @@ @@ +@@@@@@@ @@@@@@ @@@@@@ @@@@@@@ @@@@@@ @@@@@@@ +@@ @@ @@ @@ @@ @@ @@ @@ +@@ @@ @@ @@ @@ @@ @@ @@ +@@@@@@@ @@@@@@@@ @@@@@@@@ @@ @@@@@@@@ @@ @@ +` + +const normalize = (source: string): string[] => { + const lines = source.trim().split('\n') + const width = Math.max(...lines.map(line => line.length)) + return lines.map(line => line.padEnd(width, ' ')) +} + +const scale = (source: string, width: number, height: number): string[] => { + const lines = normalize(source) + const sourceHeight = lines.length + const sourceWidth = lines[0]?.length ?? 0 + const result: string[] = [] + + for (let y = 0; y < height; y += 1) { + const sourceY = Math.min(sourceHeight - 1, Math.floor(((y + 0.5) / height) * sourceHeight)) + let line = '' + + for (let x = 0; x < width; x += 1) { + const sourceX = Math.min(sourceWidth - 1, Math.floor(((x + 0.5) / width) * sourceWidth)) + line += lines[sourceY]?.[sourceX] === '@' ? '@' : ' ' + } + + result.push(line) + } + + return result +} + +const combine = (icon: string[], wordmark: string[], gap: number): string[] => { + const iconWidth = icon[0]?.length ?? 0 + const height = Math.max(icon.length, wordmark.length) + const wordTop = Math.max(0, Math.floor((height - wordmark.length) / 2)) + const result: string[] = [] + + for (let y = 0; y < height; y += 1) { + const iconLine = icon[y] ?? ' '.repeat(iconWidth) + const wordLine = wordmark[y - wordTop] ?? '' + result.push(`${iconLine}${' '.repeat(gap)}${wordLine}`.trimEnd()) + } + + return result +} + +export function renderStartupLogo(columns = process.stdout.columns ?? 80): string { + const maxWidth = Math.max(36, columns - 2) + const gap = maxWidth < 60 ? 2 : 4 + const iconWidth = Math.min(20, Math.max(14, Math.floor(maxWidth * 0.25))) + const iconHeight = Math.max(8, Math.round(iconWidth * 0.55)) + const wordWidth = Math.max(20, maxWidth - iconWidth - gap) + const wordHeight = 6 + + const icon = scale(iconSource, iconWidth, iconHeight) + const wordmark = scale(wordmarkSource, wordWidth, wordHeight) + + return combine(icon, wordmark, gap).join('\n') +} diff --git a/packages/cli/src/lib/manifest.ts b/packages/cli/src/lib/manifest.ts index ff497dae..5b7dfe63 100644 --- a/packages/cli/src/lib/manifest.ts +++ b/packages/cli/src/lib/manifest.ts @@ -125,6 +125,16 @@ export const commandManifest: ManifestCommand[] = [ description: 'Clear stored authentication', examples: ['beeper auth logout'], }, + { + command: 'auth email start', + description: 'Start email sign-in for a target', + examples: ['beeper auth email start --email you@example.com --target work --json'], + }, + { + command: 'auth email response', + description: 'Finish email sign-in with a verification code', + examples: ['beeper auth email response --setup-request-id --code --target work --json'], + }, { command: 'verify', description: 'Finish setup verification or verify another device', diff --git a/packages/cli/src/lib/matrix-direct.ts b/packages/cli/src/lib/matrix-direct.ts deleted file mode 100644 index b2b349fb..00000000 --- a/packages/cli/src/lib/matrix-direct.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { getAppState } from './app-state.js' -import { resolveTarget } from './targets.js' - -type MatrixFlags = { - 'base-url'?: string - target?: string -} - -type MatrixContext = { homeserver: string; token: string } - -export async function matrixContext(flags: MatrixFlags): Promise { - const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] }) - const token = process.env.BEEPER_ACCESS_TOKEN || target.auth?.accessToken - if (!token) throw new Error('Matrix fallback requires stored target auth or BEEPER_ACCESS_TOKEN.') - const state = await getAppState({ - baseURL: flags['base-url'], - target: flags.target, - token, - }) - const homeserver = state.matrix?.homeserver - if (!homeserver) throw new Error('Matrix fallback could not determine the homeserver.') - return { homeserver, token } -} - -export async function createMatrixDM(flags: MatrixFlags, userID: string): Promise<{ room_id: string }> { - return matrixRequest(await matrixContext(flags), 'POST', '/_matrix/client/v3/createRoom', { - invite: [userID], - is_direct: true, - preset: 'trusted_private_chat', - }) -} - -export async function sendMatrixText(flags: MatrixFlags, roomID: string, text: string): Promise<{ accepted: true; state: 'accepted'; chatID: string; pendingMessageID: string; hint: string }> { - const txnID = `beeper-cli-${Date.now()}-${Math.random().toString(36).slice(2)}` - await matrixRequest(await matrixContext(flags), 'PUT', `/_matrix/client/v3/rooms/${encodeURIComponent(roomID)}/send/m.room.message/${encodeURIComponent(txnID)}`, { - body: text, - msgtype: 'm.text', - }) - return { - accepted: true, - state: 'accepted', - chatID: roomID, - pendingMessageID: txnID, - hint: 'Matrix accepted the send request. Use messages show or watch to resolve the final event.', - } -} - -export async function listMatrixMessages(flags: MatrixFlags, roomID: string, limit: number): Promise { - const response = await matrixRequest<{ chunk?: unknown[] }>(await matrixContext(flags), 'GET', `/_matrix/client/v3/rooms/${encodeURIComponent(roomID)}/messages?dir=b&limit=${limit}`) - return response.chunk ?? [] -} - -export function shouldFallbackToMatrix(chatID: string, error: unknown): boolean { - if (!chatID.startsWith('!')) return false - const message = error instanceof Error ? error.message : String(error) - return /getChat|listMessages|sendMessage|Chat not found/i.test(message) -} - -async function matrixRequest(context: MatrixContext, method: 'GET' | 'POST' | 'PUT', path: string, body?: Record): Promise { - const response = await fetch(new URL(path, context.homeserver), { - method, - headers: { - authorization: `Bearer ${context.token}`, - ...(body ? { 'content-type': 'application/json' } : {}), - }, - body: body ? JSON.stringify(body) : undefined, - }) - if (!response.ok) throw new Error(`${method} ${path} failed: ${response.status} ${await response.text()}`) - return response.json() as Promise -} diff --git a/packages/cli/src/lib/profiles.ts b/packages/cli/src/lib/profiles.ts index 0106c4e4..e52d768e 100644 --- a/packages/cli/src/lib/profiles.ts +++ b/packages/cli/src/lib/profiles.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process' import { execFile } from 'node:child_process' import { closeSync, openSync } from 'node:fs' -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -54,7 +54,8 @@ export async function startProfile(target: Target): Promise { const installations = await readInstallations().catch(() => ({ desktop: undefined })) - const args = installations.desktop?.path ? ['-n', installations.desktop.path, '--args'] : ['-n', '-a', 'Beeper', '--args'] + 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}`) @@ -70,6 +71,53 @@ export async function launchDesktopApp(target?: Target): Promise<{ id: string; s return { id: target?.id ?? 'desktop', startedAt: new Date().toISOString() } } +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 + if (process.platform === 'darwin') { + for (const path of [ + '/Applications/Beeper.app', + '/Applications/Beeper Nightly.app', + ]) { + if (await isBeeperDesktopApp(path)) return path + } + } + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local') + const candidates = [ + join(localAppData, 'Programs', 'Beeper', 'Beeper.exe'), + join(localAppData, 'Programs', 'Beeper Nightly', 'Beeper Nightly.exe'), + ] + for (const path of candidates) { + if (await pathExists(path)) return path + } + } + for (const path of ['/usr/bin/beeper', '/usr/local/bin/beeper']) { + if (await pathExists(path)) return path + } + return undefined +} + +async function isBeeperDesktopApp(path: string): Promise { + if (!await pathExists(path)) return false + if (process.platform !== 'darwin') return true + const bundleID = await readBundleID(path) + return bundleID === 'com.automattic.beeper.desktop' || bundleID === 'com.automattic.beeper.desktop.nightly' +} + +async function readBundleID(appPath: string): Promise { + try { + const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [ + '-c', + 'Print CFBundleIdentifier', + join(appPath, 'Contents', 'Info.plist'), + ]) + return stdout.trim() || undefined + } catch { + return undefined + } +} + export async function stopProfile(target: Target): Promise { assertProfile(target) if (target.type === 'desktop') throw new Error('Quit Beeper Desktop from the app.') @@ -327,3 +375,12 @@ async function waitForExit(pid: number, timeoutMs: number): Promise { async function sleep(ms: number): Promise { await new Promise(resolve => setTimeout(resolve, ms)) } + +async function pathExists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} diff --git a/packages/cli/test/cli-smoke.ts b/packages/cli/test/cli-smoke.ts index 77b30e0d..aa524850 100644 --- a/packages/cli/test/cli-smoke.ts +++ b/packages/cli/test/cli-smoke.ts @@ -9,7 +9,7 @@ import { downloadURLFor, feedURLFor, normalizeInstallRequest } from '../dist/lib const root = fileURLToPath(new URL('..', import.meta.url)) const configDir = '/tmp/beeper-cli-test' -const run = (...args) => spawnSync(process.execPath, ['./bin/run.js', ...args], { +const run = (...args) => spawnSync(process.execPath, ['./bin/dev.js', ...args], { cwd: root, encoding: 'utf8', env: { @@ -47,6 +47,8 @@ const expectedCommands = [ 'targets tunnel', 'auth status', 'auth logout', + 'auth email start', + 'auth email response', 'verify', 'verify status', 'verify approve', @@ -170,7 +172,8 @@ assert.match(setupHelp, /--oauth/, 'setup should expose OAuth setup') assert.match(setupHelp, /--remote/, 'setup should expose remote setup shortcut') assert.match(setupHelp, /--server/, 'setup should expose Server setup shortcut') assert.match(setupHelp, /--desktop/, 'setup should expose Desktop setup shortcut') -assert.doesNotMatch(setupHelp, /--email|--code|--accept-terms/, 'setup must not expose email-code login flags') +assert.match(setupHelp, /--email/, 'setup should expose email setup start') +assert.doesNotMatch(setupHelp, /--code|--accept-terms/, 'setup must not accept OTP or terms flags in the first command') const man = JSON.parse(ok('man', '--json')) assert.equal(man.success, true) @@ -215,7 +218,20 @@ envelope = JSON.parse(result.stderr) assert.equal(envelope.success, false) assert.match(envelope.error, /read-only mode/) -const rpcResult = spawnSync(process.execPath, ['./bin/run.js', 'rpc'], { +result = run('setup', '--remote', 'http://127.0.0.1:9', '--target', 'email-remote', '--email', 'qatest+123456@beeper.com', '--json') +assert.notEqual(result.status, 0) +envelope = JSON.parse(result.stderr) +assert.equal(envelope.success, false) +assert.match(envelope.error, /auth email start/) +assert.doesNotMatch(envelope.error, /--code|OTP/i, 'setup must direct automation to the two-step email commands without accepting OTP itself') + +result = run('targets', 'show', 'email-remote', '--json') +assert.equal(result.status, 0, result.stderr) +envelope = JSON.parse(result.stdout) +assert.equal(envelope.success, true) +assert.equal(envelope.data.baseURL, 'http://127.0.0.1:9') + +const rpcResult = spawnSync(process.execPath, ['./bin/dev.js', 'rpc'], { cwd: root, encoding: 'utf8', env: { diff --git a/packages/cli/test/e2e-staging.ts b/packages/cli/test/e2e-staging.ts index cc4ede29..752c8db2 100644 --- a/packages/cli/test/e2e-staging.ts +++ b/packages/cli/test/e2e-staging.ts @@ -20,6 +20,11 @@ const accountCount = Number(process.env.BEEPER_E2E_ACCOUNT_COUNT || 3) const portStart = Number(process.env.BEEPER_E2E_PORT_START || 24_573) const desktopCount = Number(process.env.BEEPER_E2E_DESKTOP_TARGETS || 1) const serverCount = Number(process.env.BEEPER_E2E_SERVER_TARGETS || Math.max(1, accountCount - desktopCount)) +const remoteBaseURLs = (process.env.BEEPER_E2E_REMOTE_BASE_URLS || '') + .split(',') + .map(value => value.trim()) + .filter(Boolean) +const commandTimeoutMs = Number(process.env.BEEPER_E2E_COMMAND_TIMEOUT_MS || 60_000) const phases = (process.env.BEEPER_E2E_PHASES || process.argv.slice(2).join(',') || 'plan') .split(',') .map(phase => phase.trim()) @@ -44,6 +49,7 @@ const report = { notes: [], coverage: { commands: [], + help: [], api: [], skipped: [], }, @@ -101,7 +107,7 @@ async function phasePlan() { const commands = [ 'bun run --filter beeper-cli build', `BEEPER_E2E_ENV_FILE=.env.e2e BEEPER_E2E_PHASES=targets,install-server,start,login,readiness,verify,messaging,surface,cleanup BEEPER_E2E_RUN_ID=${runID} bun packages/cli/test/e2e-staging.ts`, - `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/run.js targets list --json`, + `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js targets list --json`, ] report.commands.push(...commands.map(command => ({ phase: 'plan', command }))) report.notes.push('Default phase is plan only. Add explicit BEEPER_E2E_PHASES before launching targets.') @@ -115,9 +121,11 @@ async function phaseTargets() { const targets = plannedTargets() report.targets = targets for (const target of targets) { - const args = target.kind === 'desktop' - ? ['targets', 'add', 'desktop', target.name, '--server-env', 'staging', '--port', String(target.port), '--json'] - : ['targets', 'add', 'server', target.name, '--server-env', 'staging', '--port', String(target.port), '--json'] + const args = target.kind === 'remote' + ? ['targets', 'add', 'remote', target.name, target.baseURL, '--json'] + : target.kind === 'desktop' + ? ['targets', 'add', 'desktop', target.name, '--server-env', 'staging', '--port', String(target.port), '--json'] + : ['targets', 'add', 'server', target.name, '--server-env', 'staging', '--port', String(target.port), '--json'] const result = runCli(args, { allowFailure: true }) if (result.status !== 0 && !`${result.stderr}${result.stdout}`.includes('already exists')) fail(result, args) recordCommand('targets', args, result) @@ -136,6 +144,15 @@ async function phaseInstallServer() { async function phaseStart() { for (const target of plannedTargets()) { + if (target.kind === 'remote') { + try { + await waitForInfo(target) + } catch (error) { + recordFailure('start', target, error) + } + await writeReport() + continue + } const args = ['targets', 'start', target.name, '--json'] const result = runCli(args, { env: serverEnv(), allowFailure: true }) recordCommand('start', args, result) @@ -156,7 +173,7 @@ async function phaseLogin() { for (const target of plannedTargets()) { try { await waitForInfo(target) - if (target.kind === 'server') { + if (target.kind === 'server' || target.kind === 'remote') { if (!await loginServerViaSetupAPI(target)) continue } else { const args = ['setup', '--target', target.name, '--local', '--json'] @@ -236,21 +253,11 @@ async function phaseMessaging() { recordCommand('messaging', startArgs, start) const body = parseEnvelope(start.stdout) let chatID = body?.data?.chat?.id ?? body?.data?.id ?? body?.data?.chatID - if (!chatID && /uninitialized undefined account: hungryserv/i.test(start.stderr)) { - const createRoomBody = JSON.stringify({ - invite: [receiver.matrix.userID], - is_direct: true, - preset: 'trusted_private_chat', - }) - const createRoomArgs = ['api', 'post', '/_matrix/client/v3/createRoom', '--target', sender.name, '--body', createRoomBody, '--json'] - const createRoom = runCli(createRoomArgs, { env, allowFailure: true }) - recordCommand('messaging', createRoomArgs, createRoom) - chatID = parseEnvelope(createRoom.stdout)?.data?.room_id - } if (!chatID) { - recordFailure('messaging', sender, 'Could not infer chat ID from chats start; run send/list commands manually with the chat ID from the target UI.') + recordBlock('messaging', sender, 'Could not infer a Desktop-indexed chat ID from chats start. Server/local bridge coverage must come from the Desktop API chat surface, not raw Matrix rooms.') return } + report.coverage.chatID = chatID for (const args of [ ['send', 'text', '--to', chatID, '--message', `staging e2e ${runID}`, '--target', sender.name, '--json'], ['messages', 'list', '--chat', chatID, '--target', sender.name, '--limit', '10', '--json'], @@ -273,37 +280,50 @@ async function phaseGroupMessaging(targets) { return } - const roomName = `CLI E2E ${runID}` - const createRoomBody = JSON.stringify({ - invite: distinctReceivers.slice(0, 2).map(target => target.matrix.userID), - name: roomName, - preset: 'trusted_private_chat', - }) - const createRoomArgs = ['api', 'post', '/_matrix/client/v3/createRoom', '--target', sender.name, '--body', createRoomBody, '--json'] - const createRoom = runCli(createRoomArgs, { env: { BEEPER_ACCESS_TOKEN: sender.accessToken }, allowFailure: true }) - recordCommand('messaging-group', createRoomArgs, createRoom) - const chatID = parseEnvelope(createRoom.stdout)?.data?.room_id + const startArgs = ['chats', 'start', distinctReceivers[0].matrix.userID, '--target', sender.name, '--account', 'matrix', '--title', `CLI E2E ${runID}`, '--json'] + const start = runCli(startArgs, { env: { BEEPER_ACCESS_TOKEN: sender.accessToken }, allowFailure: true }) + recordCommand('messaging-group', startArgs, start) + const chatID = parseEnvelope(start.stdout)?.data?.chat?.id ?? parseEnvelope(start.stdout)?.data?.id ?? parseEnvelope(start.stdout)?.data?.chatID if (!chatID) { - recordFailure('messaging-group', sender, 'Could not create Matrix group room through the raw API fallback.') + recordBlock('messaging-group', sender, 'Could not create a group chat through the Desktop API chat surface. Raw Matrix createRoom/join is intentionally not used.') return } + report.coverage.groupChatID = chatID const text = `group staging e2e ${runID}` const sendArgs = ['send', 'text', '--to', chatID, '--message', text, '--target', sender.name, '--json'] const send = runCli(sendArgs, { env: { BEEPER_ACCESS_TOKEN: sender.accessToken }, allowFailure: true }) recordCommand('messaging-group', sendArgs, send) + await sleep(1000) for (const target of [sender, ...distinctReceivers.slice(0, 2)]) { const listArgs = ['messages', 'list', '--chat', chatID, '--target', target.name, '--limit', '10', '--json'] const list = runCli(listArgs, { env: { BEEPER_ACCESS_TOKEN: target.accessToken }, allowFailure: true }) recordCommand('messaging-group', listArgs, list) - if (list.status !== 0) recordFailure('messaging-group', target, `group message list failed for ${target.name}`) + if (list.status !== 0 && /not in room|M_FORBIDDEN/i.test(`${list.stderr}${list.stdout}`)) { + recordBlock('messaging-group', target, 'Group room invite was created, but this target has not joined the room yet.') + } else if (list.status !== 0) { + recordFailure('messaging-group', target, `group message list failed for ${target.name}`) + } } } async function phaseSurface() { + await phaseHelpSurface() await phaseApiSurface() await phaseCliSurface() + await phaseControlSurface() +} + +async function phaseHelpSurface() { + const commands = await generatedCommands() + for (const command of ['', ...commands]) { + const args = command ? [...command.split(' '), '--help'] : ['--help'] + const result = runCli(args, { allowFailure: true }) + recordCommand('help-surface', args, result) + recordCoverage('help', args, result) + if (result.status !== 0) recordFailure('help-surface', undefined, `beeper ${args.join(' ')} failed with status ${result.status}`) + } } async function phaseApiSurface() { @@ -346,11 +366,22 @@ async function phaseCliSurface() { return } const env = { BEEPER_ACCESS_TOKEN: target.accessToken } - const chatID = await findReusableChatID(target, env) + const sdkChatID = await findReusableChatID(target, env) + const chatID = sdkChatID const messageID = chatID ? await findReusableMessageID(target, chatID, env) : undefined const reminderAt = new Date(Date.now() + 86_400_000).toISOString() const cases = [ + ['version', '--json'], + ['docs', '--json'], + ['man', '--json'], + ['config', 'path', '--json'], + ['config', 'get', '--json'], + ['config', 'set', 'defaultTarget', target.name, '--json'], + ['config', 'get', 'defaultTarget', '--json'], + ['targets', 'show', target.name, '--json'], + ['targets', 'status', target.name, '--json'], + ['targets', 'use', target.name, '--json'], ['status', '--target', target.name, '--json'], ['doctor', '--target', target.name, '--json'], ['auth', 'status', '--target', target.name, '--json'], @@ -360,7 +391,12 @@ async function phaseCliSurface() { ['accounts', 'list', '--target', target.name, '--json'], ['accounts', 'add', '--target', target.name, '--json'], ['accounts', 'add', 'local-dummy', '--target', target.name, '--flow', 'password', '--field', 'username=cli-e2e', '--field', 'password=correctpassword', '--non-interactive', '--json'], + ['accounts', 'add', 'local-dummy', '--target', target.name, '--login-id', 'cli-e2e', '--flow', 'password', '--field', 'username=cli-e2e', '--field', 'password=correctpassword', '--non-interactive', '--json'], + ['accounts', 'add', 'local-dummy', '--target', target.name, '--flow', 'cookies', '--cookie', 'username=cli-e2e-cookies', '--cookie', 'password=correctpassword', '--non-interactive', '--json'], + ['accounts', 'add', 'local-dummy', '--target', target.name, '--flow', 'localstorage', '--cookie', 'username=cli-e2e-localstorage', '--cookie', 'password=correctpassword', '--non-interactive', '--json'], + ['accounts', 'add', 'local-dummy', '--target', target.name, '--flow', 'displayandwait', '--non-interactive', '--json'], ['accounts', 'list', '--target', target.name, '--account', 'local-dummy', '--json'], + ['config', 'get', 'defaultAccount', '--json'], ['chats', 'list', '--target', target.name, '--limit', '20', '--json'], ['chats', 'search', runID, '--target', target.name, '--limit', '10', '--json'], ['contacts', 'list', '--target', target.name, '--limit', '20', '--json'], @@ -368,56 +404,164 @@ async function phaseCliSurface() { ['verify', 'status', '--target', target.name, '--json'], ['verify', 'list', '--target', target.name, '--json'], ] + if (target.kind !== 'remote') cases.splice(9, 0, ['targets', 'logs', target.name, '--lines', '5']) if (chatID) { cases.push( ['chats', 'show', '--chat', chatID, '--target', target.name, '--json'], - ['chats', 'pin', '--chat', chatID, '--target', target.name, '--json'], - ['chats', 'unpin', '--chat', chatID, '--target', target.name, '--json'], - ['chats', 'archive', '--chat', chatID, '--target', target.name, '--json'], - ['chats', 'unarchive', '--chat', chatID, '--target', target.name, '--json'], - ['chats', 'mute', '--chat', chatID, '--target', target.name, '--json'], - ['chats', 'unmute', '--chat', chatID, '--target', target.name, '--json'], - ['chats', 'mark-read', '--chat', chatID, '--target', target.name, '--json'], - ['chats', 'mark-unread', '--chat', chatID, '--target', target.name, '--json'], - ['chats', 'priority', '--chat', chatID, '--level', 'inbox', '--target', target.name, '--json'], - ['chats', 'description', '--chat', chatID, '--description', `CLI E2E ${runID}`, '--target', target.name, '--json'], - ['chats', 'description', '--chat', chatID, '--clear', '--target', target.name, '--json'], - ['chats', 'draft', '--chat', chatID, '--text', `draft ${runID}`, '--target', target.name, '--json'], - ['chats', 'draft', '--chat', chatID, '--clear', '--target', target.name, '--json'], - ['chats', 'disappear', '--chat', chatID, '--seconds', 'off', '--target', target.name, '--json'], - ['chats', 'remind', '--chat', chatID, '--when', reminderAt, '--target', target.name, '--json'], - ['chats', 'unremind', '--chat', chatID, '--target', target.name, '--json'], - ['presence', '--chat', chatID, '--state', 'typing', '--duration', '1', '--target', target.name, '--json'], ['messages', 'list', '--chat', chatID, '--target', target.name, '--limit', '10', '--json'], - ['messages', 'search', runID, '--chat', chatID, '--target', target.name, '--limit', '10', '--json'], - ['messages', 'export', '--chat', chatID, '--target', target.name, '--limit', '10', '--output', '-', '--json'], ['send', 'text', '--to', chatID, '--message', `surface ${runID}`, '--target', target.name, '--json'], ) } - if (chatID && messageID) { + if (sdkChatID) { + cases.push( + ['chats', 'pin', '--chat', sdkChatID, '--target', target.name, '--json'], + ['chats', 'unpin', '--chat', sdkChatID, '--target', target.name, '--json'], + ['chats', 'archive', '--chat', sdkChatID, '--target', target.name, '--json'], + ['chats', 'unarchive', '--chat', sdkChatID, '--target', target.name, '--json'], + ['chats', 'mute', '--chat', sdkChatID, '--target', target.name, '--json'], + ['chats', 'unmute', '--chat', sdkChatID, '--target', target.name, '--json'], + ['chats', 'mark-read', '--chat', sdkChatID, '--target', target.name, '--json'], + ['chats', 'mark-unread', '--chat', sdkChatID, '--target', target.name, '--json'], + ['chats', 'priority', '--chat', sdkChatID, '--level', 'inbox', '--target', target.name, '--json'], + ['chats', 'description', '--chat', sdkChatID, '--description', `CLI E2E ${runID}`, '--target', target.name, '--json'], + ['chats', 'description', '--chat', sdkChatID, '--clear', '--target', target.name, '--json'], + ['chats', 'draft', '--chat', sdkChatID, '--text', `draft ${runID}`, '--target', target.name, '--json'], + ['chats', 'draft', '--chat', sdkChatID, '--clear', '--target', target.name, '--json'], + ['chats', 'disappear', '--chat', sdkChatID, '--seconds', 'off', '--target', target.name, '--json'], + ['chats', 'remind', '--chat', sdkChatID, '--when', reminderAt, '--target', target.name, '--json'], + ['chats', 'unremind', '--chat', sdkChatID, '--target', target.name, '--json'], + ['presence', '--chat', sdkChatID, '--state', 'typing', '--duration', '1', '--target', target.name, '--json'], + ['messages', 'search', runID, '--chat', sdkChatID, '--target', target.name, '--limit', '10', '--json'], + ['messages', 'export', '--chat', sdkChatID, '--target', target.name, '--limit', '10', '--output', '-', '--json'], + ) + } else { + for (const command of ['chats pin/unpin/archive/unarchive/mute/unmute/mark-read/mark-unread/priority/description/draft/disappear/remind/unremind', 'presence']) { + report.coverage.skipped.push({ command, reason: 'No Desktop-indexed chat was available from Beeper Server; raw Matrix rooms do not support Desktop chat mutation APIs.' }) + } + report.coverage.skipped.push({ command: 'messages search --chat', reason: 'No Desktop-indexed chat was available from Beeper Server; raw Matrix rooms are not searched through Desktop message APIs.' }) + report.coverage.skipped.push({ command: 'messages export', reason: 'No Desktop-indexed chat was available from Beeper Server; raw Matrix rooms are not exported through Desktop message APIs.' }) + } + + if (sdkChatID && messageID) { cases.push( - ['messages', 'show', '--chat', chatID, '--id', messageID, '--target', target.name, '--json'], - ['messages', 'context', '--chat', chatID, '--id', messageID, '--target', target.name, '--before', '2', '--after', '2', '--json'], - ['send', 'react', '--to', chatID, '--id', messageID, '--reaction', '+1', '--target', target.name, '--json'], - ['send', 'unreact', '--to', chatID, '--id', messageID, '--reaction', '+1', '--target', target.name, '--json'], - ['api', 'request', 'DELETE', `/v1/chats/${encodeURIComponent(chatID)}/messages/${encodeURIComponent(messageID)}/reactions`, '--body', '{"reactionKey":"+1"}', '--target', target.name, '--json'], + ['messages', 'show', '--chat', sdkChatID, '--id', messageID, '--target', target.name, '--json'], + ['messages', 'context', '--chat', sdkChatID, '--id', messageID, '--target', target.name, '--before', '2', '--after', '2', '--json'], + ['send', 'react', '--to', sdkChatID, '--id', messageID, '--reaction', '+1', '--target', target.name, '--json'], + ['send', 'unreact', '--to', sdkChatID, '--id', messageID, '--reaction', '+1', '--target', target.name, '--json'], ) + } else if (!sdkChatID) { + report.coverage.skipped.push({ command: 'messages show/context and send react/unreact', reason: 'No Desktop-indexed chat/message was available from Beeper Server; raw Matrix rooms do not support these Desktop message APIs.' }) } for (const args of cases) { const result = runCli(args, { env, allowFailure: true }) recordCommand('cli-surface', args, result) - recordCoverage('commands', args, result) + const expectedDoctorDiagnostic = args[0] === 'doctor' && result.status !== 0 && parseEnvelope(result.stdout)?.data + recordCoverage('commands', args, result, expectedDoctorDiagnostic ? true : undefined) if (args[0] === 'accounts' && args[1] === 'add' && args.length === 5) { - recordBlock('cli-surface', target, 'accounts add without a bridge intentionally lists available account types; local-dummy covers the actual login flow.') - } else if (args[0] === 'doctor' && result.status !== 0 && parseEnvelope(result.stdout)?.data) { + report.notes.push('accounts add without a bridge returned the bridge-picker data; local-dummy covers the actual login flow.') + } else if (expectedDoctorDiagnostic) { report.notes.push('doctor returned non-zero because the target is not fully healthy; JSON diagnostics were still returned.') } else if (result.status !== 0) { recordFailure('cli-surface', target, `beeper ${args.join(' ')} failed with status ${result.status}`) } } + + await phaseLocalDummyAccountSurface(target, env) + +} + +async function phaseLocalDummyAccountSurface(target, env) { + const listArgs = ['accounts', 'list', '--target', target.name, '--account', 'local-dummy', '--json'] + const list = runCli(listArgs, { env, allowFailure: true }) + recordCommand('cli-surface', listArgs, list) + recordCoverage('commands', listArgs, list) + const accounts = parseEnvelope(list.stdout)?.data + const account = Array.isArray(accounts) ? accounts.find(item => item?.id || item?.accountID) : undefined + const accountID = account?.id ?? account?.accountID + if (!accountID) { + recordFailure('cli-surface', target, 'local-dummy login completed but accounts list did not return a reusable account ID.') + return + } + for (const args of [ + ['accounts', 'show', accountID, '--target', target.name, '--json'], + ['accounts', 'use', accountID, '--target', target.name, '--json'], + ['config', 'get', 'defaultAccount', '--json'], + ]) { + const result = runCli(args, { env, allowFailure: true }) + recordCommand('cli-surface', args, result) + recordCoverage('commands', args, result) + if (result.status !== 0) recordFailure('cli-surface', target, `beeper ${args.join(' ')} failed with status ${result.status}`) + } +} + +async function phaseControlSurface() { + const targets = await plannedTargetsWithAuth() + const target = targets.find(item => item.accessToken) ?? targets[0] + if (!target) return + + for (const args of [ + ['update', '--server', '--check', '--json'], + ]) { + const result = runCli(args, { env: serverEnv(), allowFailure: true }) + recordCommand('control-surface', args, result) + recordCoverage('commands', args, result) + if (result.status !== 0) recordFailure('control-surface', target, `beeper ${args.join(' ')} failed with status ${result.status}`) + if (args[0] === 'targets' && args[1] === 'restart') { + try { + await waitForInfo(target) + } catch (error) { + recordFailure('control-surface', target, error) + } + } + } + if (target.kind !== 'remote') { + const args = ['targets', 'restart', target.name, '--json'] + const result = runCli(args, { env: serverEnv(), allowFailure: true }) + recordCommand('control-surface', args, result) + recordCoverage('commands', args, result) + if (result.status !== 0) recordFailure('control-surface', target, `beeper ${args.join(' ')} failed with status ${result.status}`) + try { + await waitForInfo(target) + } catch (error) { + recordFailure('control-surface', target, error) + } + } else { + report.coverage.skipped.push({ command: 'targets restart', reason: 'Remote source Server targets are not lifecycle-managed by the CLI.' }) + } + + const remoteName = `remote-${runID}` + for (const args of [ + ['targets', 'add', 'remote', remoteName, 'http://127.0.0.1:9', '--json'], + ['targets', 'show', remoteName, '--json'], + ['targets', 'status', remoteName, '--json'], + ['targets', 'remove', remoteName, '--json'], + ]) { + const result = runCli(args, { allowFailure: true }) + recordCommand('control-surface', args, result) + const expectedUnreachable = args[0] === 'targets' && args[1] === 'status' && result.status !== 0 && parseEnvelope(result.stdout)?.data + recordCoverage('commands', args, result, expectedUnreachable ? true : undefined) + if (args[0] === 'targets' && args[1] === 'status' && result.status !== 0 && parseEnvelope(result.stdout)?.data) { + report.notes.push('remote target status returned non-zero because the test URL is intentionally unreachable; JSON diagnostics were still returned.') + } else if (result.status !== 0) { + recordFailure('control-surface', target, `beeper ${args.join(' ')} failed with status ${result.status}`) + } + } + + const logoutTarget = targets.filter(item => item.accessToken).at(-1) + if (logoutTarget) { + for (const args of [ + ['auth', 'logout', '--target', logoutTarget.name, '--json'], + ['auth', 'status', '--target', logoutTarget.name, '--json'], + ]) { + const result = runCli(args, { allowFailure: true }) + recordCommand('control-surface', args, result) + recordCoverage('commands', args, result) + if (result.status !== 0) recordFailure('control-surface', logoutTarget, `beeper ${args.join(' ')} failed with status ${result.status}`) + } + } } async function phaseVerifySameAccountDevices(targets) { @@ -438,6 +582,7 @@ async function phaseVerifySameAccountDevices(targets) { return } + await Promise.all(pair.map(target => waitForVerificationState(target))) const [initiator, responder] = await verificationPair(pair) const startArgs = ['verify', 'start', '--target', initiator.name, '--user', responder.matrix.userID, '--json'] const start = runCli(startArgs, { env: { BEEPER_ACCESS_TOKEN: initiator.accessToken }, allowFailure: true }) @@ -491,6 +636,18 @@ async function phaseVerifySameAccountDevices(targets) { } } +async function waitForVerificationState(target) { + for (let attempt = 0; attempt < 30; attempt++) { + const args = ['verify', 'status', '--target', target.name, '--json'] + const result = runCli(args, { env: { BEEPER_ACCESS_TOKEN: target.accessToken }, allowFailure: true }) + recordCommand('verify-devices', args, result) + const state = parseEnvelope(result.stdout)?.data?.state + if (result.status === 0 && (state === 'ready' || state === 'needs-verification' || state === 'needs-recovery-key' || state === 'needs-secrets')) return state + await sleep(1000) + } + throw new Error(`Timed out waiting for ${target.name} to reach a verification-ready state`) +} + async function verificationPair(pair) { const states = [] for (const target of pair) { @@ -538,6 +695,8 @@ async function phaseCleanup() { if (target.kind === 'server') { const stop = runCli(['targets', 'stop', target.name, '--json'], { allowFailure: true }) recordCommand('cleanup', ['targets', 'stop', target.name, '--json'], stop) + } else if (target.kind === 'remote') { + report.notes.push(`Remote Server target ${target.name} was not lifecycle-managed by the harness.`) } else { report.notes.push(`Desktop target ${target.name} may need manual quit if it was launched through the app.`) } @@ -547,6 +706,9 @@ async function phaseCleanup() { function plannedTargets() { if (report.targets?.length) return report.targets + if (remoteBaseURLs.length) { + return remoteBaseURLs.slice(0, accountCount).map((baseURL, index) => targetPlan('remote', index, index, baseURL)) + } const targets = [] for (let i = 0; i < desktopCount; i++) { targets.push(targetPlan('desktop', i, targets.length)) @@ -571,16 +733,17 @@ async function plannedTargetsWithAuth() { return targets } -function targetPlan(kind, index, ordinal) { +function targetPlan(kind, index, ordinal, baseURL) { const email = process.env[`BEEPER_E2E_EMAIL_${ordinal + 1}`] || `qatest+${emailBase + ordinal}@beeper.com` + const port = Number(process.env[`BEEPER_E2E_PORT_${ordinal + 1}`] || (portStart + ordinal)) return { kind, index, ordinal, name: process.env[`BEEPER_E2E_TARGET_${ordinal + 1}`] || `${kind}-${runID}-${index + 1}`, email, - port: Number(process.env[`BEEPER_E2E_PORT_${ordinal + 1}`] || (portStart + ordinal)), - baseURL: `http://127.0.0.1:${Number(process.env[`BEEPER_E2E_PORT_${ordinal + 1}`] || (portStart + ordinal))}`, + port, + baseURL: baseURL || `http://127.0.0.1:${port}`, } } @@ -596,6 +759,7 @@ function runCli(args, options = {}) { const result = spawnSync(process.execPath, [cliBin, ...args], { cwd: repoRoot, encoding: 'utf8', + timeout: commandTimeoutMs, env: { ...process.env, ...options.env, @@ -648,17 +812,17 @@ function recordLoginBlock(target, args, result) { const command = `beeper ${args.join(' ')}` if (target.kind === 'desktop' && /signed-in local Beeper Desktop session|missing access_token/i.test(output)) { recordBlock('login', target, 'Sign in to the isolated Desktop target, then rerun the login/readiness phases.', [ - `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/run.js targets start ${target.name} --json`, - `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/run.js setup --target ${target.name} --local --json`, + `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js targets start ${target.name} --json`, + `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js setup --target ${target.name} --local --json`, `BEEPER_E2E_RUN_ID=${runID} BEEPER_E2E_OTP="$QA_OTP" BEEPER_E2E_PHASES=login,readiness bun packages/cli/test/e2e-staging.ts`, ]) return } - if (target.kind === 'server' && /OAuth authorization failed|needs-login|server_error/i.test(output)) { + if ((target.kind === 'server' || target.kind === 'remote') && /OAuth authorization failed|needs-login|server_error/i.test(output)) { recordBlock('login', target, 'Complete Server setup sign-in, then rerun the login/readiness phases.', [ - `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/run.js targets start ${target.name} --json`, - `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/run.js auth email start --target ${target.name} --email ${target.email} --json`, - `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/run.js auth email response --target ${target.name} --setup-request-id "$SETUP_REQUEST_ID" --code "$QA_OTP" --username qatest --yes --json`, + `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js targets start ${target.name} --json`, + `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js auth email start --target ${target.name} --email ${target.email} --json`, + `BEEPER_CLI_CONFIG_DIR=${configDir} bun packages/cli/bin/dev.js auth email response --target ${target.name} --setup-request-id "$SETUP_REQUEST_ID" --code "$QA_OTP" --username qatest --yes --json`, `BEEPER_E2E_RUN_ID=${runID} BEEPER_E2E_OTP="$QA_OTP" BEEPER_E2E_PHASES=login,readiness bun packages/cli/test/e2e-staging.ts`, ]) return @@ -738,7 +902,7 @@ async function findReusableChatID(target, env) { const items = parseEnvelope(result.stdout)?.data const chat = Array.isArray(items) ? items.find(item => item?.id || item?.localChatID || item?.chatID) : undefined if (!chat) { - recordBlock('cli-surface', target, 'No reusable chat found. Messaging phase should create one, or provide existing signed-in QA accounts with Beeper chats.') + report.coverage.skipped.push({ command: 'Desktop-indexed chat mutation surface', reason: 'No reusable Desktop-indexed chat was returned by chats list.' }) return undefined } return chat.localChatID ?? chat.id ?? chat.chatID @@ -748,22 +912,30 @@ async function findReusableMessageID(target, chatID, env) { const result = runCli(['messages', 'list', '--chat', chatID, '--target', target.name, '--limit', '20', '--json'], { env, allowFailure: true }) recordCommand('surface-setup', ['messages', 'list', '--chat', chatID, '--target', target.name, '--limit', '20', '--json'], result) const items = parseEnvelope(result.stdout)?.data - const message = Array.isArray(items) ? items.find(item => item?.id || item?.messageID || item?.eventID) : undefined + const message = Array.isArray(items) ? items.find(item => item?.id || item?.messageID || item?.eventID || item?.event_id) : undefined if (!message) { - recordBlock('cli-surface', target, 'No reusable message found. Send a message or run the messaging phase before message-specific surface tests.') + report.coverage.skipped.push({ command: 'message-specific Desktop surface', reason: 'No reusable message ID was returned by messages list.' }) return undefined } - return message.id ?? message.messageID ?? message.eventID + return message.id ?? message.messageID ?? message.eventID ?? message.event_id } -function recordCoverage(type, args, result) { +function recordCoverage(type, args, result, ok = result.status === 0) { + report.coverage[type] ??= [] report.coverage[type].push({ command: `beeper ${redactCommandOutput(args.join(' '))}`, status: result.status, - ok: result.status === 0, + ok, }) } +async function generatedCommands() { + const source = await readFile(path.join(repoRoot, 'src/commands.generated.ts'), 'utf8') + return [...source.matchAll(/'([^']+)': Command/g)] + .map(match => match[1].replaceAll(':', ' ')) + .sort() +} + function isCoveredByCliSurface(pathname) { return [ '/v1/info', diff --git a/packages/cli/test/messages-search-validation.test.ts b/packages/cli/test/messages-search-validation.test.ts index 80eb7750..ad053898 100644 --- a/packages/cli/test/messages-search-validation.test.ts +++ b/packages/cli/test/messages-search-validation.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'bun:test' const cliRoot = fileURLToPath(new URL('..', import.meta.url)) function run(...args: string[]) { - return spawnSync(process.execPath, ['./bin/run.js', ...args], { + return spawnSync(process.execPath, ['./bin/dev.js', ...args], { cwd: cliRoot, encoding: 'utf8', env: { ...process.env, BEEPER_CLI_CONFIG_DIR: '/tmp/beeper-cli-bun-test', BEEPER_NO_LOGO: '1' }, From d68362bb07007ff92cb63c4e434594504b5e67a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 16:44:15 +0200 Subject: [PATCH 04/20] wip --- packages/cli/src/commands/setup.ts | 8 ++- packages/cli/src/lib/local-desktop.ts | 76 +++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 9d408cdf..2c907989 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -4,7 +4,7 @@ import { driveVerification, evaluateReadiness, type Readiness } from '../lib/app import { ensureDesktopToken, findLocalDesktop } from '../lib/desktop-auth.js' import { promptText, promptYesNoDefaultYes } from '../lib/app-api.js' import { installDesktop, installServer, readInstallations } from '../lib/installations.js' -import { connectedAccountSummary, findLocalDesktopSession, type LocalDesktopSession } from '../lib/local-desktop.js' +import { connectedAccountSummary, findLocalDesktopSession, localConnectedAccountSummary, localDesktopReadiness, type LocalDesktopSession } from '../lib/local-desktop.js' import { loginWithPKCE } from '../lib/oauth.js' import { findDesktopAppPath, launchDesktopApp, startProfile } from '../lib/profiles.js' import { interactiveEmailSetup } from '../lib/setup-login.js' @@ -390,10 +390,8 @@ async function prepareLocalDesktopSetup(target: Target, flags: SetupFlags): Prom managed: target.managed ?? false, } const session = await findLocalDesktopSession(resolvedTarget) - const [readiness, accounts] = await Promise.all([ - evaluateReadiness({ baseURL: resolvedTarget.baseURL, target: resolvedTarget.id, token: session.auth.accessToken }), - connectedAccountSummary(resolvedTarget, session.auth).catch(() => []), - ]) + const readiness = localDesktopReadiness(session) + const accounts = await localConnectedAccountSummary(session.dataDir).catch(() => []) return { accounts, readiness, session, target: resolvedTarget } } diff --git a/packages/cli/src/lib/local-desktop.ts b/packages/cli/src/lib/local-desktop.ts index 999f522c..0df67d61 100644 --- a/packages/cli/src/lib/local-desktop.ts +++ b/packages/cli/src/lib/local-desktop.ts @@ -4,6 +4,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { BeeperDesktop } from '@beeper/desktop-api' +import type { Readiness } from './app-state.js' import type { StoredAuth, Target } from './targets.js' const execFileAsync = promisify(execFile) @@ -12,7 +13,9 @@ export type LocalDesktopSession = { auth: StoredAuth dataDir: string deviceID?: string + firstSyncDone?: boolean homeserver?: string + state: Record userID?: string } @@ -28,7 +31,9 @@ export async function findLocalDesktopSession(target?: Target): Promise { const token = auth?.accessToken ?? target.auth?.accessToken if (!token) return [] @@ -50,6 +88,15 @@ export async function connectedAccountSummary(target: Target, auth?: StoredAuth) .slice(0, 8) } +export async function localConnectedAccountSummary(dataDir: string): Promise { + const bridgeAccounts = await readKeyValue(dataDir, 'bridgeAccounts').catch(() => undefined) + const rows = Array.isArray(bridgeAccounts) ? bridgeAccounts : [] + const names = rows + .map(item => accountName(item)) + .filter((name): name is string => Boolean(name)) + return [...new Set(names)].slice(0, 8) +} + async function localDesktopDataDirs(): Promise { const candidates = new Set() if (process.env.BEEPER_USER_DATA_DIR) return [process.env.BEEPER_USER_DATA_DIR] @@ -70,25 +117,32 @@ async function localDesktopDataDirs(): Promise { } async function readBeeperState(dataDir: string): Promise> { + const parsed = await readKeyValue(dataDir, 'beeperState') + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid beeperState') + return parsed as Record +} + +async function readKeyValue(dataDir: string, key: string): Promise { const dbPath = join(dataDir, 'index.db') const { stdout } = await execFileAsync('sqlite3', [ '-json', dbPath, - "SELECT value FROM key_values WHERE key = 'beeperState' LIMIT 1", + `SELECT value FROM key_values WHERE key = '${sqlString(key)}' LIMIT 1`, ]) const rows = JSON.parse(stdout || '[]') as Array<{ value?: string }> const value = rows[0]?.value - if (!value) throw new Error('missing beeperState') - const parsed = JSON.parse(value) - if (!parsed || typeof parsed !== 'object') throw new Error('invalid beeperState') - return parsed as Record + if (!value) throw new Error(`missing ${key}`) + return JSON.parse(value) } function accountName(item: unknown): string | undefined { if (!item || typeof item !== 'object') return undefined const record = item as Record const bridge = record.bridge && typeof record.bridge === 'object' ? record.bridge as Record : undefined + const network = record.network && typeof record.network === 'object' ? record.network as Record : undefined return stringValue(record.network) + ?? stringValue(network?.displayName) + ?? stringValue(network?.name) ?? stringValue(record.displayName) ?? stringValue(record.name) ?? stringValue(bridge?.type) @@ -99,3 +153,15 @@ function accountName(item: unknown): string | undefined { function stringValue(value: unknown): string | undefined { return typeof value === 'string' && value ? value : undefined } + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined +} + +function recordValue(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined +} + +function sqlString(value: string): string { + return value.replaceAll("'", "''") +} From 867279811cb27f675b1184db62e859a84c16f534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:09 +0200 Subject: [PATCH 05/20] Fix CLI binary release repository --- packages/cli/bin/run.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js index 434300d5..78f0f0d4 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -17,7 +17,8 @@ const arch = normalizeArch(process.arch) const extension = platform === 'windows' ? '.exe' : '' const executableName = `beeper-${platform}-${arch}${extension}` const releaseTag = process.env.BEEPER_CLI_RELEASE_TAG || `v${version}` -const releaseBaseURL = (process.env.BEEPER_CLI_RELEASE_BASE_URL || `https://github.com/beeper/desktop-api-cli/releases/download/${releaseTag}`).replace(/\/$/, '') +const releaseRepository = process.env.GITHUB_REPOSITORY || 'beeper/cli' +const releaseBaseURL = (process.env.BEEPER_CLI_RELEASE_BASE_URL || `https://github.com/${releaseRepository}/releases/download/${releaseTag}`).replace(/\/$/, '') const cacheRoot = process.env.BEEPER_CLI_BINARY_CACHE_DIR || join(homedir(), '.cache', 'beeper-cli') const cacheDir = join(cacheRoot, version, `${platform}-${arch}`) const cachedExecutable = join(cacheDir, platform === 'windows' ? 'beeper.exe' : 'beeper') From 3faaa2ca538ab9112c8a26f75e360172b4ce93fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:14 +0200 Subject: [PATCH 06/20] Cap CLI binary download redirects --- packages/cli/bin/run.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js index 78f0f0d4..94e540cd 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -93,13 +93,14 @@ async function fetchExpectedHash() { } } -async function download(url, destination, options = {}) { +async function download(url, destination, options = {}, redirectCount = 0) { + if (redirectCount > 10) throw new Error(`too many redirects while downloading ${basename(url)}`) await mkdir(dirname(destination), { recursive: true }) await new Promise((resolve, reject) => { const request = get(url, response => { if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { response.resume() - download(new URL(response.headers.location, url).toString(), destination, options).then(resolve, reject) + download(new URL(response.headers.location, url).toString(), destination, options, redirectCount + 1).then(resolve, reject) return } if (response.statusCode !== 200) { From c8b07e66eca78597da39f5e99f10eca55e6eb47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:24 +0200 Subject: [PATCH 07/20] Stream cloudflared downloads to disk --- packages/cli-plugin-cloudflare/src/lib/cloudflared.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli-plugin-cloudflare/src/lib/cloudflared.ts b/packages/cli-plugin-cloudflare/src/lib/cloudflared.ts index c9e98b8e..bada2c6a 100644 --- a/packages/cli-plugin-cloudflare/src/lib/cloudflared.ts +++ b/packages/cli-plugin-cloudflare/src/lib/cloudflared.ts @@ -1,6 +1,10 @@ -import { access, chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { createWriteStream } from 'node:fs' +import { access, chmod, mkdir, rename, rm } from 'node:fs/promises' import { arch, platform } from 'node:os' import { basename, dirname, join } from 'node:path' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import type { ReadableStream } from 'node:stream/web' import { fileURLToPath } from 'node:url' import { execFileSync, spawn, type ChildProcess } from 'node:child_process' @@ -193,7 +197,7 @@ function downloadURL(system = platform(), cpu = arch()): string { async function downloadFile(url: string, to: string): Promise { const response = await fetch(url, { redirect: 'follow' }) if (!response.ok || !response.body) throw new Error(`Could not download ${url}: ${response.status} ${response.statusText}`) - await writeFile(to, Buffer.from(await response.arrayBuffer())) + await pipeline(Readable.fromWeb(response.body as unknown as ReadableStream), createWriteStream(to)) } export function findTunnelURL(data: string, domain = cloudflaredDomain()): string | undefined { From bb462abc9214492b70c782960deff2a0474eeb67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:24 +0200 Subject: [PATCH 08/20] Stream installer downloads to disk --- packages/cli/src/lib/installations.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/installations.ts b/packages/cli/src/lib/installations.ts index 7a7ead7a..19abe049 100644 --- a/packages/cli/src/lib/installations.ts +++ b/packages/cli/src/lib/installations.ts @@ -1,6 +1,10 @@ +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' +import type { ReadableStream } from 'node:stream/web' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { beeperDir } from './targets.js' @@ -361,7 +365,9 @@ function stringField(value: unknown, fields: string[]): string | undefined { } async function writeResponseToFile(response: Response, path: string): Promise { - await writeFile(path, Buffer.from(await response.arrayBuffer())) + if (!response.body) throw new Error('Download response did not include a body.') + + await pipeline(Readable.fromWeb(response.body as unknown as ReadableStream), createWriteStream(path)) } function filenameFromResponse(response: Response): string | undefined { From 90d74cf8c53641b4a3e089882f97c49ae683955c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:24 +0200 Subject: [PATCH 09/20] Resolve publish package directories portably --- scripts/publish-packages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts index c04b34da..9881bc1e 100644 --- a/scripts/publish-packages.ts +++ b/scripts/publish-packages.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { existsSync } from "node:fs"; import { readdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; const root = process.cwd(); const args = Bun.argv.slice(2); @@ -68,7 +68,7 @@ const packageJsonPaths = packageDirs .filter((path) => existsSync(path)); const packages = await Promise.all( - packageJsonPaths.map(async (path) => ({ path, dir: path.replace(/\/package\.json$/, ""), json: await readJson(path) })), + packageJsonPaths.map(async (path) => ({ path, dir: dirname(path), json: await readJson(path) })), ); const publishable = packages.filter( From c5b5d9b2a9d7e2d930cfbba04d2f10d6d37473ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:24 +0200 Subject: [PATCH 10/20] Avoid redundant release package build --- .github/workflows/publish-release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index ea95c281..5f62b060 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -26,8 +26,6 @@ jobs: run: bun install --frozen-lockfile - name: Test run: bun run test - - name: Build package - run: bun run --filter beeper-cli build - name: Publish GitHub release env: GH_TOKEN: ${{ github.token }} From dcfbd0e793eea9f60f07d2cae1b79eb22ce789e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:24 +0200 Subject: [PATCH 11/20] Require numeric bridge selection input --- packages/cli/src/commands/accounts/add.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/accounts/add.ts b/packages/cli/src/commands/accounts/add.ts index 43e3215e..1db3c81b 100644 --- a/packages/cli/src/commands/accounts/add.ts +++ b/packages/cli/src/commands/accounts/add.ts @@ -98,7 +98,7 @@ async function chooseAccountType(items: AccountType[]): Promise { try { for (;;) { const answer = (await rl.question('Select a bridge: ')).trim() - const selected = Number.parseInt(answer, 10) + const selected = /^\d+$/.test(answer) ? Number.parseInt(answer, 10) : Number.NaN if (Number.isInteger(selected) && selected >= 1 && selected <= available.length) return available[selected - 1]!.id const byID = available.find(account => account.id === answer) if (byID) return byID.id From dcf276629a77ed11edd6d5a3aad551f2471f0b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:24 +0200 Subject: [PATCH 12/20] Escape README flag option pipes --- packages/cli/README.md | 30 ++++++++++++------------- packages/cli/scripts/generate-readme.ts | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 5a2a2934..62bf3c60 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -423,7 +423,7 @@ Flags: | Flag | Type | Description | | --- | --- | --- | -| `--channel=` | option | Install release channel Default: stable | +| `--channel=` | option | Install release channel Default: stable | | `--desktop` | boolean | Set up a local Beeper Desktop target | | `--email=` | option | Sign in with an email address | | `--install` | boolean | Allow installing missing managed runtime | @@ -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. Staging forces nightly. Default: production | | `--username=` | option | Username to use if setup creates a new account | Examples: @@ -457,7 +457,7 @@ Flags: | Flag | Type | Description | | --- | --- | --- | -| `--channel=` | option | Desktop release channel Default: stable | +| `--channel=` | option | Desktop release channel Default: stable | Examples: @@ -479,8 +479,8 @@ Flags: | Flag | Type | Description | | --- | --- | --- | -| `--channel=` | option | Server release channel Default: stable | -| `--server-env=` | option | Server environment. Staging forces nightly. Default: production | +| `--channel=` | option | Server release channel Default: stable | +| `--server-env=` | option | Server environment. Staging forces nightly. Default: production | Examples: @@ -521,7 +521,7 @@ Flags: | Flag | Type | Description | | --- | --- | --- | | `--available` | boolean | Only bridges available to add (--no-available to exclude) | -| `--provider=` | option | Limit to bridge provider | +| `--provider=` | option | Limit to bridge provider | 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. Staging forces nightly. 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. Staging forces nightly. Default: production | Examples: @@ -1250,7 +1250,7 @@ Flags: | `--login-id=` | option | Existing login ID to re-login as | | `--non-interactive` | boolean | Do not prompt; require --flow, --field, and --cookie values when needed. | | `--webview` | boolean | Use Bun.WebView to collect cookie login fields when a cookie step is returned. | -| `--webview-backend=` | option | Bun.WebView backend for cookie login steps. Default: chrome | +| `--webview-backend=` | option | Bun.WebView backend for cookie login steps. Default: chrome | | `--webview-timeout=` | option | Seconds to wait for Bun.WebView cookie collection. Default: 120 | Examples: @@ -1631,7 +1631,7 @@ Flags: | Flag | Type | Description | | --- | --- | --- | | `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | -| `--level=` | option | Destination: inbox (default mailbox) or low (Low Priority) Required. | +| `--level=` | option | Destination: inbox (default mailbox) or low (Low Priority) Required. | | `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | Examples: @@ -1911,12 +1911,12 @@ Flags: | `--after=` | option | Only messages at or after this ISO timestamp | | `--before=` | option | Only messages at or before this ISO timestamp | | `--chat=...` | option | Limit to a chat selector. Repeat for multiple. | -| `--chat-type=` | option | Only group chats or direct messages | +| `--chat-type=` | option | Only group chats or direct messages | | `--exclude-low-priority` | boolean | Exclude low-priority chats | | `--ids` | boolean | Print only message IDs | | `--include-muted` | boolean | Include muted chats | | `--limit=` | option | Maximum results Default: 50 | -| `--media=...` | option | Filter by media type. Repeat for multiple. | +| `--media=...` | option | Filter by media type. Repeat for multiple. | | `--sender=` | option | me, others, or a user ID | Examples: @@ -2249,7 +2249,7 @@ Flags: | `--chat=` | option | Chat selector (ID, local ID, title, or search text) Required. | | `--duration=` | option | When --state is typing, send paused automatically after this many seconds | | `--pick=` | option | Pick the Nth result when the selector is ambiguous (1-indexed) | -| `--state=` | option | Indicator to send Default: typing | +| `--state=` | option | Indicator to send Default: typing | Examples: @@ -2415,8 +2415,8 @@ Flags: | Flag | Type | Description | | --- | --- | --- | | `-c, --chat=...` | option | Chat ID to subscribe to. Defaults to all chats. | -| `--exclude-type=...` | option | Drop events of these types. Repeat for multiple. | -| `--include-type=...` | option | Only forward events of these types. Repeat for multiple. | +| `--exclude-type=...` | option | Drop events of these types. Repeat for multiple. | +| `--include-type=...` | option | Only forward events of these types. Repeat for multiple. | | `--webhook=` | option | Forward each event to this URL as a POST request (best-effort, fire-and-forget) | | `--webhook-queue=` | option | Maximum pending webhook deliveries before dropping events Default: 64 | | `--webhook-secret=` | option | HMAC-SHA256 secret. Signs payloads with X-Beeper-Signature: sha256= | diff --git a/packages/cli/scripts/generate-readme.ts b/packages/cli/scripts/generate-readme.ts index 2bacdc82..76c561c5 100644 --- a/packages/cli/scripts/generate-readme.ts +++ b/packages/cli/scripts/generate-readme.ts @@ -436,7 +436,7 @@ function commandSection(command) { if (flags.length > 0) { parts.push('', 'Flags:', '', '| Flag | Type | Description |', '| --- | --- | --- |'); for (const flag of flags.sort((a, b) => a.name.localeCompare(b.name))) { - parts.push(`| \`${flagLabel(flag)}\` | ${flag.type || 'boolean'} | ${escapeTable(flagDescription(flag))} |`); + parts.push(`| \`${escapeTable(flagLabel(flag))}\` | ${flag.type || 'boolean'} | ${escapeTable(flagDescription(flag))} |`); } } From c621fd4ca7e22646f1f979bb7b7bb7e1a3f3b6d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:24 +0200 Subject: [PATCH 13/20] Type chats start request payload --- packages/cli/src/commands/chats/start.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/chats/start.ts b/packages/cli/src/commands/chats/start.ts index c3d439c9..59f5c6f7 100644 --- a/packages/cli/src/commands/chats/start.ts +++ b/packages/cli/src/commands/chats/start.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core' +import type { ChatStartParams } from '@beeper/desktop-api/resources/chats' import { BeeperCommand, ensureWritable } from '../../lib/command.js' import { createClient } from '../../lib/client.js' import { printData } from '../../lib/output.js' @@ -11,13 +12,15 @@ export default class ChatsStart extends BeeperCommand { account: Flags.string({ description: 'Account selector. Defaults to the single available account or the matrix account.' }), title: Flags.string({ description: 'Optional initial title for a new group chat' }), } + async run(): Promise { const { args, flags } = await this.parse(ChatsStart) ensureWritable(flags) const client = await createClient(flags) const accountID = flags.account ? await resolveAccountID(client, flags.account) : await defaultAccountID(client) const user = userQueryFromInput(args.user) - await printData(await client.chats.start({ accountID, user, title: flags.title } as any), flags.json ? 'json' : 'human') + const payload: ChatStartParams & { title?: string } = { accountID, user, title: flags.title } + await printData(await client.chats.start(payload), flags.json ? 'json' : 'human') } } From 46ac66e00d74f3e1064ca496bc100c976f057abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:24 +0200 Subject: [PATCH 14/20] Fix recovery key reset statement spacing --- packages/cli/src/commands/verify/reset-recovery-key.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/src/commands/verify/reset-recovery-key.ts b/packages/cli/src/commands/verify/reset-recovery-key.ts index 8f4cb42c..f2676d98 100644 --- a/packages/cli/src/commands/verify/reset-recovery-key.ts +++ b/packages/cli/src/commands/verify/reset-recovery-key.ts @@ -2,21 +2,27 @@ import { BeeperCommand, ensureWritable } from '../../lib/command.js' import { createClient } from '../../lib/client.js' import { printData } from '../../lib/output.js' import { promptYesNoDefaultYes } from '../../lib/app-api.js' + export default class AuthVerifyResetRecoveryKey extends BeeperCommand { static override summary = 'Create a new encrypted-messages recovery key' + async run(): Promise { const { flags } = await this.parse(AuthVerifyResetRecoveryKey) ensureWritable(flags) const client = await createClient(flags) const reset = await client.app.login.verification.recoveryKey.reset.create({}) + if ((flags.json || !process.stdin.isTTY) && !flags.yes) { throw new Error('Resetting the recovery key requires --yes in non-interactive mode so the new key can be confirmed.') } + if (!flags.yes) { process.stderr.write(`New recovery key:\n${reset.recoveryKey}\n`) if (!await promptYesNoDefaultYes('I saved this recovery key. Use it for this account?')) throw new Error('Recovery key reset cancelled.') } + const confirmed = await client.app.login.verification.recoveryKey.reset.confirm({ recoveryKey: reset.recoveryKey }) + await printData({ recoveryKey: reset.recoveryKey, session: confirmed.session }, flags.json ? 'json' : 'human') } } From f8148887f93aeea94b9491a636cb6cc8d8df68da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:30 +0200 Subject: [PATCH 15/20] Persist remote setup after auth succeeds --- packages/cli/src/commands/setup.ts | 2 +- packages/cli/test/cli-smoke.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 2c907989..c18ca881 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -206,9 +206,9 @@ export default class Setup extends BeeperCommand { baseURL: flags.remote!, managed: false, } + const result = flags.email ? await setupEmailTarget(target, flags) : await setupOAuthTarget(target, flags, 'remote-oauth') await writeTarget(target) if (!flags.target) await updateConfig(config => ({ ...config, defaultTarget: config.defaultTarget ?? target.id })) - const result = flags.email ? await setupEmailTarget(target, flags) : await setupOAuthTarget(target, flags, 'remote-oauth') await this.printSetupResult(result, flags) } diff --git a/packages/cli/test/cli-smoke.ts b/packages/cli/test/cli-smoke.ts index aa524850..feb5b168 100644 --- a/packages/cli/test/cli-smoke.ts +++ b/packages/cli/test/cli-smoke.ts @@ -226,10 +226,10 @@ assert.match(envelope.error, /auth email start/) assert.doesNotMatch(envelope.error, /--code|OTP/i, 'setup must direct automation to the two-step email commands without accepting OTP itself') result = run('targets', 'show', 'email-remote', '--json') -assert.equal(result.status, 0, result.stderr) -envelope = JSON.parse(result.stdout) -assert.equal(envelope.success, true) -assert.equal(envelope.data.baseURL, 'http://127.0.0.1:9') +assert.notEqual(result.status, 0) +envelope = JSON.parse(result.stderr) +assert.equal(envelope.success, false) +assert.match(envelope.error, /Unknown Beeper target/) const rpcResult = spawnSync(process.execPath, ['./bin/dev.js', 'rpc'], { cwd: root, From df05cc915b2734133b64518439fa6d9c6598b18e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:35 +0200 Subject: [PATCH 16/20] Honor setup install channel flags --- packages/cli/src/commands/setup.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index c18ca881..5b45031b 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -588,10 +588,12 @@ async function maybeDriveOnboarding(result: SetupResult, flags: SetupFlags): Pro async function installWithCopy(type: 'desktop' | 'server', flags: SetupFlags): Promise { const label = type === 'desktop' ? 'Beeper Desktop' : 'local Beeper Server' - if (!flags.json && process.stdin.isTTY) process.stdout.write(`Installing ${label} stable from beeper.com...\n`) - if (type === 'desktop') await installDesktop({ channel: 'stable', serverEnv: flags['server-env'] }) - else await installServer({ channel: 'stable', serverEnv: 'production' }) - if (!flags.json && process.stdin.isTTY) process.stdout.write(`Installed ${label} stable.\n\n`) + const channel = flags.channel === 'nightly' ? 'nightly' : 'stable' + const serverEnv = flags['server-env'] === 'staging' ? 'staging' : 'production' + if (!flags.json && process.stdin.isTTY) process.stdout.write(`Installing ${label} ${channel} from beeper.com...\n`) + if (type === 'desktop') await installDesktop({ channel, serverEnv }) + else await installServer({ channel, serverEnv }) + if (!flags.json && process.stdin.isTTY) process.stdout.write(`Installed ${label} ${channel}.\n\n`) } function setupResultDetail(result: SetupResult): string | undefined { From 325e8b1850ee2c63e75fc3e6c87514f4f79462a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:48 +0200 Subject: [PATCH 17/20] Guard desktop Linux binary probing --- packages/cli/src/lib/profiles.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/profiles.ts b/packages/cli/src/lib/profiles.ts index e52d768e..6599bcad 100644 --- a/packages/cli/src/lib/profiles.ts +++ b/packages/cli/src/lib/profiles.ts @@ -74,6 +74,7 @@ export async function launchDesktopApp(target?: Target): Promise<{ id: string; s 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 + if (process.platform === 'darwin') { for (const path of [ '/Applications/Beeper.app', @@ -82,6 +83,7 @@ export async function findDesktopAppPath(): Promise { if (await isBeeperDesktopApp(path)) return path } } + if (process.platform === 'win32') { const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local') const candidates = [ @@ -92,9 +94,13 @@ export async function findDesktopAppPath(): Promise { if (await pathExists(path)) return path } } - for (const path of ['/usr/bin/beeper', '/usr/local/bin/beeper']) { - if (await pathExists(path)) return path + + if (process.platform === 'linux') { + for (const path of ['/usr/bin/beeper', '/usr/local/bin/beeper']) { + if (await pathExists(path)) return path + } } + return undefined } From 0e5e42b85d2e4af2c634cf41036e7b7b20a13d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:09:54 +0200 Subject: [PATCH 18/20] Require complete local desktop readiness --- packages/cli/src/lib/local-desktop.ts | 51 +++++++++++++++++---------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/lib/local-desktop.ts b/packages/cli/src/lib/local-desktop.ts index 0df67d61..9c3c8790 100644 --- a/packages/cli/src/lib/local-desktop.ts +++ b/packages/cli/src/lib/local-desktop.ts @@ -44,7 +44,37 @@ export async function findLocalDesktopSession(target?: Target): Promise Date: Mon, 18 May 2026 17:09:54 +0200 Subject: [PATCH 19/20] Restrict restart coverage to server targets --- packages/cli/test/e2e-staging.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/e2e-staging.ts b/packages/cli/test/e2e-staging.ts index 752c8db2..fa5e07f4 100644 --- a/packages/cli/test/e2e-staging.ts +++ b/packages/cli/test/e2e-staging.ts @@ -517,7 +517,7 @@ async function phaseControlSurface() { } } } - if (target.kind !== 'remote') { + if (target.kind === 'server') { const args = ['targets', 'restart', target.name, '--json'] const result = runCli(args, { env: serverEnv(), allowFailure: true }) recordCommand('control-surface', args, result) @@ -529,7 +529,7 @@ async function phaseControlSurface() { recordFailure('control-surface', target, error) } } else { - report.coverage.skipped.push({ command: 'targets restart', reason: 'Remote source Server targets are not lifecycle-managed by the CLI.' }) + report.coverage.skipped.push({ command: 'targets restart', reason: 'Only server targets are lifecycle-managed by the CLI.' }) } const remoteName = `remote-${runID}` From e4d1226aa46b1ad627fb378c9480b8b55a7232fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 18 May 2026 17:20:04 +0200 Subject: [PATCH 20/20] Add command alias support to CLI generator Add a small alias map to the command generator and expand detected command entries to include aliases. The generator now flatMaps files into command+alias entries, deduplicates import paths and reuses a single import identifier per module. Update the generated commands file to include new aliases (e.g. accounts, chats, bridges, targets, contacts) and adjust imports order, and update smoke tests to assert alias behavior. --- packages/cli/scripts/generate-command-map.ts | 19 ++++++++++++++++--- packages/cli/src/commands.generated.ts | 6 ++++++ packages/cli/test/cli-smoke.ts | 4 ++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/cli/scripts/generate-command-map.ts b/packages/cli/scripts/generate-command-map.ts index c0f095b1..3e68239d 100644 --- a/packages/cli/scripts/generate-command-map.ts +++ b/packages/cli/scripts/generate-command-map.ts @@ -7,16 +7,29 @@ const root = fileURLToPath(new URL('..', import.meta.url)) const commandsDir = join(root, 'src', 'commands') const outPath = join(root, 'src', 'commands.generated.ts') +const listAliases: Record = { + 'accounts:list': ['accounts'], + 'bridges:list': ['bridges'], + 'chats:list': ['chats', 'accounts:chats'], + 'contacts:list': ['contacts'], + 'targets:list': ['targets'], +} + const files = await listCommandFiles(commandsDir) -const entries = files +const canonicalEntries = files .map(file => ({ command: fileToCommand(file), importPath: `./commands/${relative(commandsDir, file).split(sep).join('/').replace(/\.(ts|tsx)$/, '.js')}`, })) .sort((a, b) => a.command.localeCompare(b.command)) +const entries = canonicalEntries + .flatMap(entry => [entry, ...(listAliases[entry.command] ?? []).map(command => ({ command, importPath: entry.importPath }))]) + .sort((a, b) => a.command.localeCompare(b.command)) -const imports = entries.map((entry, index) => `import Command${index} from '${entry.importPath}'`).join('\n') -const mapEntries = entries.map((entry, index) => ` '${entry.command}': Command${index},`).join('\n') +const importPaths = canonicalEntries.map(entry => entry.importPath) +const commandImports = new Map(importPaths.map((importPath, index) => [importPath, `Command${index}`])) +const imports = importPaths.map((importPath, index) => `import Command${index} from '${importPath}'`).join('\n') +const mapEntries = entries.map(entry => ` '${entry.command}': ${commandImports.get(entry.importPath)},`).join('\n') await writeFile( outPath, diff --git a/packages/cli/src/commands.generated.ts b/packages/cli/src/commands.generated.ts index 8cbaf8eb..ed34e12a 100644 --- a/packages/cli/src/commands.generated.ts +++ b/packages/cli/src/commands.generated.ts @@ -101,7 +101,9 @@ import Command99 from './commands/version.js' import Command100 from './commands/watch.js' export const commands = { + 'accounts': Command1, 'accounts:add': Command0, + 'accounts:chats': Command21, 'accounts:list': Command1, 'accounts:remove': Command2, 'accounts:show': Command3, @@ -114,8 +116,10 @@ export const commands = { 'auth:logout': Command10, 'auth:status': Command11, 'autocomplete': Command12, + 'bridges': Command13, 'bridges:list': Command13, 'bridges:show': Command14, + 'chats': Command21, 'chats:archive': Command15, 'chats:avatar': Command16, 'chats:description': Command17, @@ -143,6 +147,7 @@ export const commands = { 'config:path': Command39, 'config:reset': Command40, 'config:set': Command41, + 'contacts': Command42, 'contacts:list': Command42, 'contacts:search': Command43, 'contacts:show': Command44, @@ -172,6 +177,7 @@ export const commands = { 'send:voice': Command68, 'setup': Command69, 'status': Command70, + 'targets': Command76, 'targets:add:desktop': Command71, 'targets:add:remote': Command72, 'targets:add:server': Command73, diff --git a/packages/cli/test/cli-smoke.ts b/packages/cli/test/cli-smoke.ts index feb5b168..7a379f8b 100644 --- a/packages/cli/test/cli-smoke.ts +++ b/packages/cli/test/cli-smoke.ts @@ -186,7 +186,11 @@ assert.equal(availablePlugins.data[0].name, '@beeper/cli-plugin-cloudflare') assert.equal(availablePlugins.data[0].status, 'not installed') assert.deepEqual(availablePlugins.data[0].commands, ['targets tunnel']) assert.match(ok('chats', 'list', '--help'), /preferred chat selectors/, 'chats list --ids should describe preferred selectors') +assert.match(ok('chats', '--help'), /preferred chat selectors/, 'chats should alias chats list') +assert.match(ok('accounts', 'chats', '--help'), /preferred chat selectors/, 'accounts chats should alias chats list') +assert.match(ok('accounts', '--help'), /List connected accounts/, 'accounts should alias accounts list') assert.match(ok('bridges', 'list', '--help'), /connect chat accounts/, 'bridges list should expose bridge catalog') +assert.match(ok('bridges', '--help'), /connect chat accounts/, 'bridges should alias bridges list') assert.match(ok('verify', '--help'), /device verification/, 'verify should be a root command') assert.throws(() => ok('auth', 'verify', '--help'), /failed/, 'auth verify must not remain public') assert.throws(() => ok('messages', 'react', '--help'), /failed/, 'messages react must not remain public')