diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 7f7b1b6b..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 }} @@ -38,25 +36,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..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 { 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 Bun.write(to, response) + await pipeline(Readable.fromWeb(response.body as unknown as ReadableStream), createWriteStream(to)) } export function findTunnelURL(data: string, domain = cloudflaredDomain()): string | undefined { diff --git a/packages/cli/README.md b/packages/cli/README.md index 00afe8e6..62bf3c60 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 | @@ -421,14 +423,16 @@ 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 | | `--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 | +| `--server-env=` | option | Server environment. Staging forces nightly. Default: production | +| `--username=` | option | Username to use if setup creates a new account | Examples: @@ -453,7 +457,7 @@ Flags: | Flag | Type | Description | | --- | --- | --- | -| `--channel=` | option | Desktop release channel Default: stable | +| `--channel=` | option | Desktop release channel Default: stable | Examples: @@ -475,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: @@ -517,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: @@ -569,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: @@ -598,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: @@ -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 @@ -1202,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: @@ -1583,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: @@ -1863,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: @@ -2201,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: @@ -2367,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/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..94e540cd 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -1,11 +1,120 @@ -#!/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 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') + +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 = {}, 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, redirectCount + 1).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/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/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))} |`); } } diff --git a/packages/cli/src/commands.generated.ts b/packages/cli/src/commands.generated.ts index ccbf0678..ed34e12a 100644 --- a/packages/cli/src/commands.generated.ts +++ b/packages/cli/src/commands.generated.ts @@ -6,100 +6,104 @@ 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': Command1, 'accounts:add': Command0, + 'accounts:chats': Command21, 'accounts:list': Command1, 'accounts:remove': Command2, 'accounts:show': Command3, @@ -107,95 +111,101 @@ 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': Command13, + 'bridges:list': Command13, + 'bridges:show': Command14, + 'chats': Command21, + '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': Command42, + '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': Command76, + '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..1db3c81b 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 = /^\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 + 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/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..59f5c6f7 100644 --- a/packages/cli/src/commands/chats/start.ts +++ b/packages/cli/src/commands/chats/start.ts @@ -1,8 +1,8 @@ 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' -import { createMatrixDM } from '../../lib/matrix-direct.js' import { listAccountIDs, resolveAccountID, userQueryFromInput } from '../../lib/resolve.js' export default class ChatsStart extends BeeperCommand { @@ -12,29 +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) - 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') - } + const payload: ChatStartParams & { title?: string } = { accountID, user, title: flags.title } + await printData(await client.chats.start(payload), 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/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 16105cce..5b45031b 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -1,18 +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 { connectedAccountSummary, findLocalDesktopSession, type LocalDesktopSession } from '../lib/local-desktop.js' +import { promptText, promptYesNoDefaultYes } from '../lib/app-api.js' +import { installDesktop, installServer, readInstallations } from '../lib/installations.js' +import { connectedAccountSummary, findLocalDesktopSession, localConnectedAccountSummary, localDesktopReadiness, 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, @@ -32,6 +35,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 = [ @@ -39,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.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 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) { @@ -71,26 +81,28 @@ 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) } 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) @@ -98,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 } } @@ -123,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') } @@ -138,8 +187,18 @@ 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 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, @@ -147,17 +206,17 @@ 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 = 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 }) @@ -166,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 @@ -179,25 +243,81 @@ 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 json?: boolean local?: boolean oauth?: boolean + email?: string remote?: string server?: boolean 'server-env'?: string target?: string + username?: string yes?: boolean } @@ -210,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) { @@ -229,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, @@ -259,13 +390,38 @@ 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 } } +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) @@ -311,6 +467,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 } @@ -328,11 +492,238 @@ 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' + 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 { + 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/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/commands/verify/reset-recovery-key.ts b/packages/cli/src/commands/verify/reset-recovery-key.ts index 575d32e8..f2676d98 100644 --- a/packages/cli/src/commands/verify/reset-recovery-key.ts +++ b/packages/cli/src/commands/verify/reset-recovery-key.ts @@ -1,12 +1,28 @@ 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/installations.ts b/packages/cli/src/lib/installations.ts index 8e05cdcb..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' @@ -213,7 +217,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 +364,12 @@ function stringField(value: unknown, fields: string[]): string | undefined { return undefined } +async function writeResponseToFile(response: Response, path: string): Promise { + 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 { const contentDisposition = response.headers.get('content-disposition') const match = contentDisposition?.match(/filename="?([^";]+)"?/i) diff --git a/packages/cli/src/lib/local-desktop.ts b/packages/cli/src/lib/local-desktop.ts index 999f522c..9c3c8790 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 +103,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 +132,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 +168,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("'", "''") +} 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 232dc546..00000000 --- a/packages/cli/src/lib/matrix-direct.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { appRequest } from './app-api.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 appRequest<{ matrix?: { homeserver?: string } }>('GET', '/v1/app/setup', { - 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..6599bcad 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,59 @@ 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 + } + } + + if (process.platform === 'linux') { + 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 +381,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/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/cli-smoke.ts b/packages/cli/test/cli-smoke.ts index 77b30e0d..7a379f8b 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) @@ -183,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') @@ -215,7 +222,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.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, encoding: 'utf8', env: { diff --git a/packages/cli/test/e2e-staging.ts b/packages/cli/test/e2e-staging.ts index 892a7b7c..fa5e07f4 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') @@ -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'], @@ -265,42 +272,58 @@ 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), - 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, ...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) - 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() { @@ -314,11 +337,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) @@ -344,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'], @@ -358,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'], @@ -366,54 +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( - ['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'], + ['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', 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.') + 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 === 'server') { + 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: 'Only server targets are 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) { @@ -434,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 }) @@ -487,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) { @@ -534,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.`) } @@ -543,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)) @@ -567,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}`, } } @@ -592,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, @@ -644,16 +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 api post /v1/app/setup/start --target ${target.name} --no-auth`, + `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 @@ -662,73 +831,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) { @@ -765,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 @@ -775,28 +912,36 @@ 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', '/v1/spec', - '/v1/app', - '/v1/app/verifications', + '/v1/app/setup', + '/v1/app/setup/verifications', '/v1/accounts', '/v1/chats', '/v1/contacts', 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' }, diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts new file mode 100644 index 00000000..9881bc1e --- /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 { dirname, 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: dirname(path), 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}.`);