From 55909ecbf2c9a54ecaf43dff22be4ba794eb0f74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:53:53 +0000 Subject: [PATCH 01/15] chore(deps): bump actions/setup-node from 6 to 7 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 534c265..27be2a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,7 +112,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: "24" registry-url: "https://registry.npmjs.org" From f0f0f32a58e4f31cb91a3a491cc0cd230d7c340d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:53:57 +0000 Subject: [PATCH 02/15] chore(deps): bump actions/setup-go from 6 to 7 Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- .github/workflows/govulncheck.yml | 2 +- .github/workflows/pages.yml | 2 +- .github/workflows/release.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 800c951..d4b3bd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 17b1d83..e8668dc 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index cebe716..467d690 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 534c265..1466d62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: fetch-depth: 0 # GoReleaser needs full history for changelog - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true From 10b63cec95843351ab77144bbe3537dd49a338c6 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 6 Aug 2026 17:31:15 +0800 Subject: [PATCH 03/15] docs(env): document Quickstart env file handling --- README.md | 2 ++ docs/env-local.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++ docs/llms.txt | 2 ++ 3 files changed, 75 insertions(+) create mode 100644 docs/env-local.md diff --git a/README.md b/README.md index 871515b..76ba1ce 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,8 @@ Quickstart template behavior: Existing `.env` and `.env.local` files are preserved: the CLI appends missing credentials, updates existing credential keys, and comments out duplicate or stale Agora credential aliases for the selected runtime. +See [Using `.env.local`](docs/env-local.md) for how the CLI creates and updates env files from Quickstart examples and the selected project's credentials. + See [docs/automation.md](docs/automation.md) for JSON fields and the full credential matrix. ### Repo-local binding diff --git a/docs/env-local.md b/docs/env-local.md new file mode 100644 index 0000000..bf53e8f --- /dev/null +++ b/docs/env-local.md @@ -0,0 +1,71 @@ +--- +title: Using .env.local +--- + +# Using `.env.local` + +For official Next.js, Python, and Go Quickstarts, Agora CLI creates or updates +the runtime-specific env file with the Agora App ID and App Certificate for the +selected project. It does not download a ready-made dotenv file from Console. +The CLI starts with the example env file from the cloned repository, then +writes the credential keys required by that runtime. + +## How the file is created + +| Command | Behavior | +|---------|----------| +| `agora init --template ` | Clones the Quickstart, selects or creates a project, and writes its env file. | +| `agora quickstart create ...` | Writes the env file when a project is resolved; without a project, it clones the template only. | +| `agora quickstart env write [dir]` | Creates or updates the runtime-specific env file in an existing Quickstart. | +| `agora project env write [path]` | Creates or updates a dotenv file at the selected path without cloning a Quickstart. | + +Quickstart env layouts: + +| Quickstart | Example source | Target path | Credential keys | +|------------|----------------|-------------|-----------------| +| Next.js | `env.local.example` | `.env.local` | `NEXT_PUBLIC_AGORA_APP_ID`, `NEXT_AGORA_APP_CERTIFICATE` | +| Python | `server/.env.example` | `server/.env.local` | `AGORA_APP_ID`, `AGORA_APP_CERTIFICATE` | +| Go | `server/.env.example` | `server/.env.local` | `AGORA_APP_ID`, `AGORA_APP_CERTIFICATE` | + +If the target env file already exists, the CLI uses it as the starting content +and updates the Agora credential keys while preserving unrelated entries. If +the target does not exist, the CLI starts from the Quickstart's example file. +If neither file exists, it creates a new file containing the credential entries. + +To refresh credentials or switch the Quickstart to another project, run the env +write command again with the target project. The CLI updates the same env file +in place: + +```bash +cd +agora quickstart env write . --project +``` + +Prefer `agora quickstart env write` for official Quickstarts. Use +`agora project env write ` when you want to write credentials to a +specific dotenv path outside the official Quickstart layout. + +## Where the credentials come from + +After authentication, the CLI fetches the selected project's details from the +Agora CLI project API. The App ID and App Certificate returned for that project +are written to the local env file. + +Project selection follows this precedence: + +1. Explicit `--project ` +2. Repo-local `.agora/project.json` +3. Global project context set by `agora project use` + +The selected project must have an App Certificate. If it does not, enable one +in Agora Console or select a different project before writing the env file. + +Restart the development server after updating the env file so it reloads the +new values. + +## Keep credentials private + +The env file can contain an App Certificate. Do not commit it to version +control, paste its values into issues or logs, or share it outside the intended +development environment. Confirm that the file is covered by the repository's +`.gitignore` rules. diff --git a/docs/llms.txt b/docs/llms.txt index fd44624..65158cb 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -19,6 +19,7 @@ Check project health: agora project doctor --json - Command reference: /cli/commands.html - Automation (JSON contract): /cli/automation.html - Error codes: /cli/error-codes.html +- Environment files: /cli/env-local.html - Telemetry controls: /cli/telemetry.html ### Agent-Friendly Markdown @@ -26,6 +27,7 @@ Check project health: agora project doctor --json - Commands: /cli/md/commands.md - Automation: /cli/md/automation.md - Error codes: /cli/md/error-codes.md +- Environment files: /cli/md/env-local.md - Install guide: /cli/md/install.md - Telemetry: /cli/md/telemetry.md - Agent rules: /cli/md/agents/README.md From ecb9e2ccc27e14721e59584d38ce59d9a532b92f Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 6 Aug 2026 21:01:44 +0800 Subject: [PATCH 04/15] docs(env): add documentation for Quickstart env file creation and update sitemap --- CHANGELOG.md | 4 ++++ docs/sitemap.xml | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e22c4e5..bff4fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] +### Documentation + +- Add `docs/env-local.md` explaining that Quickstart env files are created by the CLI from the template example plus the selected project's App ID and App Certificate (not downloaded from Console); link it from `README.md`, `docs/llms.txt`, and `docs/sitemap.xml`. + ## [0.2.8] - 2026-07-28 Region-aware authentication, OAuth UX, quickstart compatibility, and installer and documentation delivery improvements. diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 30b1d84..430c162 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -29,6 +29,11 @@ layout: none monthly 0.8 + + {{ site.url }}{{ site.baseurl }}/env-local.html + monthly + 0.7 + {{ site.url }}{{ site.baseurl }}/telemetry.html monthly @@ -60,6 +65,11 @@ layout: none monthly 0.8 + + {{ site.url }}{{ site.baseurl }}/md/env-local.md + monthly + 0.7 + {{ site.url }}{{ site.baseurl }}/md/install.md monthly From 0d6db1e43b2d697c3e0dc02d2c839e78b93889d8 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Fri, 7 Aug 2026 17:11:03 +0800 Subject: [PATCH 05/15] docs(README): enhance Quick Start instructions and clarify project selection process --- CHANGELOG.md | 4 +++ README.md | 63 ++++++++++++++++++++++++++++++-------- internal/cli/quickstart.go | 2 +- 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e22c4e5..8cf36dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] +### Documentation + +- Clarify the README Quick Start around interactive `agora init` project selection, `--project` / `--new-project`, `.agora/project.json` vs `project use`, and that env files are written by the CLI rather than downloaded from Console. + ## [0.2.8] - 2026-07-28 Region-aware authentication, OAuth UX, quickstart compatibility, and installer and documentation delivery improvements. diff --git a/README.md b/README.md index 871515b..34d5d43 100644 --- a/README.md +++ b/README.md @@ -76,23 +76,58 @@ Requires the Go toolchain pinned in [go.mod](go.mod). For direct installer optio ## Quick Start +Recommended path: install the CLI, log in, then run `agora init`. The CLI binds a project, clones an official quickstart, writes the runtime env file (App ID and App Certificate), and creates `.agora/project.json`. You do **not** need to download an env file from Agora Console for this flow. + ```bash +# 1) Log in agora login + +# 2) Create a local demo bound to a project +# Interactive (TTY): pick an existing project or create a new one from the prompt agora init my-nextjs-demo --template nextjs +# Non-interactive / scripts: pass an existing project, or force-create one +# agora init my-nextjs-demo --template nextjs --project +# agora init my-nextjs-demo --template nextjs --new-project + +# 3) Install deps and start the app (follow nextSteps from init; Next.js example:) +cd my-nextjs-demo +pnpm install +# Newer pnpm may block dependency build scripts (ERR_PNPM_IGNORED_BUILDS). If so: +# pnpm approve-builds --all +# pnpm rebuild +pnpm dev +# Other templates print their own commands, e.g. python: bun run setup && bun run dev +# go: make setup && make dev + +# 4) Open the app in a browser (Next.js default: http://localhost:3000) +open http://localhost:3000 +# Linux: xdg-open http://localhost:3000 +# Windows: start http://localhost:3000 + +# 5) Optional: check project/workspace readiness agora project doctor --json ``` +`init` also prints template-specific next steps in its output. Refresh credentials or rebind the repo later with: + +```bash +cd my-nextjs-demo +agora quickstart env write . --project +``` + +If a command reports `No project selected`, pass `--project`, run `agora project use `, or work inside a directory that already has `.agora/project.json`. That is expected when none of those contexts exist—not a missing Console env download. + Command examples use `agora` for the installed CLI. Local source builds use `./agora` from the repo root. ## What You Can Build Quickly | Goal | Command | What You Get | |------|---------|--------------| -| Next.js video app | `agora init my-nextjs-demo --template nextjs` | A cloned Next.js quickstart, project binding, and `.env.local` | -| Python voice agent | `agora init my-python-demo --template python` | A Python quickstart with Agora credentials written for the backend | -| Go voice agent | `agora init my-go-demo --template go` | A Go quickstart with Agora credentials written for the backend | +| Next.js video app | `agora init my-nextjs-demo --template nextjs` | A cloned Next.js quickstart, `.agora` binding, and `.env.local` | +| Python voice agent | `agora init my-python-demo --template python` | A Python quickstart with `server/.env.local` credentials | +| Go voice agent | `agora init my-go-demo --template go` | A Go quickstart with `server/.env.local` credentials | -Run `agora quickstart list` to see all available templates. +In an interactive terminal, `init` without `--project` prompts you to choose an existing project or create a new one. Pass `--project ` or `--new-project` for scripts/`--json`/CI. Run `agora quickstart list` to see all available templates. ## Command Model @@ -116,13 +151,14 @@ The command model is intentionally layered: | Goal | Command | |------|---------| -| New user, one shot | `agora init --template ` | +| New user, one shot | `agora init --template ` (interactive project picker; or `--project` / `--new-project`) | | List available templates | `agora quickstart list` | | Clone a starter only | `agora quickstart create ...` | -| Re-sync env in a cloned quickstart | `agora quickstart env write [dir]` | +| Re-sync / rebind env in a cloned quickstart | `agora quickstart env write [dir] --project ` | | Write env to an arbitrary path / non-quickstart repo | `agora project env write ` | +| Set machine-wide default project | `agora project use ` | | Install self-test | `agora doctor --json` | -| Project/workspace readiness | `agora project doctor --json` | +| Project/workspace readiness | `agora project doctor --json` (add `--deep` in a bound repo) | | Manage feature webhooks | `agora project webhook ... --json` | ### Env-related commands @@ -146,7 +182,7 @@ agora introspect --json ### `init` -Recommended onboarding command. It creates or binds a project, clones a quickstart, writes env, persists context, and prints next steps. +Recommended onboarding command. In an interactive TTY it can prompt you to pick or create a project. Prefer `--project ` or `--new-project` for non-interactive/`--json`/CI runs. It clones a quickstart, writes the template env file from the project API, writes `.agora/project.json`, updates global context, and prints next steps. ### `quickstart` @@ -198,7 +234,9 @@ Prints build metadata. Release binaries include version, commit, and build date. ## Env Files and Project Binding -`quickstart env write` and `project env write` both keep dotenv files limited to runtime credentials, but they target different workflows: +Env files hold runtime credentials. Project selection is separate: use `--project`, `.agora/project.json`, or `agora project use`. The CLI writes App ID and App Certificate from the selected project's API response into the template env file. It does **not** download a ready-made dotenv from Agora Console. + +Prefer `agora quickstart env write` inside official quickstarts. Use `agora project env write ` only when you need a specific dotenv path outside that layout. | Command | Env path | Key names | |---------|----------|-----------| @@ -212,7 +250,7 @@ Quickstart template behavior: - Go quickstarts copy `server/.env.example` to `server/.env.local`, then use `AGORA_APP_ID` plus `AGORA_APP_CERTIFICATE` - Existing Python and Go quickstarts keep their recorded env path and legacy `APP_ID` / `APP_CERTIFICATE` keys when reconfigured. -`project env write` auto-detects Next.js workspaces (or accepts `--template nextjs|standard`) and writes `AGORA_APP_ID` / `AGORA_APP_CERTIFICATE` or the Next.js equivalents. Use `quickstart env write` when you want the CLI to choose the official quickstart's env path. +`project env write` auto-detects Next.js workspaces (or accepts `--template nextjs|standard`) and writes `AGORA_APP_ID` / `AGORA_APP_CERTIFICATE` or the Next.js equivalents. Existing `.env` and `.env.local` files are preserved: the CLI appends missing credentials, updates existing credential keys, and comments out duplicate or stale Agora credential aliases for the selected runtime. @@ -220,7 +258,7 @@ See [docs/automation.md](docs/automation.md) for JSON fields and the full creden ### Repo-local binding -The CLI writes repo-local project metadata to `.agora/project.json` so it can detect which Agora project a cloned demo is bound to even when you work inside the repo later. +`.agora/project.json` is the **repo-local** project binding (not the env file). It lets the CLI know which Agora project a cloned demo uses when you work inside that repo later. `agora project use` only sets a **machine-wide** default and does not rewrite `.agora/project.json`. Project resolution precedence is consistent across commands: @@ -331,7 +369,8 @@ The most common issues: - **OAuth browser does not open**: `agora login --no-browser` prints the URL so you can open it elsewhere; or `agora config update --browser-auto-open=false`. - **`git` is missing**: `agora init` and `agora quickstart create` shell out to `git clone`. Install `git` and retry. - **Project has no app certificate**: `quickstart env write`, `init`, and `project env --with-secrets` need a project with an App Certificate. Pick another project or enable one in [Agora Console](https://console.agora.io). -- **No project selected**: pass `--project `, run `agora project use `, or run from a repo that already has `.agora/project.json`. +- **No project selected**: the command has no project context. Pass `--project `, run `agora project use `, or work inside a repo with `.agora/project.json` (created by `init` / `quickstart env write`). Do not expect the CLI to infer a project from `.env.local` alone. +- **Wrong or stale credentials in a quickstart**: re-run `agora quickstart env write . --project ` from the demo directory instead of pasting a Console-downloaded env file. Full guide with debug logging, CI tips, completion troubleshooting, and the `--debug` flag: [docs/troubleshooting.md](docs/troubleshooting.md). diff --git a/internal/cli/quickstart.go b/internal/cli/quickstart.go index b8b717d..386c2bc 100644 --- a/internal/cli/quickstart.go +++ b/internal/cli/quickstart.go @@ -94,7 +94,7 @@ func quickstartTemplates() []quickstartTemplate { AppCertificateKey: "APP_CERTIFICATE", }, }, - InstallCommand: "bun install", + InstallCommand: "bun run setup", RunCommand: "bun run dev", EnvDocsSummary: "Copies server/.env.example to server/.env.local, then writes AGORA_APP_ID and AGORA_APP_CERTIFICATE.", SupportsInit: true, From f3f8ee8f1c1256a884686a6e345ba165729af39d Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Fri, 7 Aug 2026 17:25:06 +0800 Subject: [PATCH 06/15] docs(changelog, README): update Python quickstart install command and clarify project selection process --- CHANGELOG.md | 6 +++++- README.md | 13 +++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf36dd..14f5462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,13 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] +### Changed + +- Align the Python quickstart `nextSteps` install command with the upstream template (`bun run setup` instead of `bun install`). + ### Documentation -- Clarify the README Quick Start around interactive `agora init` project selection, `--project` / `--new-project`, `.agora/project.json` vs `project use`, and that env files are written by the CLI rather than downloaded from Console. +- Clarify the README Quick Start around `agora init` project selection (including `Default Project` reuse vs interactive picker), `--project` / `--new-project`, `.agora/project.json` vs `project use`, and that env files are written by the CLI rather than downloaded from Console. ## [0.2.8] - 2026-07-28 diff --git a/README.md b/README.md index 34d5d43..7c07691 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,10 @@ Recommended path: install the CLI, log in, then run `agora init`. The CLI binds agora login # 2) Create a local demo bound to a project -# Interactive (TTY): pick an existing project or create a new one from the prompt +# Interactive (TTY): reuses "Default Project" if present; otherwise prompts to pick or create agora init my-nextjs-demo --template nextjs -# Non-interactive / scripts: pass an existing project, or force-create one +# Non-interactive / scripts / --json / CI: pass an existing project, or force-create one +# (otherwise falls back to the most recent project, or creates one when none exist) # agora init my-nextjs-demo --template nextjs --project # agora init my-nextjs-demo --template nextjs --new-project @@ -127,7 +128,7 @@ Command examples use `agora` for the installed CLI. Local source builds use `./a | Python voice agent | `agora init my-python-demo --template python` | A Python quickstart with `server/.env.local` credentials | | Go voice agent | `agora init my-go-demo --template go` | A Go quickstart with `server/.env.local` credentials | -In an interactive terminal, `init` without `--project` prompts you to choose an existing project or create a new one. Pass `--project ` or `--new-project` for scripts/`--json`/CI. Run `agora quickstart list` to see all available templates. +By default `init` reuses a project named `Default Project` when present. In an interactive TTY without that project, it prompts you to pick an existing project or create a new one. Non-interactive/`--json`/CI runs fall back to the most recent project (or create one when none exist). Pass `--project ` or `--new-project` to control selection explicitly. Run `agora quickstart list` to see all available templates. ## Command Model @@ -151,10 +152,10 @@ The command model is intentionally layered: | Goal | Command | |------|---------| -| New user, one shot | `agora init --template ` (interactive project picker; or `--project` / `--new-project`) | +| New user, one shot | `agora init --template ` (reuses `Default Project` / interactive picker; or `--project` / `--new-project`) | | List available templates | `agora quickstart list` | | Clone a starter only | `agora quickstart create ...` | -| Re-sync / rebind env in a cloned quickstart | `agora quickstart env write [dir] --project ` | +| Re-sync / rebind env in a cloned quickstart | `agora quickstart env write [dir]` (optional `--project` to rebind) | | Write env to an arbitrary path / non-quickstart repo | `agora project env write ` | | Set machine-wide default project | `agora project use ` | | Install self-test | `agora doctor --json` | @@ -182,7 +183,7 @@ agora introspect --json ### `init` -Recommended onboarding command. In an interactive TTY it can prompt you to pick or create a project. Prefer `--project ` or `--new-project` for non-interactive/`--json`/CI runs. It clones a quickstart, writes the template env file from the project API, writes `.agora/project.json`, updates global context, and prints next steps. +Recommended onboarding command. By default it reuses a project named `Default Project` when present. In an interactive TTY without that project, it prompts you to pick or create one. Non-interactive/`--json`/CI runs fall back to the most recent project (or create one when none exist). Prefer `--project ` or `--new-project` for explicit selection. It clones a quickstart, writes the template env file from the project API, writes `.agora/project.json`, updates global context, and prints next steps. ### `quickstart` From d8c3d2a788201ad23d9dc02b255b00979abdd7f3 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Fri, 7 Aug 2026 17:29:57 +0800 Subject: [PATCH 07/15] docs(README): remove fallback explanation for project selection in init command --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 7c07691..62490b5 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,6 @@ agora login # Interactive (TTY): reuses "Default Project" if present; otherwise prompts to pick or create agora init my-nextjs-demo --template nextjs # Non-interactive / scripts / --json / CI: pass an existing project, or force-create one -# (otherwise falls back to the most recent project, or creates one when none exist) # agora init my-nextjs-demo --template nextjs --project # agora init my-nextjs-demo --template nextjs --new-project From 8cd22dfe9a79fdd6e2b2c39d56fa0e9f7c4cd5d5 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Wed, 12 Aug 2026 16:57:44 +0800 Subject: [PATCH 08/15] docs: document PowerShell 7 requirement for Windows installer --- README.md | 3 +++ docs/install.md | 2 ++ 2 files changed, 5 insertions(+) diff --git a/README.md b/README.md index 62490b5..6541433 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Native Agora CLI for authentication, project management, quickstart setup, and d - macOS 12+, Linux (glibc 2.31+ or musl), or Windows 10+ for the prebuilt binaries. - `git` on `PATH` for `agora init` and `agora quickstart create` (they shell out to `git clone`). +- PowerShell 7+ (`pwsh`) for the Windows installer. Windows PowerShell 5.1 is not supported. - For the source build, the Go toolchain pinned in [`go.mod`](go.mod). ### Install the CLI @@ -32,6 +33,8 @@ Windows PowerShell: irm https://dl.agora.io/cli/install.ps1 | iex ``` +Requires PowerShell 7+ (`pwsh`). If `irm ... | iex` fails in Windows PowerShell 5.1, switch to PowerShell 7 first. + Alternative install paths (GitHub-hosted; use `install.ps1` for PowerShell): ```bash diff --git a/docs/install.md b/docs/install.md index b2eac3f..f968d2d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -105,6 +105,8 @@ The shell installer is idempotent. Re-running with the same `--version` will det ### Windows (PowerShell) +Requires PowerShell 7+ (`pwsh`). Windows PowerShell 5.1 is not supported by this installer. + Install the latest release: ```powershell From 8156fd3ccacd98568a10549f9f130ce9fb7c892c Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 20 Aug 2026 17:14:25 -0400 Subject: [PATCH 09/15] docs(env): clarify project resolution and link changelog --- CHANGELOG.md | 2 +- docs/env-local.md | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bff4fde..a2b9f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Earlier entries pre-date this convention and only carry their version's compare ### Documentation -- Add `docs/env-local.md` explaining that Quickstart env files are created by the CLI from the template example plus the selected project's App ID and App Certificate (not downloaded from Console); link it from `README.md`, `docs/llms.txt`, and `docs/sitemap.xml`. +- Add `docs/env-local.md` explaining that Quickstart env files are created by the CLI from the template example plus the selected project's App ID and App Certificate (not downloaded from Console); link it from `README.md`, `docs/llms.txt`, and `docs/sitemap.xml` ([#59](https://github.com/AgoraIO/cli/pull/59)). ## [0.2.8] - 2026-07-28 diff --git a/docs/env-local.md b/docs/env-local.md index bf53e8f..d5e5500 100644 --- a/docs/env-local.md +++ b/docs/env-local.md @@ -51,12 +51,19 @@ After authentication, the CLI fetches the selected project's details from the Agora CLI project API. The App ID and App Certificate returned for that project are written to the local env file. -Project selection follows this precedence: +Commands that resolve an existing project context, including `quickstart create`, +`quickstart env write`, and `project env write`, use this precedence: 1. Explicit `--project ` 2. Repo-local `.agora/project.json` 3. Global project context set by `agora project use` +`agora init` has a separate onboarding flow. Use `--project` to select an +existing project or `--new-project` to force creation. Without either flag, it +prefers a project named `Default Project`, prompts in an interactive terminal, +uses the most recently created project in non-interactive runs, or creates a +project when none exist. + The selected project must have an App Certificate. If it does not, enable one in Agora Console or select a different project before writing the env file. From d4c2a2592e3eb6b18aa4efb2364a6583b6918fe6 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 20 Aug 2026 17:14:27 -0400 Subject: [PATCH 10/15] docs: correct quickstart context and changelog links --- CHANGELOG.md | 4 ++-- README.md | 13 +++++-------- docs/install.md | 2 -- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f5462..45de4f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,11 @@ Earlier entries pre-date this convention and only carry their version's compare ### Changed -- Align the Python quickstart `nextSteps` install command with the upstream template (`bun run setup` instead of `bun install`). +- Align the Python quickstart `nextSteps` install command with the upstream template (`bun run setup` instead of `bun install`) ([#60](https://github.com/AgoraIO/cli/pull/60)). ### Documentation -- Clarify the README Quick Start around `agora init` project selection (including `Default Project` reuse vs interactive picker), `--project` / `--new-project`, `.agora/project.json` vs `project use`, and that env files are written by the CLI rather than downloaded from Console. +- Clarify the README Quick Start around `agora init` project selection (including `Default Project` reuse vs interactive picker), `--project` / `--new-project`, `.agora/project.json` vs `project use`, and that env files are written by the CLI rather than downloaded from Console ([#60](https://github.com/AgoraIO/cli/pull/60)). ## [0.2.8] - 2026-07-28 diff --git a/README.md b/README.md index 6541433..21ef33f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ Native Agora CLI for authentication, project management, quickstart setup, and d - macOS 12+, Linux (glibc 2.31+ or musl), or Windows 10+ for the prebuilt binaries. - `git` on `PATH` for `agora init` and `agora quickstart create` (they shell out to `git clone`). -- PowerShell 7+ (`pwsh`) for the Windows installer. Windows PowerShell 5.1 is not supported. - For the source build, the Go toolchain pinned in [`go.mod`](go.mod). ### Install the CLI @@ -33,8 +32,6 @@ Windows PowerShell: irm https://dl.agora.io/cli/install.ps1 | iex ``` -Requires PowerShell 7+ (`pwsh`). If `irm ... | iex` fails in Windows PowerShell 5.1, switch to PowerShell 7 first. - Alternative install paths (GitHub-hosted; use `install.ps1` for PowerShell): ```bash @@ -88,7 +85,7 @@ agora login # 2) Create a local demo bound to a project # Interactive (TTY): reuses "Default Project" if present; otherwise prompts to pick or create agora init my-nextjs-demo --template nextjs -# Non-interactive / scripts / --json / CI: pass an existing project, or force-create one +# For deterministic non-interactive / --json / CI runs, select or create explicitly # agora init my-nextjs-demo --template nextjs --project # agora init my-nextjs-demo --template nextjs --new-project @@ -118,7 +115,7 @@ cd my-nextjs-demo agora quickstart env write . --project ``` -If a command reports `No project selected`, pass `--project`, run `agora project use `, or work inside a directory that already has `.agora/project.json`. That is expected when none of those contexts exist—not a missing Console env download. +If an env or project command reports `No project selected`, pass `--project`, run `agora project use `, or work inside a directory that already has `.agora/project.json`. That is expected when none of those contexts exist—not a missing Console env download. `agora init` uses the onboarding selection flow described above instead. Command examples use `agora` for the installed CLI. Local source builds use `./agora` from the repo root. @@ -159,7 +156,7 @@ The command model is intentionally layered: | Clone a starter only | `agora quickstart create ...` | | Re-sync / rebind env in a cloned quickstart | `agora quickstart env write [dir]` (optional `--project` to rebind) | | Write env to an arbitrary path / non-quickstart repo | `agora project env write ` | -| Set machine-wide default project | `agora project use ` | +| Set global CLI project context | `agora project use ` | | Install self-test | `agora doctor --json` | | Project/workspace readiness | `agora project doctor --json` (add `--deep` in a bound repo) | | Manage feature webhooks | `agora project webhook ... --json` | @@ -261,9 +258,9 @@ See [docs/automation.md](docs/automation.md) for JSON fields and the full creden ### Repo-local binding -`.agora/project.json` is the **repo-local** project binding (not the env file). It lets the CLI know which Agora project a cloned demo uses when you work inside that repo later. `agora project use` only sets a **machine-wide** default and does not rewrite `.agora/project.json`. +`.agora/project.json` is the **repo-local** project binding (not the env file). It lets the CLI know which Agora project a cloned demo uses when you work inside that repo later. `agora project use` only sets the **global CLI context** and does not rewrite `.agora/project.json`. -Project resolution precedence is consistent across commands: +Commands that resolve an existing project context, including env-write commands, use this precedence: 1. explicit `--project` or positional project argument 2. repo-local `.agora/project.json` resolved from the target repo path diff --git a/docs/install.md b/docs/install.md index f968d2d..b2eac3f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -105,8 +105,6 @@ The shell installer is idempotent. Re-running with the same `--version` will det ### Windows (PowerShell) -Requires PowerShell 7+ (`pwsh`). Windows PowerShell 5.1 is not supported by this installer. - Install the latest release: ```powershell From 09fcba856264cd687be0a2f9396c588c25415102 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 20 Aug 2026 17:41:51 -0400 Subject: [PATCH 11/15] updated changelog and isntall scripts --- .github/workflows/ci.yml | 18 +++++ CHANGELOG.md | 9 +++ CONTRIBUTING.md | 2 +- README.md | 9 ++- docs/install.md | 8 ++- go.mod | 2 +- install.ps1 | 11 ++++ ...2026-07-29-oauth-callback-response-race.md | 66 +++++++++++++++++++ 8 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 internal-docs/issues/2026-07-29-oauth-callback-response-race.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 800c951..264cdc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -365,6 +365,24 @@ jobs: Remove-Item Env:VERSION, Env:AGORA_INSTALL_SOURCE, Env:RELEASES_DOWNLOAD_BASE_URL, Env:RELEASES_PAGE_URL -ErrorAction SilentlyContinue } + - name: Reject Windows PowerShell 5.1 installer runs + if: runner.os == 'Windows' + shell: pwsh + run: | + $previousNativePreference = $PSNativeCommandUseErrorActionPreference + $PSNativeCommandUseErrorActionPreference = $false + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File ./install.ps1 2>&1 | Out-String + $exitCode = $LASTEXITCODE + $PSNativeCommandUseErrorActionPreference = $previousNativePreference + $global:LASTEXITCODE = 0 + + if ($exitCode -eq 0) { + throw 'Expected install.ps1 to reject Windows PowerShell 5.1.' + } + if ($output -notmatch 'requires PowerShell 7 or newer') { + throw "Expected an actionable PowerShell 7 requirement, got: $output" + } + - name: Smoke test PowerShell installer S3 fallback if: runner.os == 'Windows' shell: pwsh diff --git a/CHANGELOG.md b/CHANGELOG.md index e22c4e5..dc83b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] +### Changed + +- Pause npm installation guidance because the published `agoraio-cli` package may be stale; direct installers and verified release archives remain the supported installation paths ([68632f7](https://github.com/AgoraIO/cli/commit/68632f7)). +- Expand installation guidance with the canonical Agora CDN, GitHub-hosted fallbacks, the S3 mirror option, and a PowerShell 7 guard with an actionable process-scoped execution-policy workaround ([03f46af](https://github.com/AgoraIO/cli/commit/03f46af), [#61](https://github.com/AgoraIO/cli/pull/61)). + +### Fixed + +- Bump the pinned Go toolchain to 1.26.6 to address reachable standard-library vulnerabilities reported by `govulncheck` in Go 1.26.5. + ## [0.2.8] - 2026-07-28 Region-aware authentication, OAuth UX, quickstart compatibility, and installer and documentation delivery improvements. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91d81ad..20a227e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ For end-user behavior and machine-readable contracts, see: Requirements: -- **Go** 1.26.2+ (see `go.mod`). Release builds intentionally track the current stable Go toolchain; this distributed CLI does not target older Go compiler support. +- **Go** 1.26.6+ (see `go.mod`). Release builds intentionally track the current stable Go toolchain; this distributed CLI does not target older Go compiler support. - **Git**. - (Optional) `golangci-lint` v1.64.8 — install matches CI; instructions in the next section. diff --git a/README.md b/README.md index 516e598..d870ece 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Native Agora CLI for authentication, project management, quickstart setup, and d - macOS 12+, Linux (glibc 2.31+ or musl), or Windows 10+ for the prebuilt binaries. - `git` on `PATH` for `agora init` and `agora quickstart create` (they shell out to `git clone`). +- PowerShell 7+ (`pwsh`) for the native Windows installer. - For the source build, the Go toolchain pinned in [`go.mod`](go.mod). ### Install the CLI @@ -26,19 +27,21 @@ agora --help The script is served from the Agora CDN (`dl.agora.io`, CloudFront). Binaries download from GitHub by default and automatically fall back to the CDN mirror if GitHub is unreachable; downloads are verified against `checksums.txt` regardless of source. -Windows PowerShell: +Windows PowerShell 7+: ```powershell irm https://dl.agora.io/cli/install.ps1 | iex ``` -If your PowerShell execution policy blocks inline scripts (the default on most Windows clients), download the installer to disk and run it with `-ExecutionPolicy Bypass`. The `Invoke-WebRequest` form works on both Windows PowerShell 5.1 and PowerShell 7+: +If execution policy blocks the installer, download it to disk and launch it in a new PowerShell 7 process with a process-scoped bypass: ```powershell Invoke-WebRequest -Uri https://dl.agora.io/cli/install.ps1 -OutFile .\install.ps1 -powershell -ExecutionPolicy Bypass -File .\install.ps1 +pwsh -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 ``` +An organization-level `MachinePolicy` or `UserPolicy` can override the process setting. In that case, ask an administrator to allow the script or use a verified release archive instead. + Alternative install paths (GitHub-hosted; use `install.ps1` for PowerShell): ```bash diff --git a/docs/install.md b/docs/install.md index b9497f1..c46b79c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -105,6 +105,8 @@ The shell installer is idempotent. Re-running with the same `--version` will det ### Windows (PowerShell) +The installer requires PowerShell 7+ (`pwsh`). Windows PowerShell 5.1 exits before making filesystem or network changes and prints the PowerShell 7 recovery commands. + Install the latest release: ```powershell @@ -132,13 +134,15 @@ The Windows installer installs `agora.exe` into `%LOCALAPPDATA%\Programs\Agora\b #### Alternative: download first, then run -Windows PowerShell's default execution policy blocks inline scripts on most machines (`Restricted` is the default for standard users; `RemoteSigned` rejects unsigned CDN content even for admins). If you hit that, download the installer to disk and run it with `-ExecutionPolicy Bypass`. The `Invoke-WebRequest` form below works on both Windows PowerShell 5.1 and PowerShell 7+: +If execution policy blocks the installer, download it to disk and launch it in a new PowerShell 7 process with a process-scoped bypass: ```powershell Invoke-WebRequest -Uri https://dl.agora.io/cli/install.ps1 -OutFile .\install.ps1 -powershell -ExecutionPolicy Bypass -File .\install.ps1 +pwsh -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 ``` +The process-scoped bypass does not override an organization-level `MachinePolicy` or `UserPolicy`. In a managed environment where Group Policy still blocks the script, ask an administrator to allow it or use a verified release archive instead. + ### Direct GitHub fallback The GitHub Pages URLs above are the recommended installer entry points. diff --git a/go.mod b/go.mod index 8f1b18b..9587d1d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/AgoraIO/cli -go 1.26.5 +go 1.26.6 require ( github.com/spf13/cobra v1.10.2 diff --git a/install.ps1 b/install.ps1 index 75b581f..24842a1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -36,6 +36,17 @@ param( [switch]$SkipShell ) +if ($PSVersionTable.PSVersion.Major -lt 7) { + $message = @" +Agora CLI installer requires PowerShell 7 or newer. +Download the installer, then launch it with a process-scoped execution-policy bypass: + Invoke-WebRequest -Uri https://dl.agora.io/cli/install.ps1 -OutFile .\install.ps1 + pwsh -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 +"@ + [Console]::Error.WriteLine($message) + exit 2 +} + Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' diff --git a/internal-docs/issues/2026-07-29-oauth-callback-response-race.md b/internal-docs/issues/2026-07-29-oauth-callback-response-race.md new file mode 100644 index 0000000..5ed5ceb --- /dev/null +++ b/internal-docs/issues/2026-07-29-oauth-callback-response-race.md @@ -0,0 +1,66 @@ +--- +title: Gracefully finish OAuth callback responses before closing the server +status: open +priority: high +area: auth +reported: 2026-07-29 +--- + +# Gracefully finish OAuth callback responses before closing the server + +## Summary + +`agora login` can close its local OAuth callback server before the browser has +received the rendered success or error page. This causes intermittent browser +request failures even when token exchange and session persistence succeed. + +## Evidence + +The Ubuntu CI run for `TestCLILoginAndWhoAmI` failed with: + +```text +expected localized CN success page, got status=0 body= +``` + +The same test passed on a subsequent run without a code change, indicating a +timing-dependent failure. + +## Root cause + +`callbackServer.CompleteSuccess` unblocks after the callback handler writes the +response body, but before the handler returns and the HTTP response is fully +flushed to the client. `login` then returns and its deferred +`callbackServer.Close` calls `http.Server.Close`, which can terminate the +active callback connection. + +The integration test records the browser response only when `http.DefaultClient.Do` +succeeds. On a connection error it leaves the status at `0` and the body empty, +which obscures the underlying transport error. + +## Impact + +- A user can complete OAuth authentication successfully but see a blank or + failed browser callback page. +- Login integration tests can fail intermittently, especially on Linux CI. + +## Proposed fix + +1. Replace immediate callback-server shutdown with graceful shutdown that waits + for the callback handler to return before closing active connections. +2. Bound graceful shutdown with a context timeout so login cannot hang during + cleanup. +3. Keep listener cleanup idempotent for the IPv4 and IPv6 callback listeners. +4. Update `TestCLILoginAndWhoAmI` to capture and report the browser request + error instead of reporting only `status=0 body=`. +5. Add a regression test that verifies the client receives the complete success + page before callback-server shutdown completes. + +## Acceptance criteria + +- The browser receives HTTP 200 and the complete localized success page after a + successful login. +- Error paths still return their intended status and safe error page. +- `go test -count=1 ./...` is stable across repeated Linux runs. +- Callback shutdown has a bounded timeout and does not leak listeners or + goroutines. + From f7c4333be20fdef960519e59340f1b6910c73795 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Fri, 21 Aug 2026 13:12:08 -0400 Subject: [PATCH 12/15] test: increase CLI coverage to 75 percent --- cmd/gendocs/main.go | 57 +++-- cmd/gendocs/main_test.go | 97 ++++++++ internal/cli/docgen_test.go | 52 ++++ internal/cli/install_doctor_test.go | 42 ++++ internal/cli/integration_catalog_test.go | 126 ++++++++++ .../cli/integration_release_surface_test.go | 229 ++++++++++++++++++ internal/cli/upgrade_test.go | 218 +++++++++++++++++ 7 files changed, 801 insertions(+), 20 deletions(-) create mode 100644 cmd/gendocs/main_test.go create mode 100644 internal/cli/docgen_test.go create mode 100644 internal/cli/integration_catalog_test.go create mode 100644 internal/cli/integration_release_surface_test.go diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 2b8437b..bd63766 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -15,47 +15,64 @@ import ( "bytes" "flag" "fmt" + "io" "os" "github.com/AgoraIO/cli/internal/cli" ) +var ( + newRootForDocs = cli.NewRootForDocs + renderCommandReference = cli.RenderCommandReference +) + func main() { - out := flag.String("o", "docs/commands.md", "destination markdown file") - check := flag.Bool("check", false, "exit non-zero if the destination file would change (used in CI to detect drift)") - flag.Parse() + if exitCode := run(os.Args[1:], os.Stderr); exitCode != 0 { + os.Exit(exitCode) + } +} + +func run(args []string, stderr io.Writer) int { + flags := flag.NewFlagSet("gendocs", flag.ContinueOnError) + flags.SetOutput(stderr) + out := flags.String("o", "docs/commands.md", "destination markdown file") + check := flags.Bool("check", false, "exit non-zero if the destination file would change (used in CI to detect drift)") + if err := flags.Parse(args); err != nil { + return 2 + } - root, err := cli.NewRootForDocs() + root, err := newRootForDocs() if err != nil { - fmt.Fprintf(os.Stderr, "gendocs: failed to build root command: %v\n", err) - os.Exit(1) + fmt.Fprintf(stderr, "gendocs: failed to build root command: %v\n", err) + return 1 } var buffer bytes.Buffer - if err := cli.RenderCommandReference(&buffer, root); err != nil { - fmt.Fprintf(os.Stderr, "gendocs: render failed: %v\n", err) - os.Exit(1) + if err := renderCommandReference(&buffer, root); err != nil { + fmt.Fprintf(stderr, "gendocs: render failed: %v\n", err) + return 1 } if *check { existing, err := os.ReadFile(*out) if err != nil { - fmt.Fprintf(os.Stderr, "gendocs: cannot read %s for drift check: %v\n", *out, err) - fmt.Fprintln(os.Stderr, "Hint: run `make docs-commands` to generate it.") - os.Exit(2) + fmt.Fprintf(stderr, "gendocs: cannot read %s for drift check: %v\n", *out, err) + fmt.Fprintln(stderr, "Hint: run `make docs-commands` to generate it.") + return 2 } if !bytes.Equal(existing, buffer.Bytes()) { - fmt.Fprintf(os.Stderr, "gendocs: %s is out of date.\n", *out) - fmt.Fprintln(os.Stderr, "Run `make docs-commands` and commit the result.") - os.Exit(1) + fmt.Fprintf(stderr, "gendocs: %s is out of date.\n", *out) + fmt.Fprintln(stderr, "Run `make docs-commands` and commit the result.") + return 1 } - fmt.Fprintf(os.Stderr, "gendocs: %s is up to date.\n", *out) - return + fmt.Fprintf(stderr, "gendocs: %s is up to date.\n", *out) + return 0 } if err := os.WriteFile(*out, buffer.Bytes(), 0o644); err != nil { - fmt.Fprintf(os.Stderr, "gendocs: failed to write %s: %v\n", *out, err) - os.Exit(1) + fmt.Fprintf(stderr, "gendocs: failed to write %s: %v\n", *out, err) + return 1 } - fmt.Fprintf(os.Stderr, "gendocs: wrote %s (%d bytes)\n", *out, buffer.Len()) + fmt.Fprintf(stderr, "gendocs: wrote %s (%d bytes)\n", *out, buffer.Len()) + return 0 } diff --git a/cmd/gendocs/main_test.go b/cmd/gendocs/main_test.go new file mode 100644 index 0000000..ffae24d --- /dev/null +++ b/cmd/gendocs/main_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestRunWritesAndChecksCommandReference(t *testing.T) { + outputPath := filepath.Join(t.TempDir(), "commands.md") + var stderr bytes.Buffer + if exitCode := run([]string{"-o", outputPath}, &stderr); exitCode != 0 { + t.Fatalf("run(write) exit = %d, stderr = %s", exitCode, stderr.String()) + } + generated, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if !bytes.Contains(generated, []byte("agora quickstart create")) { + t.Fatalf("generated reference does not contain quickstart create") + } + if !strings.Contains(stderr.String(), "gendocs: wrote") { + t.Fatalf("write stderr = %q", stderr.String()) + } + + stderr.Reset() + if exitCode := run([]string{"-check", "-o", outputPath}, &stderr); exitCode != 0 { + t.Fatalf("run(check) exit = %d, stderr = %s", exitCode, stderr.String()) + } + if !strings.Contains(stderr.String(), "is up to date") { + t.Fatalf("check stderr = %q", stderr.String()) + } +} + +func TestRunReportsGeneratorFailures(t *testing.T) { + originalRoot := newRootForDocs + originalRender := renderCommandReference + t.Cleanup(func() { + newRootForDocs = originalRoot + renderCommandReference = originalRender + }) + + newRootForDocs = func() (*cobra.Command, error) { + return nil, errors.New("root failed") + } + var stderr bytes.Buffer + if exitCode := run(nil, &stderr); exitCode != 1 || !strings.Contains(stderr.String(), "failed to build root command") { + t.Fatalf("root failure = exit %d, stderr %s", exitCode, stderr.String()) + } + + newRootForDocs = originalRoot + renderCommandReference = func(io.Writer, *cobra.Command) error { + return errors.New("render failed") + } + stderr.Reset() + if exitCode := run(nil, &stderr); exitCode != 1 || !strings.Contains(stderr.String(), "render failed") { + t.Fatalf("render failure = exit %d, stderr %s", exitCode, stderr.String()) + } +} + +func TestRunReportsDriftAndFileFailures(t *testing.T) { + directory := t.TempDir() + stalePath := filepath.Join(directory, "stale.md") + if err := os.WriteFile(stalePath, []byte("stale"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + tests := []struct { + name string + args []string + code int + want string + }{ + {name: "drift", args: []string{"-check", "-o", stalePath}, code: 1, want: "out of date"}, + {name: "missing check file", args: []string{"-check", "-o", filepath.Join(directory, "missing.md")}, code: 2, want: "cannot read"}, + {name: "write failure", args: []string{"-o", directory}, code: 1, want: "failed to write"}, + {name: "invalid flag", args: []string{"-unknown"}, code: 2, want: "flag provided but not defined"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stderr bytes.Buffer + if exitCode := run(tt.args, &stderr); exitCode != tt.code { + t.Fatalf("run() exit = %d, want %d; stderr = %s", exitCode, tt.code, stderr.String()) + } + if !strings.Contains(stderr.String(), tt.want) { + t.Fatalf("stderr does not contain %q: %s", tt.want, stderr.String()) + } + }) + } +} diff --git a/internal/cli/docgen_test.go b/internal/cli/docgen_test.go new file mode 100644 index 0000000..e2b1165 --- /dev/null +++ b/internal/cli/docgen_test.go @@ -0,0 +1,52 @@ +package cli + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestRenderCommandReferenceFromPublicRoot(t *testing.T) { + root, err := NewRootForDocs() + if err != nil { + t.Fatalf("NewRootForDocs() error = %v", err) + } + + var out bytes.Buffer + if err := RenderCommandReference(&out, root); err != nil { + t.Fatalf("RenderCommandReference() error = %v", err) + } + + document := out.String() + for _, want := range []string{ + "## `agora quickstart create`", + "`--template-only`", + "## `agora project doctor`", + } { + if !strings.Contains(document, want) { + t.Errorf("generated command reference does not contain %q", want) + } + } +} + +func TestRenderCommandReferenceReturnsWriteError(t *testing.T) { + root, err := NewRootForDocs() + if err != nil { + t.Fatalf("NewRootForDocs() error = %v", err) + } + + wantErr := errors.New("write failed") + err = RenderCommandReference(errorWriter{err: wantErr}, root) + if !errors.Is(err, wantErr) { + t.Fatalf("RenderCommandReference() error = %v, want %v", err, wantErr) + } +} + +type errorWriter struct { + err error +} + +func (w errorWriter) Write([]byte) (int, error) { + return 0, w.err +} diff --git a/internal/cli/install_doctor_test.go b/internal/cli/install_doctor_test.go index 16cd1e7..960fde4 100644 --- a/internal/cli/install_doctor_test.go +++ b/internal/cli/install_doctor_test.go @@ -1,6 +1,8 @@ package cli import ( + "net/http" + "net/http/httptest" "runtime" "strings" "testing" @@ -133,3 +135,43 @@ func TestInstallDoctorNetworkEndpointsFollowCurrentRegion(t *testing.T) { } }) } + +func TestInstallDoctorPublicJSONAndPrettyOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + + baseEnv := map[string]string{ + "AGORA_API_BASE_URL": server.URL, + "AGORA_LOG_LEVEL": "error", + "AGORA_OAUTH_BASE_URL": server.URL, + } + + jsonEnv := cloneDoctorEnv(baseEnv) + jsonEnv["XDG_CONFIG_HOME"] = t.TempDir() + persistSessionForIntegration(t, jsonEnv["XDG_CONFIG_HOME"]) + jsonResult := runCLI(t, []string{"doctor", "--json"}, cliRunOptions{env: jsonEnv}) + for _, want := range []string{`"command":"doctor"`, `"checks"`, `"category":"network"`, `"category":"auth"`} { + if !strings.Contains(jsonResult.stdout, want) { + t.Errorf("doctor JSON output does not contain %q: %s", want, jsonResult.stdout) + } + } + + prettyEnv := cloneDoctorEnv(baseEnv) + prettyEnv["XDG_CONFIG_HOME"] = t.TempDir() + prettyResult := runCLI(t, []string{"--pretty", "--no-color", "doctor"}, cliRunOptions{env: prettyEnv}) + for _, want := range []string{"Install", "Network", "Auth", "Summary"} { + if !strings.Contains(prettyResult.stdout, want) { + t.Errorf("pretty doctor output does not contain %q: %s", want, prettyResult.stdout) + } + } +} + +func cloneDoctorEnv(input map[string]string) map[string]string { + result := make(map[string]string, len(input)) + for key, value := range input { + result[key] = value + } + return result +} diff --git a/internal/cli/integration_catalog_test.go b/internal/cli/integration_catalog_test.go new file mode 100644 index 0000000..0957f36 --- /dev/null +++ b/internal/cli/integration_catalog_test.go @@ -0,0 +1,126 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestEnvHelpPublicOutputModes(t *testing.T) { + jsonResult := runCLI(t, []string{"env-help", "--json"}, cliRunOptions{}) + if jsonResult.exitCode != 0 { + t.Fatalf("env-help --json exit = %d, stderr = %s", jsonResult.exitCode, jsonResult.stderr) + } + for _, want := range []string{`"command":"env-help"`, `"name":"AGORA_OUTPUT"`, `"category":"output"`} { + if !strings.Contains(jsonResult.stdout, want) { + t.Errorf("env-help JSON output does not contain %q: %s", want, jsonResult.stdout) + } + } + + prettyResult := runCLI(t, []string{"--pretty", "--no-color", "env-help"}, cliRunOptions{}) + if prettyResult.exitCode != 0 { + t.Fatalf("pretty env-help exit = %d, stderr = %s", prettyResult.exitCode, prettyResult.stderr) + } + for _, want := range []string{"Agora CLI environment variables", "[OUTPUT]", "AGORA_OUTPUT"} { + if !strings.Contains(prettyResult.stdout, want) { + t.Errorf("pretty env-help output does not contain %q: %s", want, prettyResult.stdout) + } + } +} + +func TestSkillsPublicCommands(t *testing.T) { + tests := []struct { + name string + args []string + want []string + }{ + { + name: "filtered JSON list", + args: []string{"skills", "list", "--category", "scaffold", "--json"}, + want: []string{`"command":"skills list"`, `"category":"scaffold"`, `"items"`}, + }, + { + name: "pretty list", + args: []string{"--pretty", "--no-color", "skills", "list", "--tag", "convoai"}, + want: []string{"Skills (", "convoai"}, + }, + { + name: "pretty show", + args: []string{"--pretty", "--no-color", "skills", "show", "create-python-voice-agent"}, + want: []string{"create-python-voice-agent", "Steps", "Next Steps"}, + }, + { + name: "pretty search", + args: []string{"--pretty", "--no-color", "skills", "search", "voice"}, + want: []string{"Skills matching", "voice"}, + }, + { + name: "empty search", + args: []string{"--pretty", "--no-color", "skills", "search", "no-such-skill-query"}, + want: []string{"No skills matched"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runCLI(t, tt.args, cliRunOptions{}) + if result.exitCode != 0 { + t.Fatalf("exit = %d, stderr = %s", result.exitCode, result.stderr) + } + for _, want := range tt.want { + if !strings.Contains(result.stdout, want) { + t.Errorf("output does not contain %q: %s", want, result.stdout) + } + } + }) + } +} + +func TestSkillsPublicErrors(t *testing.T) { + tests := []struct { + name string + args []string + code string + }{ + {name: "missing show ID", args: []string{"skills", "show", "--json"}, code: "skill id is required"}, + {name: "unknown skill", args: []string{"skills", "show", "no-such-skill", "--json"}, code: "SKILL_NOT_FOUND"}, + {name: "missing search query", args: []string{"skills", "search", "--json"}, code: "search query is required"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runCLI(t, tt.args, cliRunOptions{}) + if result.exitCode == 0 { + t.Fatalf("expected failure, stdout = %s", result.stdout) + } + if !strings.Contains(result.stdout, tt.code) { + t.Fatalf("output does not contain error marker %q: %s", tt.code, result.stdout) + } + }) + } +} + +func TestPublicShellCompletions(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "skill ID", args: []string{"__complete", "skills", "show", "create-n"}, want: "create-nextjs"}, + {name: "skill category", args: []string{"__complete", "skills", "list", "--category", "s"}, want: "scaffold"}, + {name: "skill tag", args: []string{"__complete", "skills", "list", "--tag", "conv"}, want: "convoai"}, + {name: "quickstart template", args: []string{"__complete", "quickstart", "create", "demo", "--template", "py"}, want: "python"}, + {name: "feature", args: []string{"__complete", "project", "feature", "status", "r"}, want: "rtc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runCLI(t, tt.args, cliRunOptions{}) + if result.exitCode != 0 { + t.Fatalf("completion exit = %d, stderr = %s", result.exitCode, result.stderr) + } + if !strings.Contains(result.stdout, tt.want) { + t.Fatalf("completion output does not contain %q: %s", tt.want, result.stdout) + } + }) + } +} diff --git a/internal/cli/integration_release_surface_test.go b/internal/cli/integration_release_surface_test.go new file mode 100644 index 0000000..17bb47b --- /dev/null +++ b/internal/cli/integration_release_surface_test.go @@ -0,0 +1,229 @@ +package cli + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReleaseCatalogAndStatusCommands(t *testing.T) { + configHome := t.TempDir() + env := map[string]string{ + "AGORA_BROWSER_AUTO_OPEN": "0", + "AGORA_LOG_LEVEL": "error", + "XDG_CONFIG_HOME": configHome, + } + + tests := []struct { + name string + args []string + want []string + }{ + {name: "quickstart list JSON", args: []string{"quickstart", "list", "--json"}, want: []string{`"command":"quickstart list"`, `"items"`, `"python"`}}, + {name: "quickstart list pretty", args: []string{"--pretty", "--no-color", "quickstart", "list"}, want: []string{"Quickstarts", "python"}}, + {name: "version pretty", args: []string{"--pretty", "--no-color", "version"}, want: []string{"Version", "dev"}}, + {name: "telemetry status JSON", args: []string{"telemetry", "status", "--json"}, want: []string{`"command":"telemetry"`, `"action":"status"`, `"enabled"`}}, + {name: "telemetry status pretty", args: []string{"--pretty", "--no-color", "telemetry", "status"}, want: []string{"Telemetry"}}, + {name: "telemetry disable", args: []string{"--pretty", "--no-color", "telemetry", "disable"}, want: []string{"Telemetry", "Enabled", "no"}}, + {name: "telemetry enable", args: []string{"telemetry", "enable", "--json"}, want: []string{`"command":"telemetry"`, `"action":"enable"`, `"enabled":true`}}, + {name: "open docs", args: []string{"--pretty", "--no-color", "open", "--target", "docs", "--no-browser"}, want: []string{"https://", "docs"}}, + {name: "full help", args: []string{"--help", "--all"}, want: []string{"agora init", "agora quickstart create", "agora project doctor"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runCLI(t, tt.args, cliRunOptions{env: env}) + if result.exitCode != 0 { + t.Fatalf("exit = %d, stderr = %s", result.exitCode, result.stderr) + } + for _, want := range tt.want { + if !strings.Contains(strings.ToLower(result.stdout), strings.ToLower(want)) { + t.Errorf("output does not contain %q: %s", want, result.stdout) + } + } + }) + } +} + +func TestLogoutPublicOutputModes(t *testing.T) { + configHome := t.TempDir() + env := map[string]string{ + "AGORA_LOG_LEVEL": "error", + "XDG_CONFIG_HOME": configHome, + } + persistSessionForIntegration(t, configHome) + projectID := "project-id" + projectName := "demo" + if err := saveContext(env, projectContext{CurrentProjectID: &projectID, CurrentProjectName: &projectName}); err != nil { + t.Fatalf("saveContext() error = %v", err) + } + + prettyResult := runCLI(t, []string{"--pretty", "--no-color", "logout"}, cliRunOptions{env: env}) + if prettyResult.exitCode != 0 || !strings.Contains(strings.ToLower(prettyResult.stdout), "logged-out") { + t.Fatalf("pretty logout = exit %d, stdout %q, stderr %q", prettyResult.exitCode, prettyResult.stdout, prettyResult.stderr) + } + + jsonResult := runCLI(t, []string{"auth", "logout", "--json"}, cliRunOptions{env: env}) + if jsonResult.exitCode != 0 || !strings.Contains(jsonResult.stdout, `"command":"logout"`) { + t.Fatalf("JSON logout = exit %d, stdout %q, stderr %q", jsonResult.exitCode, jsonResult.stdout, jsonResult.stderr) + } +} + +func TestProjectAndWebhookPublicPrettyOutput(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + t.Cleanup(func() { _ = api.server.Close() }) + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + persistSessionForIntegration(t, configHome) + env := webhookTestEnv(configHome, api.baseURL) + + create := runCLI(t, []string{ + "project", "webhook", "create", + "--project", "demo", + "--feature", "rtc", + "--url", "https://example.com/webhook", + "--events", "channel-created,1002", + "--json", + }, cliRunOptions{env: env}) + if create.exitCode != 0 || !strings.Contains(create.stdout, `"configId":42`) { + t.Fatalf("webhook create = exit %d, stdout %s, stderr %s", create.exitCode, create.stdout, create.stderr) + } + + tests := []struct { + name string + args []string + want []string + }{ + {name: "project list", args: []string{"--pretty", "--no-color", "project", "list"}, want: []string{"demo", "prj_0001"}}, + {name: "project use", args: []string{"--pretty", "--no-color", "project", "use", "demo"}, want: []string{"Current Project", "demo"}}, + {name: "project show", args: []string{"--pretty", "--no-color", "project", "show", "demo"}, want: []string{"demo", "app_0001"}}, + {name: "project feature list", args: []string{"--pretty", "--no-color", "project", "feature", "list", "demo"}, want: []string{"rtc", "rtm", "convoai"}}, + {name: "webhook list", args: []string{"--pretty", "--no-color", "project", "webhook", "list", "--project", "demo", "--feature", "rtc"}, want: []string{"42", "example.com/webhook", "enabled"}}, + {name: "webhook show", args: []string{"--pretty", "--no-color", "project", "webhook", "show", "42", "--project", "demo", "--feature", "rtc"}, want: []string{"42", "example.com/webhook", "channel-created"}}, + {name: "project env", args: []string{"--pretty", "--no-color", "project", "env", "--project", "demo"}, want: []string{"AGORA_APP_ID", "app_0001"}}, + {name: "project feature status", args: []string{"--pretty", "--no-color", "project", "feature", "status", "rtc", "demo"}, want: []string{"rtc", "demo"}}, + {name: "project feature enable", args: []string{"--pretty", "--no-color", "project", "feature", "enable", "rtm", "demo"}, want: []string{"rtm", "demo"}}, + {name: "project doctor", args: []string{"--pretty", "--no-color", "project", "doctor", "demo", "--feature", "rtc"}, want: []string{"rtc", "demo", "Summary"}}, + {name: "webhook update", args: []string{"--pretty", "--no-color", "project", "webhook", "update", "42", "--project", "demo", "--feature", "rtc", "--url", "https://example.com/updated"}, want: []string{"42", "example.com/updated"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runCLI(t, tt.args, cliRunOptions{env: env}) + if result.exitCode != 0 { + t.Fatalf("exit = %d, stdout = %s, stderr = %s", result.exitCode, result.stdout, result.stderr) + } + for _, want := range tt.want { + if !strings.Contains(strings.ToLower(result.stdout), strings.ToLower(want)) { + t.Errorf("output does not contain %q: %s", want, result.stdout) + } + } + }) + } + + deleted := runCLI(t, []string{"--pretty", "--no-color", "project", "webhook", "delete", "42", "--project", "demo", "--feature", "rtc", "--yes"}, cliRunOptions{env: env}) + if deleted.exitCode != 0 || !strings.Contains(strings.ToLower(deleted.stdout), "deleted") { + t.Fatalf("webhook delete = exit %d, stdout %s, stderr %s", deleted.exitCode, deleted.stdout, deleted.stderr) + } + + created := runCLI(t, []string{"--pretty", "--no-color", "project", "create", "release-coverage"}, cliRunOptions{env: env}) + if created.exitCode != 0 || !strings.Contains(created.stdout, "release-coverage") { + t.Fatalf("project create = exit %d, stdout %s, stderr %s", created.exitCode, created.stdout, created.stderr) + } +} + +func TestConfigPublicCommands(t *testing.T) { + env := map[string]string{ + "AGORA_LOG_LEVEL": "error", + "XDG_CONFIG_HOME": t.TempDir(), + } + + pathResult := runCLI(t, []string{"config", "path", "--json"}, cliRunOptions{env: env}) + if pathResult.exitCode != 0 || !strings.Contains(pathResult.stdout, `"command":"config path"`) || !strings.Contains(pathResult.stdout, "config.json") { + t.Fatalf("config path = exit %d, stdout %s, stderr %s", pathResult.exitCode, pathResult.stdout, pathResult.stderr) + } + + prettyGet := runCLI(t, []string{"--pretty", "--no-color", "config", "get"}, cliRunOptions{env: env}) + if prettyGet.exitCode != 0 || !strings.Contains(prettyGet.stdout, "config get") { + t.Fatalf("config get = exit %d, stdout %s, stderr %s", prettyGet.exitCode, prettyGet.stdout, prettyGet.stderr) + } + + update := runCLI(t, []string{"config", "update", "--log-level", "debug", "--browser-auto-open=false", "--telemetry-enabled=false", "--json"}, cliRunOptions{env: env}) + if update.exitCode != 0 || !strings.Contains(update.stdout, `"command":"config update"`) { + t.Fatalf("config update = exit %d, stdout %s, stderr %s", update.exitCode, update.stdout, update.stderr) + } + + jsonGet := runCLI(t, []string{"config", "get", "--json"}, cliRunOptions{env: env}) + for _, want := range []string{`"command":"config get"`, `"logLevel":"debug"`, `"browserAutoOpen":false`, `"telemetryEnabled":false`} { + if !strings.Contains(jsonGet.stdout, want) { + t.Errorf("config get output does not contain %q: %s", want, jsonGet.stdout) + } + } +} + +func TestInstallDoctorPublicFailureOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + t.Cleanup(server.Close) + + emptyHome := t.TempDir() + result := runCLI(t, []string{"doctor", "--json"}, cliRunOptions{env: map[string]string{ + "AGORA_API_BASE_URL": server.URL, + "AGORA_LOG_LEVEL": "error", + "AGORA_OAUTH_BASE_URL": server.URL, + "HOME": emptyHome, + "PATH": filepath.Join(emptyHome, "bin"), + "XDG_CONFIG_HOME": filepath.Join(emptyHome, "config"), + }}) + if result.exitCode == 0 { + t.Fatalf("doctor failure fixture unexpectedly succeeded: %s", result.stdout) + } + for _, want := range []string{`"command":"doctor"`, `"status":"fail"`, `"category":"network"`, `"category":"auth"`} { + if !strings.Contains(result.stdout, want) { + t.Errorf("doctor failure output does not contain %q: %s", want, result.stdout) + } + } +} + +func TestUpgradeCheckUsesReleaseFixture(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/releases/latest") { + _, _ = io.WriteString(w, `{"tag_name":"v99.0.0"}`) + return + } + http.NotFound(w, r) + })) + t.Cleanup(server.Close) + executable, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable() error = %v", err) + } + if resolved, resolveErr := filepath.EvalSymlinks(executable); resolveErr == nil { + executable = resolved + } + receiptPath, err := writeInstallReceipt(executable, "0.0.0", "test fixture") + if err != nil { + t.Fatalf("writeInstallReceipt() error = %v", err) + } + t.Cleanup(func() { _ = os.Remove(receiptPath) }) + + result := runCLI(t, []string{"upgrade", "--check", "--json"}, cliRunOptions{env: map[string]string{ + "AGORA_LOG_LEVEL": "error", + "GITHUB_API_URL": server.URL, + "XDG_CONFIG_HOME": t.TempDir(), + }}) + if result.exitCode != 0 { + t.Fatalf("upgrade --check exit = %d, stdout = %s, stderr = %s", result.exitCode, result.stdout, result.stderr) + } + for _, want := range []string{`"command":"upgrade"`, `"latestVersion":"99.0.0"`} { + if !strings.Contains(result.stdout, want) { + t.Errorf("upgrade check output does not contain %q: %s", want, result.stdout) + } + } +} diff --git a/internal/cli/upgrade_test.go b/internal/cli/upgrade_test.go index 70c41c4..635d377 100644 --- a/internal/cli/upgrade_test.go +++ b/internal/cli/upgrade_test.go @@ -1,6 +1,18 @@ package cli import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "runtime" "strings" "testing" @@ -57,3 +69,209 @@ func TestInstallerUpgradeCommandUsesDirectGitHubScript(t *testing.T) { t.Fatalf("installer command = %q, want raw GitHub shell installer", command) } } + +func TestResolveLatestVersionFromGitHubFixture(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/AgoraIO/cli/releases/latest" { + http.NotFound(w, r) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("Authorization header = %q", got) + } + _, _ = io.WriteString(w, `{"tag_name":"v1.2.3"}`) + })) + t.Cleanup(server.Close) + + version, err := resolveLatestVersion(map[string]string{ + "GITHUB_API_URL": server.URL, + "GITHUB_REPO": "AgoraIO/cli", + "GITHUB_TOKEN": "test-token", + }) + if err != nil { + t.Fatalf("resolveLatestVersion() error = %v", err) + } + if version != "1.2.3" { + t.Fatalf("resolveLatestVersion() = %q, want 1.2.3", version) + } +} + +func TestResolveLatestVersionFailures(t *testing.T) { + tests := []struct { + name string + status int + body string + }{ + {name: "HTTP error", status: http.StatusServiceUnavailable, body: "unavailable"}, + {name: "invalid JSON", status: http.StatusOK, body: "{"}, + {name: "missing tag", status: http.StatusOK, body: `{}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.status) + _, _ = io.WriteString(w, tt.body) + })) + t.Cleanup(server.Close) + + if _, err := resolveLatestVersion(map[string]string{"GITHUB_API_URL": server.URL}); err == nil { + t.Fatal("resolveLatestVersion() error = nil, want failure") + } + }) + } +} + +func TestDownloadFileAndChecksumHelpers(t *testing.T) { + payload := []byte("release archive") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("Authorization header = %q", got) + } + _, _ = w.Write(payload) + })) + t.Cleanup(server.Close) + + destination := filepath.Join(t.TempDir(), "archive.tar.gz") + if err := downloadFile(server.URL, destination, map[string]string{"GH_TOKEN": "test-token"}); err != nil { + t.Fatalf("downloadFile() error = %v", err) + } + downloaded, err := os.ReadFile(destination) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if !bytes.Equal(downloaded, payload) { + t.Fatalf("downloaded data = %q, want %q", downloaded, payload) + } + + digest := sha256.Sum256(payload) + wantSHA := hex.EncodeToString(digest[:]) + gotSHA, err := sha256OfFile(destination) + if err != nil { + t.Fatalf("sha256OfFile() error = %v", err) + } + if gotSHA != wantSHA { + t.Fatalf("sha256OfFile() = %q, want %q", gotSHA, wantSHA) + } + + checksums := filepath.Join(t.TempDir(), "checksums.txt") + contents := fmt.Sprintf("ignored other.zip\n%s *archive.tar.gz\n", wantSHA) + if err := os.WriteFile(checksums, []byte(contents), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + gotExpected, err := expectedChecksumFor(checksums, "archive.tar.gz") + if err != nil { + t.Fatalf("expectedChecksumFor() error = %v", err) + } + if gotExpected != wantSHA { + t.Fatalf("expectedChecksumFor() = %q, want %q", gotExpected, wantSHA) + } + missing, err := expectedChecksumFor(checksums, "missing.zip") + if err != nil || missing != "" { + t.Fatalf("expectedChecksumFor() missing archive = %q, %v; want empty result", missing, err) + } +} + +func TestDownloadFileRejectsHTTPFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "no release", http.StatusNotFound) + })) + t.Cleanup(server.Close) + + if err := downloadFile(server.URL, filepath.Join(t.TempDir(), "release"), nil); err == nil { + t.Fatal("downloadFile() error = nil, want failure") + } +} + +func TestExtractReleaseArchivesAndCopy(t *testing.T) { + const binaryName = "agora" + payload := []byte("native binary") + directory := t.TempDir() + + tarPath := filepath.Join(directory, "release.tar.gz") + writeTarGzFixture(t, tarPath, "nested/"+binaryName, payload) + tarOutput := filepath.Join(directory, "from-tar") + if err := extractFromTarGz(tarPath, binaryName, tarOutput); err != nil { + t.Fatalf("extractFromTarGz() error = %v", err) + } + assertFileContents(t, tarOutput, payload) + + zipPath := filepath.Join(directory, "release.zip") + writeZipFixture(t, zipPath, "nested/"+binaryName, payload) + zipOutput := filepath.Join(directory, "from-zip") + if err := extractFromZip(zipPath, binaryName, zipOutput); err != nil { + t.Fatalf("extractFromZip() error = %v", err) + } + assertFileContents(t, zipOutput, payload) + + copyOutput := filepath.Join(directory, "copy") + if err := copyFile(zipOutput, copyOutput); err != nil { + t.Fatalf("copyFile() error = %v", err) + } + assertFileContents(t, copyOutput, payload) + + if err := extractFromTarGz(tarPath, "missing", filepath.Join(directory, "missing-tar")); err == nil { + t.Fatal("extractFromTarGz() missing binary error = nil") + } + if err := extractFromZip(zipPath, "missing", filepath.Join(directory, "missing-zip")); err == nil { + t.Fatal("extractFromZip() missing binary error = nil") + } +} + +func writeTarGzFixture(t *testing.T, path, name string, payload []byte) { + t.Helper() + file, err := os.Create(path) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + gzipWriter := gzip.NewWriter(file) + tarWriter := tar.NewWriter(gzipWriter) + if err := tarWriter.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(payload))}); err != nil { + t.Fatalf("WriteHeader() error = %v", err) + } + if _, err := tarWriter.Write(payload); err != nil { + t.Fatalf("tar Write() error = %v", err) + } + if err := tarWriter.Close(); err != nil { + t.Fatalf("tar Close() error = %v", err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatalf("gzip Close() error = %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("file Close() error = %v", err) + } +} + +func writeZipFixture(t *testing.T, path, name string, payload []byte) { + t.Helper() + file, err := os.Create(path) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + zipWriter := zip.NewWriter(file) + entry, err := zipWriter.Create(name) + if err != nil { + t.Fatalf("zip Create() error = %v", err) + } + if _, err := entry.Write(payload); err != nil { + t.Fatalf("zip Write() error = %v", err) + } + if err := zipWriter.Close(); err != nil { + t.Fatalf("zip Close() error = %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("file Close() error = %v", err) + } +} + +func assertFileContents(t *testing.T, path string, want []byte) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", path, err) + } + if !bytes.Equal(got, want) { + t.Fatalf("ReadFile(%q) = %q, want %q", path, got, want) + } +} From 91cb8f717ff69f85003e1cd0c553fb266eb74935 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Fri, 21 Aug 2026 13:12:48 -0400 Subject: [PATCH 13/15] feat: align quickstart project and env setup --- CHANGELOG.md | 2 + README.md | 10 +- docs/automation.md | 2 + docs/commands.md | 1 + docs/env-local.md | 6 +- docs/error-codes.md | 2 + internal/cli/init.go | 2 +- internal/cli/integration_help_test.go | 2 +- internal/cli/integration_init_test.go | 39 ++++++ internal/cli/integration_project_test.go | 4 +- internal/cli/integration_quickstart_test.go | 83 +++++++++++-- internal/cli/mcp.go | 15 +-- internal/cli/mcp_test.go | 19 ++- internal/cli/quickstart.go | 130 +++++++++++++++++--- internal/cli/quickstart_test.go | 66 ++++++++-- internal/cli/skills.go | 4 +- 16 files changed, 334 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b773603..097de4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,10 +24,12 @@ Earlier entries pre-date this convention and only carry their version's compare - Pause npm installation guidance because the published `agoraio-cli` package may be stale; direct installers and verified release archives remain the supported installation paths ([68632f7](https://github.com/AgoraIO/cli/commit/68632f7)). - Expand installation guidance with the canonical Agora CDN, GitHub-hosted fallbacks, the S3 mirror option, and a PowerShell 7 guard with an actionable process-scoped execution-policy workaround ([03f46af](https://github.com/AgoraIO/cli/commit/03f46af), [#61](https://github.com/AgoraIO/cli/pull/61)). - Align the Python quickstart `nextSteps` install command with the upstream template (`bun run setup` instead of `bun install`) ([#60](https://github.com/AgoraIO/cli/pull/60)). +- Make clone-only quickstart creation explicit with `--template-only`; interactive runs prompt for an existing project when none resolves, while JSON, CI, and non-TTY runs fail before cloning with `QUICKSTART_PROJECT_REQUIRED`. ### Fixed - Bump the pinned Go toolchain to 1.26.6 to address reachable standard-library vulnerabilities reported by `govulncheck` in Go 1.26.5. +- Ensure `agora init` and quickstart env writes create `server/.env` with `AGORA_APP_ID` and `AGORA_APP_CERTIFICATE` for Python and Go, including older directory layouts that previously emitted unsupported unprefixed names; align with the upstream Python and Go env-file migrations ([Python #26](https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python/pull/26), [Go #8](https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go/pull/8)). ### Documentation diff --git a/README.md b/README.md index d666e9e..fe5faff 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,8 @@ Command examples use `agora` for the installed CLI. Local source builds use `./a | Goal | Command | What You Get | |------|---------|--------------| | Next.js video app | `agora init my-nextjs-demo --template nextjs` | A cloned Next.js quickstart, `.agora` binding, and `.env.local` | -| Python voice agent | `agora init my-python-demo --template python` | A Python quickstart with `server/.env.local` credentials | -| Go voice agent | `agora init my-go-demo --template go` | A Go quickstart with `server/.env.local` credentials | +| Python voice agent | `agora init my-python-demo --template python` | A Python quickstart with `server/.env` credentials | +| Go voice agent | `agora init my-go-demo --template go` | A Go quickstart with `server/.env` credentials | | Android voice AI app | `agora init my-android-demo --template android` | An Android client with credentials written only to the included Python server | Android follows the same project binding and env-writing flow as the web quickstarts. Its `nextSteps` additionally cover starting the Python server, opening a temporary HTTPS tunnel, writing that public URL to `local.properties`, and assembling the Android client. The App Certificate remains only in `server/.env.local`. @@ -166,7 +166,7 @@ The command model is intentionally layered: |------|---------| | New user, one shot | `agora init --template ` (reuses `Default Project` / interactive picker; or `--project` / `--new-project`) | | List available templates | `agora quickstart list` | -| Clone a starter only | `agora quickstart create ...` | +| Clone a starter only | `agora quickstart create ... --template-only` | | Re-sync / rebind env in a cloned quickstart | `agora quickstart env write [dir]` (optional `--project` to rebind) | | Write env to an arbitrary path / non-quickstart repo | `agora project env write ` | | Set global CLI project context | `agora project use ` | @@ -259,8 +259,8 @@ Prefer `agora quickstart env write` inside official quickstarts. Use `agora proj Quickstart template behavior: - Next.js quickstarts write `.env.local` with `NEXT_PUBLIC_AGORA_APP_ID` plus `NEXT_AGORA_APP_CERTIFICATE` -- Python quickstarts copy `server/.env.example` to `server/.env.local`, then use `AGORA_APP_ID` plus `AGORA_APP_CERTIFICATE` -- Go quickstarts copy `server/.env.example` to `server/.env.local`, then use `AGORA_APP_ID` plus `AGORA_APP_CERTIFICATE` +- Python quickstarts copy `server/.env.example` to `server/.env`, then use `AGORA_APP_ID` plus `AGORA_APP_CERTIFICATE` +- Go quickstarts copy `server/.env.example` to `server/.env`, then use `AGORA_APP_ID` plus `AGORA_APP_CERTIFICATE` - Existing Python and Go quickstarts keep their recorded env path and legacy `APP_ID` / `APP_CERTIFICATE` keys when reconfigured. `project env write` auto-detects Next.js workspaces (or accepts `--template nextjs|standard`) and writes `AGORA_APP_ID` / `AGORA_APP_CERTIFICATE` or the Next.js equivalents. diff --git a/docs/automation.md b/docs/automation.md index 531486b..0745c94 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -42,6 +42,7 @@ Use this guide for: - Use `--yes` (or `-y`) / `AGORA_NO_INPUT=1` to assume the default answer to confirmation prompts. Following industry convention for `-y` (apt-style), the flag never starts brand-new interactive flows: in JSON, CI, or non-TTY contexts the CLI still fails fast with the same `AUTH_UNAUTHENTICATED` error you would have seen without `--yes`, instead of silently launching an OAuth browser flow. - Interactive login prompts only appear in interactive pretty-mode TTY runs. Automation should authenticate up front with `agora login`; `--json`, `AGORA_OUTPUT=json`, detected CI environments, and non-TTY stdin all skip the prompt and fail with `AUTH_UNAUTHENTICATED`. - In non-interactive runs (`--yes`, JSON, CI, non-TTY), pass `--template` explicitly to `agora init`. The CLI now fails fast with `QUICKSTART_TEMPLATE_REQUIRED` instead of silently selecting a template. +- In non-interactive `quickstart create` runs, pass `--project ` (or establish global project context) to configure the scaffold, or pass `--template-only` to explicitly skip project and credential resolution. Otherwise the CLI fails before cloning with `QUICKSTART_PROJECT_REQUIRED`. - Output mode precedence is: explicit CLI flag (`--json` or `--output`) first, user-set `AGORA_OUTPUT` second, then user-customized config file value, then **CI auto-detect → JSON** (see below), then pretty. - Set `AGORA_AGENT=` in automated environments to explicitly label agent traffic in the API `User-Agent`. When unset, the CLI may infer a coarse label such as `cursor`, `claude-code`, `cline`, `windsurf`, `codex`, or `aider` from known agent environment markers. Set `AGORA_AGENT_DISABLE_INFER=1` to disable inference. - Use `agora mcp serve` to expose local Agora CLI tools to MCP-capable agents. The full surface is exposed: `agora.version`, `agora.introspect`, `agora.auth.{status,logout}`, `agora.config.{path,get}`, `agora.telemetry.status`, `agora.upgrade.check`, `agora.project.{list,show,use,create,doctor,env,env_write}`, `agora.project.feature.{list,status,enable}`, `agora.project.webhook.{events,list,show,create,update,delete}`, `agora.quickstart.{list,create,env_write}`, and `agora.init`. Authentication is intentionally **not** exposed via MCP because OAuth requires an interactive browser; run `agora login` once on the host first. @@ -614,6 +615,7 @@ Display-oriented fields: Automation notes: - `--ref ` pins the cloned quickstart source for workshops and reproducible demos. +- `--template-only` explicitly skips project lookup and env-file creation. Without a resolved project or this flag, non-interactive runs fail with `QUICKSTART_PROJECT_REQUIRED` before cloning. Example: diff --git a/docs/commands.md b/docs/commands.md index 8a49931..7f286e9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -333,6 +333,7 @@ Clone an official Agora quickstart into a new directory | `--project` | `string` | — | project ID or exact project name to use for env seeding | | `--ref` | `string` | — | git branch, tag, or ref to clone for pinned workshops | | `--template` | `string` | — | quickstart template ID from `agora quickstart list` | +| `--template-only` | `bool` | — | clone without resolving a project or writing credentials | ### `agora quickstart env` diff --git a/docs/env-local.md b/docs/env-local.md index d5e5500..6cacf61 100644 --- a/docs/env-local.md +++ b/docs/env-local.md @@ -15,7 +15,7 @@ writes the credential keys required by that runtime. | Command | Behavior | |---------|----------| | `agora init --template ` | Clones the Quickstart, selects or creates a project, and writes its env file. | -| `agora quickstart create ...` | Writes the env file when a project is resolved; without a project, it clones the template only. | +| `agora quickstart create ...` | Writes the env file when a project is resolved; use `--template-only` to explicitly clone without credentials. Interactive runs prompt when no project resolves. | | `agora quickstart env write [dir]` | Creates or updates the runtime-specific env file in an existing Quickstart. | | `agora project env write [path]` | Creates or updates a dotenv file at the selected path without cloning a Quickstart. | @@ -24,8 +24,8 @@ Quickstart env layouts: | Quickstart | Example source | Target path | Credential keys | |------------|----------------|-------------|-----------------| | Next.js | `env.local.example` | `.env.local` | `NEXT_PUBLIC_AGORA_APP_ID`, `NEXT_AGORA_APP_CERTIFICATE` | -| Python | `server/.env.example` | `server/.env.local` | `AGORA_APP_ID`, `AGORA_APP_CERTIFICATE` | -| Go | `server/.env.example` | `server/.env.local` | `AGORA_APP_ID`, `AGORA_APP_CERTIFICATE` | +| Python | `server/.env.example` | `server/.env` | `AGORA_APP_ID`, `AGORA_APP_CERTIFICATE` | +| Go | `server/.env.example` | `server/.env` | `AGORA_APP_ID`, `AGORA_APP_CERTIFICATE` | If the target env file already exists, the CLI uses it as the starting content and updates the Agora credential keys while preserving unrelated entries. If diff --git a/docs/error-codes.md b/docs/error-codes.md index c622205..d176137 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -39,6 +39,8 @@ This catalog is the source of truth for stable codes. CI runs `make snapshot-err | `QUICKSTART_TEMPLATE_UNKNOWN` | 1 | The template ID is not known to this CLI. | Run `agora quickstart list`. | | `QUICKSTART_TEMPLATE_UNAVAILABLE` | 1 | The template exists but is not currently available. | Choose an available template. | | `QUICKSTART_TEMPLATE_ENV_UNSUPPORTED` | 1 | The selected template does not define an env target path. | Choose a template with env support or configure the env file manually. | +| `QUICKSTART_PROJECT_REQUIRED` | 1 | `quickstart create` could not resolve a project in a non-interactive run, or the account has no projects to select interactively. | Pass `--project`, set global context with `agora project use`, use `agora init`, or explicitly pass `--template-only`. | +| `QUICKSTART_CREATE_ABORTED` | 1 | The interactive quickstart project picker was canceled. | Re-run and select a project, or pass `--template-only`. | | `QUICKSTART_TARGET_EXISTS` | 1 | The clone target already exists. | Choose a new directory. | | `QUICKSTART_REF_INVALID` | 1 | `--ref` is empty after trimming, starts with `-`, or contains whitespace/control characters. | Pass a valid git branch, tag, or commit (no leading `-`). | | `QUICKSTART_REPO_OVERRIDE_INVALID` | 1 | The `AGORA_QUICKSTART_