diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddaf63dc..00a2d32d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,64 +1,134 @@ # Contributing to Aether Agent -Thanks for helping out. Aether Agent is a small, focused TypeScript client — easy -to read end to end in an afternoon. +Aether Agent is a TypeScript CLI for Node.js 24 or newer. Keep changes focused, +fail closed at security boundaries, and avoid runtime dependencies unless the +benefit and review plan are explicit. -## Setup +## Set up a development checkout -```bash -git clone https://github.com/AetherAI3/aether-agent +```sh +git clone https://github.com/AetherAI3/aether-agent.git cd aether-agent -npm install +npm ci --ignore-scripts npm run build +``` + +Use `npm ci --ignore-scripts`, not `npm install`, for a reproducible checkout. +The package intentionally has zero runtime dependencies. + +## Repository map + +```text +src/main.ts executable entry and top-level dispatch +src/commands/ shell/slash commands and command manifest +src/core/ transport, auth, brains, tools, policy, diagnostics +src/ui/ terminal rendering and interaction +src/skills/builtin/ packaged built-in skills and eval cases +scripts/ docs, production, release-truth, and demo tooling +test/ node:test unit, integration, CLI, and policy tests +docs/generated/ manifest/catalogue outputs; do not hand-edit +docs/model-catalogue/ verified Cloud public-projection snapshot +install.sh / install.ps1 POSIX and Windows installers +``` + +`src/core/client.ts` is the shared client route. Public command metadata lives +in `src/commands/command_manifest_data.ts`; executable loaders remain separate. +Regenerate derived documentation after changing the manifest. + +## Local verification + +Run the narrow tests for your change while developing, then run the full gates: + +```sh +npm run build +npm run typecheck +npm run lint npm test +npm run smoke +npm run docs:check npm run verify:production +npm run release:truth +npm pack --dry-run ``` -Node ≥ 24. Zero runtime dependencies — the client uses only Node built-ins, and -that's a feature we'd like to keep. Please don't add a runtime dep without a very -good reason and a maintainer's nod. +`npm run lint` is the dependency-free strict TypeScript lint gate. `npm test` +builds first, then runs the repository's unit and integration tests from +`dist/test`. The smoke harness is environment-aware: signed-in Cloud and local +Ollama checks can report skips when those services are not configured, but a +real failure must not be relabeled as a skip. -## Layout +Useful installed-CLI smoke checks after `npm run build` are: +```sh +node dist/src/main.js --version +node dist/src/main.js --help +node dist/src/main.js auth status +node dist/src/main.js doctor +node dist/src/main.js doctor --live --no-ui ``` -src/ - main.ts CLI entry + arg parsing + command dispatch - index.ts public library API (createClient) - core/ the universal route — transport, stream, client, auth, ... - commands/ one file per command (chat, models, login, audit, ...) -test/ node:test unit tests + +Use a temporary `AETHER_CONFIG_DIR` when testing first-run or authentication +states. Never commit a token, config directory, support bundle, or live receipt. + +## Generated and public truth + +- Run `npm run docs:generate` only when the command manifest or verified public + catalogue projection changed; commit every resulting generated file. +- `npm run docs:check` must be clean. Do not hand-edit `docs/generated/**` or + generated catalogue outputs. +- Runtime `aether models` is authoritative for account-scoped model + availability. Catalogue refreshes must come through the canonical Cloud + public-projection and digest verification path. +- `npm run verify:production` checks package contents, installers, workflows, + exact-tarball installation, and public-document policy. +- `npm run release:truth` performs the public/release truth checks, including + live registry evidence when available. An unavailable required probe is not + a pass. + +## Installer syntax checks + +On macOS, Linux, or WSL: + +```sh +sh -n install.sh +bash -n install.sh ``` -The keystone is `src/core/client.ts` — the one chat route every surface shares. -Most features touch a `core/` module + a `commands/` file. +On Windows PowerShell: -## Ground rules +```powershell +$tokens = $null +$errors = $null +[System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path .\install.ps1), + [ref]$tokens, + [ref]$errors +) > $null +if ($errors) { $errors; exit 1 } +``` -- **Tests pass.** `npm test` is green before you open a PR. Add tests for new - logic (the stream decoder, catalog parsing, and arg resolution are all unit- - tested — match that bar). -- **Types pass.** `npm run typecheck` uses the repository's pinned TypeScript 7 - compiler and strict project settings. -- **Production policy passes.** `npm run verify:production` checks the npm - package allowlist, pinned CI actions, bounded jobs, and installer safety. -- **Types are honest.** No `any` in application code; narrow `unknown`. -- **Small files, one job each.** If a file grows past ~300 lines it's probably - doing too much. -- **Comments explain *why*.** The code says what. -- **No secrets, ever.** No tokens, keys, or internal hostnames in code, tests, - comments, or fixtures. Aether Agent talks to the public Aether API and nothing - else. +Installers must keep npm lifecycle scripts disabled and must not recommend a +mutable-main `curl | sh` flow. Test permission, PATH, unsupported-Node, and +headless-browser failures without weakening TLS or authentication. -## What makes a great PR +## Pull request expectations -- A clear title and a one-paragraph "why". -- Focused scope — one change per PR. -- Tests for the behavior you added or fixed. -- A note in the PR if you changed the public library API. +- Explain the user-visible problem and why the change is scoped to it. +- Add regression coverage for success, refusal, and actionable failure output. +- Keep generated files in sync and list the exact verification commands run. +- Call out platform coverage and anything not exercised live. +- Include `npm pack --dry-run` evidence for package/runtime changes. +- Do not version-bump, tag, publish, or edit release evidence in a feature PR. +- Do not mix unrelated cleanup into the change. -## Reporting bugs / ideas +## Security boundaries -Open an [issue](https://github.com/AetherAI3/aether-agent/issues). For security -reports, **do not** open a public issue — see [`SECURITY.md`](SECURITY.md). +Never weaken TLS enforcement, credential handling, secret redaction, +confirmation gates, sandbox/workspace confinement, tool permissions, or local +authority checks to make a test pass. Do not add internal hosts, private model +routes, tokens, keys, or real account data to source, fixtures, logs, docs, or +PR text. Security reports belong in the private process described in +[`SECURITY.md`](SECURITY.md), not a public issue. -By contributing you agree your contributions are licensed under Apache-2.0. +By contributing, you agree that your contributions are licensed under +Apache-2.0. diff --git a/docs/generated/commands.md b/docs/generated/commands.md index aeb54032..eeffc50b 100644 --- a/docs/generated/commands.md +++ b/docs/generated/commands.md @@ -6,7 +6,7 @@ This reference is generated from the validated, versioned command manifest. Avai Global shell flags accepted by the manifest: -`--agent`, `--all`, `--apply`, `--audit`, `--available`, `--ci`, `--cwd`, `--effort`, `--help`, `--interactive`, `--json`, `--junit`, `--license-key`, `--local`, `--model`, `--no-browser`, `--no-log`, `--no-skills`, `--out`, `--password`, `--pool`, `--quiet`, `--repo`, `--resume`, `--scope`, `--skill`, `--swarm`, `--test-cmd`, `--token`, `--username`, `--version`, `--with-token`, `--worktree`, `--yes` +`--agent`, `--all`, `--apply`, `--audit`, `--available`, `--ci`, `--cwd`, `--effort`, `--help`, `--interactive`, `--json`, `--junit`, `--license-key`, `--local`, `--model`, `--no-browser`, `--no-log`, `--no-skills`, `--out`, `--password`, `--pool`, `--quiet`, `--repo`, `--resume`, `--scope`, `--skill`, `--test-cmd`, `--token`, `--username`, `--version`, `--with-token`, `--worktree`, `--yes` ## Shell commands diff --git a/install.ps1 b/install.ps1 index aaa60b6e..0954bd25 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,4 +1,4 @@ -# Aether Agent installer — Windows (PowerShell). +# Aether Agent installer - Windows (PowerShell). # # Download, inspect, then run this file with: .\install.ps1 # @@ -16,69 +16,27 @@ if ($Version -notmatch '^[0-9A-Za-z.+-]+$') { throw "Invalid Version: use latest, a dist-tag, or a semver." } -# ── Color helpers ── -$cyan = 11 # BrightCyan (ANSI 44 ≈ #1aa6b7) -$ice = 12 # BrightBlue (ANSI 117 ≈ #87d7ff) +# Color helpers +$cyan = 11 # BrightCyan $green = 10 # BrightGreen -$red = 12 # Red (error — redefined below) $dim = 8 # DarkGray $red_error = 12 $green_ok = 10 function Write-Header($msg) { Write-Host $msg -ForegroundColor $cyan } -function Write-Success($msg) { Write-Host " ✓ " -NoNewline -ForegroundColor $green_ok; Write-Host $msg -ForegroundColor $dim } -function Write-ErrorMsg($msg) { Write-Host " ✗ " -NoNewline -ForegroundColor $red_error; Write-Host $msg } +function Write-Success($msg) { Write-Host " [ok] " -NoNewline -ForegroundColor $green_ok; Write-Host $msg -ForegroundColor $dim } +function Write-ErrorMsg($msg) { Write-Host " [error] " -NoNewline -ForegroundColor $red_error; Write-Host $msg } function Write-Info($msg) { Write-Host " $msg" -ForegroundColor $dim } function Write-Step($msg) { Write-Host "$msg... " -NoNewline -ForegroundColor $dim } -# ── Cloud glyph ── -function Write-Cloud { - Write-Host " ▄▄███▄▄ " -ForegroundColor $ice - Write-Host " ▄█████████▄ " -ForegroundColor $ice - Write-Host " ███▄███▄███ " -ForegroundColor $ice - Write-Host " ▀████▄████▀ " -ForegroundColor $ice - Write-Host " ▀ ▀ ▀ ▀ " -ForegroundColor $ice -} - -# ── Box drawing helper (PowerShell terminal supports Unicode box-drawing) ── -function Write-BoxTop($width = 62) { - $h = "─" * ($width - 2) - Write-Host "┌${h}┐" -ForegroundColor $cyan -} -function Write-BoxBottom($width = 62) { - $h = "─" * ($width - 2) - Write-Host "└${h}┘" -ForegroundColor $cyan -} -function Write-BoxLine($text, $width = 62) { - $inner = $width - 6 - $pad = [Math]::Max(0, $inner - $text.Length) - Write-Host "│" -NoNewline -ForegroundColor $cyan - Write-Host " ${text}" -NoNewline - Write-Host (" " * $pad + " ") -NoNewline - Write-Host "│" -ForegroundColor $cyan -} -function Write-BoxEmpty($width = 62) { - Write-BoxLine "" $width -} - -# ── Banner ── +# Banner Write-Host "" -Write-Cloud -Write-Host "" -Write-BoxTop -Write-BoxEmpty -Write-Host "│" -NoNewline -ForegroundColor $cyan -Write-Host " ☁ Aether Agent — Terminal Coding Agent Installer " -NoNewline -Write-Host "│" -ForegroundColor $cyan -Write-Host "│" -NoNewline -ForegroundColor $cyan -Write-Host " npm release $Version · aethersystems.net " -NoNewline -ForegroundColor $dim -Write-Host "│" -ForegroundColor $cyan -Write-BoxEmpty -Write-BoxBottom +Write-Header "Aether Agent - Terminal Coding Agent Installer" +Write-Info "npm release $Version | aethersystems.net" Write-Host "" -# ── Check Node.js ── +# Check Node.js if (-not $SkipNodeCheck) { Write-Step "Checking Node.js" $nodePath = Get-Command node -ErrorAction SilentlyContinue @@ -102,7 +60,7 @@ if (-not $SkipNodeCheck) { } } -# ── Check npm ── +# Check npm Write-Step "Checking npm" $npmPath = Get-Command npm -ErrorAction SilentlyContinue if (-not $npmPath) { @@ -113,15 +71,15 @@ if (-not $npmPath) { $npmVersion = npm -v 2>$null Write-Success "npm v${npmVersion}" -# ── Check if already installed ── +# Check if already installed $alreadyInstalled = $false $aetherPath = Get-Command aether -ErrorAction SilentlyContinue if ($aetherPath) { $alreadyInstalled = $true - Write-Info "aether-agent already installed — will install $Version." + Write-Info "aether-agent already installed - will install $Version." } -# ── Install ── +# Install Write-Host "" if ($alreadyInstalled) { Write-Step "Updating aether-agent" @@ -136,67 +94,47 @@ if ($alreadyInstalled) { if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne $null) { Write-ErrorMsg "Install failed. Check your network and npm permissions." Write-Host "" - Write-Info "If you see EACCES or permission errors:" - Write-Info " 1. Run PowerShell as Administrator" - Write-Info " 2. Or configure npm prefix: npm config set prefix $env:APPDATA\`npm" + if ($Version -ne "latest") { + Write-Info "If npm reports 'No matching version found', $Version is not published." + Write-Info "Published versions: npm view aether-agents versions" + } + $userPrefix = Join-Path $env:LOCALAPPDATA "npm" + Write-Info "For EACCES or permission errors, use a per-user prefix:" + Write-Info (' npm install -g "aether-agents@{0}" --ignore-scripts --prefix "{1}"' -f $Version, $userPrefix) + Write-Info ("Then add $userPrefix to your user PATH and reopen PowerShell.") exit 1 } $aetherPath = Get-Command aether -ErrorAction SilentlyContinue -$aetherVersion = "unknown" -if ($aetherPath) { - try { $aetherVersion = (aether --version 2>$null) } catch {} -} -Write-Success "Aether Agent ${aetherVersion} installed!" - -# ── Verify ── -if (-not (Get-Command aether -ErrorAction SilentlyContinue)) { +# Verify +if (-not $aetherPath) { + $npmPrefix = (npm config get prefix 2>$null | Select-Object -First 1) Write-ErrorMsg "aether command not found on PATH after install." - Write-Info "npm global prefix: $(npm config get prefix)" - Write-Info "Add $(npm config get prefix) to your PATH, or reinstall Node." + Write-Info "npm global prefix: $npmPrefix" + Write-Info "Add that directory to your user PATH, reopen PowerShell, then run:" + Write-Info " aether --version" exit 1 } -# ── Next steps box ── +$aetherVersion = "unknown" +try { $aetherVersion = (aether --version 2>$null) } catch {} +Write-Success "Aether Agent ${aetherVersion} installed!" + +# Next steps +Write-Host "" +Write-Header "Ready - hosted first run" +Write-Host ' aether --version' +Write-Host ' aether auth login' +Write-Host ' aether auth status' +Write-Host ' aether models' +Write-Host ' aether agent "explain this repository"' +Write-Host ' aether doctor' +Write-Host ' aether doctor --live' +Write-Info "No browser: aether auth login --no-browser" Write-Host "" -Write-BoxTop -Write-BoxEmpty -Write-Host "│" -NoNewline -ForegroundColor $cyan -Write-Host " ✓ " -NoNewline -ForegroundColor $green_ok -Write-Host "Ready! Next: sign in to unlock the full model fleet. " -NoNewline -Write-Host "│" -ForegroundColor $cyan -Write-BoxEmpty -Write-Host "│" -NoNewline -ForegroundColor $cyan -Write-Host " " -NoNewline -Write-Host "aether auth login" -NoNewline -ForegroundColor $cyan -Write-Host (" " * 42) -NoNewline -Write-Host "│" -ForegroundColor $cyan -Write-BoxEmpty -Write-Host "│" -NoNewline -ForegroundColor $cyan -Write-Host " Opens aethersystems.net/platform in your browser. " -NoNewline -ForegroundColor $dim -Write-Host "│" -ForegroundColor $cyan -Write-Host "│" -NoNewline -ForegroundColor $cyan -Write-Host " → click Approve → you're in. " -NoNewline -ForegroundColor $dim -Write-Host "│" -ForegroundColor $cyan -Write-BoxEmpty -Write-Host "│" -NoNewline -ForegroundColor $cyan -Write-Host " Then start coding: " -NoNewline -ForegroundColor $dim -Write-Host "│" -ForegroundColor $cyan -Write-Host "│" -NoNewline -ForegroundColor $cyan -Write-Host " " -NoNewline -Write-Host "aether `"explain src/router.ts`"" -NoNewline -ForegroundColor $dim -Write-Host (" " * 28) -NoNewline -Write-Host "│" -ForegroundColor $cyan -Write-Host "│" -NoNewline -ForegroundColor $cyan -Write-Host " " -NoNewline -Write-Host "aether agent `"fix the failing tests`"" -NoNewline -ForegroundColor $dim -Write-Host (" " * 23) -NoNewline -Write-Host "│" -ForegroundColor $cyan -Write-Host "│" -NoNewline -ForegroundColor $cyan -Write-Host " " -NoNewline -Write-Host "aether agent --local `"same, fully offline`"" -NoNewline -ForegroundColor $dim -Write-Host (" " * 19) -NoNewline -Write-Host "│" -ForegroundColor $cyan -Write-BoxEmpty -Write-BoxBottom +Write-Header "Optional local Ollama path" +Write-Host ' aether setup --local' +Write-Host ' aether local pull qwen2.5-coder:7b --yes' +Write-Host ' aether agent --local "explain this repository"' +Write-Info "Ollama must be installed and running before the local steps." Write-Host "" diff --git a/install.sh b/install.sh index 68f3d1d2..6e4b1e16 100644 --- a/install.sh +++ b/install.sh @@ -78,6 +78,14 @@ fi NODE_V=$(node -v 2>/dev/null | sed 's/^v//') NODE_MAJ=$(echo "$NODE_V" | cut -d. -f1) +case "$NODE_MAJ" in + ""|*[!0-9]*) + echo "" + error "Could not read the Node.js version (received: ${NODE_V:-empty})." + info "Repair Node.js, reopen your terminal, then run: node --version" + exit 1 + ;; +esac success "Node.js v${NODE_V}" if [ "$NODE_MAJ" -lt 24 ]; then @@ -125,39 +133,46 @@ if ! npm install -g "aether-agents@${AETHER_VERSION}" --ignore-scripts; then echo "" fi info "If you see EACCES errors, try one of:" - info " npm install -g aether-agents@${AETHER_VERSION} --ignore-scripts --prefix ~/.local" - info " Or install Node with a version manager from https://nodejs.org/en/download" + info " npm install -g aether-agents@${AETHER_VERSION} --ignore-scripts --prefix \"${HOME}/.local\"" + info " export PATH=\"${HOME}/.local/bin:\$PATH\"" + info "Persist that PATH in your shell profile, or install Node with a version manager." exit 1 fi -AETH_V=$(aether --version 2>/dev/null || echo "unknown") -success "Aether Agent ${AETH_V} installed!" - # ── Verify ── if ! command -v aether >/dev/null 2>&1; then + NPM_PREFIX=$(npm config get prefix 2>/dev/null || true) + AETHER_BIN="${NPM_PREFIX}/bin" error "aether command not found on PATH after install." - info "npm global prefix: $(npm config get prefix)" - info "Add $(npm config get prefix)/bin to your PATH, or reinstall Node." + info "npm global prefix: ${NPM_PREFIX}" + info "Add it for this shell, then retry:" + info " export PATH=\"${AETHER_BIN}:\$PATH\"" + info " aether --version" echo "" info "For nvm users: nvm use --delete-prefix v24 --silent" exit 1 fi -# ── Next steps box ── +AETH_V=$(aether --version 2>/dev/null || echo "unknown") +success "Aether Agent ${AETH_V} installed!" + +# ── Next steps ── echo "" -echo "${cyan}┌──────────────────────────────────────────────────────────────┐${n}" -echo "${cyan}│${n} ${cyan}│${n}" -echo "${cyan}│${n} ${green}${check}${n} ${bold}Ready!${n} Next: sign in to unlock the full model fleet. ${cyan}│${n}" -echo "${cyan}│${n} ${cyan}│${n}" -echo "${cyan}│${n} ${bold}${cyan}aether auth login${n} ${cyan}│${n}" -echo "${cyan}│${n} ${cyan}│${n}" -echo "${cyan}│${n} ${dim}Opens aethersystems.net/platform in your browser.${n} ${cyan}│${n}" -echo "${cyan}│${n} ${dim}${arrow} click Approve ${arrow} you're in.${n} ${cyan}│${n}" -echo "${cyan}│${n} ${cyan}│${n}" -echo "${cyan}│${n} ${dim}Then start coding:${n} ${cyan}│${n}" -echo "${cyan}│${n} ${dim}aether \"explain src/router.ts\"${n} ${cyan}│${n}" -echo "${cyan}│${n} ${dim}aether agent \"fix the failing tests\"${n} ${cyan}│${n}" -echo "${cyan}│${n} ${dim}aether agent --local \"same, fully offline\"${n} ${cyan}│${n}" -echo "${cyan}│${n} ${cyan}│${n}" -echo "${cyan}└──────────────────────────────────────────────────────────────┘${n}" +header "Ready — hosted first run" +printf '%s\n' \ + " aether --version" \ + " aether auth login" \ + " aether auth status" \ + " aether models" \ + " aether agent \"explain this repository\"" \ + " aether doctor" \ + " aether doctor --live" +info "No browser: aether auth login --no-browser" +echo "" +header "Optional local Ollama path" +printf '%s\n' \ + " aether setup --local" \ + " aether local pull qwen2.5-coder:7b --yes" \ + " aether agent --local \"explain this repository\"" +info "Ollama must be installed and running before the local steps." echo "" diff --git a/package.json b/package.json index fa945314..d7cfd7d4 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "scripts": { "build": "tsc -p tsconfig.json && node dist/scripts/copy-skill-assets.js", "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "npm run typecheck", "dev": "npm run build && node dist/src/main.js", "start": "node dist/src/main.js", "test": "npm run build && node --test --test-isolation=none \"dist/test/**/*.test.js\"", diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 37ef800a..4b3df01c 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -226,7 +226,11 @@ export function renderCommandReference(commands: readonly CommandManifestEntry[] "", "Global shell flags accepted by the manifest:", "", - [...new Set(visible.filter((entry) => entry.surface === "shell").flatMap((entry) => entry.acceptedGlobalFlags))].sort().map((flag) => inlineCode(`--${flag}`)).join(", "), + [...new Set(visible.filter((entry) => entry.surface === "shell").flatMap((entry) => entry.acceptedGlobalFlags))] + .filter((flag) => flag !== "swarm") + .sort() + .map((flag) => inlineCode(`--${flag}`)) + .join(", "), "", ]; for (const surface of ["shell", "slash"] as const satisfies readonly CommandSurface[]) { diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 020b630c..fbd34079 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -11,7 +11,7 @@ import { cmdLogin, cmdLogout, type LoginOpts } from "./login.js"; import { MODELS_PATH, REFRESH_PATH } from "../core/transport.js"; import { HttpError, errorHint, errorMessage } from "../core/errors.js"; import { isApiKeyToken } from "../core/auth.js"; -import { box, titledBox, hyperlink, orange, green, darkBlue, brightWhite, lightBlue } from "../ui/box.js"; +import { box, titledBox, hyperlink } from "../ui/box.js"; import { CLOUD } from "../ui/logo.js"; import { theme } from "../ui/theme.js"; import { formatErrorLine } from "../ui/error_line.js"; @@ -24,6 +24,13 @@ export function isApiToken(token: string | null | undefined): boolean { return isApiKeyToken(token); } +export type AuthVerificationState = "signed-out" | "verified" | "expired" | "unverified"; + +interface AuthPanel { + output: string; + state: AuthVerificationState; +} + function mask(t: string): string { return t.length <= 12 ? "\u2022".repeat(Math.max(4, t.length)) : `${t.slice(0, 8)}\u2026${t.slice(-4)}`; } @@ -43,9 +50,9 @@ function centeredCloud(): string { // Exported so tests can render the panel directly against a fake AppContext // without going through cmdAuth's stdout write. -export async function renderAuthBox(ctx: AppContext): Promise { +async function renderAuthPanel(ctx: AppContext): Promise { const t = await ctx.tokens.get(); - if (!t) return renderLoggedOut(); + if (!t) return { output: renderLoggedOut(), state: "signed-out" }; // Fetch tier info for display let tier = ""; @@ -55,9 +62,10 @@ export async function renderAuthBox(ctx: AppContext): Promise { // unreachable" and must not be swallowed the same way, or `status` would // claim "Authenticated" for a dead session (the stale-AETHER_TOKEN case PR // #47 fixed elsewhere). Any other failure (no HttpError, or a 5xx) is a - // genuine network/server problem: keep the existing silent local-only - // fallback for those. + // genuine network/server problem: report a stored-but-unverified credential + // rather than claiming the user is authenticated. let sessionExpired = false; + let verificationUnavailable = false; try { const cat = await ctx.api.getJson<{ tier?: string; default?: string }>(MODELS_PATH); tier = cat.tier ?? ""; @@ -65,14 +73,15 @@ export async function renderAuthBox(ctx: AppContext): Promise { } catch (err) { if (err instanceof HttpError && (err.status === 401 || err.status === 403)) { sessionExpired = true; - } - // Otherwise: server unreachable — still show what we know locally. + } else verificationUnavailable = true; } const kind = isApiToken(t) ? "API key" : "session token"; const header = sessionExpired ? theme.yellow("⚠") + " " + theme.bold("Aether Agent — Session expired") - : theme.iceBlue("☁") + " " + theme.bold("Aether Agent — Authenticated"); + : verificationUnavailable + ? theme.yellow("⚠") + " " + theme.bold("Aether Agent — Verification unavailable") + : theme.iceBlue("☁") + " " + theme.bold("Aether Agent — Authenticated"); const lines: string[] = [ "", header, @@ -89,6 +98,12 @@ export async function renderAuthBox(ctx: AppContext): Promise { " " + theme.yellow("Server rejected this token — sign in again:"), " " + theme.bold(theme.cyan("aether auth login")), ); + } else if (verificationUnavailable) { + lines.push( + "", + " " + theme.yellow("Credential stored, but the server could not verify it."), + " " + theme.bold(theme.cyan("aether doctor --live")), + ); } else { if (tier) { lines.push(" " + theme.dim("Tier:") + " " + (tier === "free" ? theme.dim(tier) : theme.cyan(tier))); @@ -108,8 +123,17 @@ export async function renderAuthBox(ctx: AppContext): Promise { "", ); - const title = sessionExpired ? "Session expired" : "Authenticated"; - return [centeredCloud(), "", titledBox(lines, title, { width: BOX_W })].join("\n"); + const state: AuthVerificationState = sessionExpired + ? "expired" + : verificationUnavailable + ? "unverified" + : "verified"; + const title = state === "expired" ? "Session expired" : state === "unverified" ? "Not verified" : "Authenticated"; + return { output: [centeredCloud(), "", titledBox(lines, title, { width: BOX_W })].join("\n"), state }; +} + +export async function renderAuthBox(ctx: AppContext): Promise { + return (await renderAuthPanel(ctx)).output; } // ── Logged-out welcome panel ── @@ -117,31 +141,17 @@ export async function renderAuthBox(ctx: AppContext): Promise { const PLATFORM_URL = process.env["AETHER_LOGIN_URL"] ?? "https://aethersystems.net/platform/device"; function renderLoggedOut(): string { - // Per-model brand colors - const fleet = [ - orange("Claude"), - green("GPT"), - darkBlue("DeepSeek"), - brightWhite("Kimi"), - lightBlue("Gemma"), - theme.dim("& more"), - ].join(" \u00b7 "); - - const orch = theme.dim("Aether orchestrators: ") + - theme.cyan("Neo") + theme.dim(", ") + - theme.cyan("Kronus") + theme.dim(", & more"); - const lines = [ "", theme.iceBlue("\u2601") + " " + theme.bold("Welcome to Aether Agent"), "", - theme.dim("Sign in to unlock the full model fleet:"), - " " + fleet, - " " + orch, + theme.dim("Sign in, verify the session, then query live availability:"), "", " " + theme.bold(theme.cyan("aether auth login")), + " " + theme.dim("aether auth status"), + " " + theme.dim("aether models"), "", - theme.dim("Opens ") + hyperlink(PLATFORM_URL, theme.cyan("aethersystems.net/platform")), + theme.dim("Opens ") + hyperlink(PLATFORM_URL, theme.cyan("aethersystems.net/platform/device")), theme.dim("in your browser \u2192 click Approve \u2192 done."), "", " " + theme.dim("No browser? Head to:"), @@ -150,7 +160,7 @@ function renderLoggedOut(): string { theme.dim("Quick:"), theme.dim(" aether auth login Sign in via browser"), theme.dim(" aether auth login --no-browser Print URL instead"), - theme.dim(" aether auth --help All auth commands"), + theme.dim(" aether auth help All auth commands"), "", ]; @@ -178,9 +188,9 @@ export async function cmdAuth( // connection, "the REPL just looks hung" (the exact class PR #47 fixed // for slash.ts's catalog fetch). Match that convention here. process.stdout.write(theme.dim("checking session…\n")); - const panel = await renderAuthBox(ctx); - process.stdout.write(panel + "\n"); - return 0; + const panel = await renderAuthPanel(ctx); + process.stdout.write(panel.output + "\n"); + return panel.state === "verified" ? 0 : 1; } case "token": return authToken(ctx); diff --git a/src/commands/cli_registry.ts b/src/commands/cli_registry.ts index 2c5266a8..91f37a71 100644 --- a/src/commands/cli_registry.ts +++ b/src/commands/cli_registry.ts @@ -65,6 +65,8 @@ export const GLOBAL_FLAGS: FlagTable = { "no-log": { type: "boolean", default: false }, worktree: { type: "boolean", default: false }, repo: { type: "string" }, + // Accepted only so existing experimental callers receive code.ts's explicit + // refusal. It is intentionally absent from normal help and generated docs. swarm: { type: "string" }, resume: { type: "string" }, out: { type: "string" }, @@ -287,6 +289,9 @@ export function renderCliHelp(target?: string): string { target, footer: [ "Global flags: --model --agent --cwd --json --audit -y/--yes -h/--help -v/--version", + "First run: aether auth login -> aether auth status -> aether models", + 'Hosted task: aether agent "explain this repository"', + "Local setup: aether setup --local", "Unknown command text remains a bare prompt.", ], }); diff --git a/src/commands/command_manifest.ts b/src/commands/command_manifest.ts index 17b7c304..40da6838 100644 --- a/src/commands/command_manifest.ts +++ b/src/commands/command_manifest.ts @@ -310,6 +310,9 @@ export function renderManifestHelp(surface: "shell" | "slash", target = ""): str target, footer: [ "Global flags: --model --agent --cwd --json --audit -y/--yes -h/--help -v/--version", + "First run: aether auth login -> aether auth status -> aether models", + 'Hosted task: aether agent "explain this repository"', + "Local setup: aether setup --local", "Unknown command text remains a bare prompt.", ], }); diff --git a/src/commands/login.ts b/src/commands/login.ts index a4b5e30b..ed58b7d0 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -11,7 +11,8 @@ import type { AppContext } from "../core/context.js"; import { loginWithPassword } from "../core/auth.js"; import { LOGOUT_PATH } from "../core/transport.js"; import { requestDeviceCode, pollForToken } from "../core/device.js"; -import { openBrowser } from "../core/browser.js"; +import { openBrowserChecked } from "../core/browser.js"; +import type { OpenOutcome } from "../core/opener.js"; import { theme } from "../ui/theme.js"; import { errorHint, errorMessage } from "../core/errors.js"; import { formatErrorLine } from "../ui/error_line.js"; @@ -25,6 +26,12 @@ export interface LoginOpts { noBrowser?: boolean; } +export interface LoginDependencies { + openBrowser: (url: string) => OpenOutcome; +} + +const LOGIN_DEPENDENCIES: LoginDependencies = { openBrowser: openBrowserChecked }; + /** After a successful login, flag a shell-level AETHER_TOKEN: it is re-read by * every NEW process and would shadow the token just stored — the classic * "login succeeded but the next command 401s" trap (PR #47). */ @@ -43,7 +50,11 @@ function warnEnvTokenShadow(): void { } } -export async function cmdLogin(ctx: AppContext, opts: LoginOpts): Promise { +export async function cmdLogin( + ctx: AppContext, + opts: LoginOpts, + dependencies: LoginDependencies = LOGIN_DEPENDENCIES, +): Promise { // 1. Direct token. if (opts.token) { await ctx.tokens.set(opts.token); @@ -99,7 +110,17 @@ export async function cmdLogin(ctx: AppContext, opts: LoginOpts): Promise { + if (!(await ctx.tokens.get())) { + process.stderr.write( + formatErrorLine("Not signed in", { hint: "run `aether auth login`, then `aether models`" }), + ); + return null; + } + try { + return await ctx.api.getJson(MODELS_PATH); + } catch (error) { + process.stderr.write(formatErrorLine(errorMessage(error), { hint: errorHint(error, ctx.cfg.baseUrl) })); + return null; + } +} export async function cmdModels(ctx: AppContext, argv: string[]): Promise { const sub = argv[0]; if (sub === "use") { const id = argv[1]; - if (!id) { + if (!id || argv.length !== 2) { process.stderr.write("usage: aether models use \n"); return 2; } @@ -35,13 +52,33 @@ export async function cmdModels(ctx: AppContext, argv: string[]): Promise model.id === hostedId); + if (!selected) { + process.stderr.write(`Model ${JSON.stringify(hostedId)} is not in the live catalogue. Run: aether models\n`); + return 1; + } + if (!selected.available) { + const tier = selected.tier_min ? ` (requires ${selected.tier_min})` : ""; + process.stderr.write( + `Model ${JSON.stringify(hostedId)} is unavailable for this account${tier}. Run: aether models\n`, + ); + return 1; + } ctx.cfg.defaultModel = hostedId; - saveConfig(ctx.cfg); - process.stdout.write(`default model → ${id}\n`); + try { + saveConfig(ctx.cfg); + } catch (error) { + process.stderr.write(`Could not save the default model: ${errorMessage(error)}\n`); + return 1; + } + process.stdout.write(`default model → ${hostedId}\n`); return 0; } - const cat = await ctx.api.getJson(MODELS_PATH); + const cat = await liveCatalogue(ctx); + if (!cat) return 1; if (ctx.flags.json) { process.stdout.write(JSON.stringify(cat, null, 2) + "\n"); return 0; @@ -60,7 +97,8 @@ export async function cmdModels(ctx: AppContext, argv: string[]): Promise { - const cat = await ctx.api.getJson(MODELS_PATH); + const cat = await liveCatalogue(ctx); + if (!cat) return 1; const orchestrators = cat.models.filter((m) => m.kind === "orchestrator"); if (ctx.flags.json) { process.stdout.write(JSON.stringify(orchestrators, null, 2) + "\n"); diff --git a/src/main.ts b/src/main.ts index 16834c39..30e16826 100644 --- a/src/main.ts +++ b/src/main.ts @@ -39,6 +39,22 @@ import { commandFlags } from "./core/command_dispatch.js"; /** Coerce a parsed flag value to string | undefined. */ const sf = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); +export const MINIMUM_NODE_MAJOR = 24; + +/** A preflight that turns an engine mismatch into a recovery command instead + * of letting an older runtime fail later with an opaque syntax/API error. */ +export function unsupportedNodeMessage(version: string): string | null { + const majorText = version.trim().replace(/^v/i, "").split(".", 1)[0] ?? ""; + const major = Number.parseInt(majorText, 10); + if (Number.isInteger(major) && major >= MINIMUM_NODE_MAJOR) return null; + const found = version.trim() || "unknown"; + return ( + `Aether Agent requires Node.js >= ${MINIMUM_NODE_MAJOR} (found ${found}). ` + + "Upgrade Node, reopen your terminal, then run: aether --version\n" + + "https://nodejs.org/en/download" + ); +} + // Real top-level subcommand names, sourced from the union of the switch's // registry and the dispatch table (cli_registry.ts) — the same registries the // dispatch is cross-checked against — so this can never drift from the actual @@ -58,6 +74,11 @@ function suggestTopLevel(token: string): string | null { } export async function main(argv: string[]): Promise { + const nodeError = unsupportedNodeMessage(process.versions.node); + if (nodeError) { + process.stderr.write(`${errTheme.red("✗")} ${nodeError}\n`); + return 1; + } const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, diff --git a/test/auth_401.test.ts b/test/auth_401.test.ts index 682e2b58..49b0c993 100644 --- a/test/auth_401.test.ts +++ b/test/auth_401.test.ts @@ -307,7 +307,7 @@ test("renderAuthBox: a 403 from /models also renders 'Session expired' (not sile } }); -test("renderAuthBox: a genuine network outage (no HttpError) still falls back to the silent 'Authenticated' panel", async () => { +test("renderAuthBox: a genuine network outage reports a stored but unverified credential", async () => { const real = globalThis.fetch; const tokens = new StaticTokenStore("aek_deadtoken1234"); globalThis.fetch = (async () => { @@ -316,7 +316,10 @@ test("renderAuthBox: a genuine network outage (no HttpError) still falls back to try { const api = new ApiClient("https://api.example", tokens); const panel = stripAnsi(await renderAuthBox(fakeCtx(api, tokens))); - assert.match(panel, /Authenticated/, "an unreachable server is not a rejected session"); + assert.match(panel, /Verification unavailable/); + assert.match(panel, /Credential stored, but the server could not verify it/); + assert.match(panel, /aether doctor --live/); + assert.doesNotMatch(panel, /Authenticated/, "an unreachable server must not be reported as verified"); assert.doesNotMatch(panel, /Session expired/); } finally { globalThis.fetch = real; @@ -433,6 +436,27 @@ test("cmdAuth bare `aether auth` (no subcommand): same loading line before the / } }); +test("cmdAuth status exits nonzero when signed out or rejected, and zero only after live verification", async () => { + const cases = [ + { token: "", response: null, expected: 1 }, + { token: "aek_rejected", response: jsonRes(401, { detail: "expired" }), expected: 1 }, + { token: "aek_verified", response: jsonRes(200, { tier: "pro", default: "aether-large" }), expected: 0 }, + ] as const; + const real = globalThis.fetch; + const cap = captureStdout(); + try { + for (const item of cases) { + const tokens = new StaticTokenStore(item.token); + if (item.response) stubFetch(() => item.response, []); + const api = new ApiClient("https://api.example", tokens); + assert.equal(await cmdAuth(fakeCtx(api, tokens), ["status"], {} as LoginOpts), item.expected); + } + } finally { + globalThis.fetch = real; + cap.restore(); + } +}); + // ── 7. authRefresh (LOOP-06 round 3): the catch block must go through the // shared formatErrorLine/errorHint convention — same as chat.ts's printError // — instead of a hand-built, unstyled `✗ ` template string with no diff --git a/test/first_run.test.ts b/test/first_run.test.ts new file mode 100644 index 00000000..dc8cabd3 --- /dev/null +++ b/test/first_run.test.ts @@ -0,0 +1,167 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { COMMAND_MANIFEST, renderManifestHelp } from "../src/commands/command_manifest.js"; +import { MINIMUM_NODE_MAJOR, unsupportedNodeMessage } from "../src/main.js"; +import { renderCommandReference } from "../scripts/generate-docs.js"; +import { stripAnsi } from "../src/ui/theme.js"; + +const root = process.cwd(); +const posixInstaller = readFileSync(join(root, "install.sh"), "utf8"); +const windowsInstaller = readFileSync(join(root, "install.ps1"), "utf8"); + +test("unsupported Node versions fail with one copy/paste recovery path", () => { + assert.equal(MINIMUM_NODE_MAJOR, 24); + assert.equal(unsupportedNodeMessage("24.0.0"), null); + assert.equal(unsupportedNodeMessage("v26.1.0"), null); + for (const version of ["23.9.0", "v20.0.0", "", "not-a-version"]) { + const message = unsupportedNodeMessage(version); + assert.ok(message); + assert.match(message, /requires Node\.js >= 24/); + assert.match(message, /reopen your terminal.*aether --version/s); + assert.match(message, /nodejs\.org\/en\/download/); + } +}); + +test("normal help and generated docs expose first-run truth without gated surfaces", () => { + const help = stripAnsi(renderManifestHelp("shell")); + assert.match(help, /aether auth login -> aether auth status -> aether models/); + assert.match(help, /aether agent "explain this repository"/); + assert.match(help, /aether setup --local/); + assert.doesNotMatch(help, /--swarm\b/); + assert.doesNotMatch(help, /aether device\b/); + + const generated = renderCommandReference(COMMAND_MANIFEST); + assert.doesNotMatch(generated, /--swarm\b/); + assert.doesNotMatch(generated, /`aether device\b/); + assert.match(generated, /Availability is evaluated at runtime/); +}); + +test("installers keep safe npm execution and exact real first-run commands", () => { + for (const text of [posixInstaller, windowsInstaller]) { + assert.match(text, /npm install -g [^\n]*aether-agents@[^\n]*--ignore-scripts/); + assert.doesNotMatch(text, /curl[^\n]*\|\s*(?:ba)?sh/); + assert.doesNotMatch(text, /full model fleet|fully offline/i); + for (const command of [ + "aether --version", + "aether auth login", + "aether auth status", + "aether models", + "aether setup --local", + "aether local pull qwen2.5-coder:7b --yes", + "aether agent --local", + "aether doctor --live", + ]) assert.match(text, new RegExp(command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + assert.match(posixInstaller, /export PATH=/); + assert.match(windowsInstaller, /per-user prefix/); + assert.doesNotMatch(windowsInstaller, /APPDATA\\`npm/); +}); + +test("install.sh passes POSIX shell syntax validation where sh is available", (t) => { + if (process.platform === "win32") return t.skip("POSIX shell validation runs on the Linux CI job"); + const result = spawnSync("sh", ["-n", join(root, "install.sh")], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); + +test("install.ps1 passes the PowerShell parser where PowerShell is available", (t) => { + const candidates = process.platform === "win32" ? ["pwsh.exe", "powershell.exe"] : ["pwsh"]; + const executable = candidates.find((candidate) => { + const probe = spawnSync(candidate, ["-NoProfile", "-NonInteractive", "-Command", "$PSVersionTable.PSVersion.Major"], { + encoding: "utf8", + timeout: 15_000, + }); + return !probe.error && probe.status === 0; + }); + if (!executable) return t.skip("PowerShell is not installed on this runner"); + const command = [ + "$tokens = $null", + "$errors = $null", + "[System.Management.Automation.Language.Parser]::ParseFile($env:AETHER_INSTALLER_UNDER_TEST, [ref]$tokens, [ref]$errors) > $null", + "if ($errors.Count -gt 0) { $errors | ForEach-Object { [Console]::Error.WriteLine($_.Message) }; exit 1 }", + ].join("; "); + const result = spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], { + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, AETHER_INSTALLER_UNDER_TEST: resolve(root, "install.ps1") }, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); + +function writeExecutable(path: string, text: string): void { + writeFileSync(path, text, "utf8"); + chmodSync(path, 0o755); +} + +interface InstallerRun { + status: number | null; + stdout: string; + stderr: string; +} + +function runPosixInstaller(nodeVersion: string, npmBody: string): InstallerRun { + const sandbox = mkdtempSync(join(tmpdir(), "aether-installer-first-run-")); + const bin = join(sandbox, "bin"); + mkdirSync(bin); + try { + writeExecutable(join(bin, "node"), `#!/bin/sh\nprintf '%s\\n' '${nodeVersion}'\n`); + writeExecutable(join(bin, "npm"), `#!/bin/sh\n${npmBody}\n`); + const result = spawnSync("sh", [join(root, "install.sh")], { + cwd: sandbox, + encoding: "utf8", + env: { + ...process.env, + AETHER_VERSION: "latest", + FAKE_BIN: bin, + NO_COLOR: "1", + PATH: [bin, "/usr/bin", "/bin"].join(delimiter), + }, + }); + return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } +} + +test("install.sh rejects old Node before npm and explains a missing PATH entry", (t) => { + if (process.platform === "win32") return t.skip("POSIX installer behavior runs on the Linux CI job"); + const oldNode = runPosixInstaller("v23.9.0", "exit 99"); + assert.equal(oldNode.status, 1); + assert.match(oldNode.stdout + oldNode.stderr, /Node\.js >= 24 required.*v23\.9\.0/s); + assert.doesNotMatch(oldNode.stdout + oldNode.stderr, /npm v/); + + const missingPath = runPosixInstaller("v24.18.0", [ + 'if [ "$1" = "-v" ]; then printf "%s\\n" "11.0.0"; exit 0; fi', + 'if [ "$1" = "install" ]; then exit 0; fi', + 'if [ "$1" = "config" ]; then printf "%s\\n" "$HOME/.local"; exit 0; fi', + "exit 1", + ].join("\n")); + assert.equal(missingPath.status, 1); + assert.match(missingPath.stdout + missingPath.stderr, /command not found on PATH/); + assert.match(missingPath.stdout + missingPath.stderr, /export PATH=.*\.local\/bin/); + assert.match(missingPath.stdout + missingPath.stderr, /aether --version/); +}); + +test("install.sh success prints the hosted and local paths it actually installed", (t) => { + if (process.platform === "win32") return t.skip("POSIX installer behavior runs on the Linux CI job"); + const result = runPosixInstaller("v24.18.0", [ + 'if [ "$1" = "-v" ]; then printf "%s\\n" "11.0.0"; exit 0; fi', + 'if [ "$1" = "install" ]; then', + " printf '%s\\n' '#!/bin/sh' 'printf \"%s\\n\" \"0.3.0\"' > \"$FAKE_BIN/aether\"", + ' chmod 755 "$FAKE_BIN/aether"', + " exit 0", + "fi", + "exit 1", + ].join("\n")); + assert.equal(result.status, 0, result.stderr || result.stdout); + const output = result.stdout + result.stderr; + assert.match(output, /Aether Agent 0\.3\.0 installed/); + assert.match(output, /aether auth login/); + assert.match(output, /aether auth status/); + assert.match(output, /aether models/); + assert.match(output, /aether setup --local/); + assert.match(output, /aether agent --local/); +}); diff --git a/test/local_setup.test.ts b/test/local_setup.test.ts index 7827dad1..0961c9e7 100644 --- a/test/local_setup.test.ts +++ b/test/local_setup.test.ts @@ -142,6 +142,44 @@ test("hosted model selection rejects an Ollama namespace before network or confi assert.equal(ctx.cfg.defaultModel, ""); }); +test("hosted model catalogue failures are actionable and never save unavailable choices", async () => { + const signedOut = context({}, null); + signedOut.api = new Proxy({} as AppContext["api"], { get: () => { throw new Error("hosted API touched"); } }); + const noAuth = await capture(() => cmdModels(signedOut, [])); + assert.equal(noAuth.code, 1); + assert.match(noAuth.stderr, /aether auth login.*aether models/s); + + const ctx = context({}, "aek_test"); + ctx.api = { + getJson: async () => ({ + tier: "free", + default: "model-ready", + models: [ + { + id: "model-ready", label: "Ready", kind: "model", provider: "test", + context_window: 1000, tier_min: "free", enabled: true, available: true, + monthly_uvt_cap: 100, is_default: true, + }, + { + id: "model-locked", label: "Locked", kind: "model", provider: "test", + context_window: 1000, tier_min: "pro", enabled: true, available: false, + monthly_uvt_cap: null, is_default: false, + }, + ], + }), + } as unknown as AppContext["api"]; + + const missing = await capture(() => cmdModels(ctx, ["use", "model-missing"])); + assert.equal(missing.code, 1); + assert.match(missing.stderr, /not in the live catalogue.*aether models/); + assert.equal(ctx.cfg.defaultModel, ""); + + const locked = await capture(() => cmdModels(ctx, ["use", "model-locked"])); + assert.equal(locked.code, 1); + assert.match(locked.stderr, /unavailable for this account.*requires pro.*aether models/); + assert.equal(ctx.cfg.defaultModel, ""); +}); + test("auto-local rejects a bare explicit model before starting a brain", async () => { const ctx = context({ model: "gpt-5.6-sol", local: false }); await assert.rejects( diff --git a/test/login.test.ts b/test/login.test.ts index e3ea4870..da8fd2b0 100644 --- a/test/login.test.ts +++ b/test/login.test.ts @@ -192,3 +192,47 @@ test("cmdLogin (device-code flow): a denied authorization still gets the styled stderr.restore(); } }); + +test("cmdLogin reports an unavailable browser and continues with the printed device URL", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = (async (url: unknown) => { + const value = String(url); + if (value.includes("/auth/device/code")) { + return new Response(JSON.stringify({ + device_code: "dc-browser", + user_code: "BROW-SER", + verification_uri: "https://x.example/device", + verification_uri_complete: "https://x.example/device?code=BROW-SER", + interval: 0, + expires_in: 30, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (value.includes("/auth/device/token")) { + return new Response(JSON.stringify({ access_token: "aek_browser_fallback" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected fetch in test: ${value}`); + }) as typeof fetch; + const ctx = fakeCtxWithApi(); + const realStdoutWrite = process.stdout.write.bind(process.stdout); + let stdout = ""; + process.stdout.write = ((chunk: unknown) => ((stdout += String(chunk)), true)) as typeof process.stdout.write; + const stderr = captureStderr(); + try { + const code = await cmdLogin(ctx, {}, { + openBrowser: () => ({ status: "unavailable", detail: "synthetic headless session" }), + }); + assert.equal(code, 0); + assert.match(stdout, /https:\/\/x\.example\/device/); + assert.match(stdout, /BROW-SER/); + assert.match(stderr.text(), /Browser was not opened \(unavailable\)/); + assert.match(stderr.text(), /aether auth login --no-browser/); + assert.equal(await ctx.tokens.get(), "aek_browser_fallback"); + } finally { + globalThis.fetch = realFetch; + process.stdout.write = realStdoutWrite; + stderr.restore(); + } +});