From c9f5f63593bc66144878d91008e519c0c2c58228 Mon Sep 17 00:00:00 2001 From: zhaoren Date: Mon, 7 Sep 2026 14:14:03 +0800 Subject: [PATCH 01/10] feat(quickstart): add RTC video-call onboarding Add the RTC-only video-call scenario across project presets, init, quickstart, MCP, completion, deep doctor, and automation docs. Resolve pinned pnpm setup steps with an npx fallback after cloning. --- README.md | 9 +- docs/automation.md | 51 +- docs/commands.md | 11 +- docs/error-codes.md | 7 +- docs/llms.txt | 2 + .../plans/2026-09-02-rtc-quickstart-cli.md | 95 ++++ internal/cli/commands.go | 8 +- internal/cli/completion.go | 32 +- internal/cli/completion_test.go | 34 ++ internal/cli/doctor.go | 64 +-- internal/cli/init.go | 53 ++- internal/cli/integration_init_test.go | 59 +++ internal/cli/integration_project_test.go | 37 ++ internal/cli/integration_quickstart_test.go | 133 ++++++ internal/cli/introspect.go | 11 +- internal/cli/local_project.go | 1 + internal/cli/mcp.go | 36 +- internal/cli/mcp_test.go | 23 + internal/cli/projects.go | 99 +++- internal/cli/quickstart.go | 434 ++++++++++++++---- internal/cli/quickstart_setup.go | 117 +++++ internal/cli/quickstart_setup_test.go | 149 ++++++ internal/cli/quickstart_test.go | 125 ++++- internal/cli/recipes.go | 2 +- internal/cli/render.go | 33 +- internal/cli/render_test.go | 11 + internal/cli/skills.go | 30 +- .../cli/testdata/golden/introspect-enums.json | 14 + 28 files changed, 1480 insertions(+), 200 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-02-rtc-quickstart-cli.md create mode 100644 internal/cli/completion_test.go create mode 100644 internal/cli/quickstart_setup.go create mode 100644 internal/cli/quickstart_setup_test.go diff --git a/README.md b/README.md index 922f656..abf5df6 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,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` | +| Next.js voice agent | `agora init my-nextjs-demo --template nextjs` | A cloned conversational AI quickstart, `.agora` binding, and `.env.local` | +| RTC video call | `agora init my-video-demo --template nextjs --scenario video-call` | An RTC-only Next.js quickstart, project binding, and `.env.local` | | 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 | @@ -274,6 +275,9 @@ Quickstart template behavior: - 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 when reconfigured, while legacy `APP_ID` / `APP_CERTIFICATE` assignments are commented out and replaced with `AGORA_APP_ID` / `AGORA_APP_CERTIFICATE`. +- The RTC Next.js quickstart reads its pinned pnpm version after clone. Matching + pnpm produces native setup steps; otherwise the CLI uses a version-pinned npx + fallback when available. The CLI never installs dependencies or global tools. `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. @@ -306,6 +310,7 @@ It stores durable non-secret metadata: - `projectName` - `region` - `template` +- `scenario` (when the binding identifies a quickstart scenario) - `projectType` (framework hint used for env layout when present) - `envPath` @@ -359,7 +364,7 @@ Example: ```bash export AGORA_HOME="$(mktemp -d)" -agora init my-nextjs-demo --template nextjs --json +agora init my-nextjs-demo --template nextjs --scenario video-call --json agora quickstart create my-python-demo --template python --project my-project --json agora quickstart env write my-python-demo --json agora project doctor --json diff --git a/docs/automation.md b/docs/automation.md index 59aca96..ae8e64c 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -345,6 +345,7 @@ Example: ./agora init my-nextjs-demo --template nextjs --json ./agora init my-nextjs-demo --template nextjs --new-project --json ./agora init my-agent --recipe tool-calling --new-project --json +./agora init my-video-demo --template nextjs --scenario video-call --new-project --json ``` By default `init` reuses an existing project — preferring one named exactly `"Default Project"`. If no default exists, interactive sessions show existing projects with a create-new option and default to the most recently created project; JSON, CI, and non-TTY runs select the most recent project automatically. Pass `--new-project` to force creation. Use `--project ` to bind to a specific project. @@ -375,20 +376,26 @@ Required `data` fields: - `metadataPath` Repo-local project binding file path, currently `.agora/project.json`. - `enabledFeatures` - Array of features enabled during this run. Defaults to `rtc`, `rtm`, and `convoai` for newly created projects unless overridden with `--feature`. Empty for existing projects since the CLI did not create them in this run. + Array of features enabled during this run. A new project starts from the selected scenario's required features and merges explicit `--feature` values; `nextjs + video-call` enables only `rtc` unless more features are requested. Empty for existing projects since the CLI did not create them in this run. - `nextSteps` - Ordered list of suggested follow-up commands for the selected source. + Ordered list of suggested follow-up commands for the selected source. For the RTC Next.js quickstart, these use matching pnpm or a version-pinned npx fallback detected after clone. - `status` Currently `ready`. Optional fields: - `template` Present for built-in quickstart initialization. +- `scenario`, `requiredFeatures` + Present for built-in quickstart initialization; describe the selected scenario and its required features. - `recipe`, `recipeUrl`, `recipeRawUrl`, `primaryPrompt`, `cloneUrl` Present for recipe-backed initialization. The CLI resolves this metadata from the official recipes API before it selects or creates a project. - `rtmDataCenter` RTM data center configured on the new project when RTM was enabled. Defaults to `NA` when `--rtm-data-center` is omitted. +- `packageManager` + Present when the selected quickstart exposes a supported pinned package manager. + Fields are `name`, `requiredVersion`, optional `detectedVersion`, `strategy` + (`native`, `npx`, or `unavailable`), `ready`, and optional `message`. Display-oriented fields: - `title` @@ -396,6 +403,13 @@ Display-oriented fields: Safe branch fields: - `sourceType` - `sourceId` +- `template` +- `scenario` +- `requiredFeatures` +- `packageManager.name` +- `packageManager.requiredVersion` +- `packageManager.strategy` +- `packageManager.ready` - `projectAction` - `projectId` - `path` @@ -653,6 +667,9 @@ Required `data` fields: Each item currently includes: - `id` +- `template` +- `scenario` +- `requiredFeatures` - `title` - `description` - `runtime` @@ -661,9 +678,14 @@ Each item currently includes: - `available` - `envDocs` - `supportsInit` +- `installCommand` +- `runCommand` Safe branch fields: - `items[].id` +- `items[].template` +- `items[].scenario` +- `items[].requiredFeatures` - `items[].runtime` - `items[].repoUrl` - `items[].available` @@ -680,6 +702,14 @@ 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. +- `--scenario ` selects an exact scenario; when omitted, the template's default scenario is used. +- `nextjs + video-call` requires only `rtc` and honors `AGORA_QUICKSTART_NEXTJS_VIDEO_CALL_REPO_URL` for local mirrors and fixtures. +- Non-default scenarios such as `nextjs + video-call` must provide `agora.quickstart.json` with matching `template` and `scenario`. The CLI validates it after clone and removes the target before writing env or binding data when validation fails. Existing default-scenario quickstarts remain compatible without a manifest. +- After cloning `nextjs + video-call`, the CLI reads `package.json#packageManager`. + An exact pnpm match produces `pnpm install --frozen-lockfile` and `pnpm dev`. + Missing or mismatched pnpm produces pinned npx steps when npx is available. + If neither is available, `packageManager.ready` is false and no unusable + install or run command is included in `nextSteps`. Example: @@ -691,6 +721,8 @@ Required `data` fields: - `action` Always `create`. - `template` +- `scenario` +- `requiredFeatures` - `title` - `runtime` - `cloneUrl` @@ -711,9 +743,20 @@ Required `data` fields: Optional fields: - `projectId` - `projectName` +- `packageManager` + Present for RTC Next.js when `package.json#packageManager` is a strict + `pnpm@..` value. Its fields are `name`, + `requiredVersion`, optional `detectedVersion`, `strategy`, `ready`, and an + optional diagnostic `message`. Safe branch fields: - `template` +- `scenario` +- `requiredFeatures` +- `packageManager.name` +- `packageManager.requiredVersion` +- `packageManager.strategy` +- `packageManager.ready` - `path` - `envStatus` - `envPath` @@ -737,6 +780,8 @@ Required `data` fields: - `action` Always `env-write`. - `template` +- `scenario` +- `requiredFeatures` - `title` - `path` Absolute path to the quickstart root. @@ -760,6 +805,8 @@ Env write behavior: Safe branch fields: - `template` +- `scenario` +- `requiredFeatures` - `path` - `envPath` - `projectId` diff --git a/docs/commands.md b/docs/commands.md index 90d0cbc..1d7ff39 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -113,6 +113,7 @@ Create a project and initialize an official quickstart or recipe | `--project` | `string` | — | existing project ID or exact project name to bind to | | `--recipe` | `string` | — | official Agora recipe slug (run agora recipes list to discover slugs) | | `--rtm-data-center` | `string` | — | RTM data center to configure when rtm is enabled on a newly created project (CN, NA, EU, or AP); defaults to NA | +| `--scenario` | `string` | — | quickstart scenario; omitted selects the template default | | `--template` | `string` | — | quickstart template ID to use | ### `agora introspect` @@ -174,7 +175,7 @@ Create a new remote Agora project | `--feature` | `stringArray` | `[]` | enable one or more features after creation; defaults to rtc, rtm, convoai; convoai also enables rtm | | `--idempotency-key` | `string` | — | caller-provided key for safe retries when supported by the API | | `--rtm-data-center` | `string` | — | RTM data center to configure when rtm is enabled (CN, NA, EU, or AP); defaults to NA | -| `--template` | `string` | — | apply a higher-level project preset such as voice-agent | +| `--template` | `string` | — | apply a project scenario preset: video-call, voice-agent | ### `agora project doctor` @@ -333,6 +334,7 @@ Clone an official Agora quickstart into a new directory | `--dir` | `string` | — | target directory for the cloned quickstart; defaults to | | `--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 | +| `--scenario` | `string` | — | quickstart scenario; omitted selects the template default | | `--template` | `string` | — | quickstart template ID from `agora quickstart list` | | `--template-only` | `bool` | — | clone without resolving a project or writing credentials | @@ -349,6 +351,7 @@ Write the quickstart env file for the current or selected project | Flag | Type | Default | Description | |------|------|---------|-------------| | `--project` | `string` | — | project ID or exact project name to use for env seeding | +| `--scenario` | `string` | — | quickstart scenario; if omitted, use binding, manifest, or the template default | | `--template` | `string` | — | quickstart template ID; if omitted, the CLI detects it from the repo layout | ### `agora quickstart list` @@ -462,3 +465,9 @@ Show the current auth status **`doctorStatus`**: `healthy`, `warning`, `not_ready`, `auth_error` **`recipeTypes`**: `all`, `ai`, `rtc` + +**`projectTemplates`**: `video-call`, `voice-agent` + +**`quickstartTemplates`**: `nextjs`, `python`, `go` + +**`quickstartScenarios`**: `voice-agent`, `video-call` diff --git a/docs/error-codes.md b/docs/error-codes.md index 3b31465..422249b 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -30,12 +30,18 @@ This catalog is the source of truth for stable codes. CI runs `make snapshot-err | `PROJECT_NO_CERTIFICATE` | 1 | The selected project has no app certificate for env seeding. | Enable an app certificate in Console or select another project. | | `PROJECT_ENV_TEMPLATE_UNKNOWN` | 1 | The `--template` value for `project env write` is not supported. | Use `nextjs` or `standard`. | | `PROJECT_NOT_READY` | 1 | `project doctor` could not surface a more specific issue. | Re-run `project doctor` for details. | +| `PROJECT_TEMPLATE_UNKNOWN` | 1 | `project create --template` is not a known project scenario preset. | Use `video-call` or `voice-agent`; inspect `agora introspect --json` for the current catalog. | ### Quickstart / init | Code | Exit | Meaning | Recovery | |------|------|---------|----------| | `QUICKSTART_TEMPLATE_UNKNOWN` | 1 | The template ID is not known to this CLI. | Run `agora quickstart list`. | +| `QUICKSTART_SCENARIO_UNKNOWN` | 1 | The scenario is not known to this CLI. | Run `agora quickstart list` and use an `items[].scenario` value. | +| `QUICKSTART_SCENARIO_UNSUPPORTED` | 1 | The scenario exists but is not supported by the selected template. | Choose a template/scenario pair returned by `agora quickstart list`. | +| `QUICKSTART_SELECTION_MISMATCH` | 1 | Explicit flags, `.agora/project.json`, and `agora.quickstart.json` disagree about template/scenario identity. | Correct the conflicting selection; do not overwrite env until all sources agree. | +| `QUICKSTART_MANIFEST_INVALID` | 1 | `agora.quickstart.json` is malformed, uses an unsupported schema, has missing identity fields, or is required but absent. | Restore a schema v1 manifest with `template` and `scenario` matching the quickstart. | +| `QUICKSTART_REQUIRED_FEATURE_MISSING` | 1 | An existing project does not provide every feature required by the selected quickstart. | Run the `agora project feature enable ...` command in the error, then retry. | | `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`. | @@ -85,7 +91,6 @@ These codes appear inside `data.checks[].issues[].code` and (for blocking issues | `WORKSPACE_ENV_FILE_MISSING` | 1 | A quickstart env file expected by the bound template is missing. | Run the command from `suggestedCommand` (typically `agora quickstart env write`). | | `WORKSPACE_ENV_READ_FAILED` | 1 | The CLI could not read the quickstart env file. | Run the command from `suggestedCommand` (`agora quickstart env write . --project `); if it still fails, inspect file permissions and contents. | | `WORKSPACE_ENV_PROJECT_MISMATCH` | 1 | The quickstart env file points at a different App ID than the selected project. | Run the command from `suggestedCommand` to overwrite the env file. | -| `WORKSPACE_ENV_METADATA_MISSING` | 1 | The quickstart env file is missing Agora-managed project metadata comments. | Run the command from `suggestedCommand` to refresh metadata. | | `WORKSPACE_ENV_APP_ID_MISSING` | 1 | A quickstart env file is missing the required app ID key. | Run the command from `suggestedCommand`. | | `WORKSPACE_ENV_APP_ID_MISMATCH` | 1 | A quickstart env file points at a different app ID. | Run the command from `suggestedCommand`. | | `APP_CREDENTIALS_MISSING` | 1 | The selected project has no app ID / app certificate yet. | Run the command from `suggestedCommand` (`agora project show --project `) to re-fetch credentials; if still missing, enable the app certificate in Console (`agora open --target console`). | diff --git a/docs/llms.txt b/docs/llms.txt index 1f1083a..0551621 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -11,6 +11,8 @@ Login: agora login Initialize project: agora init my-demo --template nextjs --new-project Initialize official recipe: agora init my-agent --recipe tool-calling --new-project Check project health: agora project doctor --json +Create a project for video calling: agora project create demo --template video-call --json +Create the Next.js video-call quickstart: agora quickstart create demo --template nextjs --scenario video-call --json ## Documentation Index diff --git a/docs/superpowers/plans/2026-09-02-rtc-quickstart-cli.md b/docs/superpowers/plans/2026-09-02-rtc-quickstart-cli.md new file mode 100644 index 0000000..83fbbef --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-rtc-quickstart-cli.md @@ -0,0 +1,95 @@ +# RTC Quickstart CLI Implementation Plan + +> **For agentic workers:** Implement this plan task-by-task with a red-green-refactor loop. This task is local-only: do not commit, push, or mutate GitHub state. + +**Goal:** Add the `nextjs + video-call` quickstart scenario and video-call project preset described by `rtc-qs-design/03-cli-design.md`, with matching CLI, JSON, MCP, completion, and doctor behavior. + +**Architecture:** Replace the one-entry-per-template quickstart model with a catalog keyed by stable ID plus `template + scenario`. A shared selector resolves explicit flags, local binding, repository manifest, and legacy detection; all command surfaces consume that selector and the shared stable feature merger. + +**Tech Stack:** Go 1.26.5, Cobra, standard-library JSON/filesystem/process APIs, existing fake BFF and local Git integration fixtures. + +--- + +### Task 1: Project presets and stable feature calculation + +**Files:** +- Modify: `internal/cli/projects.go` +- Modify: `internal/cli/commands.go` +- Test: `internal/cli/features_test.go` +- Test: `internal/cli/integration_project_test.go` + +- [ ] Add a failing test for `project create --template video-call --dry-run --json` returning `template: video-call` and `enabledFeatures: [rtc]`. +- [ ] Add a failing test proving an unknown preset returns `PROJECT_TEMPLATE_UNKNOWN` before any fake BFF create request. +- [ ] Implement the project preset catalog and one stable feature merger that expands dependencies, deduplicates, and orders by `featureCatalog`. +- [ ] Route dry-run and real project creation through the same preset parser. +- [ ] Run focused project tests and keep old no-template behavior unchanged. + +### Task 2: Scenario catalog, manifest, and local binding + +**Files:** +- Modify: `internal/cli/quickstart.go` +- Modify: `internal/cli/local_project.go` +- Test: `internal/cli/quickstart_test.go` +- Test: `internal/cli/project_env_layout_test.go` + +- [ ] Add a failing selector test for `nextjs + video-call`, default `nextjs + voice-agent`, and unsupported combinations. +- [ ] Add failing manifest tests for valid schema v1, malformed JSON, missing fields, unsupported schema, and selection conflicts. +- [ ] Add `scenario` to local bindings while preserving legacy bindings without it. +- [ ] Implement the `nextjs-video-call` catalog entry, manifest parser, selector precedence, mismatch errors, and scenario-specific repo override key. +- [ ] Keep manifest identity aligned with the CLI parameter model: schema v1 requires explicit `template` and `scenario`; existing default-scenario quickstarts remain valid without a manifest. +- [ ] Run focused quickstart and binding tests. + +### Task 3: Quickstart and init command paths + +**Files:** +- Modify: `internal/cli/quickstart.go` +- Modify: `internal/cli/init.go` +- Modify: `internal/cli/render.go` +- Test: `internal/cli/integration_quickstart_test.go` +- Test: `internal/cli/integration_init_test.go` + +- [ ] Add a failing integration test for list/create/env-write JSON fields and RTC env layout. +- [ ] Add failing clone tests proving a missing or mismatched video-call manifest fails before env/binding writes, removes the cloned target, and does not affect default quickstarts without manifests. +- [ ] Implement `--scenario` on `quickstart create`, `quickstart env write`, and `init`. +- [ ] Validate required manifests immediately after clone and before stripping Git metadata or writing credentials; preserve the structured manifest error while reporting clone cleanup. +- [ ] Include `template`, `scenario`, and `requiredFeatures` in result payloads and bindings. +- [ ] Validate an existing project's required features before clone; return `QUICKSTART_REQUIRED_FEATURE_MISSING` with a remediation command. +- [ ] Use scenario requirements as new-project defaults, merged with explicit `--feature` values. +- [ ] Run focused quickstart and init tests, including legacy Next.js/Python/Go cases. + +### Task 4: Doctor, MCP, completion, and introspection + +**Files:** +- Modify: `internal/cli/doctor.go` +- Modify: `internal/cli/mcp.go` +- Modify: `internal/cli/completion.go` +- Modify: `internal/cli/introspect.go` +- Modify: `internal/cli/skills.go` +- Test: `internal/cli/mcp_test.go` +- Test: `internal/cli/integration_help_test.go` +- Test: `internal/cli/integration_project_test.go` +- Test: `internal/cli/quickstart_test.go` + +- [ ] Add failing tests for MCP scenario schemas/dispatch and completion values. +- [ ] Add failing deep-doctor tests for binding/manifest/catalog/env consistency. +- [ ] Route MCP tools through the same command selectors and expose scenario in schemas/results. +- [ ] Complete project preset and scenario values from their catalogs; filter scenario completion by template when available. +- [ ] Update `create-nextjs-video-app` to run `init --template nextjs --scenario video-call --new-project --json`, use `pnpm install && pnpm dev`, and recommend `project doctor --feature rtc --deep --json`. +- [ ] Add a catalog test that locks the built-in RTC skill to the video-call scenario and the Quickstart's package-manager/runtime commands. +- [ ] Ensure introspection exposes every new flag and enum source. +- [ ] Run focused doctor, MCP, completion, and introspection tests. + +### Task 5: Documentation and complete local verification + +**Files:** +- Modify: `docs/commands.md` +- Modify: `docs/automation.md` +- Modify: `docs/llms.txt` + +- [ ] Regenerate command documentation from the live Cobra tree. +- [ ] Document stable JSON/MCP fields, project presets, scenarios, errors, manifest, and repo override. +- [ ] Run `gofmt` and `go test ./...`. +- [ ] Build `./agora` and inspect `--help --all`, `introspect --json`, and MCP tool schemas. +- [ ] Clone `https://github.com/littleDogWang/agora-rtc-nextjs-quickstart` into a temporary local fixture and verify its manifest/env layout. +- [ ] Run all new commands locally using isolated config and fake/local endpoints where remote control-plane state would otherwise be required. +- [ ] Report observed command results separately from runtime/media behavior, which remains outside CLI scope. diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 25a1e09..f7879d2 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -610,7 +610,10 @@ func (a *App) buildProjectCreate() *cobra.Command { return err } if dryRun { - plannedFeatures := projectCreateFeatures(template, features) + plannedFeatures, err := resolveProjectCreateFeatures(template, features) + if err != nil { + return err + } plannedRTMDataCenter, err := rtmDataCenterForFeatures(plannedFeatures, normalizedRTMDataCenter) if err != nil { return err @@ -638,10 +641,11 @@ func (a *App) buildProjectCreate() *cobra.Command { }, } cmd.Flags().StringVar(&rtmDataCenter, "rtm-data-center", "", "RTM data center to configure when rtm is enabled (CN, NA, EU, or AP); defaults to NA") - cmd.Flags().StringVar(&template, "template", "", "apply a higher-level project preset such as voice-agent") + cmd.Flags().StringVar(&template, "template", "", "apply a project scenario preset: "+strings.Join(projectPresetIDs(), ", ")) cmd.Flags().StringArrayVar(&features, "feature", nil, fmt.Sprintf("enable one or more features after creation; defaults to %s; convoai also enables rtm", featureListString())) cmd.Flags().BoolVar(&dryRun, "dry-run", false, "return the planned project create result without creating remote resources") cmd.Flags().StringVar(&idempotencyKey, "idempotency-key", "", "caller-provided key for safe retries when supported by the API") + _ = cmd.RegisterFlagCompletionFunc("template", completeProjectPresetIDs) return cmd } diff --git a/internal/cli/completion.go b/internal/cli/completion.go index d6b6dc8..4bd38b9 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -79,12 +79,40 @@ func filterProjectCompletions(items []projectSummary, toComplete string) []strin func completeQuickstartTemplateIDs(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { results := []string{} + seen := map[string]bool{} for _, template := range quickstartTemplates() { if !template.Available { continue } - if strings.HasPrefix(strings.ToLower(template.ID), strings.ToLower(toComplete)) { - results = append(results, fmt.Sprintf("%s\t%s", template.ID, template.Title)) + if !seen[template.Template] && strings.HasPrefix(strings.ToLower(template.Template), strings.ToLower(toComplete)) { + results = append(results, template.Template) + seen[template.Template] = true + } + } + return results, cobra.ShellCompDirectiveNoFileComp +} + +func completeQuickstartScenarios(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + templateID, _ := cmd.Flags().GetString("template") + results := []string{} + seen := map[string]bool{} + for _, definition := range quickstartTemplates() { + if !definition.Available || (templateID != "" && definition.Template != templateID) || seen[definition.Scenario] { + continue + } + if strings.HasPrefix(strings.ToLower(definition.Scenario), strings.ToLower(toComplete)) { + results = append(results, fmt.Sprintf("%s\t%s", definition.Scenario, definition.Title)) + seen[definition.Scenario] = true + } + } + return results, cobra.ShellCompDirectiveNoFileComp +} + +func completeProjectPresetIDs(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + results := []string{} + for _, preset := range projectPresetIDs() { + if strings.HasPrefix(strings.ToLower(preset), strings.ToLower(toComplete)) { + results = append(results, preset) } } return results, cobra.ShellCompDirectiveNoFileComp diff --git a/internal/cli/completion_test.go b/internal/cli/completion_test.go new file mode 100644 index 0000000..582b28c --- /dev/null +++ b/internal/cli/completion_test.go @@ -0,0 +1,34 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestQuickstartScenarioCompletionFiltersByTemplate(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("template", "nextjs", "") + items, _ := completeQuickstartScenarios(cmd, nil, "") + joined := strings.Join(items, "\n") + if !strings.Contains(joined, "voice-agent") || !strings.Contains(joined, "video-call") { + t.Fatalf("nextjs scenarios = %v", items) + } + + if err := cmd.Flags().Set("template", "go"); err != nil { + t.Fatal(err) + } + items, _ = completeQuickstartScenarios(cmd, nil, "") + joined = strings.Join(items, "\n") + if !strings.Contains(joined, "voice-agent") || strings.Contains(joined, "video-call") { + t.Fatalf("go scenarios = %v", items) + } +} + +func TestProjectPresetCompletionUsesCatalog(t *testing.T) { + items, _ := completeProjectPresetIDs(nil, nil, "") + if strings.Join(items, ",") != "video-call,voice-agent" { + t.Fatalf("project preset completions = %v", items) + } +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 6a86100..860b362 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -167,40 +167,53 @@ func buildWorkspaceDoctorDetails(target projectTarget) (doctorCheckCategory, map items = append(items, doctorCheckItem{Name: "metadata_project_match", Message: "Repo binding matches the selected project", Status: "pass"}) } - templateID := strings.TrimSpace(binding.Template) - if templateID == "" { - if template, detectErr := resolveQuickstartTemplateForPath(root, ""); detectErr == nil { - templateID = template.ID + template, selectionErr := resolveQuickstartTemplateForPath(root, binding.Template, binding.Scenario) + if selectionErr != nil { + code := "WORKSPACE_TEMPLATE_UNKNOWN" + if structured, ok := selectionErr.(*cliError); ok && structured.Code != "" { + code = structured.Code } - } - if templateID == "" { - items = append(items, doctorCheckItem{Name: "workspace_template", Message: "Could not detect quickstart template for this repo", Status: "warn"}) - warnings = append(warnings, doctorIssue{Code: "WORKSPACE_TEMPLATE_UNKNOWN", Message: "Could not detect quickstart template for this repo"}) + items = append(items, doctorCheckItem{Name: "workspace_selection", Message: selectionErr.Error(), Status: "fail"}) + blocking = append(blocking, doctorIssue{Code: code, Message: selectionErr.Error()}) check := doctorCheckCategory{Category: "workspace", Items: items} check.Status = summarizeCategoryStatus(items) return check, workspace, blocking, warnings } - workspace["template"] = templateID - items = append(items, doctorCheckItem{Name: "workspace_template", Message: "Detected template: " + templateID, Status: "pass"}) + if !template.DefaultScenario { + if manifestErr := validateRequiredQuickstartManifest(root, template); manifestErr != nil { + code := "QUICKSTART_MANIFEST_INVALID" + if structured, ok := manifestErr.(*cliError); ok && structured.Code != "" { + code = structured.Code + } + message := manifestErr.Error() + items = append(items, doctorCheckItem{Name: "workspace_manifest", Message: message, Status: "fail"}) + blocking = append(blocking, doctorIssue{Code: code, Message: message}) + check := doctorCheckCategory{Category: "workspace", Items: items} + check.Status = summarizeCategoryStatus(items) + return check, workspace, blocking, warnings + } + items = append(items, doctorCheckItem{Name: "workspace_manifest", Message: "Quickstart manifest matches the selected scenario", Status: "pass"}) + } + workspace["template"] = template.Template + workspace["scenario"] = template.Scenario + workspace["requiredFeatures"] = append([]string{}, template.RequiredFeatures...) + items = append(items, doctorCheckItem{Name: "workspace_selection", Message: "Detected quickstart: " + template.Template + " + " + template.Scenario, Status: "pass"}) - template, found := findQuickstartTemplate(templateID) envRel := strings.TrimSpace(binding.EnvPath) layout := quickstartEnvLayout{} - if found { - if envRel != "" { - layout, _ = quickstartEnvLayoutForEnvPath(*template, envRel) - } - if layout.EnvTargetPath == "" { - if detected, ok := quickstartEnvLayoutForPath(root, *template); ok { - layout = detected - } else if fallback, ok := template.defaultEnvLayout(); ok { - layout = fallback - } - } - if envRel == "" { - envRel = layout.EnvTargetPath + if envRel != "" { + layout, _ = quickstartEnvLayoutForEnvPath(template, envRel) + } + if layout.EnvTargetPath == "" { + if detected, ok := quickstartEnvLayoutForPath(root, template); ok { + layout = detected + } else if fallback, ok := template.defaultEnvLayout(); ok { + layout = fallback } } + if envRel == "" { + envRel = layout.EnvTargetPath + } if envRel == "" { items = append(items, doctorCheckItem{Name: "workspace_env_path", Message: "Could not determine quickstart env target path", Status: "warn"}) warnings = append(warnings, doctorIssue{Code: "WORKSPACE_ENV_PATH_UNKNOWN", Message: "Could not determine quickstart env target path"}) @@ -268,8 +281,7 @@ func buildWorkspaceDoctorDetails(target projectTarget) (doctorCheckCategory, map items = append(items, doctorCheckItem{Name: "workspace_env_project_match", Message: "Env metadata matches the selected project", Status: "pass"}) } } else { - items = append(items, doctorCheckItem{Name: "workspace_env_project_match", Message: "Env metadata is missing project comments from Agora-managed block", Status: "warn"}) - warnings = append(warnings, doctorIssue{Code: "WORKSPACE_ENV_METADATA_MISSING", Message: "Quickstart env file is missing Agora-managed project metadata comments"}) + items = append(items, doctorCheckItem{Name: "workspace_env_project_match", Message: "Project identity is recorded in .agora/project.json; env metadata comments are optional", Status: "pass"}) } appIDKey := layout.AppIDKey diff --git a/internal/cli/init.go b/internal/cli/init.go index e734f23..3dd1026 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -39,6 +39,7 @@ func initNextSteps(template quickstartTemplate, targetDir string) []string { func (a *App) buildInitCommand() *cobra.Command { var templateID string var recipeID string + var scenario string var dir string var existingProject string var rtmDataCenter string @@ -78,6 +79,9 @@ Use --feature to specify which features to enable on a newly created project (re } templateID = selected } + if recipeID != "" && scenario != "" { + return &cliError{Message: "--scenario is only valid with a quickstart template.", Code: "INIT_SOURCE_CONFLICT"} + } targetDir := dir if strings.TrimSpace(targetDir) == "" { targetDir = args[0] @@ -101,11 +105,11 @@ Use --feature to specify which features to enable on a newly created project (re } result, err = a.initRecipeProject(args[0], targetDir, recipe, existingProject, features, rtmDataCenter, newProject, promptForReuse, cmd.ErrOrStderr(), os.Stdin, progress) } else { - template, ok := findQuickstartTemplate(templateID) - if !ok { - return &cliError{Message: fmt.Sprintf("unknown quickstart template %q. Run `agora quickstart list` to see available templates.", templateID), Code: "QUICKSTART_TEMPLATE_UNKNOWN"} + template, selectErr := selectQuickstartDefinition(templateID, scenario) + if selectErr != nil { + return selectErr } - result, err = a.initProject(args[0], targetDir, *template, existingProject, features, rtmDataCenter, newProject, promptForReuse, cmd.ErrOrStderr(), os.Stdin, progress) + result, err = a.initProject(args[0], targetDir, template, existingProject, features, rtmDataCenter, newProject, promptForReuse, cmd.ErrOrStderr(), os.Stdin, progress) } if err != nil { return err @@ -122,12 +126,15 @@ Use --feature to specify which features to enable on a newly created project (re } cmd.Flags().StringVar(&templateID, "template", "", "quickstart template ID to use") cmd.Flags().StringVar(&recipeID, "recipe", "", "official Agora recipe slug (run agora recipes list to discover slugs)") + cmd.Flags().StringVar(&scenario, "scenario", "", "quickstart scenario; omitted selects the template default") cmd.Flags().StringVar(&dir, "dir", "", "target directory for the cloned quickstart; defaults to ") cmd.Flags().StringVar(&existingProject, "project", "", "existing project ID or exact project name to bind to") cmd.Flags().StringVar(&rtmDataCenter, "rtm-data-center", "", "RTM data center to configure when rtm is enabled on a newly created project (CN, NA, EU, or AP); defaults to NA") cmd.Flags().StringArrayVar(&features, "feature", nil, fmt.Sprintf("enable a feature on the newly created project (repeatable); defaults to %s; convoai also enables rtm", featureListString())) cmd.Flags().StringArrayVar(&agentRules, "add-agent-rules", nil, "write AI agent rules into the quickstart (repeatable: cursor, claude, windsurf)") cmd.Flags().BoolVar(&newProject, "new-project", false, "always create a new Agora project instead of reusing an existing one") + _ = cmd.RegisterFlagCompletionFunc("template", completeQuickstartTemplateIDs) + _ = cmd.RegisterFlagCompletionFunc("scenario", completeQuickstartScenarios) return cmd } @@ -140,7 +147,7 @@ func (a *App) selectInitTemplate(cmd *cobra.Command) (string, error) { } templates := []quickstartTemplate{} for _, template := range quickstartTemplates() { - if template.Available && template.SupportsInit { + if template.Available && template.SupportsInit && template.DefaultScenario { templates = append(templates, template) } } @@ -150,7 +157,7 @@ func (a *App) selectInitTemplate(cmd *cobra.Command) (string, error) { out := cmd.ErrOrStderr() fmt.Fprintln(out, "Choose a quickstart template:") for index, template := range templates { - fmt.Fprintf(out, " %d. %s (%s)\n", index+1, template.ID, template.Title) + fmt.Fprintf(out, " %d. %s (%s)\n", index+1, template.Template, template.Title) } fmt.Fprint(out, "Template: ") reader := bufio.NewReader(os.Stdin) @@ -160,13 +167,13 @@ func (a *App) selectInitTemplate(cmd *cobra.Command) (string, error) { } answer = strings.TrimSpace(answer) if answer == "" { - return templates[0].ID, nil + return templates[0].Template, nil } if index, err := strconv.Atoi(answer); err == nil && index >= 1 && index <= len(templates) { - return templates[index-1].ID, nil + return templates[index-1].Template, nil } - if _, ok := findQuickstartTemplate(answer); ok { - return answer, nil + if definition, err := selectQuickstartDefinition(answer, ""); err == nil { + return definition.Template, nil } return "", &cliError{Message: fmt.Sprintf("unknown quickstart template %q. Run `agora quickstart list` to see available templates.", answer), Code: "QUICKSTART_TEMPLATE_UNKNOWN"} } @@ -360,7 +367,7 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e if _, err := resolveScaffoldTarget(targetDir); err != nil { return nil, err } - resolution, err := a.resolveInitProjectForScaffold(name, existingProject, features, rtmDataCenter, newProject, promptForReuse, promptOut, promptIn, progress) + resolution, err := a.resolveInitProjectForScaffold(name, template.RequiredFeatures, existingProject, features, rtmDataCenter, newProject, promptForReuse, promptOut, promptIn, progress) if err != nil { return nil, err } @@ -380,7 +387,7 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e "envPath": quickstartResult["envPath"], "envStatus": quickstartResult["envStatus"], "metadataPath": filepath.ToSlash(filepath.Join(localAgoraDirName, localProjectFileName)), - "nextSteps": initNextSteps(template, asString(quickstartResult["path"])), + "nextSteps": quickstartResult["nextSteps"], "path": quickstartResult["path"], "projectAction": resolution.projectAction, "projectId": target.project.ProjectID, @@ -391,22 +398,31 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e "sourceId": template.ID, "sourceType": "quickstart", "status": "ready", - "template": template.ID, + "template": template.Template, + "scenario": template.Scenario, + "requiredFeatures": append([]string{}, template.RequiredFeatures...), "title": template.Title, } + if packageManager, ok := quickstartResult["packageManager"]; ok { + result["packageManager"] = packageManager + } if resolution.createdRTMDataCenter != "" { result["rtmDataCenter"] = resolution.createdRTMDataCenter } return result, nil } -func (a *App) resolveInitProjectForScaffold(name, existingProject string, features []string, rtmDataCenter string, newProject bool, promptForReuse bool, promptOut io.Writer, promptIn io.Reader, progress progressEmitter) (initProjectResolution, error) { +func (a *App) resolveInitProjectForScaffold(name string, scaffoldFeatures []string, existingProject string, features []string, rtmDataCenter string, newProject bool, promptForReuse bool, promptOut io.Writer, promptIn io.Reader, progress progressEmitter) (initProjectResolution, error) { var target projectTarget projectAction := "existing" projectSelectionReason := "explicit_project" enabledFeatures := []string{} needsCreate := false createdRTMDataCenter := "" + requiredFeatures, err := mergeFeatureRequirements(scaffoldFeatures, features) + if err != nil { + return initProjectResolution{}, err + } switch { case strings.TrimSpace(existingProject) != "": @@ -473,7 +489,7 @@ func (a *App) resolveInitProjectForScaffold(name, existingProject string, featur } if needsCreate { - featuresToEnable := normalizeProjectCreateFeatures(features) + featuresToEnable := requiredFeatures progress.emit("project:create", "Creating Agora project", map[string]any{"projectName": name, "features": featuresToEnable}) projectResult, err := a.projectCreate(name, "", featuresToEnable, rtmDataCenter, "") if err != nil { @@ -483,7 +499,9 @@ func (a *App) resolveInitProjectForScaffold(name, existingProject string, featur if list, ok := projectResult["enabledFeatures"].([]string); ok { enabledFeatures = list } - createdRTMDataCenter = asString(projectResult["rtmDataCenter"]) + if value, ok := projectResult["rtmDataCenter"]; ok { + createdRTMDataCenter = asString(value) + } resolved, err := a.resolveProjectTarget(asString(projectResult["projectId"])) if err != nil { return initProjectResolution{}, err @@ -491,6 +509,9 @@ func (a *App) resolveInitProjectForScaffold(name, existingProject string, featur target = resolved progress.emit("project:created", "Agora project ready", map[string]any{"projectId": target.project.ProjectID, "projectName": target.project.Name}) } else { + if err := a.validateProjectRequiredFeatures(target, requiredFeatures); err != nil { + return initProjectResolution{}, err + } progress.emit("project:reuse", "Reusing existing Agora project", map[string]any{"projectId": target.project.ProjectID, "projectName": target.project.Name}) } diff --git a/internal/cli/integration_init_test.go b/internal/cli/integration_init_test.go index 63e6e89..29b89bd 100644 --- a/internal/cli/integration_init_test.go +++ b/internal/cli/integration_init_test.go @@ -10,6 +10,65 @@ import ( "testing" ) +func TestCLIInitRTCVideoCallCreatesRTCOnlyProject(t *testing.T) { + configHome := t.TempDir() + rootDir := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + repo := createLocalGitRepo(t, map[string]string{ + "agora.quickstart.json": `{"schemaVersion":1,"template":"nextjs","scenario":"video-call"}`, + "env.local.example": "NEXT_PUBLIC_AGORA_APP_ID=\nNEXT_AGORA_APP_CERTIFICATE=\n", + "package.json": `{"packageManager":"pnpm@9.15.9","scripts":{"dev":"next dev"}}`, + }) + target := filepath.Join(rootDir, "rtc-init") + result := runCLI(t, []string{"init", "rtc-init", "--template", "nextjs", "--scenario", "video-call", "--new-project", "--dir", target, "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + "AGORA_QUICKSTART_NEXTJS_VIDEO_CALL_REPO_URL": repo, + }, workdir: rootDir}) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"enabledFeatures":["rtc"]`) || !strings.Contains(result.stdout, `"scenario":"video-call"`) || !strings.Contains(result.stdout, `"requiredFeatures":["rtc"]`) { + t.Fatalf("unexpected rtc init result: %+v", result) + } + if !strings.Contains(result.stdout, `"packageManager":{"name":"pnpm","requiredVersion":"9.15.9"`) { + t.Fatalf("rtc init is missing package manager setup metadata: %+v", result) + } + if strings.Contains(result.stdout, `"rtmDataCenter"`) || strings.Contains(result.stdout, `"convoai"`) { + t.Fatalf("rtc init enabled unrelated features: %+v", result) + } + binding, err := loadLocalProjectBinding(target) + if err != nil { + t.Fatal(err) + } + if binding.Template != "nextjs" || binding.Scenario != "video-call" { + t.Fatalf("unexpected rtc init binding: %+v", binding) + } +} + +func TestCLIInitChecksExistingProjectFeaturesBeforeClone(t *testing.T) { + configHome := t.TempDir() + rootDir := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("RTC Only", "prj_rtc_only", "app_rtc_only", "global") + api.projects[project.ProjectID] = &project + persistSessionForIntegration(t, configHome) + target := filepath.Join(rootDir, "must-not-exist") + + result := runCLI(t, []string{"init", "reuse-demo", "--template", "nextjs", "--scenario", "video-call", "--project", project.ProjectID, "--feature", "rtm", "--dir", target, "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }, workdir: rootDir}) + if result.exitCode != 1 || !strings.Contains(result.stdout, `"code":"QUICKSTART_REQUIRED_FEATURE_MISSING"`) || !strings.Contains(result.stdout, "agora project feature enable rtm") { + t.Fatalf("unexpected missing feature result: %+v", result) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("feature validation must happen before clone, stat err=%v", err) + } +} + func TestCLIInitCreatesProjectAndQuickstart(t *testing.T) { configHome := t.TempDir() rootDir := t.TempDir() diff --git a/internal/cli/integration_project_test.go b/internal/cli/integration_project_test.go index b6874cf..847315a 100644 --- a/internal/cli/integration_project_test.go +++ b/internal/cli/integration_project_test.go @@ -173,6 +173,43 @@ func TestCLIProjectCreateDefaultsToCoreFeatures(t *testing.T) { } } +func TestCLIProjectCreateVideoCallPresetAndRejectsUnknownPresetBeforeRemoteWrite(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + + videoCall := runCLI(t, []string{"project", "create", "RTC Demo", "--template", "video-call", "--dry-run", "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }}) + if videoCall.exitCode != 0 || !strings.Contains(videoCall.stdout, `"template":"video-call"`) || !strings.Contains(videoCall.stdout, `"enabledFeatures":["rtc"]`) { + t.Fatalf("unexpected video-call preset dry-run result: %+v", videoCall) + } + if strings.Contains(videoCall.stdout, `"rtm"`) || strings.Contains(videoCall.stdout, `"convoai"`) { + t.Fatalf("video-call preset must not include unrelated features: %+v", videoCall) + } + + api.mu.Lock() + requestsBefore := len(api.requests) + api.mu.Unlock() + unknown := runCLI(t, []string{"project", "create", "Unknown Demo", "--template", "not-a-preset", "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }}) + if unknown.exitCode != 1 || !strings.Contains(unknown.stdout, `"code":"PROJECT_TEMPLATE_UNKNOWN"`) { + t.Fatalf("unexpected unknown preset result: %+v", unknown) + } + api.mu.Lock() + requestsAfter := len(api.requests) + api.mu.Unlock() + if requestsAfter != requestsBefore { + t.Fatalf("unknown preset reached the remote API: requests before=%d after=%d", requestsBefore, requestsAfter) + } +} + func TestCLIProjectUseShowFeatureAndDoctorHappyPath(t *testing.T) { configHome := t.TempDir() api := newFakeCLIBFF() diff --git a/internal/cli/integration_quickstart_test.go b/internal/cli/integration_quickstart_test.go index 1c4f2d3..8d6a853 100644 --- a/internal/cli/integration_quickstart_test.go +++ b/internal/cli/integration_quickstart_test.go @@ -11,6 +11,139 @@ import ( "testing" ) +func TestCLIRTCVideoCallQuickstartScenario(t *testing.T) { + configHome := t.TempDir() + rootDir := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("RTC Project", "prj_rtc", "app_rtc", "global") + api.projects[project.ProjectID] = &project + persistSessionForIntegration(t, configHome) + + repo := createLocalGitRepo(t, map[string]string{ + "agora.quickstart.json": `{"schemaVersion":1,"template":"nextjs","scenario":"video-call"}`, + "env.local.example": "NEXT_PUBLIC_AGORA_APP_ID=\nNEXT_AGORA_APP_CERTIFICATE=\n", + "package.json": `{"packageManager":"pnpm@9.15.9","scripts":{"dev":"next dev"}}`, + }) + commonEnv := map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + "AGORA_QUICKSTART_NEXTJS_VIDEO_CALL_REPO_URL": repo, + } + + list := runCLI(t, []string{"quickstart", "list", "--json"}, cliRunOptions{env: commonEnv, workdir: rootDir}) + if list.exitCode != 0 || !strings.Contains(list.stdout, `"id":"nextjs-video-call"`) || !strings.Contains(list.stdout, `"scenario":"video-call"`) || !strings.Contains(list.stdout, `"requiredFeatures":["rtc"]`) { + t.Fatalf("unexpected quickstart list result: %+v", list) + } + + target := filepath.Join(rootDir, "rtc-demo") + create := runCLI(t, []string{"quickstart", "create", "rtc-demo", "--template", "nextjs", "--scenario", "video-call", "--project", project.ProjectID, "--dir", target, "--json"}, cliRunOptions{env: commonEnv, workdir: rootDir}) + if create.exitCode != 0 || !strings.Contains(create.stdout, `"template":"nextjs"`) || !strings.Contains(create.stdout, `"scenario":"video-call"`) || !strings.Contains(create.stdout, `"requiredFeatures":["rtc"]`) { + t.Fatalf("unexpected rtc quickstart create result: %+v", create) + } + if !strings.Contains(create.stdout, `"packageManager":{"name":"pnpm","requiredVersion":"9.15.9"`) { + t.Fatalf("rtc quickstart create is missing package manager setup metadata: %+v", create) + } + binding, err := loadLocalProjectBinding(target) + if err != nil { + t.Fatal(err) + } + if binding.Template != "nextjs" || binding.Scenario != "video-call" || binding.EnvPath != ".env.local" { + t.Fatalf("unexpected rtc quickstart binding: %+v", binding) + } + envRaw, err := os.ReadFile(filepath.Join(target, ".env.local")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(envRaw), "NEXT_PUBLIC_AGORA_APP_ID=app_rtc") || !strings.Contains(string(envRaw), "NEXT_AGORA_APP_CERTIFICATE=") { + t.Fatalf("unexpected rtc env: %s", envRaw) + } + if project.SignKey != nil && strings.Contains(create.stdout, *project.SignKey) { + t.Fatal("quickstart JSON leaked the app certificate") + } + + write := runCLI(t, []string{"quickstart", "env", "write", target, "--json"}, cliRunOptions{env: commonEnv, workdir: rootDir}) + if write.exitCode != 0 || !strings.Contains(write.stdout, `"scenario":"video-call"`) { + t.Fatalf("unexpected rtc quickstart env write result: %+v", write) + } + + doctor := runCLI(t, []string{"project", "doctor", "--feature", "rtc", "--deep", "--json"}, cliRunOptions{env: commonEnv, workdir: target}) + if doctor.exitCode != 0 || !strings.Contains(doctor.stdout, `"status":"healthy"`) || !strings.Contains(doctor.stdout, `"scenario":"video-call"`) { + t.Fatalf("unexpected rtc deep doctor result: %+v", doctor) + } +} + +func TestCLIVideoCallQuickstartValidatesManifestBeforeSetup(t *testing.T) { + for _, tt := range []struct { + name string + files map[string]string + code string + }{ + { + name: "missing manifest", + files: map[string]string{ + "env.local.example": "NEXT_PUBLIC_AGORA_APP_ID=\nNEXT_AGORA_APP_CERTIFICATE=\n", + }, + code: "QUICKSTART_MANIFEST_INVALID", + }, + { + name: "mismatched manifest", + files: map[string]string{ + "agora.quickstart.json": `{"schemaVersion":1,"template":"nextjs","scenario":"voice-agent"}`, + "env.local.example": "NEXT_PUBLIC_AGORA_APP_ID=\nNEXT_AGORA_APP_CERTIFICATE=\n", + }, + code: "QUICKSTART_SELECTION_MISMATCH", + }, + } { + t.Run(tt.name, func(t *testing.T) { + configHome := t.TempDir() + rootDir := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("RTC Project", "prj_manifest", "app_manifest", "global") + api.projects[project.ProjectID] = &project + persistSessionForIntegration(t, configHome) + repo := createLocalGitRepo(t, tt.files) + target := filepath.Join(rootDir, "rtc-demo") + + result := runCLI(t, []string{"quickstart", "create", "rtc-demo", "--template", "nextjs", "--scenario", "video-call", "--project", project.ProjectID, "--dir", target, "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + "AGORA_QUICKSTART_NEXTJS_VIDEO_CALL_REPO_URL": repo, + }, workdir: rootDir}) + if result.exitCode != 1 || !strings.Contains(result.stdout, `"code":"`+tt.code+`"`) { + t.Fatalf("unexpected manifest validation result: %+v", result) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("invalid clone target must be removed, stat err=%v", err) + } + }) + } +} + +func TestCLIDefaultQuickstartDoesNotRequireManifest(t *testing.T) { + rootDir := t.TempDir() + repo := createLocalGitRepo(t, map[string]string{ + "env.local.example": "NEXT_PUBLIC_AGORA_APP_ID=\nNEXT_AGORA_APP_CERTIFICATE=\n", + "app/page.tsx": "export default function Page() { return null }\n", + }) + target := filepath.Join(rootDir, "voice-agent-demo") + + result := runCLI(t, []string{"quickstart", "create", "voice-agent-demo", "--template", "nextjs", "--template-only", "--dir", target, "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": t.TempDir(), + "AGORA_LOG_LEVEL": "error", + "AGORA_QUICKSTART_NEXTJS_REPO_URL": repo, + }, workdir: rootDir}) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"scenario":"voice-agent"`) { + t.Fatalf("default quickstart without manifest failed: %+v", result) + } + if _, err := os.Stat(target); err != nil { + t.Fatalf("expected default quickstart target to remain: %v", err) + } +} + func TestCLIQuickstartListAndCreate(t *testing.T) { configHome := t.TempDir() api := newFakeCLIBFF() diff --git a/internal/cli/introspect.go b/internal/cli/introspect.go index 274b3cd..37559ec 100644 --- a/internal/cli/introspect.go +++ b/internal/cli/introspect.go @@ -79,10 +79,13 @@ func buildIntrospectionData(root *cobra.Command) map[string]any { "globalFlags": globalFlags, "pseudoCommands": buildPseudoCommands(), "enums": map[string][]string{ - "features": featureIDs(), - "recipeTypes": {"all", "ai", "rtc"}, - "outputModes": {"pretty", "json"}, - "doctorStatus": {"healthy", "warning", "not_ready", "auth_error"}, + "features": featureIDs(), + "recipeTypes": {"all", "ai", "rtc"}, + "outputModes": {"pretty", "json"}, + "doctorStatus": {"healthy", "warning", "not_ready", "auth_error"}, + "projectTemplates": projectPresetIDs(), + "quickstartTemplates": quickstartTemplateIDs(), + "quickstartScenarios": quickstartScenarioIDs(), }, "version": versionInfo(), } diff --git a/internal/cli/local_project.go b/internal/cli/local_project.go index 43a0a14..148850e 100644 --- a/internal/cli/local_project.go +++ b/internal/cli/local_project.go @@ -20,6 +20,7 @@ type localProjectBinding struct { ProjectType string `json:"projectType,omitempty"` Template string `json:"template,omitempty"` Recipe string `json:"recipe,omitempty"` + Scenario string `json:"scenario,omitempty"` EnvPath string `json:"envPath,omitempty"` } diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 3c4ec26..d10dc02 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -244,12 +244,13 @@ func mcpTools() []map[string]any { mcpTool("agora.quickstart.create", "Clone a quickstart with a project or explicitly as template-only", map[string]string{ "name": "string", "template": "string", + "scenario": "string", "project": "string", "templateOnly": "boolean", "ref": "string", "dir": "string", }), - mcpTool("agora.quickstart.env_write", "Write env values into a previously-cloned quickstart", map[string]string{"dir": "string", "template": "string", "project": "string"}), + mcpTool("agora.quickstart.env_write", "Write env values into a previously-cloned quickstart", map[string]string{"dir": "string", "template": "string", "scenario": "string", "project": "string"}), // Recipe catalog mcpTool("agora.recipes.list", "List recipes from the Agora catalog", map[string]string{"type": "string"}), @@ -261,6 +262,7 @@ func mcpTools() []map[string]any { "dir": "string", "template": "string", "recipe": "string", + "scenario": "string", "project": "string", "newProject": "boolean", "rtmDataCenter": "string", @@ -396,7 +398,11 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi return nil, err } features := stringSliceArg(args, "features") - progress.emit("project:create", "Creating Agora project", map[string]any{"projectName": name, "features": projectCreateFeatures(stringArg(args, "template"), features)}) + plannedFeatures, err := resolveProjectCreateFeatures(stringArg(args, "template"), features) + if err != nil { + return nil, err + } + progress.emit("project:create", "Creating Agora project", map[string]any{"projectName": name, "features": plannedFeatures}) result, err := a.projectCreate( name, stringArg(args, "template"), @@ -416,7 +422,7 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi return a.projectEnvValues(stringArg(args, "project"), boolArg(args, "withSecrets", false)) case "agora.project.env_write": - return a.quickstartEnvWrite(defaultString(stringArg(args, "workspaceDir"), "."), stringArg(args, "template"), stringArg(args, "project")) + return a.quickstartEnvWrite(defaultString(stringArg(args, "workspaceDir"), "."), stringArg(args, "template"), "", stringArg(args, "project")) case "agora.project.feature.list": target, err := a.resolveProjectTarget(stringArg(args, "project")) @@ -517,23 +523,23 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi if !template.Available { continue } - items = append(items, map[string]any{"id": template.ID, "title": template.Title, "runtime": template.Runtime, "repoUrl": template.RepoURL, "supportsInit": template.SupportsInit}) + items = append(items, map[string]any{"id": template.ID, "template": template.Template, "scenario": template.Scenario, "requiredFeatures": append([]string{}, template.RequiredFeatures...), "title": template.Title, "runtime": template.Runtime, "repoUrl": template.RepoURL, "supportsInit": template.SupportsInit}) } return map[string]any{"items": items}, nil case "agora.quickstart.create": - template, ok := findQuickstartTemplate(stringArg(args, "template")) - if !ok { - return nil, &cliError{Message: "unknown quickstart template. Run `agora quickstart list`.", Code: "QUICKSTART_TEMPLATE_UNKNOWN"} + template, err := selectQuickstartDefinition(stringArg(args, "template"), stringArg(args, "scenario")) + if err != nil { + return nil, err } target := defaultString(stringArg(args, "dir"), stringArg(args, "name")) if target == "" { return nil, errors.New("name or dir is required") } - return a.quickstartCreate(*template, target, stringArg(args, "project"), boolArg(args, "templateOnly", false), false, io.Discard, bytes.NewReader(nil), stringArg(args, "ref"), progress) + return a.quickstartCreate(template, target, stringArg(args, "project"), boolArg(args, "templateOnly", false), false, io.Discard, bytes.NewReader(nil), stringArg(args, "ref"), progress) case "agora.quickstart.env_write": - return a.quickstartEnvWrite(defaultString(stringArg(args, "dir"), "."), stringArg(args, "template"), stringArg(args, "project")) + return a.quickstartEnvWrite(defaultString(stringArg(args, "dir"), "."), stringArg(args, "template"), stringArg(args, "scenario"), stringArg(args, "project")) case "agora.recipes.list": response, err := a.listRecipes(defaultString(stringArg(args, "type"), "all")) @@ -562,6 +568,10 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi if templateID == "" && recipeID == "" { return nil, &cliError{Message: "init source is required; pass template or recipe.", Code: "INIT_SOURCE_REQUIRED"} } + scenario := stringArg(args, "scenario") + if recipeID != "" && scenario != "" { + return nil, &cliError{Message: "scenario is only valid with a quickstart template.", Code: "INIT_SOURCE_CONFLICT"} + } // CRITICAL: when serving over stdio, os.Stdin is the JSON-RPC // transport stream and os.Stderr might be observed by the // host. Pass an empty reader and an in-memory writer so a @@ -576,11 +586,11 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi } return a.initRecipeProject(name, targetDir, recipe, stringArg(args, "project"), stringSliceArg(args, "features"), stringArg(args, "rtmDataCenter"), boolArg(args, "newProject", false), false, &promptOut, bytes.NewReader(nil), progress) } - template, ok := findQuickstartTemplate(templateID) - if !ok { - return nil, &cliError{Message: "unknown quickstart template. Run `agora quickstart list`.", Code: "QUICKSTART_TEMPLATE_UNKNOWN"} + template, err := selectQuickstartDefinition(templateID, scenario) + if err != nil { + return nil, err } - return a.initProject(name, targetDir, *template, stringArg(args, "project"), stringSliceArg(args, "features"), stringArg(args, "rtmDataCenter"), boolArg(args, "newProject", false), false, &promptOut, bytes.NewReader(nil), progress) + return a.initProject(name, targetDir, template, stringArg(args, "project"), stringSliceArg(args, "features"), stringArg(args, "rtmDataCenter"), boolArg(args, "newProject", false), false, &promptOut, bytes.NewReader(nil), progress) default: return nil, fmt.Errorf("unknown MCP tool %q", name) diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index 871b7bd..c927c74 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -185,6 +185,28 @@ func TestMCPInitRequiresExactlyOneSource(t *testing.T) { assertCLIErrorCode(t, err, "INIT_SOURCE_CONFLICT") } +func TestMCPQuickstartToolsExposeScenario(t *testing.T) { + wantScenario := map[string]bool{ + "agora.init": true, + "agora.quickstart.create": true, + "agora.quickstart.env_write": true, + } + for _, tool := range mcpTools() { + name, _ := tool["name"].(string) + if !wantScenario[name] { + continue + } + properties := tool["inputSchema"].(map[string]any)["properties"].(map[string]any) + if scenario, ok := properties["scenario"].(map[string]any); !ok || scenario["type"] != "string" { + t.Fatalf("%s scenario schema = %#v, want string", name, properties["scenario"]) + } + delete(wantScenario, name) + } + if len(wantScenario) != 0 { + t.Fatalf("missing MCP tools: %v", wantScenario) + } +} + func TestMCPProjectWebhookDeleteRequiresConfirm(t *testing.T) { a := newTestApp(t) _, err := a.callMCPTool("agora.project.webhook.delete", map[string]any{ @@ -326,6 +348,7 @@ func TestMCPQuickstartCreateEmitsProgressNotifications(t *testing.T) { } func TestMCPQuickstartCreateRequiresProjectOrTemplateOnly(t *testing.T) { + t.Chdir(t.TempDir()) a := newTestApp(t) target := filepath.Join(t.TempDir(), "demo") frame := []byte(`{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"agora.quickstart.create","arguments":{"template":"nextjs","dir":` + strconv.Quote(target) + `}}}` + "\n") diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 7e7bb4f..97e222d 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -289,7 +289,10 @@ func (a *App) projectCreate(name, template string, features []string, rtmDataCen return nil, err } region := currentRegionFromContext(ctx) - features = projectCreateFeatures(template, features) + features, err = resolveProjectCreateFeatures(template, features) + if err != nil { + return nil, err + } rtmDataCenter, err = rtmDataCenterForFeatures(features, rtmDataCenter) if err != nil { return nil, err @@ -321,7 +324,7 @@ func (a *App) projectCreate(name, template string, features []string, rtmDataCen // command that re-fetches the list. Wipe it so the next completion // triggers a refresh. _ = clearProjectListCache(a.env) - result := map[string]any{"action": "create", "appId": project.AppID, "enabledFeatures": enabled, "projectId": project.ProjectID, "projectName": project.Name, "region": region} + result := map[string]any{"action": "create", "appId": project.AppID, "enabledFeatures": enabled, "projectId": project.ProjectID, "projectName": project.Name, "region": region, "template": template} if rtmDataCenter != "" { result["rtmDataCenter"] = rtmDataCenter } @@ -357,23 +360,91 @@ func rtmDataCenterForFeatures(features []string, value string) (string, error) { return normalized, nil } -func normalizeProjectCreateFeatures(features []string) []string { - if len(features) == 0 { - return defaultInitFeatures() +type projectPreset struct { + ID string + RequiredFeatures []string +} + +var projectPresets = []projectPreset{ + {ID: "video-call", RequiredFeatures: []string{"rtc"}}, + {ID: "voice-agent", RequiredFeatures: []string{"rtc", "rtm", "convoai"}}, +} + +func findProjectPreset(id string) (projectPreset, bool) { + for _, preset := range projectPresets { + if preset.ID == id { + return preset, true + } + } + return projectPreset{}, false +} + +func projectPresetIDs() []string { + ids := make([]string, 0, len(projectPresets)) + for _, preset := range projectPresets { + ids = append(ids, preset.ID) + } + return ids +} + +func resolveProjectCreateFeatures(template string, features []string) ([]string, error) { + next := make([]string, 0, len(features)+len(featureCatalog)) + if template != "" { + preset, ok := findProjectPreset(template) + if !ok { + return nil, &cliError{ + Message: fmt.Sprintf("Unknown project template %q. Valid templates: %s.", template, strings.Join(projectPresetIDs(), ", ")), + Code: "PROJECT_TEMPLATE_UNKNOWN", + } + } + next = append(next, preset.RequiredFeatures...) + } + next = append(next, features...) + if len(next) == 0 { + next = defaultInitFeatures() } - return features + return mergeFeatureRequirements(next) } -func projectCreateFeatures(template string, features []string) []string { - next := append([]string{}, features...) - if template == "voice-agent" { - next = append(next, featureIDs()...) +func mergeFeatureRequirements(featureGroups ...[]string) ([]string, error) { + next := []string{} + for _, features := range featureGroups { + next = append(next, features...) + } + if featureListIncludes(next, "convoai") { + next = append(next, "rtm") + } + + selected := make(map[string]bool, len(next)) + for _, feature := range next { + if err := validateFeatureID(feature); err != nil { + return nil, err + } + selected[feature] = true } - next = normalizeProjectCreateFeatures(next) - if featureListIncludes(next, "convoai") && !featureListIncludes(next, "rtm") { - next = append([]string{"rtm"}, next...) + ordered := make([]string, 0, len(selected)) + for _, feature := range featureIDs() { + if selected[feature] { + ordered = append(ordered, feature) + } + } + return ordered, nil +} + +func (a *App) validateProjectRequiredFeatures(target projectTarget, requiredFeatures []string) error { + for _, feature := range requiredFeatures { + item, err := a.getFeatureItem(feature, target.project, target.region) + if err != nil { + return err + } + if item.Status != "enabled" && item.Status != "included" { + return &cliError{ + Message: fmt.Sprintf("Existing project %q is missing required feature %q. Run `agora project feature enable %s %s` and retry.", target.project.Name, feature, feature, target.project.ProjectID), + Code: "QUICKSTART_REQUIRED_FEATURE_MISSING", + } + } } - return next + return nil } func featureListIncludes(features []string, target string) bool { diff --git a/internal/cli/quickstart.go b/internal/cli/quickstart.go index ce569db..d024ef9 100644 --- a/internal/cli/quickstart.go +++ b/internal/cli/quickstart.go @@ -2,6 +2,7 @@ package cli import ( "bufio" + "encoding/json" "errors" "fmt" "io" @@ -15,12 +16,24 @@ import ( "github.com/spf13/cobra" ) +const quickstartManifestFileName = "agora.quickstart.json" + +type quickstartManifest struct { + SchemaVersion int `json:"schemaVersion"` + Template string `json:"template"` + Scenario string `json:"scenario"` +} + type quickstartTemplate struct { - ID string - Title string - Description string - Runtime string - RepoURL string + ID string + Template string + Scenario string + DefaultScenario bool + RequiredFeatures []string + Title string + Description string + Runtime string + RepoURL string // RepoURLCN / DocsURLCN are the cn-region variants. They currently // mirror the global URLs because the conversational-AI quickstarts // have no China-hosted mirror yet; set them to the cn URL when one @@ -52,14 +65,18 @@ type quickstartEnvLayout struct { func quickstartTemplates() []quickstartTemplate { return []quickstartTemplate{ { - ID: "nextjs", - Title: "Conversational AI Next.js Quickstart", - Description: "Clone the official Next.js conversational AI quickstart.", - Runtime: "node", - RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", - RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", - DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", - DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", + ID: "nextjs", + Template: "nextjs", + Scenario: "voice-agent", + DefaultScenario: true, + RequiredFeatures: []string{"rtc", "rtm", "convoai"}, + Title: "Conversational AI Next.js Quickstart", + Description: "Clone the official Next.js conversational AI quickstart.", + Runtime: "node", + RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", + RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", + DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", + DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", EnvLayouts: []quickstartEnvLayout{{ DetectPaths: []string{"env.local.example", "app"}, EnvExamplePath: "env.local.example", @@ -74,14 +91,18 @@ func quickstartTemplates() []quickstartTemplate { Available: true, }, { - ID: "python", - Title: "Conversational AI Python Quickstart", - Description: "Clone the official Python conversational AI quickstart.", - Runtime: "python", - RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", - RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", - DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", - DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", + ID: "python", + Template: "python", + Scenario: "voice-agent", + DefaultScenario: true, + RequiredFeatures: []string{"rtc", "rtm", "convoai"}, + Title: "Conversational AI Python Quickstart", + Description: "Clone the official Python conversational AI quickstart.", + Runtime: "python", + RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", + RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", + DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", + DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", EnvLayouts: []quickstartEnvLayout{ { DetectPaths: []string{"server/requirements.txt"}, @@ -105,14 +126,18 @@ func quickstartTemplates() []quickstartTemplate { Available: true, }, { - ID: "go", - Title: "Conversational AI Go Quickstart", - Description: "Clone the official Go conversational AI quickstart.", - Runtime: "go", - RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", - RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", - DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", - DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", + ID: "go", + Template: "go", + Scenario: "voice-agent", + DefaultScenario: true, + RequiredFeatures: []string{"rtc", "rtm", "convoai"}, + Title: "Conversational AI Go Quickstart", + Description: "Clone the official Go conversational AI quickstart.", + Runtime: "go", + RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", + RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", + DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", + DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", EnvLayouts: []quickstartEnvLayout{ { DetectPaths: []string{"server/go.mod"}, @@ -136,14 +161,18 @@ func quickstartTemplates() []quickstartTemplate { Available: true, }, { - ID: "android", - Title: "Conversational AI Android Quickstart", - Description: "Clone the official Android client and Python server quickstart.", - Runtime: "android", - RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", - RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", - DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", - DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", + ID: "android", + Template: "android", + Scenario: "voice-agent", + DefaultScenario: true, + RequiredFeatures: []string{"rtc", "rtm", "convoai"}, + Title: "Conversational AI Android Quickstart", + Description: "Clone the official Android client and Python server quickstart.", + Runtime: "android", + RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", + RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", + DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", + DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", EnvLayouts: []quickstartEnvLayout{{ DetectPaths: []string{"server/.env.example", "server/requirements-dev.txt", "app/src/main/AndroidManifest.xml"}, EnvExamplePath: "server/.env.example", @@ -162,12 +191,35 @@ func quickstartTemplates() []quickstartTemplate { SupportsInit: true, Available: true, }, + { + ID: "nextjs-video-call", + Template: "nextjs", + Scenario: "video-call", + RequiredFeatures: []string{"rtc"}, + Title: "Next.js Video Call Quickstart", + Description: "Build a one-to-one RTC audio and video call.", + Runtime: "node", + RepoURL: "https://github.com/littleDogWang/agora-rtc-nextjs-quickstart", + DocsURL: "https://github.com/littleDogWang/agora-rtc-nextjs-quickstart", + EnvLayouts: []quickstartEnvLayout{{ + DetectPaths: []string{"agora.quickstart.json", "env.local.example"}, + EnvExamplePath: "env.local.example", + EnvTargetPath: ".env.local", + AppIDKey: "NEXT_PUBLIC_AGORA_APP_ID", + AppCertificateKey: "NEXT_AGORA_APP_CERTIFICATE", + }}, + InstallCommand: "pnpm install", + RunCommand: "pnpm dev", + EnvDocsSummary: "Writes NEXT_PUBLIC_AGORA_APP_ID for the browser and NEXT_AGORA_APP_CERTIFICATE for server-side token generation.", + SupportsInit: true, + Available: true, + }, } } func findQuickstartTemplate(id string) (*quickstartTemplate, bool) { for _, template := range quickstartTemplates() { - if template.ID == id { + if template.ID == id || (template.Template == id && template.DefaultScenario) { copy := template return ©, true } @@ -175,6 +227,62 @@ func findQuickstartTemplate(id string) (*quickstartTemplate, bool) { return nil, false } +func quickstartTemplateIDs() []string { + ids := []string{} + seen := map[string]bool{} + for _, definition := range quickstartTemplates() { + if !seen[definition.Template] { + ids = append(ids, definition.Template) + seen[definition.Template] = true + } + } + return ids +} + +func quickstartScenarioIDs() []string { + ids := []string{} + seen := map[string]bool{} + for _, definition := range quickstartTemplates() { + if !seen[definition.Scenario] { + ids = append(ids, definition.Scenario) + seen[definition.Scenario] = true + } + } + return ids +} + +func selectQuickstartDefinition(templateID, scenario string) (quickstartTemplate, error) { + templateID = strings.TrimSpace(templateID) + scenario = strings.TrimSpace(scenario) + knownTemplate := false + knownScenario := false + for _, definition := range quickstartTemplates() { + if definition.Scenario == scenario { + knownScenario = true + } + if definition.Template != templateID { + continue + } + knownTemplate = true + if scenario == "" && definition.DefaultScenario { + return definition, nil + } + if scenario != "" && definition.Scenario == scenario { + return definition, nil + } + } + if !knownTemplate { + return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("Unknown quickstart template %q. Run `agora quickstart list` to see valid templates.", templateID), Code: "QUICKSTART_TEMPLATE_UNKNOWN"} + } + if scenario == "" { + return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("Quickstart template %q has no default scenario.", templateID), Code: "QUICKSTART_SCENARIO_UNKNOWN"} + } + if knownScenario { + return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("Scenario %q is not supported by template %q. Run `agora quickstart list` to see supported combinations.", scenario, templateID), Code: "QUICKSTART_SCENARIO_UNSUPPORTED"} + } + return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("Unknown quickstart scenario %q. Run `agora quickstart list` to see valid scenarios.", scenario), Code: "QUICKSTART_SCENARIO_UNKNOWN"} +} + func (a *App) buildQuickstartCommand() *cobra.Command { cmd := &cobra.Command{ Use: "quickstart", @@ -221,15 +329,20 @@ func (a *App) buildQuickstartList() *cobra.Command { continue } items = append(items, map[string]any{ - "available": template.Available, - "description": template.Description, - "docsUrl": quickstartDocsURL(template, a.authRegion()), - "envDocs": template.EnvDocsSummary, - "id": template.ID, - "repoUrl": quickstartRepoURLForRegion(template, a.authRegion()), - "runtime": template.Runtime, - "supportsInit": template.SupportsInit, - "title": template.Title, + "available": template.Available, + "description": template.Description, + "docsUrl": quickstartDocsURL(template, a.authRegion()), + "envDocs": template.EnvDocsSummary, + "id": template.ID, + "installCommand": template.InstallCommand, + "repoUrl": quickstartRepoURLForRegion(template, a.authRegion()), + "requiredFeatures": append([]string{}, template.RequiredFeatures...), + "runCommand": template.RunCommand, + "runtime": template.Runtime, + "scenario": template.Scenario, + "supportsInit": template.SupportsInit, + "template": template.Template, + "title": template.Title, }) } return renderResult(cmd, "quickstart list", map[string]any{ @@ -246,6 +359,7 @@ func (a *App) buildQuickstartList() *cobra.Command { func (a *App) buildQuickstartCreate() *cobra.Command { var templateID string + var scenario string var dir string var project string var ref string @@ -267,9 +381,9 @@ If a current project context exists, or if --project is passed, the CLI also wri if len(args) != 1 { return errors.New("quickstart name is required") } - template, ok := findQuickstartTemplate(templateID) - if !ok { - return &cliError{Message: fmt.Sprintf("unknown quickstart template %q. Run `agora quickstart list` to see available templates.", templateID), Code: "QUICKSTART_TEMPLATE_UNKNOWN"} + template, err := selectQuickstartDefinition(templateID, scenario) + if err != nil { + return err } targetDir := dir if strings.TrimSpace(targetDir) == "" { @@ -281,7 +395,7 @@ If a current project context exists, or if --project is passed, the CLI also wri !isCIEnvironment(a.osEnv) && isTTY(os.Stdin) progress := jsonProgressFor(a, cmd, "quickstart create") - result, err := a.quickstartCreate(*template, targetDir, project, templateOnly, promptForProject, cmd.ErrOrStderr(), os.Stdin, ref, progress) + result, err := a.quickstartCreate(template, targetDir, project, templateOnly, promptForProject, cmd.ErrOrStderr(), os.Stdin, ref, progress) if err != nil { return err } @@ -289,6 +403,7 @@ If a current project context exists, or if --project is passed, the CLI also wri }, } cmd.Flags().StringVar(&templateID, "template", "", "quickstart template ID from `agora quickstart list`") + cmd.Flags().StringVar(&scenario, "scenario", "", "quickstart scenario; omitted selects the template default") cmd.Flags().StringVar(&dir, "dir", "", "target directory for the cloned quickstart; defaults to ") cmd.Flags().StringVar(&project, "project", "", "project ID or exact project name to use for env seeding") cmd.Flags().StringVar(&ref, "ref", "", "git branch, tag, or ref to clone for pinned workshops") @@ -296,6 +411,7 @@ If a current project context exists, or if --project is passed, the CLI also wri cmd.MarkFlagsMutuallyExclusive("project", "template-only") _ = cmd.MarkFlagRequired("template") _ = cmd.RegisterFlagCompletionFunc("template", completeQuickstartTemplateIDs) + _ = cmd.RegisterFlagCompletionFunc("scenario", completeQuickstartScenarios) _ = cmd.RegisterFlagCompletionFunc("project", a.completeProjectNames) return cmd } @@ -363,6 +479,7 @@ func chooseQuickstartProject(in io.Reader, out io.Writer, items []projectSummary func (a *App) buildQuickstartEnv() *cobra.Command { var templateID string + var scenario string var project string cmd := &cobra.Command{ Use: "env", @@ -405,7 +522,7 @@ Python and Go quickstarts receive backend AGORA_APP_ID and AGORA_APP_CERTIFICATE if len(args) > 0 && strings.TrimSpace(args[0]) != "" { targetDir = args[0] } - result, err := a.quickstartEnvWrite(targetDir, templateID, project) + result, err := a.quickstartEnvWrite(targetDir, templateID, scenario, project) if err != nil { return err } @@ -413,8 +530,10 @@ Python and Go quickstarts receive backend AGORA_APP_ID and AGORA_APP_CERTIFICATE }, } write.Flags().StringVar(&templateID, "template", "", "quickstart template ID; if omitted, the CLI detects it from the repo layout") + write.Flags().StringVar(&scenario, "scenario", "", "quickstart scenario; if omitted, use binding, manifest, or the template default") write.Flags().StringVar(&project, "project", "", "project ID or exact project name to use for env seeding") _ = write.RegisterFlagCompletionFunc("template", completeQuickstartTemplateIDs) + _ = write.RegisterFlagCompletionFunc("scenario", completeQuickstartScenarios) _ = write.RegisterFlagCompletionFunc("project", a.completeProjectNames) cmd.AddCommand(write) return cmd @@ -470,7 +589,7 @@ func (a *App) quickstartCreate(template quickstartTemplate, targetDir, explicitP if overrideKey != "" { progress.emit("clone:override", fmt.Sprintf("Using repo override from %s", overrideKey), map[string]any{"repoUrl": repoURL, "envVar": overrideKey}) } - if err := cloneScaffoldRepo(repoURL, absTarget, ref, progress); err != nil { + if err := cloneScaffoldRepo(repoURL, absTarget, ref, progress, &template); err != nil { return nil, err } @@ -493,7 +612,8 @@ func (a *App) quickstartCreate(template quickstartTemplate, targetDir, explicitP ProjectID: boundProject.project.ProjectID, ProjectName: boundProject.project.Name, Region: boundProject.region, - Template: template.ID, + Template: template.Template, + Scenario: template.Scenario, EnvPath: writtenPath, }); err != nil { if cleanupErr := os.RemoveAll(absTarget); cleanupErr != nil { @@ -506,24 +626,30 @@ func (a *App) quickstartCreate(template quickstartTemplate, targetDir, explicitP written = append(written, writtenPath, filepath.ToSlash(filepath.Join(localAgoraDirName, localProjectFileName))) } sort.Strings(written) + setup := resolveQuickstartSetup(template, absTarget, probeQuickstartTool) result := map[string]any{ - "action": "create", - "cloneUrl": repoURL, - "docsUrl": quickstartDocsURL(template, a.authRegion()), - "envPath": envPath, - "envStatus": envStatus, - "metadataPath": "", - "path": absTarget, - "projectId": nil, - "projectName": nil, - "runtime": template.Runtime, - "status": "cloned", - "template": template.ID, - "title": template.Title, - "written": written, - "nextSteps": initNextSteps(template, absTarget), - "ref": ref, + "action": "create", + "cloneUrl": repoURL, + "docsUrl": quickstartDocsURL(template, a.authRegion()), + "envPath": envPath, + "envStatus": envStatus, + "metadataPath": "", + "path": absTarget, + "projectId": nil, + "projectName": nil, + "runtime": template.Runtime, + "scenario": template.Scenario, + "status": "cloned", + "template": template.Template, + "requiredFeatures": append([]string{}, template.RequiredFeatures...), + "title": template.Title, + "written": written, + "nextSteps": setup.NextSteps, + "ref": ref, + } + if setup.PackageManager != nil { + result["packageManager"] = setup.PackageManager } if boundProject != nil { result["projectId"] = boundProject.project.ProjectID @@ -546,12 +672,20 @@ func resolveScaffoldTarget(targetDir string) (string, error) { return absTarget, nil } -func cloneScaffoldRepo(repoURL, absTarget, ref string, progress progressEmitter) error { +func cloneScaffoldRepo(repoURL, absTarget, ref string, progress progressEmitter, expected ...*quickstartTemplate) error { progress.emit("clone:start", "Cloning scaffold repository", map[string]any{"repoUrl": repoURL, "targetPath": absTarget, "ref": ref}) if err := cloneQuickstartRepo(repoURL, absTarget, ref); err != nil { return err } progress.emit("clone:complete", "Scaffold repository cloned", map[string]any{"targetPath": absTarget}) + if len(expected) > 0 { + if err := validateRequiredQuickstartManifest(absTarget, *expected[0]); err != nil { + if cleanupErr := os.RemoveAll(absTarget); cleanupErr != nil { + return fmt.Errorf("%w; cleanup also failed for %s: %v", err, absTarget, cleanupErr) + } + return fmt.Errorf("%w; removed %s", err, absTarget) + } + } if err := stripClonedGitMetadata(absTarget); err != nil { if cleanupErr := os.RemoveAll(absTarget); cleanupErr != nil { return fmt.Errorf("failed to remove scaffold git metadata after clone: %v; cleanup also failed for %s: %v", err, absTarget, cleanupErr) @@ -562,7 +696,7 @@ func cloneScaffoldRepo(repoURL, absTarget, ref string, progress progressEmitter) return nil } -func (a *App) quickstartEnvWrite(targetDir, templateID, explicitProject string) (map[string]any, error) { +func (a *App) quickstartEnvWrite(targetDir, templateID, scenario, explicitProject string) (map[string]any, error) { absTarget, err := filepath.Abs(targetDir) if err != nil { return nil, err @@ -575,7 +709,7 @@ func (a *App) quickstartEnvWrite(targetDir, templateID, explicitProject string) return nil, fmt.Errorf("%s is not a directory.", absTarget) } - template, layout, err := resolveQuickstartEnvWriteTarget(absTarget, templateID) + template, layout, err := resolveQuickstartEnvWriteTarget(absTarget, templateID, scenario) if err != nil { return nil, err } @@ -595,21 +729,24 @@ func (a *App) quickstartEnvWrite(targetDir, templateID, explicitProject string) ProjectID: target.project.ProjectID, ProjectName: target.project.Name, Region: target.region, - Template: template.ID, + Template: template.Template, + Scenario: template.Scenario, EnvPath: envPath, }); err != nil { return nil, err } return map[string]any{ - "action": "env-write", - "envPath": envPath, - "metadataPath": filepath.ToSlash(filepath.Join(localAgoraDirName, localProjectFileName)), - "path": absTarget, - "projectId": target.project.ProjectID, - "projectName": target.project.Name, - "status": status, - "template": template.ID, - "title": template.Title, + "action": "env-write", + "envPath": envPath, + "metadataPath": filepath.ToSlash(filepath.Join(localAgoraDirName, localProjectFileName)), + "path": absTarget, + "projectId": target.project.ProjectID, + "projectName": target.project.Name, + "status": status, + "template": template.Template, + "scenario": template.Scenario, + "requiredFeatures": append([]string{}, template.RequiredFeatures...), + "title": template.Title, }, nil } @@ -763,15 +900,118 @@ func (a *App) resolveOptionalProjectTarget(explicitProject, startPath string) (p return target, true, nil } -func resolveQuickstartTemplateForPath(root, explicitTemplate string) (quickstartTemplate, error) { +func readQuickstartManifest(root string) (quickstartManifest, bool, error) { + raw, err := os.ReadFile(filepath.Join(root, quickstartManifestFileName)) + if errors.Is(err, os.ErrNotExist) { + return quickstartManifest{}, false, nil + } + if err != nil { + return quickstartManifest{}, false, err + } + var manifest quickstartManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return quickstartManifest{}, true, &cliError{Message: fmt.Sprintf("Invalid %s: %v.", quickstartManifestFileName, err), Code: "QUICKSTART_MANIFEST_INVALID"} + } + if manifest.SchemaVersion != 1 || strings.TrimSpace(manifest.Template) == "" || strings.TrimSpace(manifest.Scenario) == "" { + return quickstartManifest{}, true, &cliError{Message: fmt.Sprintf("Invalid %s: schemaVersion must be 1 and template/scenario are required.", quickstartManifestFileName), Code: "QUICKSTART_MANIFEST_INVALID"} + } + if _, err := selectQuickstartDefinition(manifest.Template, manifest.Scenario); err != nil { + return quickstartManifest{}, true, &cliError{Message: fmt.Sprintf("Invalid %s selection %s + %s: %v", quickstartManifestFileName, manifest.Template, manifest.Scenario, err), Code: "QUICKSTART_MANIFEST_INVALID"} + } + return manifest, true, nil +} + +func validateRequiredQuickstartManifest(root string, expected quickstartTemplate) error { + if expected.DefaultScenario { + return nil + } + manifest, found, err := readQuickstartManifest(root) + if err != nil { + return err + } + if !found { + return &cliError{ + Message: fmt.Sprintf("Required %s is missing for %s + %s.", quickstartManifestFileName, expected.Template, expected.Scenario), + Code: "QUICKSTART_MANIFEST_INVALID", + } + } + actual, err := selectQuickstartDefinition(manifest.Template, manifest.Scenario) + if err != nil { + return err + } + if !sameQuickstartSelection(expected, actual) { + return quickstartSelectionMismatch("selected quickstart", expected, quickstartManifestFileName, actual) + } + return nil +} + +func sameQuickstartSelection(a, b quickstartTemplate) bool { + return a.Template == b.Template && a.Scenario == b.Scenario +} + +func quickstartSelectionMismatch(leftName string, left quickstartTemplate, rightName string, right quickstartTemplate) error { + return &cliError{ + Message: fmt.Sprintf("Quickstart selection mismatch: %s is %s + %s, but %s is %s + %s.", leftName, left.Template, left.Scenario, rightName, right.Template, right.Scenario), + Code: "QUICKSTART_SELECTION_MISMATCH", + } +} + +func resolveQuickstartTemplateForPath(root, explicitTemplate, explicitScenario string) (quickstartTemplate, error) { + manifest, foundManifest, err := readQuickstartManifest(root) + if err != nil { + return quickstartTemplate{}, err + } + binding, foundBinding, bindingRoot, err := detectLocalProjectBindingFrom(root) + if err != nil { + return quickstartTemplate{}, err + } + foundBinding = foundBinding && bindingRoot == root && strings.TrimSpace(binding.Template) != "" + + type namedSelection struct { + name string + definition quickstartTemplate + } + selections := []namedSelection{} + if foundBinding { + definition, resolveErr := selectQuickstartDefinition(binding.Template, binding.Scenario) + if resolveErr != nil { + return quickstartTemplate{}, resolveErr + } + selections = append(selections, namedSelection{name: ".agora/project.json", definition: definition}) + } + if foundManifest { + definition, _ := selectQuickstartDefinition(manifest.Template, manifest.Scenario) + selections = append(selections, namedSelection{name: quickstartManifestFileName, definition: definition}) + } + if strings.TrimSpace(explicitTemplate) != "" { - template, ok := findQuickstartTemplate(explicitTemplate) - if !ok { - return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("unknown quickstart template %q. Run `agora quickstart list` to see available templates.", explicitTemplate), Code: "QUICKSTART_TEMPLATE_UNKNOWN"} + definition, resolveErr := selectQuickstartDefinition(explicitTemplate, explicitScenario) + if resolveErr != nil { + return quickstartTemplate{}, resolveErr + } + selections = append([]namedSelection{{name: "explicit flags", definition: definition}}, selections...) + } else if strings.TrimSpace(explicitScenario) != "" { + if len(selections) == 0 { + return quickstartTemplate{}, &cliError{Message: "--scenario requires --template when the repository has no binding or manifest.", Code: "QUICKSTART_TEMPLATE_REQUIRED"} + } + definition, resolveErr := selectQuickstartDefinition(selections[0].definition.Template, explicitScenario) + if resolveErr != nil { + return quickstartTemplate{}, resolveErr + } + selections = append([]namedSelection{{name: "explicit flags", definition: definition}}, selections...) + } + if len(selections) > 0 { + for index := 1; index < len(selections); index++ { + if !sameQuickstartSelection(selections[0].definition, selections[index].definition) { + return quickstartTemplate{}, quickstartSelectionMismatch(selections[0].name, selections[0].definition, selections[index].name, selections[index].definition) + } } - return *template, nil + return selections[0].definition, nil } for _, template := range quickstartTemplates() { + if !template.DefaultScenario { + continue + } if matchesQuickstartTemplate(root, template) { return template, nil } @@ -835,25 +1075,15 @@ func quickstartEnvLayoutForEnvPath(template quickstartTemplate, envPath string) return quickstartEnvLayout{}, false } -func resolveQuickstartEnvWriteTarget(root, explicitTemplate string) (quickstartTemplate, quickstartEnvLayout, error) { +func resolveQuickstartEnvWriteTarget(root, explicitTemplate, explicitScenario string) (quickstartTemplate, quickstartEnvLayout, error) { binding, foundBinding, bindingRoot, err := detectLocalProjectBindingFrom(root) if err != nil { return quickstartTemplate{}, quickstartEnvLayout{}, err } - var template quickstartTemplate - if strings.TrimSpace(explicitTemplate) == "" && foundBinding && bindingRoot == root && strings.TrimSpace(binding.Template) != "" { - found, ok := findQuickstartTemplate(binding.Template) - if !ok { - return quickstartTemplate{}, quickstartEnvLayout{}, &cliError{Message: fmt.Sprintf("unknown quickstart template %q. Run `agora quickstart list` to see available templates.", binding.Template), Code: "QUICKSTART_TEMPLATE_UNKNOWN"} - } - template = *found - } else { - resolved, resolveErr := resolveQuickstartTemplateForPath(root, explicitTemplate) - if resolveErr != nil { - return quickstartTemplate{}, quickstartEnvLayout{}, resolveErr - } - template = resolved + template, err := resolveQuickstartTemplateForPath(root, explicitTemplate, explicitScenario) + if err != nil { + return quickstartTemplate{}, quickstartEnvLayout{}, err } if foundBinding && bindingRoot == root && strings.TrimSpace(binding.EnvPath) != "" { diff --git a/internal/cli/quickstart_setup.go b/internal/cli/quickstart_setup.go new file mode 100644 index 0000000..fd53cfa --- /dev/null +++ b/internal/cli/quickstart_setup.go @@ -0,0 +1,117 @@ +package cli + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +var pinnedPackageManagerPattern = regexp.MustCompile(`^(pnpm)@([0-9]+\.[0-9]+\.[0-9]+)$`) + +type quickstartPackageManagerResult struct { + Name string `json:"name"` + RequiredVersion string `json:"requiredVersion"` + DetectedVersion string `json:"detectedVersion,omitempty"` + Strategy string `json:"strategy"` + Ready bool `json:"ready"` + Message string `json:"message,omitempty"` +} + +type quickstartSetup struct { + NextSteps []string + PackageManager *quickstartPackageManagerResult +} + +type quickstartToolProbe func(root, command string) (version string, available bool) + +func quickstartPackageManagerSummary(packageManager *quickstartPackageManagerResult) string { + if packageManager == nil { + return "" + } + base := packageManager.Name + " " + packageManager.RequiredVersion + if packageManager.Strategy == "unavailable" { + return base + " unavailable" + } + return base + " via " + packageManager.Strategy +} + +func probeQuickstartTool(root, command string) (string, bool) { + path, err := exec.LookPath(command) + if err != nil { + return "", false + } + if command == "npx" { + return "", true + } + cmd := exec.Command(path, "--version") + cmd.Dir = root + output, err := cmd.Output() + if err != nil { + return "", false + } + return strings.TrimSpace(string(output)), true +} + +func resolveQuickstartSetup(template quickstartTemplate, targetDir string, probe quickstartToolProbe) quickstartSetup { + setup := quickstartSetup{NextSteps: initNextSteps(template, targetDir)} + if template.ID != "nextjs-video-call" { + return setup + } + name, version, ok := readQuickstartPackageManager(targetDir) + if !ok || name != "pnpm" { + return setup + } + detectedVersion, available := probe(targetDir, name) + packageManager := &quickstartPackageManagerResult{ + Name: name, + RequiredVersion: version, + DetectedVersion: detectedVersion, + } + setup.PackageManager = packageManager + if available && detectedVersion == version { + setup.NextSteps = []string{ + "cd " + filepath.Base(targetDir), + "pnpm install --frozen-lockfile", + template.RunCommand, + } + packageManager.Strategy = "native" + packageManager.Ready = true + return setup + } + if _, npxAvailable := probe(targetDir, "npx"); npxAvailable { + spec := name + "@" + version + setup.NextSteps = []string{ + "cd " + filepath.Base(targetDir), + "npx --yes " + spec + " install --frozen-lockfile", + "npx --yes " + spec + " dev", + } + packageManager.Strategy = "npx" + packageManager.Ready = true + return setup + } + setup.NextSteps = []string{"cd " + filepath.Base(targetDir)} + packageManager.Strategy = "unavailable" + packageManager.Message = "pnpm " + version + " is unavailable and npx was not found; install Node.js with npm, then rerun the setup commands." + return setup +} + +func readQuickstartPackageManager(root string) (name, version string, ok bool) { + raw, err := os.ReadFile(filepath.Join(root, "package.json")) + if err != nil { + return "", "", false + } + var manifest struct { + PackageManager string `json:"packageManager"` + } + if json.Unmarshal(raw, &manifest) != nil { + return "", "", false + } + parts := pinnedPackageManagerPattern.FindStringSubmatch(strings.TrimSpace(manifest.PackageManager)) + if len(parts) != 3 { + return "", "", false + } + return parts[1], parts[2], true +} diff --git a/internal/cli/quickstart_setup_test.go b/internal/cli/quickstart_setup_test.go new file mode 100644 index 0000000..72b25f9 --- /dev/null +++ b/internal/cli/quickstart_setup_test.go @@ -0,0 +1,149 @@ +package cli + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestResolveQuickstartSetupUsesMatchingPNPM(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9"}`), 0o644); err != nil { + t.Fatal(err) + } + template, err := selectQuickstartDefinition("nextjs", "video-call") + if err != nil { + t.Fatal(err) + } + + setup := resolveQuickstartSetup(template, root, func(_ string, command string) (string, bool) { + if command == "pnpm" { + return "9.15.9", true + } + return "", false + }) + + wantSteps := []string{"cd " + filepath.Base(root), "pnpm install --frozen-lockfile", "pnpm dev"} + if !reflect.DeepEqual(setup.NextSteps, wantSteps) { + t.Fatalf("next steps:\n got: %#v\nwant: %#v", setup.NextSteps, wantSteps) + } + if setup.PackageManager == nil || setup.PackageManager.Strategy != "native" || !setup.PackageManager.Ready || setup.PackageManager.RequiredVersion != "9.15.9" || setup.PackageManager.DetectedVersion != "9.15.9" { + t.Fatalf("unexpected package manager result: %+v", setup.PackageManager) + } +} + +func TestResolveQuickstartSetupFallsBackToNPXWhenPNPMIsMissing(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9"}`), 0o644); err != nil { + t.Fatal(err) + } + template, err := selectQuickstartDefinition("nextjs", "video-call") + if err != nil { + t.Fatal(err) + } + + setup := resolveQuickstartSetup(template, root, func(_ string, command string) (string, bool) { + return "", command == "npx" + }) + + wantSteps := []string{ + "cd " + filepath.Base(root), + "npx --yes pnpm@9.15.9 install --frozen-lockfile", + "npx --yes pnpm@9.15.9 dev", + } + if !reflect.DeepEqual(setup.NextSteps, wantSteps) { + t.Fatalf("next steps:\n got: %#v\nwant: %#v", setup.NextSteps, wantSteps) + } + if setup.PackageManager == nil || setup.PackageManager.Strategy != "npx" || !setup.PackageManager.Ready || setup.PackageManager.DetectedVersion != "" { + t.Fatalf("unexpected package manager result: %+v", setup.PackageManager) + } +} + +func TestResolveQuickstartSetupFallsBackToNPXWhenPNPMVersionDiffers(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9"}`), 0o644); err != nil { + t.Fatal(err) + } + template, err := selectQuickstartDefinition("nextjs", "video-call") + if err != nil { + t.Fatal(err) + } + + setup := resolveQuickstartSetup(template, root, func(_ string, command string) (string, bool) { + if command == "pnpm" { + return "8.15.9", true + } + return "", command == "npx" + }) + + if setup.PackageManager == nil || setup.PackageManager.Strategy != "npx" || setup.PackageManager.DetectedVersion != "8.15.9" { + t.Fatalf("unexpected package manager result: %+v", setup.PackageManager) + } + if got := setup.NextSteps[1]; got != "npx --yes pnpm@9.15.9 install --frozen-lockfile" { + t.Fatalf("install step = %q", got) + } +} + +func TestResolveQuickstartSetupReportsUnavailableWithoutPNPMOrNPX(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9"}`), 0o644); err != nil { + t.Fatal(err) + } + template, err := selectQuickstartDefinition("nextjs", "video-call") + if err != nil { + t.Fatal(err) + } + + setup := resolveQuickstartSetup(template, root, func(_ string, _ string) (string, bool) { + return "", false + }) + + wantSteps := []string{"cd " + filepath.Base(root)} + if !reflect.DeepEqual(setup.NextSteps, wantSteps) { + t.Fatalf("next steps:\n got: %#v\nwant: %#v", setup.NextSteps, wantSteps) + } + if setup.PackageManager == nil || setup.PackageManager.Strategy != "unavailable" || setup.PackageManager.Ready || setup.PackageManager.Message == "" { + t.Fatalf("unexpected package manager result: %+v", setup.PackageManager) + } +} + +func TestResolveQuickstartSetupDoesNotInterpolateMalformedPackageManager(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9 && echo unsafe"}`), 0o644); err != nil { + t.Fatal(err) + } + template, err := selectQuickstartDefinition("nextjs", "video-call") + if err != nil { + t.Fatal(err) + } + + setup := resolveQuickstartSetup(template, root, func(_ string, command string) (string, bool) { + return "", command == "npx" + }) + + wantSteps := []string{"cd " + filepath.Base(root), "pnpm install", "pnpm dev"} + if !reflect.DeepEqual(setup.NextSteps, wantSteps) || setup.PackageManager != nil { + t.Fatalf("malformed declaration must use safe catalog steps, got %+v", setup) + } +} + +func TestResolveQuickstartSetupLeavesOtherQuickstartsUnchanged(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@10.23.0"}`), 0o644); err != nil { + t.Fatal(err) + } + template, err := selectQuickstartDefinition("go", "voice-agent") + if err != nil { + t.Fatal(err) + } + + setup := resolveQuickstartSetup(template, root, func(_ string, _ string) (string, bool) { + return "10.23.0", true + }) + + wantSteps := []string{"cd " + filepath.Base(root), "make setup", "make dev"} + if !reflect.DeepEqual(setup.NextSteps, wantSteps) || setup.PackageManager != nil { + t.Fatalf("non-RTC quickstart setup changed: %+v", setup) + } +} diff --git a/internal/cli/quickstart_test.go b/internal/cli/quickstart_test.go index 449c2cb..156cff7 100644 --- a/internal/cli/quickstart_test.go +++ b/internal/cli/quickstart_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "reflect" + "slices" "strings" "testing" ) @@ -77,7 +78,7 @@ func TestResolveQuickstartEnvWriteTargetSupportsCurrentAndLegacyLayouts(t *testi } } - template, layout, err := resolveQuickstartEnvWriteTarget(root, "") + template, layout, err := resolveQuickstartEnvWriteTarget(root, "", "") if err != nil { t.Fatal(err) } @@ -100,7 +101,7 @@ func TestResolveQuickstartEnvWriteTargetPreservesBoundLegacyLayout(t *testing.T) t.Fatal(err) } - template, layout, err := resolveQuickstartEnvWriteTarget(root, "") + template, layout, err := resolveQuickstartEnvWriteTarget(root, "", "") if err != nil { t.Fatal(err) } @@ -206,6 +207,77 @@ func TestGoVoiceAgentSkillUsesQuickstartWorkflow(t *testing.T) { t.Fatal("Go voice agent skill not found") } +func TestScaffoldSkillsUseConsistentDiscoveryTags(t *testing.T) { + wantTags := map[string][]string{ + "create-nextjs-video-app": {"nextjs", "rtc", "video", "video-call", "init"}, + "create-nextjs-voice-agent": {"nextjs", "convoai", "voice", "voice-agent", "init"}, + "create-python-voice-agent": {"python", "convoai", "voice", "voice-agent", "init"}, + "create-go-voice-agent": {"go", "convoai", "voice", "voice-agent", "init"}, + } + + for _, skill := range skillsCatalog() { + want, ok := wantTags[skill.ID] + if !ok { + continue + } + if !reflect.DeepEqual(skill.Tags, want) { + t.Fatalf("unexpected tags for %s:\n got: %#v\nwant: %#v", skill.ID, skill.Tags, want) + } + delete(wantTags, skill.ID) + } + if len(wantTags) != 0 { + t.Fatalf("missing scaffold skills: %#v", wantTags) + } +} + +func TestNextJSVideoAppSkillUsesVideoCallQuickstartWorkflow(t *testing.T) { + for _, skill := range skillsCatalog() { + if skill.ID != "create-nextjs-video-app" { + continue + } + wantSteps := []string{ + "agora login", + "agora init my-nextjs-demo --template nextjs --scenario video-call --new-project --json", + "Run the nextSteps returned by agora init exactly; they select pnpm or the pinned npx fallback for this machine.", + } + if !reflect.DeepEqual(skill.Steps, wantSteps) { + t.Fatalf("unexpected Next.js video app steps:\n got: %#v\nwant: %#v", skill.Steps, wantSteps) + } + if skill.Description != "Create a runnable Next.js one-to-one RTC audio and video call." || !slices.Contains(skill.Tags, "video-call") { + t.Fatalf("unexpected Next.js video app discovery metadata: description=%q tags=%#v", skill.Description, skill.Tags) + } + if !slices.Contains(skill.NextSteps, "Run agora project doctor --feature rtc --deep --json to validate the project, manifest, binding, and env.") { + t.Fatalf("Next.js video app skill is missing RTC deep doctor guidance: %#v", skill.NextSteps) + } + return + } + t.Fatal("Next.js video app skill not found") +} + +func TestNextJSVoiceAgentSkillUsesVoiceAgentQuickstartWorkflow(t *testing.T) { + for _, skill := range skillsCatalog() { + if skill.ID != "create-nextjs-voice-agent" { + continue + } + wantSteps := []string{ + "agora login", + "agora init my-nextjs-voice-agent --template nextjs --scenario voice-agent --new-project --json", + "cd my-nextjs-voice-agent && pnpm install && pnpm dev", + } + if !reflect.DeepEqual(skill.Steps, wantSteps) { + t.Fatalf("unexpected Next.js voice agent steps:\n got: %#v\nwant: %#v", skill.Steps, wantSteps) + } + if skill.Description != "Create a runnable Next.js conversational AI voice agent." || !slices.Contains(skill.Tags, "voice-agent") { + t.Fatalf("unexpected Next.js voice agent discovery metadata: description=%q tags=%#v", skill.Description, skill.Tags) + } + if !slices.Contains(skill.NextSteps, "Run agora project doctor --feature convoai --deep --json to validate project and quickstart readiness.") { + t.Fatalf("Next.js voice agent skill is missing ConvoAI deep doctor guidance: %#v", skill.NextSteps) + } + return + } + t.Fatal("Next.js voice agent skill not found") +} + func TestGitQuickstartCloneArgs(t *testing.T) { args := gitQuickstartCloneArgs("https://github.com/AgoraIO/example", "/tmp/example", "") want := []string{"-c", "credential.helper=", "clone", "--depth", "1", "--", "https://github.com/AgoraIO/example", "/tmp/example"} @@ -348,6 +420,55 @@ func TestQuickstartRepoOverrideKey(t *testing.T) { } } +func TestSelectQuickstartDefinitionUsesScenarioAndPreservesTemplateDefaults(t *testing.T) { + rtc, err := selectQuickstartDefinition("nextjs", "video-call") + if err != nil { + t.Fatal(err) + } + if rtc.ID != "nextjs-video-call" || rtc.Template != "nextjs" || rtc.Scenario != "video-call" || !reflect.DeepEqual(rtc.RequiredFeatures, []string{"rtc"}) { + t.Fatalf("unexpected rtc quickstart definition: %+v", rtc) + } + + legacy, err := selectQuickstartDefinition("nextjs", "") + if err != nil { + t.Fatal(err) + } + if legacy.Template != "nextjs" || legacy.Scenario != "voice-agent" || !legacy.DefaultScenario { + t.Fatalf("unexpected default nextjs quickstart definition: %+v", legacy) + } + + _, err = selectQuickstartDefinition("go", "video-call") + assertCLIErrorCode(t, err, "QUICKSTART_SCENARIO_UNSUPPORTED") + _, err = selectQuickstartDefinition("nextjs", "not-a-scenario") + assertCLIErrorCode(t, err, "QUICKSTART_SCENARIO_UNKNOWN") +} + +func TestResolveQuickstartDefinitionUsesManifestAndRejectsConflicts(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, quickstartManifestFileName), []byte(`{"schemaVersion":1,"template":"nextjs","scenario":"video-call"}`), 0o644); err != nil { + t.Fatal(err) + } + definition, err := resolveQuickstartTemplateForPath(root, "", "") + if err != nil { + t.Fatal(err) + } + if definition.ID != "nextjs-video-call" { + t.Fatalf("manifest resolved to %+v", definition) + } + + if err := writeLocalProjectBinding(root, localProjectBinding{ProjectID: "prj_1", Template: "nextjs", Scenario: "voice-agent"}); err != nil { + t.Fatal(err) + } + _, err = resolveQuickstartTemplateForPath(root, "", "") + assertCLIErrorCode(t, err, "QUICKSTART_SELECTION_MISMATCH") + + if err := os.WriteFile(filepath.Join(root, quickstartManifestFileName), []byte(`{"schemaVersion":2,"template":"nextjs","scenario":"video-call"}`), 0o644); err != nil { + t.Fatal(err) + } + _, err = resolveQuickstartTemplateForPath(root, "", "") + assertCLIErrorCode(t, err, "QUICKSTART_MANIFEST_INVALID") +} + func TestQuickstartRepoURLOverride(t *testing.T) { tmpl := quickstartTemplate{ ID: "nextjs", diff --git a/internal/cli/recipes.go b/internal/cli/recipes.go index c179b15..e674a6b 100644 --- a/internal/cli/recipes.go +++ b/internal/cli/recipes.go @@ -218,7 +218,7 @@ func (a *App) initRecipeProject(name, targetDir string, recipe recipeDetail, exi if err != nil { return nil, err } - resolution, err := a.resolveInitProjectForScaffold(name, existingProject, features, rtmDataCenter, newProject, promptForReuse, promptOut, promptIn, progress) + resolution, err := a.resolveInitProjectForScaffold(name, defaultInitFeatures(), existingProject, features, rtmDataCenter, newProject, promptForReuse, promptOut, promptIn, progress) if err != nil { return nil, err } diff --git a/internal/cli/render.go b/internal/cli/render.go index d310176..b347d96 100644 --- a/internal/cli/render.go +++ b/internal/cli/render.go @@ -49,7 +49,7 @@ func renderResult(cmd *cobra.Command, command string, data any) error { if list, ok := m["enabledFeatures"].([]string); ok { features = strings.Join(list, ", ") } - printBlock(out, "Project", [][2]string{{"Name", asString(m["projectName"])}, {"Project ID", asString(m["projectId"])}, {"App ID", asString(m["appId"])}, {"Region", asString(m["region"])}, {"Features", features}}) + printBlock(out, "Project", [][2]string{{"Name", asString(m["projectName"])}, {"Project ID", asString(m["projectId"])}, {"App ID", asString(m["appId"])}, {"Region", asString(m["region"])}, {"Template", asString(m["template"])}, {"Features", features}}) case "project use": m := data.(map[string]any) printBlock(out, "Current Project", [][2]string{{"Name", asString(m["projectName"])}, {"Project ID", asString(m["projectId"])}, {"Region", asString(m["region"])}}) @@ -70,10 +70,16 @@ func renderResult(cmd *cobra.Command, command string, data any) error { fmt.Fprintln(out, "Quickstarts") if items, ok := m["items"].([]map[string]any); ok { for _, item := range items { - fmt.Fprintf(out, "- %s: %s\n", asString(item["id"]), asString(item["title"])) + fmt.Fprintf(out, "- %s + %s: %s\n", asString(item["template"]), asString(item["scenario"]), asString(item["title"])) if details, _ := m["details"].(bool); details { + features := "-" + if list, ok := item["requiredFeatures"].([]string); ok { + features = strings.Join(list, ", ") + } + fmt.Fprintf(out, " ID: %s\n", asString(item["id"])) fmt.Fprintf(out, " Available: %s\n", asString(item["available"])) fmt.Fprintf(out, " Runtime: %s\n", asString(item["runtime"])) + fmt.Fprintf(out, " Required Features: %s\n", features) fmt.Fprintf(out, " Supports Init: %s\n", asString(item["supportsInit"])) fmt.Fprintf(out, " Env: %s\n", asString(item["envDocs"])) fmt.Fprintf(out, " Repo: %s\n", asString(item["repoUrl"])) @@ -82,7 +88,14 @@ func renderResult(cmd *cobra.Command, command string, data any) error { } case "quickstart create": m := data.(map[string]any) - printBlock(out, "Quickstart", [][2]string{{"Template", asString(m["template"])}, {"Path", asString(m["path"])}, {"Project", asString(m["projectName"])}, {"Env", asString(m["envStatus"])}, {"Metadata", asString(m["metadataPath"])}, {"Status", asString(m["status"])}}) + fields := [][2]string{{"Template", asString(m["template"])}, {"Scenario", asString(m["scenario"])}, {"Path", asString(m["path"])}, {"Project", asString(m["projectName"])}, {"Env", asString(m["envStatus"])}, {"Metadata", asString(m["metadataPath"])}, {"Status", asString(m["status"])}} + if packageManager, ok := m["packageManager"].(*quickstartPackageManagerResult); ok { + fields = append(fields, [2]string{"Package Manager", quickstartPackageManagerSummary(packageManager)}) + } + printBlock(out, "Quickstart", fields) + if packageManager, ok := m["packageManager"].(*quickstartPackageManagerResult); ok && packageManager.Message != "" { + fmt.Fprintf(out, "Setup: %s\n", packageManager.Message) + } if steps, ok := m["nextSteps"].([]string); ok && len(steps) > 0 { fmt.Fprintln(out) fmt.Fprintln(out, "Next Steps") @@ -92,7 +105,7 @@ func renderResult(cmd *cobra.Command, command string, data any) error { } case "quickstart env write": m := data.(map[string]any) - printBlock(out, "Quickstart Env", [][2]string{{"Template", asString(m["template"])}, {"Project", asString(m["projectName"])}, {"Path", asString(m["path"])}, {"Env Path", asString(m["envPath"])}, {"Metadata", asString(m["metadataPath"])}, {"Status", asString(m["status"])}}) + printBlock(out, "Quickstart Env", [][2]string{{"Template", asString(m["template"])}, {"Scenario", asString(m["scenario"])}, {"Project", asString(m["projectName"])}, {"Path", asString(m["path"])}, {"Env Path", asString(m["envPath"])}, {"Metadata", asString(m["metadataPath"])}, {"Status", asString(m["status"])}}) case "recipes list": m := data.(map[string]any) fmt.Fprintln(out, "Recipes") @@ -115,7 +128,17 @@ func renderResult(cmd *cobra.Command, command string, data any) error { if list, ok := m["enabledFeatures"].([]string); ok && len(list) > 0 { features = strings.Join(list, ", ") } - printBlock(out, "Init", [][2]string{{"Source", asString(m["sourceType"])}, {"Source ID", asString(m["sourceId"])}, {"Project", asString(m["projectName"])}, {"Project ID", asString(m["projectId"])}, {"Project Action", asString(m["projectAction"])}, {"Region", asString(m["region"])}, {"Path", asString(m["path"])}, {"Env Path", asString(m["envPath"])}, {"Metadata", asString(m["metadataPath"])}, {"Features", features}, {"Status", asString(m["status"])}}) + fields := [][2]string{{"Source", asString(m["sourceType"])}, {"Source ID", asString(m["sourceId"])}, {"Project", asString(m["projectName"])}, {"Project ID", asString(m["projectId"])}, {"Project Action", asString(m["projectAction"])}, {"Region", asString(m["region"])}, {"Path", asString(m["path"])}, {"Env Path", asString(m["envPath"])}, {"Metadata", asString(m["metadataPath"])}, {"Features", features}, {"Status", asString(m["status"])}} + if asString(m["scenario"]) != "" { + fields = append(fields, [2]string{"Scenario", asString(m["scenario"])}) + } + if packageManager, ok := m["packageManager"].(*quickstartPackageManagerResult); ok { + fields = append(fields, [2]string{"Package Manager", quickstartPackageManagerSummary(packageManager)}) + } + printBlock(out, "Init", fields) + if packageManager, ok := m["packageManager"].(*quickstartPackageManagerResult); ok && packageManager.Message != "" { + fmt.Fprintf(out, "Setup: %s\n", packageManager.Message) + } if steps, ok := m["nextSteps"].([]string); ok && len(steps) > 0 { fmt.Fprintln(out) fmt.Fprintln(out, "Next Steps") diff --git a/internal/cli/render_test.go b/internal/cli/render_test.go index f1d9871..acee2ac 100644 --- a/internal/cli/render_test.go +++ b/internal/cli/render_test.go @@ -59,3 +59,14 @@ func TestTerminalValueWidthZeroWhenWidthUnknown(t *testing.T) { t.Fatalf("terminalValueWidth = %d, want >= 0", got) } } + +func TestQuickstartPackageManagerSummary(t *testing.T) { + ready := &quickstartPackageManagerResult{Name: "pnpm", RequiredVersion: "9.15.9", Strategy: "npx", Ready: true} + if got := quickstartPackageManagerSummary(ready); got != "pnpm 9.15.9 via npx" { + t.Fatalf("ready summary = %q", got) + } + unavailable := &quickstartPackageManagerResult{Name: "pnpm", RequiredVersion: "9.15.9", Strategy: "unavailable"} + if got := quickstartPackageManagerSummary(unavailable); got != "pnpm 9.15.9 unavailable" { + t.Fatalf("unavailable summary = %q", got) + } +} diff --git a/internal/cli/skills.go b/internal/cli/skills.go index b6c47da..09f7e9e 100644 --- a/internal/cli/skills.go +++ b/internal/cli/skills.go @@ -34,26 +34,42 @@ func skillsCatalog() []skill { { ID: "create-nextjs-video-app", Title: "Create a Next.js video app", - Description: "Scaffold a runnable Next.js video app bound to an Agora project, with credentials wired into .env.local.", + Description: "Create a runnable Next.js one-to-one RTC audio and video call.", Category: "scaffold", - Tags: []string{"nextjs", "rtc", "video", "init"}, + Tags: []string{"nextjs", "rtc", "video", "video-call", "init"}, Steps: []string{ "agora login", - "agora init my-nextjs-demo --template nextjs --new-project --json", - "cd my-nextjs-demo && npm install && npm run dev", + "agora init my-nextjs-demo --template nextjs --scenario video-call --new-project --json", + "Run the nextSteps returned by agora init exactly; they select pnpm or the pinned npx fallback for this machine.", }, NextSteps: []string{ "Open http://localhost:3000 to verify the app boots.", - "Run agora project doctor --json to confirm RTC is enabled.", + "Run agora project doctor --feature rtc --deep --json to validate the project, manifest, binding, and env.", }, DocsURL: "https://agoraio.github.io/cli/install.html", }, + { + ID: "create-nextjs-voice-agent", + Title: "Create a Next.js voice agent (ConvoAI)", + Description: "Create a runnable Next.js conversational AI voice agent.", + Category: "scaffold", + Tags: []string{"nextjs", "convoai", "voice", "voice-agent", "init"}, + Steps: []string{ + "agora login", + "agora init my-nextjs-voice-agent --template nextjs --scenario voice-agent --new-project --json", + "cd my-nextjs-voice-agent && pnpm install && pnpm dev", + }, + NextSteps: []string{ + "Open http://localhost:3000 to verify the app boots.", + "Run agora project doctor --feature convoai --deep --json to validate project and quickstart readiness.", + }, + }, { ID: "create-python-voice-agent", Title: "Create a Python voice agent (ConvoAI)", Description: "Bootstrap a Python ConvoAI voice agent with project metadata and env wiring.", Category: "scaffold", - Tags: []string{"python", "convoai", "voice", "init"}, + Tags: []string{"python", "convoai", "voice", "voice-agent", "init"}, Steps: []string{ "agora login", "agora init my-voice-agent --template python --new-project --feature convoai --json", @@ -69,7 +85,7 @@ func skillsCatalog() []skill { Title: "Create a Go voice agent (ConvoAI)", Description: "Bootstrap a Go ConvoAI voice agent with project metadata and env wiring.", Category: "scaffold", - Tags: []string{"go", "convoai", "voice", "backend", "init"}, + Tags: []string{"go", "convoai", "voice", "voice-agent", "init"}, Steps: []string{ "agora login", "agora init my-go-voice-agent --template go --new-project --feature convoai --json", diff --git a/internal/cli/testdata/golden/introspect-enums.json b/internal/cli/testdata/golden/introspect-enums.json index 4108b61..7b2b9f9 100644 --- a/internal/cli/testdata/golden/introspect-enums.json +++ b/internal/cli/testdata/golden/introspect-enums.json @@ -14,6 +14,20 @@ "pretty", "json" ], + "projectTemplates": [ + "video-call", + "voice-agent" + ], + "quickstartScenarios": [ + "voice-agent", + "video-call" + ], + "quickstartTemplates": [ + "nextjs", + "python", + "go", + "android" + ], "recipeTypes": [ "all", "ai", From fdd46118fbacf229d6dd6139b8224a07b6998a1d Mon Sep 17 00:00:00 2001 From: zhaoren Date: Wed, 9 Sep 2026 16:48:12 +0800 Subject: [PATCH 02/10] fix(quickstart): use native npm fallback --- README.md | 5 ++-- docs/automation.md | 18 ++++++++----- docs/commands.md | 8 +++--- internal/cli/docgen.go | 10 +++++-- internal/cli/integration_init_test.go | 3 +++ internal/cli/integration_quickstart_test.go | 3 +++ internal/cli/quickstart_setup.go | 25 ++++++++++------- internal/cli/quickstart_setup_test.go | 30 ++++++++++++--------- internal/cli/quickstart_test.go | 2 +- internal/cli/render_test.go | 4 +-- internal/cli/skills.go | 2 +- 11 files changed, 70 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index abf5df6..e88785d 100644 --- a/README.md +++ b/README.md @@ -276,8 +276,9 @@ Quickstart template behavior: - 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 when reconfigured, while legacy `APP_ID` / `APP_CERTIFICATE` assignments are commented out and replaced with `AGORA_APP_ID` / `AGORA_APP_CERTIFICATE`. - The RTC Next.js quickstart reads its pinned pnpm version after clone. Matching - pnpm produces native setup steps; otherwise the CLI uses a version-pinned npx - fallback when available. The CLI never installs dependencies or global tools. + pnpm produces reproducible setup steps; otherwise the CLI uses native npm + without creating `package-lock.json`. The CLI never installs dependencies or + global tools. `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 ae8e64c..9e7df44 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -378,7 +378,7 @@ Required `data` fields: - `enabledFeatures` Array of features enabled during this run. A new project starts from the selected scenario's required features and merges explicit `--feature` values; `nextjs + video-call` enables only `rtc` unless more features are requested. Empty for existing projects since the CLI did not create them in this run. - `nextSteps` - Ordered list of suggested follow-up commands for the selected source. For the RTC Next.js quickstart, these use matching pnpm or a version-pinned npx fallback detected after clone. + Ordered list of suggested follow-up commands for the selected source. For the RTC Next.js quickstart, these use matching pnpm or a native npm fallback detected after clone. - `status` Currently `ready`. @@ -394,8 +394,10 @@ Optional fields: RTM data center configured on the new project when RTM was enabled. Defaults to `NA` when `--rtm-data-center` is omitted. - `packageManager` Present when the selected quickstart exposes a supported pinned package manager. - Fields are `name`, `requiredVersion`, optional `detectedVersion`, `strategy` - (`native`, `npx`, or `unavailable`), `ready`, and optional `message`. + Existing fields are `name`, `requiredVersion`, optional `detectedVersion`, + `strategy` (`native`, `npm`, or `unavailable`), `ready`, and optional + `message`. `selectedName` and `selectedVersion` identify the command runner + used by the resolved steps. Display-oriented fields: - `title` @@ -410,6 +412,7 @@ Safe branch fields: - `packageManager.requiredVersion` - `packageManager.strategy` - `packageManager.ready` +- `packageManager.selectedName` - `projectAction` - `projectId` - `path` @@ -707,8 +710,9 @@ Automation notes: - Non-default scenarios such as `nextjs + video-call` must provide `agora.quickstart.json` with matching `template` and `scenario`. The CLI validates it after clone and removes the target before writing env or binding data when validation fails. Existing default-scenario quickstarts remain compatible without a manifest. - After cloning `nextjs + video-call`, the CLI reads `package.json#packageManager`. An exact pnpm match produces `pnpm install --frozen-lockfile` and `pnpm dev`. - Missing or mismatched pnpm produces pinned npx steps when npx is available. - If neither is available, `packageManager.ready` is false and no unusable + Missing or mismatched pnpm produces `npm install --package-lock=false` and + `npm run dev` when npm is available. If neither is available, + `packageManager.ready` is false and no unusable install or run command is included in `nextSteps`. Example: @@ -747,7 +751,8 @@ Optional fields: Present for RTC Next.js when `package.json#packageManager` is a strict `pnpm@..` value. Its fields are `name`, `requiredVersion`, optional `detectedVersion`, `strategy`, `ready`, and an - optional diagnostic `message`. + optional diagnostic `message`. `selectedName` and `selectedVersion` identify + the package manager used by the resolved steps. Safe branch fields: - `template` @@ -757,6 +762,7 @@ Safe branch fields: - `packageManager.requiredVersion` - `packageManager.strategy` - `packageManager.ready` +- `packageManager.selectedName` - `path` - `envStatus` - `envPath` diff --git a/docs/commands.md b/docs/commands.md index 1d7ff39..00240ff 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -464,10 +464,10 @@ Show the current auth status **`doctorStatus`**: `healthy`, `warning`, `not_ready`, `auth_error` -**`recipeTypes`**: `all`, `ai`, `rtc` - **`projectTemplates`**: `video-call`, `voice-agent` -**`quickstartTemplates`**: `nextjs`, `python`, `go` - **`quickstartScenarios`**: `voice-agent`, `video-call` + +**`quickstartTemplates`**: `nextjs`, `python`, `go`, `android` + +**`recipeTypes`**: `all`, `ai`, `rtc` diff --git a/internal/cli/docgen.go b/internal/cli/docgen.go index 0084183..95b3b1a 100644 --- a/internal/cli/docgen.go +++ b/internal/cli/docgen.go @@ -12,6 +12,7 @@ package cli import ( "fmt" "io" + "sort" "strings" "github.com/spf13/cobra" @@ -82,11 +83,16 @@ func RenderCommandReference(out io.Writer, root *cobra.Command) error { seen[key] = true } } - for key, values := range enums { + remaining := make([]string, 0, len(enums)-len(seen)) + for key := range enums { if !seen[key] { - writeEnumRow(&b, key, values) + remaining = append(remaining, key) } } + sort.Strings(remaining) + for _, key := range remaining { + writeEnumRow(&b, key, enums[key]) + } } _, err := io.WriteString(out, strings.TrimRight(b.String(), "\n")+"\n") diff --git a/internal/cli/integration_init_test.go b/internal/cli/integration_init_test.go index 29b89bd..ae537a7 100644 --- a/internal/cli/integration_init_test.go +++ b/internal/cli/integration_init_test.go @@ -34,6 +34,9 @@ func TestCLIInitRTCVideoCallCreatesRTCOnlyProject(t *testing.T) { if !strings.Contains(result.stdout, `"packageManager":{"name":"pnpm","requiredVersion":"9.15.9"`) { t.Fatalf("rtc init is missing package manager setup metadata: %+v", result) } + if !strings.Contains(result.stdout, `"selectedName":`) { + t.Fatalf("rtc init is missing selected package manager metadata: %+v", result) + } if strings.Contains(result.stdout, `"rtmDataCenter"`) || strings.Contains(result.stdout, `"convoai"`) { t.Fatalf("rtc init enabled unrelated features: %+v", result) } diff --git a/internal/cli/integration_quickstart_test.go b/internal/cli/integration_quickstart_test.go index 8d6a853..4f67bf1 100644 --- a/internal/cli/integration_quickstart_test.go +++ b/internal/cli/integration_quickstart_test.go @@ -45,6 +45,9 @@ func TestCLIRTCVideoCallQuickstartScenario(t *testing.T) { if !strings.Contains(create.stdout, `"packageManager":{"name":"pnpm","requiredVersion":"9.15.9"`) { t.Fatalf("rtc quickstart create is missing package manager setup metadata: %+v", create) } + if !strings.Contains(create.stdout, `"selectedName":`) { + t.Fatalf("rtc quickstart create is missing selected package manager metadata: %+v", create) + } binding, err := loadLocalProjectBinding(target) if err != nil { t.Fatal(err) diff --git a/internal/cli/quickstart_setup.go b/internal/cli/quickstart_setup.go index fd53cfa..52bb591 100644 --- a/internal/cli/quickstart_setup.go +++ b/internal/cli/quickstart_setup.go @@ -15,6 +15,8 @@ type quickstartPackageManagerResult struct { Name string `json:"name"` RequiredVersion string `json:"requiredVersion"` DetectedVersion string `json:"detectedVersion,omitempty"` + SelectedName string `json:"selectedName,omitempty"` + SelectedVersion string `json:"selectedVersion,omitempty"` Strategy string `json:"strategy"` Ready bool `json:"ready"` Message string `json:"message,omitempty"` @@ -35,7 +37,10 @@ func quickstartPackageManagerSummary(packageManager *quickstartPackageManagerRes if packageManager.Strategy == "unavailable" { return base + " unavailable" } - return base + " via " + packageManager.Strategy + if packageManager.SelectedName == packageManager.Name { + return base + } + return base + " via " + packageManager.SelectedName + " " + packageManager.SelectedVersion } func probeQuickstartTool(root, command string) (string, bool) { @@ -43,9 +48,6 @@ func probeQuickstartTool(root, command string) (string, bool) { if err != nil { return "", false } - if command == "npx" { - return "", true - } cmd := exec.Command(path, "--version") cmd.Dir = root output, err := cmd.Output() @@ -78,23 +80,26 @@ func resolveQuickstartSetup(template quickstartTemplate, targetDir string, probe template.RunCommand, } packageManager.Strategy = "native" + packageManager.SelectedName = name + packageManager.SelectedVersion = detectedVersion packageManager.Ready = true return setup } - if _, npxAvailable := probe(targetDir, "npx"); npxAvailable { - spec := name + "@" + version + if npmVersion, npmAvailable := probe(targetDir, "npm"); npmAvailable { setup.NextSteps = []string{ "cd " + filepath.Base(targetDir), - "npx --yes " + spec + " install --frozen-lockfile", - "npx --yes " + spec + " dev", + "npm install --package-lock=false", + "npm run dev", } - packageManager.Strategy = "npx" + packageManager.Strategy = "npm" + packageManager.SelectedName = "npm" + packageManager.SelectedVersion = npmVersion packageManager.Ready = true return setup } setup.NextSteps = []string{"cd " + filepath.Base(targetDir)} packageManager.Strategy = "unavailable" - packageManager.Message = "pnpm " + version + " is unavailable and npx was not found; install Node.js with npm, then rerun the setup commands." + packageManager.Message = "pnpm " + version + " is unavailable and npm was not found; install Node.js with npm or install pnpm, then rerun the setup commands." return setup } diff --git a/internal/cli/quickstart_setup_test.go b/internal/cli/quickstart_setup_test.go index 72b25f9..477cb56 100644 --- a/internal/cli/quickstart_setup_test.go +++ b/internal/cli/quickstart_setup_test.go @@ -28,12 +28,12 @@ func TestResolveQuickstartSetupUsesMatchingPNPM(t *testing.T) { if !reflect.DeepEqual(setup.NextSteps, wantSteps) { t.Fatalf("next steps:\n got: %#v\nwant: %#v", setup.NextSteps, wantSteps) } - if setup.PackageManager == nil || setup.PackageManager.Strategy != "native" || !setup.PackageManager.Ready || setup.PackageManager.RequiredVersion != "9.15.9" || setup.PackageManager.DetectedVersion != "9.15.9" { + if setup.PackageManager == nil || setup.PackageManager.Strategy != "native" || !setup.PackageManager.Ready || setup.PackageManager.RequiredVersion != "9.15.9" || setup.PackageManager.DetectedVersion != "9.15.9" || setup.PackageManager.SelectedName != "pnpm" || setup.PackageManager.SelectedVersion != "9.15.9" { t.Fatalf("unexpected package manager result: %+v", setup.PackageManager) } } -func TestResolveQuickstartSetupFallsBackToNPXWhenPNPMIsMissing(t *testing.T) { +func TestResolveQuickstartSetupFallsBackToNPMWhenPNPMIsMissing(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9"}`), 0o644); err != nil { t.Fatal(err) @@ -44,23 +44,26 @@ func TestResolveQuickstartSetupFallsBackToNPXWhenPNPMIsMissing(t *testing.T) { } setup := resolveQuickstartSetup(template, root, func(_ string, command string) (string, bool) { - return "", command == "npx" + if command == "npm" { + return "10.9.4", true + } + return "", false }) wantSteps := []string{ "cd " + filepath.Base(root), - "npx --yes pnpm@9.15.9 install --frozen-lockfile", - "npx --yes pnpm@9.15.9 dev", + "npm install --package-lock=false", + "npm run dev", } if !reflect.DeepEqual(setup.NextSteps, wantSteps) { t.Fatalf("next steps:\n got: %#v\nwant: %#v", setup.NextSteps, wantSteps) } - if setup.PackageManager == nil || setup.PackageManager.Strategy != "npx" || !setup.PackageManager.Ready || setup.PackageManager.DetectedVersion != "" { + if setup.PackageManager == nil || setup.PackageManager.Strategy != "npm" || !setup.PackageManager.Ready || setup.PackageManager.DetectedVersion != "" || setup.PackageManager.SelectedName != "npm" || setup.PackageManager.SelectedVersion != "10.9.4" { t.Fatalf("unexpected package manager result: %+v", setup.PackageManager) } } -func TestResolveQuickstartSetupFallsBackToNPXWhenPNPMVersionDiffers(t *testing.T) { +func TestResolveQuickstartSetupFallsBackToNPMWhenPNPMVersionDiffers(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9"}`), 0o644); err != nil { t.Fatal(err) @@ -74,18 +77,21 @@ func TestResolveQuickstartSetupFallsBackToNPXWhenPNPMVersionDiffers(t *testing.T if command == "pnpm" { return "8.15.9", true } - return "", command == "npx" + if command == "npm" { + return "10.9.4", true + } + return "", false }) - if setup.PackageManager == nil || setup.PackageManager.Strategy != "npx" || setup.PackageManager.DetectedVersion != "8.15.9" { + if setup.PackageManager == nil || setup.PackageManager.Strategy != "npm" || setup.PackageManager.DetectedVersion != "8.15.9" || setup.PackageManager.SelectedName != "npm" || setup.PackageManager.SelectedVersion != "10.9.4" { t.Fatalf("unexpected package manager result: %+v", setup.PackageManager) } - if got := setup.NextSteps[1]; got != "npx --yes pnpm@9.15.9 install --frozen-lockfile" { + if got := setup.NextSteps[1]; got != "npm install --package-lock=false" { t.Fatalf("install step = %q", got) } } -func TestResolveQuickstartSetupReportsUnavailableWithoutPNPMOrNPX(t *testing.T) { +func TestResolveQuickstartSetupReportsUnavailableWithoutPNPMOrNPM(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9"}`), 0o644); err != nil { t.Fatal(err) @@ -119,7 +125,7 @@ func TestResolveQuickstartSetupDoesNotInterpolateMalformedPackageManager(t *test } setup := resolveQuickstartSetup(template, root, func(_ string, command string) (string, bool) { - return "", command == "npx" + return "", command == "npm" }) wantSteps := []string{"cd " + filepath.Base(root), "pnpm install", "pnpm dev"} diff --git a/internal/cli/quickstart_test.go b/internal/cli/quickstart_test.go index 156cff7..73bd7ce 100644 --- a/internal/cli/quickstart_test.go +++ b/internal/cli/quickstart_test.go @@ -238,7 +238,7 @@ func TestNextJSVideoAppSkillUsesVideoCallQuickstartWorkflow(t *testing.T) { wantSteps := []string{ "agora login", "agora init my-nextjs-demo --template nextjs --scenario video-call --new-project --json", - "Run the nextSteps returned by agora init exactly; they select pnpm or the pinned npx fallback for this machine.", + "Run the nextSteps returned by agora init exactly; they select exact pnpm or the native npm fallback for this machine.", } if !reflect.DeepEqual(skill.Steps, wantSteps) { t.Fatalf("unexpected Next.js video app steps:\n got: %#v\nwant: %#v", skill.Steps, wantSteps) diff --git a/internal/cli/render_test.go b/internal/cli/render_test.go index acee2ac..024a629 100644 --- a/internal/cli/render_test.go +++ b/internal/cli/render_test.go @@ -61,8 +61,8 @@ func TestTerminalValueWidthZeroWhenWidthUnknown(t *testing.T) { } func TestQuickstartPackageManagerSummary(t *testing.T) { - ready := &quickstartPackageManagerResult{Name: "pnpm", RequiredVersion: "9.15.9", Strategy: "npx", Ready: true} - if got := quickstartPackageManagerSummary(ready); got != "pnpm 9.15.9 via npx" { + ready := &quickstartPackageManagerResult{Name: "pnpm", RequiredVersion: "9.15.9", SelectedName: "npm", SelectedVersion: "10.9.4", Strategy: "npm", Ready: true} + if got := quickstartPackageManagerSummary(ready); got != "pnpm 9.15.9 via npm 10.9.4" { t.Fatalf("ready summary = %q", got) } unavailable := &quickstartPackageManagerResult{Name: "pnpm", RequiredVersion: "9.15.9", Strategy: "unavailable"} diff --git a/internal/cli/skills.go b/internal/cli/skills.go index 09f7e9e..2fb69f6 100644 --- a/internal/cli/skills.go +++ b/internal/cli/skills.go @@ -40,7 +40,7 @@ func skillsCatalog() []skill { Steps: []string{ "agora login", "agora init my-nextjs-demo --template nextjs --scenario video-call --new-project --json", - "Run the nextSteps returned by agora init exactly; they select pnpm or the pinned npx fallback for this machine.", + "Run the nextSteps returned by agora init exactly; they select exact pnpm or the native npm fallback for this machine.", }, NextSteps: []string{ "Open http://localhost:3000 to verify the app boots.", From df0cb44f9f7f7be928678608ecb960da0175e2b9 Mon Sep 17 00:00:00 2001 From: zhaoren Date: Thu, 17 Sep 2026 17:28:55 +0800 Subject: [PATCH 03/10] docs: document required quickstart template error --- docs/error-codes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/error-codes.md b/docs/error-codes.md index 422249b..e65e263 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -37,6 +37,7 @@ This catalog is the source of truth for stable codes. CI runs `make snapshot-err | Code | Exit | Meaning | Recovery | |------|------|---------|----------| | `QUICKSTART_TEMPLATE_UNKNOWN` | 1 | The template ID is not known to this CLI. | Run `agora quickstart list`. | +| `QUICKSTART_TEMPLATE_REQUIRED` | 1 | `quickstart env write` received `--scenario` without `--template`, and the target directory has no project binding or quickstart manifest to identify the template. | Pass `--template ` or run from a quickstart directory with `.agora/project.json` or `agora.quickstart.json`. | | `QUICKSTART_SCENARIO_UNKNOWN` | 1 | The scenario is not known to this CLI. | Run `agora quickstart list` and use an `items[].scenario` value. | | `QUICKSTART_SCENARIO_UNSUPPORTED` | 1 | The scenario exists but is not supported by the selected template. | Choose a template/scenario pair returned by `agora quickstart list`. | | `QUICKSTART_SELECTION_MISMATCH` | 1 | Explicit flags, `.agora/project.json`, and `agora.quickstart.json` disagree about template/scenario identity. | Correct the conflicting selection; do not overwrite env until all sources agree. | From ee69c7cc8f0ce92bc85d0344bb56d5d494b4d195 Mon Sep 17 00:00:00 2001 From: zhaoren Date: Thu, 17 Sep 2026 19:44:19 +0800 Subject: [PATCH 04/10] fix(doctor): handle wrapped CLI errors in workspace checks Document RTC quickstart onboarding and npm fallback under Unreleased. Use errors.As when reading structured workspace doctor error codes so errorlint passes and wrapped errors preserve their codes. --- CHANGELOG.md | 8 ++++++++ internal/cli/doctor.go | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 358656a..bf1f089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] +### Added + +- Add RTC video-call onboarding to `agora init` and `agora quickstart`, including scenario selection and workspace readiness checks ([c9f5f63](https://github.com/AgoraIO/cli/commit/c9f5f63)). + +### Fixed + +- Use native npm commands when pnpm is unavailable during RTC video-call quickstart setup ([fdd4611](https://github.com/AgoraIO/cli/commit/fdd4611)). + ## [0.2.9] - 2026-08-24 Quickstart project and environment consistency, Android onboarding, Windows installer compatibility, and release workflow maintenance. diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 860b362..3362d80 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "os" "path/filepath" "strings" @@ -170,7 +171,8 @@ func buildWorkspaceDoctorDetails(target projectTarget) (doctorCheckCategory, map template, selectionErr := resolveQuickstartTemplateForPath(root, binding.Template, binding.Scenario) if selectionErr != nil { code := "WORKSPACE_TEMPLATE_UNKNOWN" - if structured, ok := selectionErr.(*cliError); ok && structured.Code != "" { + var structured *cliError + if errors.As(selectionErr, &structured) && structured.Code != "" { code = structured.Code } items = append(items, doctorCheckItem{Name: "workspace_selection", Message: selectionErr.Error(), Status: "fail"}) @@ -182,7 +184,8 @@ func buildWorkspaceDoctorDetails(target projectTarget) (doctorCheckCategory, map if !template.DefaultScenario { if manifestErr := validateRequiredQuickstartManifest(root, template); manifestErr != nil { code := "QUICKSTART_MANIFEST_INVALID" - if structured, ok := manifestErr.(*cliError); ok && structured.Code != "" { + var structured *cliError + if errors.As(manifestErr, &structured) && structured.Code != "" { code = structured.Code } message := manifestErr.Error() From 63766134233a9ddb312517783c4c96ad4b4177a5 Mon Sep 17 00:00:00 2001 From: zhaoren Date: Thu, 17 Sep 2026 19:57:30 +0800 Subject: [PATCH 05/10] fix(quickstart): point RTC starter to community repository --- internal/cli/quickstart.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/quickstart.go b/internal/cli/quickstart.go index d024ef9..72482fa 100644 --- a/internal/cli/quickstart.go +++ b/internal/cli/quickstart.go @@ -199,8 +199,8 @@ func quickstartTemplates() []quickstartTemplate { Title: "Next.js Video Call Quickstart", Description: "Build a one-to-one RTC audio and video call.", Runtime: "node", - RepoURL: "https://github.com/littleDogWang/agora-rtc-nextjs-quickstart", - DocsURL: "https://github.com/littleDogWang/agora-rtc-nextjs-quickstart", + RepoURL: "https://github.com/AgoraIO-Community/agora-rtc-quickstart-nextjs", + DocsURL: "https://github.com/AgoraIO-Community/agora-rtc-quickstart-nextjs", EnvLayouts: []quickstartEnvLayout{{ DetectPaths: []string{"agora.quickstart.json", "env.local.example"}, EnvExamplePath: "env.local.example", From 9f762ab425ebb18cfed0c793f6789454daf5da64 Mon Sep 17 00:00:00 2001 From: zhaoren Date: Thu, 17 Sep 2026 20:36:46 +0800 Subject: [PATCH 06/10] docs: remove internal RTC quickstart plan from PR --- .../plans/2026-09-02-rtc-quickstart-cli.md | 95 ------------------- 1 file changed, 95 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-02-rtc-quickstart-cli.md diff --git a/docs/superpowers/plans/2026-09-02-rtc-quickstart-cli.md b/docs/superpowers/plans/2026-09-02-rtc-quickstart-cli.md deleted file mode 100644 index 83fbbef..0000000 --- a/docs/superpowers/plans/2026-09-02-rtc-quickstart-cli.md +++ /dev/null @@ -1,95 +0,0 @@ -# RTC Quickstart CLI Implementation Plan - -> **For agentic workers:** Implement this plan task-by-task with a red-green-refactor loop. This task is local-only: do not commit, push, or mutate GitHub state. - -**Goal:** Add the `nextjs + video-call` quickstart scenario and video-call project preset described by `rtc-qs-design/03-cli-design.md`, with matching CLI, JSON, MCP, completion, and doctor behavior. - -**Architecture:** Replace the one-entry-per-template quickstart model with a catalog keyed by stable ID plus `template + scenario`. A shared selector resolves explicit flags, local binding, repository manifest, and legacy detection; all command surfaces consume that selector and the shared stable feature merger. - -**Tech Stack:** Go 1.26.5, Cobra, standard-library JSON/filesystem/process APIs, existing fake BFF and local Git integration fixtures. - ---- - -### Task 1: Project presets and stable feature calculation - -**Files:** -- Modify: `internal/cli/projects.go` -- Modify: `internal/cli/commands.go` -- Test: `internal/cli/features_test.go` -- Test: `internal/cli/integration_project_test.go` - -- [ ] Add a failing test for `project create --template video-call --dry-run --json` returning `template: video-call` and `enabledFeatures: [rtc]`. -- [ ] Add a failing test proving an unknown preset returns `PROJECT_TEMPLATE_UNKNOWN` before any fake BFF create request. -- [ ] Implement the project preset catalog and one stable feature merger that expands dependencies, deduplicates, and orders by `featureCatalog`. -- [ ] Route dry-run and real project creation through the same preset parser. -- [ ] Run focused project tests and keep old no-template behavior unchanged. - -### Task 2: Scenario catalog, manifest, and local binding - -**Files:** -- Modify: `internal/cli/quickstart.go` -- Modify: `internal/cli/local_project.go` -- Test: `internal/cli/quickstart_test.go` -- Test: `internal/cli/project_env_layout_test.go` - -- [ ] Add a failing selector test for `nextjs + video-call`, default `nextjs + voice-agent`, and unsupported combinations. -- [ ] Add failing manifest tests for valid schema v1, malformed JSON, missing fields, unsupported schema, and selection conflicts. -- [ ] Add `scenario` to local bindings while preserving legacy bindings without it. -- [ ] Implement the `nextjs-video-call` catalog entry, manifest parser, selector precedence, mismatch errors, and scenario-specific repo override key. -- [ ] Keep manifest identity aligned with the CLI parameter model: schema v1 requires explicit `template` and `scenario`; existing default-scenario quickstarts remain valid without a manifest. -- [ ] Run focused quickstart and binding tests. - -### Task 3: Quickstart and init command paths - -**Files:** -- Modify: `internal/cli/quickstart.go` -- Modify: `internal/cli/init.go` -- Modify: `internal/cli/render.go` -- Test: `internal/cli/integration_quickstart_test.go` -- Test: `internal/cli/integration_init_test.go` - -- [ ] Add a failing integration test for list/create/env-write JSON fields and RTC env layout. -- [ ] Add failing clone tests proving a missing or mismatched video-call manifest fails before env/binding writes, removes the cloned target, and does not affect default quickstarts without manifests. -- [ ] Implement `--scenario` on `quickstart create`, `quickstart env write`, and `init`. -- [ ] Validate required manifests immediately after clone and before stripping Git metadata or writing credentials; preserve the structured manifest error while reporting clone cleanup. -- [ ] Include `template`, `scenario`, and `requiredFeatures` in result payloads and bindings. -- [ ] Validate an existing project's required features before clone; return `QUICKSTART_REQUIRED_FEATURE_MISSING` with a remediation command. -- [ ] Use scenario requirements as new-project defaults, merged with explicit `--feature` values. -- [ ] Run focused quickstart and init tests, including legacy Next.js/Python/Go cases. - -### Task 4: Doctor, MCP, completion, and introspection - -**Files:** -- Modify: `internal/cli/doctor.go` -- Modify: `internal/cli/mcp.go` -- Modify: `internal/cli/completion.go` -- Modify: `internal/cli/introspect.go` -- Modify: `internal/cli/skills.go` -- Test: `internal/cli/mcp_test.go` -- Test: `internal/cli/integration_help_test.go` -- Test: `internal/cli/integration_project_test.go` -- Test: `internal/cli/quickstart_test.go` - -- [ ] Add failing tests for MCP scenario schemas/dispatch and completion values. -- [ ] Add failing deep-doctor tests for binding/manifest/catalog/env consistency. -- [ ] Route MCP tools through the same command selectors and expose scenario in schemas/results. -- [ ] Complete project preset and scenario values from their catalogs; filter scenario completion by template when available. -- [ ] Update `create-nextjs-video-app` to run `init --template nextjs --scenario video-call --new-project --json`, use `pnpm install && pnpm dev`, and recommend `project doctor --feature rtc --deep --json`. -- [ ] Add a catalog test that locks the built-in RTC skill to the video-call scenario and the Quickstart's package-manager/runtime commands. -- [ ] Ensure introspection exposes every new flag and enum source. -- [ ] Run focused doctor, MCP, completion, and introspection tests. - -### Task 5: Documentation and complete local verification - -**Files:** -- Modify: `docs/commands.md` -- Modify: `docs/automation.md` -- Modify: `docs/llms.txt` - -- [ ] Regenerate command documentation from the live Cobra tree. -- [ ] Document stable JSON/MCP fields, project presets, scenarios, errors, manifest, and repo override. -- [ ] Run `gofmt` and `go test ./...`. -- [ ] Build `./agora` and inspect `--help --all`, `introspect --json`, and MCP tool schemas. -- [ ] Clone `https://github.com/littleDogWang/agora-rtc-nextjs-quickstart` into a temporary local fixture and verify its manifest/env layout. -- [ ] Run all new commands locally using isolated config and fake/local endpoints where remote control-plane state would otherwise be required. -- [ ] Report observed command results separately from runtime/media behavior, which remains outside CLI scope. From 80674c79cd920ca6df85dbdeae381eab7b586c17 Mon Sep 17 00:00:00 2001 From: zhaoren Date: Thu, 17 Sep 2026 22:08:03 +0800 Subject: [PATCH 07/10] docs(changelog): include RTC updates in v0.2.9 --- CHANGELOG.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf1f089..6dd4da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,20 +15,13 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] -### Added - -- Add RTC video-call onboarding to `agora init` and `agora quickstart`, including scenario selection and workspace readiness checks ([c9f5f63](https://github.com/AgoraIO/cli/commit/c9f5f63)). - -### Fixed - -- Use native npm commands when pnpm is unavailable during RTC video-call quickstart setup ([fdd4611](https://github.com/AgoraIO/cli/commit/fdd4611)). - ## [0.2.9] - 2026-08-24 -Quickstart project and environment consistency, Android onboarding, Windows installer compatibility, and release workflow maintenance. +Quickstart project and environment consistency, Android and RTC video-call onboarding, Windows installer compatibility, and release workflow maintenance. ### Added +- Add RTC video-call onboarding to `agora init` and `agora quickstart`, including scenario selection and workspace readiness checks ([c9f5f63](https://github.com/AgoraIO/cli/commit/c9f5f63)). - Add the Android conversational AI client/server quickstart to `agora init` and `agora quickstart`, writing credentials only to the included Python server and returning setup steps for the server, HTTPS tunnel, and Android client ([#55](https://github.com/AgoraIO/cli/pull/55)). - Add official recipe discovery with `agora recipes list/show` and recipe-backed initialization with `agora init --recipe `. Recipe metadata is resolved from the versioned `recipes.agora.io` API before project creation, and the API-provided env contract drives credential writing without framework guessing. @@ -41,6 +34,7 @@ Quickstart project and environment consistency, Android onboarding, Windows inst ### Fixed +- Use native npm commands when pnpm is unavailable during RTC video-call quickstart setup ([fdd4611](https://github.com/AgoraIO/cli/commit/fdd4611)). - Bump the pinned Go toolchain to 1.26.6 to address reachable standard-library vulnerabilities reported by `govulncheck` in Go 1.26.5 ([09fcba8](https://github.com/AgoraIO/cli/commit/09fcba8)). - Ensure `agora init`, `quickstart env write`, and `project env write` share credential-file semantics: Python and Go use `server/.env` with `AGORA_APP_ID` and `AGORA_APP_CERTIFICATE`, template example values are preserved, and unsupported legacy credential names are normalized consistently; align with the upstream Python and Go env-file migrations ([91cb8f7](https://github.com/AgoraIO/cli/commit/91cb8f7), [b285093](https://github.com/AgoraIO/cli/commit/b285093), [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)). From c481fd6534b41c605bc78737c9b0194ea0ab8ebe Mon Sep 17 00:00:00 2001 From: zhaoren Date: Sun, 20 Sep 2026 20:28:08 +0800 Subject: [PATCH 08/10] docs(changelog): move RTC updates to v0.2.10 --- CHANGELOG.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd4da7..2a1b30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,13 +15,24 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] -## [0.2.9] - 2026-08-24 +## [0.2.10] - 2026-09-20 -Quickstart project and environment consistency, Android and RTC video-call onboarding, Windows installer compatibility, and release workflow maintenance. +RTC video-call onboarding and package-manager compatibility. ### Added - Add RTC video-call onboarding to `agora init` and `agora quickstart`, including scenario selection and workspace readiness checks ([c9f5f63](https://github.com/AgoraIO/cli/commit/c9f5f63)). + +### Fixed + +- Use native npm commands when pnpm is unavailable during RTC video-call quickstart setup ([fdd4611](https://github.com/AgoraIO/cli/commit/fdd4611)). + +## [0.2.9] - 2026-08-24 + +Quickstart project and environment consistency, Android onboarding, Windows installer compatibility, and release workflow maintenance. + +### Added + - Add the Android conversational AI client/server quickstart to `agora init` and `agora quickstart`, writing credentials only to the included Python server and returning setup steps for the server, HTTPS tunnel, and Android client ([#55](https://github.com/AgoraIO/cli/pull/55)). - Add official recipe discovery with `agora recipes list/show` and recipe-backed initialization with `agora init --recipe `. Recipe metadata is resolved from the versioned `recipes.agora.io` API before project creation, and the API-provided env contract drives credential writing without framework guessing. @@ -34,7 +45,6 @@ Quickstart project and environment consistency, Android and RTC video-call onboa ### Fixed -- Use native npm commands when pnpm is unavailable during RTC video-call quickstart setup ([fdd4611](https://github.com/AgoraIO/cli/commit/fdd4611)). - Bump the pinned Go toolchain to 1.26.6 to address reachable standard-library vulnerabilities reported by `govulncheck` in Go 1.26.5 ([09fcba8](https://github.com/AgoraIO/cli/commit/09fcba8)). - Ensure `agora init`, `quickstart env write`, and `project env write` share credential-file semantics: Python and Go use `server/.env` with `AGORA_APP_ID` and `AGORA_APP_CERTIFICATE`, template example values are preserved, and unsupported legacy credential names are normalized consistently; align with the upstream Python and Go env-file migrations ([91cb8f7](https://github.com/AgoraIO/cli/commit/91cb8f7), [b285093](https://github.com/AgoraIO/cli/commit/b285093), [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)). @@ -387,7 +397,8 @@ Set `AGORA_ALLOW_UPGRADE_IN_CI=1` only when a CI job intentionally needs to muta - Support machine-readable JSON output for automation and agent workflows. - Ship automated release packaging through GoReleaser, including cross-platform archives, Linux packages, Homebrew, Scoop, npm wrapper packages, Docker images, and install scripts. -[Unreleased]: https://github.com/AgoraIO/cli/compare/v0.2.9...HEAD +[Unreleased]: https://github.com/AgoraIO/cli/compare/v0.2.10...HEAD +[0.2.10]: https://github.com/AgoraIO/cli/compare/v0.2.9...v0.2.10 [0.2.9]: https://github.com/AgoraIO/cli/compare/v0.2.8...v0.2.9 [0.2.8]: https://github.com/AgoraIO/cli/compare/v0.2.7...v0.2.8 [0.2.7]: https://github.com/AgoraIO/cli/compare/v0.2.6...v0.2.7 From dd1736f6ef6ea9abc36b7b1ec0e4e5904a30eed1 Mon Sep 17 00:00:00 2001 From: zhaoren Date: Sun, 20 Sep 2026 21:30:44 +0800 Subject: [PATCH 09/10] docs: clarify scenario feature requirements --- docs/automation.md | 7 ++++++- docs/commands.md | 2 +- internal/cli/init.go | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/automation.md b/docs/automation.md index 9e7df44..b0a4874 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -351,6 +351,8 @@ Example: By default `init` reuses an existing project — preferring one named exactly `"Default Project"`. If no default exists, interactive sessions show existing projects with a create-new option and default to the most recently created project; JSON, CI, and non-TTY runs select the most recent project automatically. Pass `--new-project` to force creation. Use `--project ` to bind to a specific project. For deterministic automation, always pass `--project ` or `--new-project`. +When reusing an existing project, `init` checks the selected scenario's required features plus any explicit `--feature` values before cloning. Each feature must be `enabled` or `included`; otherwise the command fails with `QUICKSTART_REQUIRED_FEATURE_MISSING` and suggests `agora project feature enable `. It does not automatically enable features on existing projects. Recipe-backed initialization requires `rtc`, `rtm`, and `convoai`, plus any explicit features. + Required `data` fields: - `action` Always `init`. @@ -431,6 +433,7 @@ Example: ./agora project create my-agent-demo --json ./agora project create my-agent-demo --rtm-data-center EU --json ./agora project create my-agent-demo --feature rtc --feature convoai --json +./agora project create my-video-demo --template video-call --json ``` Required `data` fields (success): @@ -441,7 +444,9 @@ Required `data` fields (success): - `appId` - `region` - `enabledFeatures` - Array of features that were enabled on the new project. Defaults to `["rtc", "rtm", "convoai"]` when no `--feature` flags are passed. Explicit `convoai` requests also include `rtm`. + Array of features that were enabled on the new project. With `--template video-call`, starts with `["rtc"]`; with `--template voice-agent`, starts with `["rtc", "rtm", "convoai"]`. Explicit `--feature` values are added to the preset's required features. Without a preset, explicit features are used, or `["rtc", "rtm", "convoai"]` when none are specified. Requests containing `convoai` also include `rtm`. +- `template` + Project preset applied (`video-call` or `voice-agent`), or an empty string when not requested. Optional fields: - `rtmDataCenter` diff --git a/docs/commands.md b/docs/commands.md index 00240ff..9cbd7d5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -108,7 +108,7 @@ Create a project and initialize an official quickstart or recipe |------|------|---------|-------------| | `--add-agent-rules` | `stringArray` | `[]` | write AI agent rules into the quickstart (repeatable: cursor, claude, windsurf) | | `--dir` | `string` | — | target directory for the cloned quickstart; defaults to | -| `--feature` | `stringArray` | `[]` | enable a feature on the newly created project (repeatable); defaults to rtc, rtm, convoai; convoai also enables rtm | +| `--feature` | `stringArray` | `[]` | add a feature to the scenario's required features (repeatable); video-call requires rtc, voice-agent requires rtc, rtm, convoai; convoai also enables rtm | | `--new-project` | `bool` | — | always create a new Agora project instead of reusing an existing one | | `--project` | `string` | — | existing project ID or exact project name to bind to | | `--recipe` | `string` | — | official Agora recipe slug (run agora recipes list to discover slugs) | diff --git a/internal/cli/init.go b/internal/cli/init.go index 3dd1026..0ab0d14 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -130,7 +130,7 @@ Use --feature to specify which features to enable on a newly created project (re cmd.Flags().StringVar(&dir, "dir", "", "target directory for the cloned quickstart; defaults to ") cmd.Flags().StringVar(&existingProject, "project", "", "existing project ID or exact project name to bind to") cmd.Flags().StringVar(&rtmDataCenter, "rtm-data-center", "", "RTM data center to configure when rtm is enabled on a newly created project (CN, NA, EU, or AP); defaults to NA") - cmd.Flags().StringArrayVar(&features, "feature", nil, fmt.Sprintf("enable a feature on the newly created project (repeatable); defaults to %s; convoai also enables rtm", featureListString())) + cmd.Flags().StringArrayVar(&features, "feature", nil, "add a feature to the scenario's required features (repeatable); video-call requires rtc, voice-agent requires rtc, rtm, convoai; convoai also enables rtm") cmd.Flags().StringArrayVar(&agentRules, "add-agent-rules", nil, "write AI agent rules into the quickstart (repeatable: cursor, claude, windsurf)") cmd.Flags().BoolVar(&newProject, "new-project", false, "always create a new Agora project instead of reusing an existing one") _ = cmd.RegisterFlagCompletionFunc("template", completeQuickstartTemplateIDs) From fbebdb9dfafc3d0b3acf2bbf647f4290ad0c60c7 Mon Sep 17 00:00:00 2001 From: zhaoren Date: Sun, 20 Sep 2026 23:10:24 +0800 Subject: [PATCH 10/10] fix(init): restore feature selection and workspace compatibility --- CHANGELOG.md | 6 +- docs/automation.md | 21 +- docs/commands.md | 2 +- docs/error-codes.md | 6 +- internal/cli/doctor.go | 18 +- internal/cli/doctor_test.go | 59 ++++++ internal/cli/init.go | 24 ++- internal/cli/integration_init_test.go | 175 +++++++++++++-- internal/cli/integration_project_test.go | 89 ++++++++ internal/cli/integration_quickstart_test.go | 44 +++- internal/cli/integration_recipe_test.go | 224 ++++++++++++++------ internal/cli/projects.go | 16 -- internal/cli/quickstart.go | 63 +++--- internal/cli/quickstart_test.go | 36 ++++ internal/cli/recipes.go | 8 +- 15 files changed, 627 insertions(+), 164 deletions(-) create mode 100644 internal/cli/doctor_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1b30a..cef0fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,7 @@ RTC video-call onboarding and package-manager compatibility. ### Added -- Add RTC video-call onboarding to `agora init` and `agora quickstart`, including scenario selection and workspace readiness checks ([c9f5f63](https://github.com/AgoraIO/cli/commit/c9f5f63)). - -### Fixed - -- Use native npm commands when pnpm is unavailable during RTC video-call quickstart setup ([fdd4611](https://github.com/AgoraIO/cli/commit/fdd4611)). +- Add RTC video-call onboarding to `agora init` and `agora quickstart`, with scenario selection, RTC-only project defaults, workspace readiness checks, and native npm setup commands when the required pnpm version is unavailable ([#69](https://github.com/AgoraIO/cli/pull/69)). ## [0.2.9] - 2026-08-24 diff --git a/docs/automation.md b/docs/automation.md index b0a4874..f9a99e2 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -351,7 +351,11 @@ Example: By default `init` reuses an existing project — preferring one named exactly `"Default Project"`. If no default exists, interactive sessions show existing projects with a create-new option and default to the most recently created project; JSON, CI, and non-TTY runs select the most recent project automatically. Pass `--new-project` to force creation. Use `--project ` to bind to a specific project. For deterministic automation, always pass `--project ` or `--new-project`. -When reusing an existing project, `init` checks the selected scenario's required features plus any explicit `--feature` values before cloning. Each feature must be `enabled` or `included`; otherwise the command fails with `QUICKSTART_REQUIRED_FEATURE_MISSING` and suggests `agora project feature enable `. It does not automatically enable features on existing projects. Recipe-backed initialization requires `rtc`, `rtm`, and `convoai`, plus any explicit features. +For newly created projects, `init` uses explicit `--feature` values when supplied, replacing the scaffold defaults. Without explicit features, `nextjs + video-call` defaults to `rtc`; existing voice-agent quickstarts and recipes default to `rtc`, `rtm`, and `convoai`. Explicit `convoai` also enables its `rtm` dependency. Unlike `project create --template `, init does not merge a project preset into the explicit feature list. + +When reusing an existing project (explicitly or automatically), `init` does not enable features or require the scaffold's feature list to be enabled before cloning. `--feature` only controls new project creation. Use `project feature enable` to enable features on an existing project, and `project doctor --feature ` to check runtime readiness. Invalid inputs, credential requirements, and scaffold identity checks still apply. + +Migration note for RTC onboarding previews: `init` no longer returns `QUICKSTART_REQUIRED_FEATURE_MISSING` for reused projects. Explicit quickstart features now replace scenario creation defaults rather than adding to them. The existing `project create` preset-merging behavior is unchanged. Required `data` fields: - `action` @@ -378,17 +382,17 @@ Required `data` fields: - `metadataPath` Repo-local project binding file path, currently `.agora/project.json`. - `enabledFeatures` - Array of features enabled during this run. A new project starts from the selected scenario's required features and merges explicit `--feature` values; `nextjs + video-call` enables only `rtc` unless more features are requested. Empty for existing projects since the CLI did not create them in this run. + Array of features enabled during this run, using the creation rules above. For example, voice-agent with explicit `--feature rtc` returns only `rtc`, while video-call without explicit features defaults to `rtc`. Empty for reused projects; this field does not enumerate all features already available on the project. - `nextSteps` Ordered list of suggested follow-up commands for the selected source. For the RTC Next.js quickstart, these use matching pnpm or a native npm fallback detected after clone. - `status` - Currently `ready`. + Currently `ready`, meaning the scaffold and configuration are prepared; it does not certify that all runtime features are enabled. Optional fields: - `template` Present for built-in quickstart initialization. - `scenario`, `requiredFeatures` - Present for built-in quickstart initialization; describe the selected scenario and its required features. + Present for built-in quickstart initialization; describe the selected scenario and its runtime requirements. `requiredFeatures` is not an initialization gate or a list of features enabled during this run. - `recipe`, `recipeUrl`, `recipeRawUrl`, `primaryPrompt`, `cloneUrl` Present for recipe-backed initialization. The CLI resolves this metadata from the official recipes API before it selects or creates a project. @@ -806,6 +810,9 @@ Required `data` fields: Currently `created`, `updated`, or `appended`. Env write behavior: +- for an existing workspace, omitted template/scenario fields are inherited from `.agora/project.json` and `agora.quickstart.json`; only fields absent from both sources fall back to detection or the template default +- `--template nextjs` alone does not change a recorded `video-call` scenario to `voice-agent`; legacy bindings without a scenario can inherit it from the manifest +- conflicting nonempty template/scenario declarations fail with `QUICKSTART_SELECTION_MISMATCH` before changing env or binding files - quickstart env files contain only the App ID and App Certificate variable names required by the template - Next.js uses `NEXT_PUBLIC_AGORA_APP_ID` and `NEXT_AGORA_APP_CERTIFICATE` - Python and Go use `AGORA_APP_ID` and `AGORA_APP_CERTIFICATE` @@ -862,6 +869,12 @@ Safe branch fields: - `blockingIssues` - `warnings` +Deep workspace checks: +- Recipe bindings are not validated against built-in quickstart layouts. They return a `WORKSPACE_TEMPLATE_UNKNOWN` warning explaining that recipe runtime/env checks are not covered. Generic project and binding identity checks still run. +- An unrecognized directory without a declared quickstart also produces a warning, not a template blocking issue. +- With no other issues, these cases return `healthy: true`, `status: "warning"`, `ok: false`, and exit code `2`. This means no blocking issue was found, not that the workspace was fully verified. +- Invalid declared quickstarts, required manifests, selection conflicts, and project/credential mismatches remain blocking. A workspace warning does not remove an existing blocking issue. + Recommended agent behavior: - branch first on `status` - use `healthy` as a fast readiness boolean diff --git a/docs/commands.md b/docs/commands.md index 9cbd7d5..b709ea0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -108,7 +108,7 @@ Create a project and initialize an official quickstart or recipe |------|------|---------|-------------| | `--add-agent-rules` | `stringArray` | `[]` | write AI agent rules into the quickstart (repeatable: cursor, claude, windsurf) | | `--dir` | `string` | — | target directory for the cloned quickstart; defaults to | -| `--feature` | `stringArray` | `[]` | add a feature to the scenario's required features (repeatable); video-call requires rtc, voice-agent requires rtc, rtm, convoai; convoai also enables rtm | +| `--feature` | `stringArray` | `[]` | select features for new projects (repeatable); explicit values override scenario defaults; omitted uses scenario defaults; ignored when reusing a project; convoai also enables rtm | | `--new-project` | `bool` | — | always create a new Agora project instead of reusing an existing one | | `--project` | `string` | — | existing project ID or exact project name to bind to | | `--recipe` | `string` | — | official Agora recipe slug (run agora recipes list to discover slugs) | diff --git a/docs/error-codes.md b/docs/error-codes.md index e65e263..dc3b70f 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -40,9 +40,9 @@ This catalog is the source of truth for stable codes. CI runs `make snapshot-err | `QUICKSTART_TEMPLATE_REQUIRED` | 1 | `quickstart env write` received `--scenario` without `--template`, and the target directory has no project binding or quickstart manifest to identify the template. | Pass `--template ` or run from a quickstart directory with `.agora/project.json` or `agora.quickstart.json`. | | `QUICKSTART_SCENARIO_UNKNOWN` | 1 | The scenario is not known to this CLI. | Run `agora quickstart list` and use an `items[].scenario` value. | | `QUICKSTART_SCENARIO_UNSUPPORTED` | 1 | The scenario exists but is not supported by the selected template. | Choose a template/scenario pair returned by `agora quickstart list`. | -| `QUICKSTART_SELECTION_MISMATCH` | 1 | Explicit flags, `.agora/project.json`, and `agora.quickstart.json` disagree about template/scenario identity. | Correct the conflicting selection; do not overwrite env until all sources agree. | +| `QUICKSTART_SELECTION_MISMATCH` | 1 | Nonempty declarations in explicit flags, `.agora/project.json`, and `agora.quickstart.json` disagree about template/scenario identity; omitted fields inherit the existing selection. | Correct the conflicting selection; do not overwrite env until all sources agree. | | `QUICKSTART_MANIFEST_INVALID` | 1 | `agora.quickstart.json` is malformed, uses an unsupported schema, has missing identity fields, or is required but absent. | Restore a schema v1 manifest with `template` and `scenario` matching the quickstart. | -| `QUICKSTART_REQUIRED_FEATURE_MISSING` | 1 | An existing project does not provide every feature required by the selected quickstart. | Run the `agora project feature enable ...` command in the error, then retry. | +| `QUICKSTART_REQUIRED_FEATURE_MISSING` | — (retired) | Previously used by RTC onboarding previews; no longer returned by `init` when reusing a project. | Initialization does not require feature enablement. Use `agora project doctor --feature ` to check runtime readiness and `project feature enable` when needed. | | `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`. | @@ -87,7 +87,7 @@ These codes appear inside `data.checks[].issues[].code` and (for blocking issues | `WORKSPACE_SCAN_FAILED` | 1 | `project doctor --deep` could not enumerate the repo workspace. | Inspect the directory permissions and retry. | | `LOCAL_PROJECT_BINDING_INVALID` | 1 | `.agora/project.json` exists but is missing `projectId`. | Re-bind the repo: `agora project use ` or `agora init` from the repo root. | | `LOCAL_PROJECT_BINDING_MISMATCH` | 1 | `.agora/project.json` points at a project that does not match the selected project. | Use `--project` to select the bound project, or rebind. | -| `WORKSPACE_TEMPLATE_UNKNOWN` | 1 | The CLI could not detect the quickstart template for this repo. | Pass `--template` to the failing command. | +| `WORKSPACE_TEMPLATE_UNKNOWN` | 2 (warning only) | Built-in quickstart layout checks do not cover this recipe or unrecognized workspace. | Inspect the recipe/workspace setup manually. `project doctor` has no `--template` flag. This warning does not override other blocking issues. | | `WORKSPACE_ENV_PATH_UNKNOWN` | 1 | The CLI could not determine the quickstart env target path. | Pass `--template` and re-run; if persistent, file an issue. | | `WORKSPACE_ENV_FILE_MISSING` | 1 | A quickstart env file expected by the bound template is missing. | Run the command from `suggestedCommand` (typically `agora quickstart env write`). | | `WORKSPACE_ENV_READ_FAILED` | 1 | The CLI could not read the quickstart env file. | Run the command from `suggestedCommand` (`agora quickstart env write . --project `); if it still fails, inspect file permissions and contents. | diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 3362d80..f54be21 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -168,6 +168,14 @@ func buildWorkspaceDoctorDetails(target projectTarget) (doctorCheckCategory, map items = append(items, doctorCheckItem{Name: "metadata_project_match", Message: "Repo binding matches the selected project", Status: "pass"}) } + if strings.TrimSpace(binding.Recipe) != "" { + message := "Recipe workspace detected; built-in quickstart layout checks do not apply. Recipe runtime and env validation are not covered by this check." + workspace["recipe"] = binding.Recipe + items = append(items, doctorCheckItem{Name: "workspace_selection", Message: message, Status: "warn"}) + warnings = append(warnings, doctorIssue{Code: "WORKSPACE_TEMPLATE_UNKNOWN", Message: message}) + check := doctorCheckCategory{Category: "workspace", Items: items, Status: summarizeCategoryStatus(items)} + return check, workspace, blocking, warnings + } template, selectionErr := resolveQuickstartTemplateForPath(root, binding.Template, binding.Scenario) if selectionErr != nil { code := "WORKSPACE_TEMPLATE_UNKNOWN" @@ -175,8 +183,14 @@ func buildWorkspaceDoctorDetails(target projectTarget) (doctorCheckCategory, map if errors.As(selectionErr, &structured) && structured.Code != "" { code = structured.Code } - items = append(items, doctorCheckItem{Name: "workspace_selection", Message: selectionErr.Error(), Status: "fail"}) - blocking = append(blocking, doctorIssue{Code: code, Message: selectionErr.Error()}) + if errors.Is(selectionErr, errQuickstartTemplateUndetected) { + message := "Could not detect a built-in quickstart for this workspace; template-specific env checks were skipped." + items = append(items, doctorCheckItem{Name: "workspace_selection", Message: message, Status: "warn"}) + warnings = append(warnings, doctorIssue{Code: code, Message: message}) + } else { + items = append(items, doctorCheckItem{Name: "workspace_selection", Message: selectionErr.Error(), Status: "fail"}) + blocking = append(blocking, doctorIssue{Code: code, Message: selectionErr.Error()}) + } check := doctorCheckCategory{Category: "workspace", Items: items} check.Status = summarizeCategoryStatus(items) return check, workspace, blocking, warnings diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go new file mode 100644 index 0000000..0850e7a --- /dev/null +++ b/internal/cli/doctor_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWorkspaceDoctorSelectionScope(t *testing.T) { + for _, tc := range []struct { + name, recipe, template, scenario, manifest, projectID, blockingCode string + wantWarning bool + }{ + {name: "unknown directory", wantWarning: true}, + {name: "recipe ignores quickstart metadata", recipe: "custom", manifest: "invalid", wantWarning: true}, + {name: "unknown directory with wrong project", projectID: "other", blockingCode: "LOCAL_PROJECT_BINDING_MISMATCH", wantWarning: true}, + {name: "recipe with wrong project", recipe: "custom", projectID: "other", blockingCode: "LOCAL_PROJECT_BINDING_MISMATCH", wantWarning: true}, + {name: "declared unknown template", template: "unknown", blockingCode: "QUICKSTART_TEMPLATE_UNKNOWN"}, + {name: "RTC missing manifest", template: "nextjs", scenario: "video-call", blockingCode: "QUICKSTART_MANIFEST_INVALID"}, + {name: "invalid manifest", manifest: "invalid", blockingCode: "QUICKSTART_MANIFEST_INVALID"}, + {name: "conflicting manifest", template: "nextjs", scenario: "video-call", manifest: `{"schemaVersion":1,"template":"nextjs","scenario":"voice-agent"}`, blockingCode: "QUICKSTART_SELECTION_MISMATCH"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + id := tc.projectID + if id == "" { + id = "prj_1" + } + if err := writeLocalProjectBinding(root, localProjectBinding{ProjectID: id, Recipe: tc.recipe, Template: tc.template, Scenario: tc.scenario}); err != nil { + t.Fatal(err) + } + if tc.manifest != "" { + if err := os.WriteFile(filepath.Join(root, quickstartManifestFileName), []byte(tc.manifest), 0o600); err != nil { + t.Fatal(err) + } + } + t.Chdir(root) + _, _, blocking, warnings := buildWorkspaceDoctorDetails(projectTarget{project: projectDetail{ProjectID: "prj_1"}}) + if tc.blockingCode == "" { + if len(blocking) != 0 { + t.Fatalf("unexpected blocking issues: %+v", blocking) + } + } else if len(blocking) != 1 || blocking[0].Code != tc.blockingCode { + t.Fatalf("blocking=%+v want=%s", blocking, tc.blockingCode) + } + if (len(warnings) > 0) != tc.wantWarning { + t.Fatalf("unexpected warnings: %+v", warnings) + } + result := projectDoctorResult{Feature: "rtc", BlockingIssues: blocking, Warnings: warnings} + finalizeDoctorOutcome(&result) + if tc.blockingCode == "" && (!result.Healthy || result.Status != "warning") { + t.Fatalf("unexpected outcome: %+v", result) + } + if tc.blockingCode != "" && (result.Healthy || result.Status != "not_ready") { + t.Fatalf("unexpected outcome: %+v", result) + } + }) + } +} diff --git a/internal/cli/init.go b/internal/cli/init.go index 0ab0d14..fc7b2f3 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -130,7 +130,7 @@ Use --feature to specify which features to enable on a newly created project (re cmd.Flags().StringVar(&dir, "dir", "", "target directory for the cloned quickstart; defaults to ") cmd.Flags().StringVar(&existingProject, "project", "", "existing project ID or exact project name to bind to") cmd.Flags().StringVar(&rtmDataCenter, "rtm-data-center", "", "RTM data center to configure when rtm is enabled on a newly created project (CN, NA, EU, or AP); defaults to NA") - cmd.Flags().StringArrayVar(&features, "feature", nil, "add a feature to the scenario's required features (repeatable); video-call requires rtc, voice-agent requires rtc, rtm, convoai; convoai also enables rtm") + cmd.Flags().StringArrayVar(&features, "feature", nil, "select features for new projects (repeatable); explicit values override scenario defaults; omitted uses scenario defaults; ignored when reusing a project; convoai also enables rtm") cmd.Flags().StringArrayVar(&agentRules, "add-agent-rules", nil, "write AI agent rules into the quickstart (repeatable: cursor, claude, windsurf)") cmd.Flags().BoolVar(&newProject, "new-project", false, "always create a new Agora project instead of reusing an existing one") _ = cmd.RegisterFlagCompletionFunc("template", completeQuickstartTemplateIDs) @@ -367,7 +367,16 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e if _, err := resolveScaffoldTarget(targetDir); err != nil { return nil, err } - resolution, err := a.resolveInitProjectForScaffold(name, template.RequiredFeatures, existingProject, features, rtmDataCenter, newProject, promptForReuse, promptOut, promptIn, progress) + // A code template supplies defaults, not a project preset. Explicit + // feature choices replace those defaults, as in the legacy init flow. + if len(features) == 0 { + features = template.RequiredFeatures + } + createFeatures, err := mergeFeatureRequirements(features) + if err != nil { + return nil, err + } + resolution, err := a.resolveInitProjectForScaffold(name, createFeatures, existingProject, rtmDataCenter, newProject, promptForReuse, promptOut, promptIn, progress) if err != nil { return nil, err } @@ -412,17 +421,13 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e return result, nil } -func (a *App) resolveInitProjectForScaffold(name string, scaffoldFeatures []string, existingProject string, features []string, rtmDataCenter string, newProject bool, promptForReuse bool, promptOut io.Writer, promptIn io.Reader, progress progressEmitter) (initProjectResolution, error) { +func (a *App) resolveInitProjectForScaffold(name string, createFeatures []string, existingProject string, rtmDataCenter string, newProject bool, promptForReuse bool, promptOut io.Writer, promptIn io.Reader, progress progressEmitter) (initProjectResolution, error) { var target projectTarget projectAction := "existing" projectSelectionReason := "explicit_project" enabledFeatures := []string{} needsCreate := false createdRTMDataCenter := "" - requiredFeatures, err := mergeFeatureRequirements(scaffoldFeatures, features) - if err != nil { - return initProjectResolution{}, err - } switch { case strings.TrimSpace(existingProject) != "": @@ -489,7 +494,7 @@ func (a *App) resolveInitProjectForScaffold(name string, scaffoldFeatures []stri } if needsCreate { - featuresToEnable := requiredFeatures + featuresToEnable := createFeatures progress.emit("project:create", "Creating Agora project", map[string]any{"projectName": name, "features": featuresToEnable}) projectResult, err := a.projectCreate(name, "", featuresToEnable, rtmDataCenter, "") if err != nil { @@ -509,9 +514,6 @@ func (a *App) resolveInitProjectForScaffold(name string, scaffoldFeatures []stri target = resolved progress.emit("project:created", "Agora project ready", map[string]any{"projectId": target.project.ProjectID, "projectName": target.project.Name}) } else { - if err := a.validateProjectRequiredFeatures(target, requiredFeatures); err != nil { - return initProjectResolution{}, err - } progress.emit("project:reuse", "Reusing existing Agora project", map[string]any{"projectId": target.project.ProjectID, "projectName": target.project.Name}) } diff --git a/internal/cli/integration_init_test.go b/internal/cli/integration_init_test.go index ae537a7..be4cf1c 100644 --- a/internal/cli/integration_init_test.go +++ b/internal/cli/integration_init_test.go @@ -4,8 +4,11 @@ package cli // Shared helpers live in integration_test.go. import ( + "encoding/json" + "net/http" "os" "path/filepath" + "slices" "strings" "testing" ) @@ -49,26 +52,158 @@ func TestCLIInitRTCVideoCallCreatesRTCOnlyProject(t *testing.T) { } } -func TestCLIInitChecksExistingProjectFeaturesBeforeClone(t *testing.T) { - configHome := t.TempDir() - rootDir := t.TempDir() - api := newFakeCLIBFF() - defer api.server.Close() - project := buildFakeProject("RTC Only", "prj_rtc_only", "app_rtc_only", "global") - api.projects[project.ProjectID] = &project - persistSessionForIntegration(t, configHome) - target := filepath.Join(rootDir, "must-not-exist") - - result := runCLI(t, []string{"init", "reuse-demo", "--template", "nextjs", "--scenario", "video-call", "--project", project.ProjectID, "--feature", "rtm", "--dir", target, "--json"}, cliRunOptions{env: map[string]string{ - "XDG_CONFIG_HOME": configHome, - "AGORA_API_BASE_URL": api.baseURL, - "AGORA_LOG_LEVEL": "error", - }, workdir: rootDir}) - if result.exitCode != 1 || !strings.Contains(result.stdout, `"code":"QUICKSTART_REQUIRED_FEATURE_MISSING"`) || !strings.Contains(result.stdout, "agora project feature enable rtm") { - t.Fatalf("unexpected missing feature result: %+v", result) - } - if _, err := os.Stat(target); !os.IsNotExist(err) { - t.Fatalf("feature validation must happen before clone, stat err=%v", err) +func TestInitFeatureSelectionCompatibility(t *testing.T) { + for _, transport := range []string{"cli", "mcp"} { + for _, tc := range []struct { + name, scenario, reuse, template string + features, want []string + invalid bool + }{ + {name: "video defaults", scenario: "video-call", want: []string{"rtc"}}, + {name: "voice defaults", scenario: "voice-agent", want: []string{"rtc", "rtm", "convoai"}}, + {name: "voice explicit rtc", scenario: "voice-agent", features: []string{"rtc"}, want: []string{"rtc"}}, + {name: "video explicit rtm", scenario: "video-call", features: []string{"rtm"}, want: []string{"rtm"}}, + {name: "voice explicit convoai", scenario: "voice-agent", features: []string{"convoai"}, want: []string{"rtm", "convoai"}}, + {name: "reuse voice", scenario: "voice-agent", reuse: "explicit", want: []string{}}, + {name: "reuse voice with features", scenario: "voice-agent", reuse: "explicit", features: []string{"convoai"}, want: []string{}}, + {name: "automatic reuse", scenario: "voice-agent", reuse: "auto", features: []string{"convoai"}, want: []string{}}, + {name: "reuse video", scenario: "video-call", reuse: "explicit", features: []string{"rtm"}, want: []string{}}, + {name: "invalid feature", scenario: "video-call", features: []string{"bad-feature"}, invalid: true}, + {name: "invalid scenario", scenario: "bad-scenario", invalid: true}, + {name: "invalid template", template: "unknown", scenario: "video-call", invalid: true}, + {name: "unsupported combination", template: "python", scenario: "video-call", invalid: true}, + } { + t.Run(transport+"/"+tc.name, func(t *testing.T) { + templateID := tc.template + if templateID == "" { + templateID = "nextjs" + } + root, configHome := t.TempDir(), t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + if tc.reuse != "" { + project := buildFakeProject("Default Project", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + } + repo := createLocalGitRepo(t, map[string]string{ + "agora.quickstart.json": `{"schemaVersion":1,"template":"nextjs","scenario":"` + tc.scenario + `"}`, + "env.local.example": "NEXT_PUBLIC_AGORA_APP_ID=\nNEXT_AGORA_APP_CERTIFICATE=\n", + "package.json": `{"name":"fixture"}`, + }) + env := map[string]string{"AGORA_HOME": "", "XDG_CONFIG_HOME": configHome, "AGORA_API_BASE_URL": api.baseURL, "AGORA_LOG_LEVEL": "error", "AGORA_QUICKSTART_NEXTJS_REPO_URL": repo, "AGORA_QUICKSTART_NEXTJS_VIDEO_CALL_REPO_URL": repo} + target := filepath.Join(root, "demo") + var data map[string]any + failed := false + if transport == "cli" { + args := []string{"init", "demo", "--template", templateID, "--scenario", tc.scenario, "--dir", target, "--json"} + switch tc.reuse { + case "": + args = append(args, "--new-project") + case "explicit": + args = append(args, "--project", "prj_0001") + } + for _, f := range tc.features { + args = append(args, "--feature", f) + } + result := runCLI(t, args, cliRunOptions{env: env, workdir: root}) + failed = result.exitCode != 0 + if !failed { + lines := strings.Split(strings.TrimSpace(result.stdout), "\n") + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &envelope); err != nil { + t.Fatal(err) + } + data = envelope.Data + } else if !tc.invalid { + t.Fatalf("init failed: %+v", result) + } + } else { + t.Chdir(root) + for key, value := range env { + t.Setenv(key, value) + } + app, err := NewApp() + if err != nil { + t.Fatal(err) + } + args := map[string]any{"name": "demo", "template": templateID, "scenario": tc.scenario, "dir": target, "newProject": tc.reuse == "", "features": tc.features} + if tc.reuse == "explicit" { + args["project"] = "prj_0001" + } + result, err := app.callMCPTool("agora.init", args, nil) + failed = err != nil + if !failed { + data = result.(map[string]any) + } else if !tc.invalid { + t.Fatal(err) + } + } + if tc.invalid { + if !failed { + t.Fatal("invalid input succeeded") + } + api.mu.Lock() + requests := len(api.requests) + api.mu.Unlock() + if requests != 0 { + t.Fatalf("invalid input reached API: %d requests", requests) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("invalid input created scaffold: %v", err) + } + return + } + got, err := json.Marshal(data["enabledFeatures"]) + if err != nil { + t.Fatal(err) + } + want, err := json.Marshal(tc.want) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("features=%s want=%s", got, want) + } + api.mu.Lock() + project := api.projects["prj_0001"] + rtm, convoai := project.FeatureState.RTMEnabled, project.FeatureState.ConvoAIEnabled + writes, featureReads := 0, 0 + for _, request := range api.requests { + if request.Method != http.MethodGet { + writes++ + } + if strings.Contains(request.Pathname, "/uap-configs/") || strings.HasSuffix(request.Pathname, "/rtm2-config") { + featureReads++ + } + } + api.mu.Unlock() + if rtm != slices.Contains(tc.want, "rtm") || convoai != slices.Contains(tc.want, "convoai") { + t.Fatalf("unexpected API state rtm=%v convoai=%v", rtm, convoai) + } + if tc.reuse != "" { + if writes != 0 || featureReads != 0 { + t.Fatalf("reuse performed %d writes and %d feature requests", writes, featureReads) + } + if data["projectAction"] != "existing" { + t.Fatalf("unexpected reuse result: %+v", data) + } + doctor := runCLI(t, []string{"project", "doctor", "prj_0001", "--feature", "convoai", "--json"}, cliRunOptions{env: env, workdir: root}) + if doctor.exitCode != 1 || !strings.Contains(doctor.stdout, `"status":"not_ready"`) { + t.Fatalf("doctor failed to report missing functionality: %+v", doctor) + } + } + binding, err := loadLocalProjectBinding(target) + if err != nil { + t.Fatal(err) + } + if binding.ProjectID != "prj_0001" || binding.Scenario != tc.scenario { + t.Fatalf("unexpected binding: %+v", binding) + } + }) + } } } diff --git a/internal/cli/integration_project_test.go b/internal/cli/integration_project_test.go index 847315a..45e2b43 100644 --- a/internal/cli/integration_project_test.go +++ b/internal/cli/integration_project_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "testing" ) @@ -578,3 +579,91 @@ func TestCLIFeatureEnableAndDoctorAuthError(t *testing.T) { t.Fatalf("unexpected unauth doctor result: %+v", unauthDoctor) } } + +func TestProjectCreatePresetFeatureCompatibility(t *testing.T) { + for _, tc := range []struct { + name, preset string + features, want []string + }{ + {name: "defaults", want: []string{"rtc", "rtm", "convoai"}}, + {name: "explicit rtc", features: []string{"rtc"}, want: []string{"rtc"}}, + {name: "voice preset", preset: "voice-agent", features: []string{"rtc"}, want: []string{"rtc", "rtm", "convoai"}}, + {name: "video preset", preset: "video-call", want: []string{"rtc"}}, + {name: "video plus rtm", preset: "video-call", features: []string{"rtm"}, want: []string{"rtc", "rtm"}}, + {name: "video plus convoai", preset: "video-call", features: []string{"convoai"}, want: []string{"rtc", "rtm", "convoai"}}, + } { + for _, transport := range []string{"dry-run", "cli", "mcp"} { + t.Run(tc.name+"/"+transport, func(t *testing.T) { + configHome, root := t.TempDir(), t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + env := map[string]string{"AGORA_HOME": "", "XDG_CONFIG_HOME": configHome, "AGORA_API_BASE_URL": api.baseURL, "AGORA_LOG_LEVEL": "error"} + var data map[string]any + if transport == "mcp" { + t.Chdir(root) + for key, value := range env { + t.Setenv(key, value) + } + app, err := NewApp() + if err != nil { + t.Fatal(err) + } + result, err := app.callMCPTool("agora.project.create", map[string]any{"name": "demo", "template": tc.preset, "features": tc.features}, nil) + if err != nil { + t.Fatal(err) + } + data = result.(map[string]any) + } else { + args := []string{"project", "create", "demo", "--json"} + if tc.preset != "" { + args = append(args, "--template", tc.preset) + } + for _, feature := range tc.features { + args = append(args, "--feature", feature) + } + if transport == "dry-run" { + args = append(args, "--dry-run") + } + result := runCLI(t, args, cliRunOptions{env: env, workdir: root}) + if result.exitCode != 0 { + t.Fatalf("create failed: %+v", result) + } + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal([]byte(result.stdout), &envelope); err != nil { + t.Fatal(err) + } + data = envelope.Data + } + got, err := json.Marshal(data["enabledFeatures"]) + if err != nil { + t.Fatal(err) + } + want, err := json.Marshal(tc.want) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("features=%s want=%s", got, want) + } + api.mu.Lock() + defer api.mu.Unlock() + if transport == "dry-run" { + if len(api.projects) != 0 || len(api.requests) != 0 { + t.Fatal("dry-run reached API") + } + } else { + project := api.projects["prj_0001"] + if project == nil { + t.Fatal("project not created") + } + if project.FeatureState.RTMEnabled != slices.Contains(tc.want, "rtm") || project.FeatureState.ConvoAIEnabled != slices.Contains(tc.want, "convoai") { + t.Fatal("API feature state differs from reported features") + } + } + }) + } + } +} diff --git a/internal/cli/integration_quickstart_test.go b/internal/cli/integration_quickstart_test.go index 4f67bf1..820d181 100644 --- a/internal/cli/integration_quickstart_test.go +++ b/internal/cli/integration_quickstart_test.go @@ -4,6 +4,7 @@ package cli // Shared helpers live in integration_test.go. import ( + "bytes" "errors" "os" "path/filepath" @@ -66,11 +67,52 @@ func TestCLIRTCVideoCallQuickstartScenario(t *testing.T) { t.Fatal("quickstart JSON leaked the app certificate") } - write := runCLI(t, []string{"quickstart", "env", "write", target, "--json"}, cliRunOptions{env: commonEnv, workdir: rootDir}) + write := runCLI(t, []string{"quickstart", "env", "write", target, "--template", "nextjs", "--json"}, cliRunOptions{env: commonEnv, workdir: rootDir}) if write.exitCode != 0 || !strings.Contains(write.stdout, `"scenario":"video-call"`) { t.Fatalf("unexpected rtc quickstart env write result: %+v", write) } + t.Setenv("AGORA_HOME", "") + for key, value := range commonEnv { + t.Setenv(key, value) + } + app, err := NewApp() + if err != nil { + t.Fatal(err) + } + mcpResult, err := app.callMCPTool("agora.quickstart.env_write", map[string]any{"dir": target, "template": "nextjs", "project": project.ProjectID}, nil) + if err != nil { + t.Fatal(err) + } + if mcpResult.(map[string]any)["scenario"] != "video-call" { + t.Fatalf("unexpected MCP scenario: %+v", mcpResult) + } + beforeEnv, err := os.ReadFile(filepath.Join(target, ".env.local")) + if err != nil { + t.Fatal(err) + } + beforeBinding, err := os.ReadFile(resolveLocalProjectFile(target)) + if err != nil { + t.Fatal(err) + } + conflict := runCLI(t, []string{"quickstart", "env", "write", target, "--template", "nextjs", "--scenario", "voice-agent", "--project", project.ProjectID, "--json"}, cliRunOptions{env: commonEnv, workdir: rootDir}) + if conflict.exitCode != 1 || !strings.Contains(conflict.stdout, `"code":"QUICKSTART_SELECTION_MISMATCH"`) { + t.Fatalf("unexpected conflict: %+v", conflict) + } + _, err = app.callMCPTool("agora.quickstart.env_write", map[string]any{"dir": target, "template": "nextjs", "scenario": "voice-agent", "project": project.ProjectID}, nil) + assertCLIErrorCode(t, err, "QUICKSTART_SELECTION_MISMATCH") + afterEnv, err := os.ReadFile(filepath.Join(target, ".env.local")) + if err != nil { + t.Fatal(err) + } + afterBinding, err := os.ReadFile(resolveLocalProjectFile(target)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(beforeEnv, afterEnv) || !bytes.Equal(beforeBinding, afterBinding) { + t.Fatal("conflicting selection changed env or binding") + } + doctor := runCLI(t, []string{"project", "doctor", "--feature", "rtc", "--deep", "--json"}, cliRunOptions{env: commonEnv, workdir: target}) if doctor.exitCode != 0 || !strings.Contains(doctor.stdout, `"status":"healthy"`) || !strings.Contains(doctor.stdout, `"scenario":"video-call"`) { t.Fatalf("unexpected rtc deep doctor result: %+v", doctor) diff --git a/internal/cli/integration_recipe_test.go b/internal/cli/integration_recipe_test.go index 6bb6163..5694cce 100644 --- a/internal/cli/integration_recipe_test.go +++ b/internal/cli/integration_recipe_test.go @@ -6,83 +6,169 @@ import ( "net/http/httptest" "os" "path/filepath" + "slices" "strings" "testing" ) func TestCLIInitFromOfficialRecipeUsesCatalogEnvContract(t *testing.T) { - configHome := t.TempDir() - rootDir := t.TempDir() - api := newFakeCLIBFF() - defer api.server.Close() - persistSessionForIntegration(t, configHome) + for _, transport := range []string{"cli", "mcp"} { + for _, tc := range []struct { + name string + features, wantFeatures []string + reuse bool + }{ + {name: "defaults", wantFeatures: []string{"rtc", "rtm", "convoai"}}, + {name: "rtc", features: []string{"rtc"}, wantFeatures: []string{"rtc"}}, + {name: "convoai", features: []string{"convoai"}, wantFeatures: []string{"rtm", "convoai"}}, + {name: "reuse", reuse: true, wantFeatures: []string{}}, + {name: "reuse with features", reuse: true, features: []string{"convoai"}, wantFeatures: []string{}}, + } { + t.Run(transport+"/"+tc.name, func(t *testing.T) { - recipeRepo := createLocalGitRepo(t, map[string]string{ - "README.md": "# Tool Calling\n", - "server/.env.example": "AGORA_APP_ID=\nAGORA_APP_CERTIFICATE=\nCUSTOM_LLM_URL=\n", - }) - recipes := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/recipes/tool-calling" { - http.NotFound(w, r) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "schemaVersion": 1, - "recipe": map[string]any{ - "slug": "tool-calling", - "title": "Tool Calling", - "mainRepoUrl": recipeRepo, - "recipeUrl": "https://recipes.agora.io/recipes/tool-calling", - "recipeRawUrl": "https://example.com/RECIPE.md", - "primaryPrompt": "Follow the official recipe.", - "type": "ai", - "official": true, - "cli": map[string]any{ - "projectType": "python", - "env": map[string]any{ - "examplePath": "server/.env.example", - "targetPath": "server/.env.local", - "appIdKey": "AGORA_APP_ID", - "appCertificateKey": "AGORA_APP_CERTIFICATE", - }, - "installCommand": "bun run setup", - "runCommand": "bun run dev", - }, - }, - }) - })) - defer recipes.Close() + configHome := t.TempDir() + rootDir := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + if tc.reuse { + project := buildFakeProject("Existing RTC", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + } - targetDir := filepath.Join(rootDir, "tool-demo") - result := runCLI(t, []string{"init", "tool-demo", "--recipe", "tool-calling", "--new-project", "--dir", targetDir, "--json"}, cliRunOptions{ - env: map[string]string{ - "XDG_CONFIG_HOME": configHome, - "AGORA_API_BASE_URL": api.baseURL, - "AGORA_RECIPES_BASE_URL": recipes.URL + "/api/v1", - "AGORA_LOG_LEVEL": "error", - }, - workdir: rootDir, - }) - if result.exitCode != 0 || !strings.Contains(result.stdout, `"sourceType":"recipe"`) || !strings.Contains(result.stdout, `"sourceId":"tool-calling"`) || !strings.Contains(result.stdout, `"envPath":"server/.env.local"`) { - t.Fatalf("unexpected recipe init result: %+v", result) - } + recipeRepo := createLocalGitRepo(t, map[string]string{ + "README.md": "# Tool Calling\n", + "server/.env.example": "AGORA_APP_ID=\nAGORA_APP_CERTIFICATE=\nCUSTOM_LLM_URL=\n", + }) + recipes := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/recipes/tool-calling" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaVersion": 1, + "recipe": map[string]any{ + "slug": "tool-calling", + "title": "Tool Calling", + "mainRepoUrl": recipeRepo, + "recipeUrl": "https://recipes.agora.io/recipes/tool-calling", + "recipeRawUrl": "https://example.com/RECIPE.md", + "primaryPrompt": "Follow the official recipe.", + "type": "ai", + "official": true, + "cli": map[string]any{ + "projectType": "python", + "env": map[string]any{ + "examplePath": "server/.env.example", + "targetPath": "server/.env.local", + "appIdKey": "AGORA_APP_ID", + "appCertificateKey": "AGORA_APP_CERTIFICATE", + }, + "installCommand": "bun run setup", + "runCommand": "bun run dev", + }, + }, + }) + })) + defer recipes.Close() - envFile, err := os.ReadFile(filepath.Join(targetDir, "server", ".env.local")) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(envFile), "AGORA_APP_ID=app_0001") || !strings.Contains(string(envFile), "CUSTOM_LLM_URL=") { - t.Fatalf("unexpected recipe env: %s", envFile) - } - binding, err := loadLocalProjectBinding(targetDir) - if err != nil { - t.Fatal(err) - } - if binding.Recipe != "tool-calling" || binding.Template != "" || binding.ProjectType != "python" || binding.EnvPath != "server/.env.local" { - t.Fatalf("unexpected recipe binding: %+v", binding) - } - if _, err := os.Stat(filepath.Join(targetDir, ".git")); !os.IsNotExist(err) { - t.Fatalf("expected cloned recipe git metadata removed, got %v", err) + targetDir := filepath.Join(rootDir, "tool-demo") + commonEnv := map[string]string{"AGORA_HOME": "", "XDG_CONFIG_HOME": configHome, "AGORA_API_BASE_URL": api.baseURL, "AGORA_RECIPES_BASE_URL": recipes.URL + "/api/v1", "AGORA_LOG_LEVEL": "error"} + var result cliResult + if transport == "cli" { + args := []string{"init", "tool-demo", "--recipe", "tool-calling", "--dir", targetDir, "--json"} + if tc.reuse { + args = append(args, "--project", "prj_0001") + } else { + args = append(args, "--new-project") + } + for _, feature := range tc.features { + args = append(args, "--feature", feature) + } + result = runCLI(t, args, cliRunOptions{env: commonEnv, workdir: rootDir}) + } else { + t.Chdir(rootDir) + for key, value := range commonEnv { + t.Setenv(key, value) + } + app, err := NewApp() + if err != nil { + t.Fatal(err) + } + args := map[string]any{"name": "tool-demo", "recipe": "tool-calling", "dir": targetDir, "newProject": !tc.reuse, "features": tc.features} + if tc.reuse { + args["project"] = "prj_0001" + } + data, err := app.callMCPTool("agora.init", args, nil) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + result = cliResult{stdout: string(raw)} + } + + if result.exitCode != 0 || !strings.Contains(result.stdout, `"sourceType":"recipe"`) || !strings.Contains(result.stdout, `"sourceId":"tool-calling"`) || !strings.Contains(result.stdout, `"envPath":"server/.env.local"`) { + t.Fatalf("unexpected recipe init result: %+v", result) + } + + wantFeatures, err := json.Marshal(tc.wantFeatures) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.stdout, `"enabledFeatures":`+string(wantFeatures)) { + t.Fatalf("unexpected enabled features: %s", result.stdout) + } + api.mu.Lock() + project := api.projects["prj_0001"] + rtm, convoai := project.FeatureState.RTMEnabled, project.FeatureState.ConvoAIEnabled + writes := 0 + for _, request := range api.requests { + if request.Method != http.MethodGet { + writes++ + } + } + api.mu.Unlock() + if rtm != slices.Contains(tc.wantFeatures, "rtm") || convoai != slices.Contains(tc.wantFeatures, "convoai") { + t.Fatalf("unexpected API feature state: rtm=%v convoai=%v", rtm, convoai) + } + if tc.reuse && writes != 0 { + t.Fatalf("recipe reuse made %d remote writes", writes) + } + if tc.name == "defaults" { + doctor := runCLI(t, []string{"project", "doctor", "--deep", "--json"}, cliRunOptions{env: commonEnv, workdir: targetDir}) + var report struct { + Data projectDoctorResult `json:"data"` + } + if err := json.Unmarshal([]byte(doctor.stdout), &report); err != nil { + t.Fatal(err) + } + if doctor.exitCode != 2 || !report.Data.Healthy || report.Data.Status != "warning" || len(report.Data.BlockingIssues) != 0 { + t.Fatalf("recipe incorrectly blocked: %+v", doctor) + } + } + envFile, err := os.ReadFile(filepath.Join(targetDir, "server", ".env.local")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(envFile), "AGORA_APP_ID=app_0001") || !strings.Contains(string(envFile), "CUSTOM_LLM_URL=") { + t.Fatalf("unexpected recipe env: %s", envFile) + } + binding, err := loadLocalProjectBinding(targetDir) + if err != nil { + t.Fatal(err) + } + if binding.Recipe != "tool-calling" || binding.Template != "" || binding.ProjectType != "python" || binding.EnvPath != "server/.env.local" { + t.Fatalf("unexpected recipe binding: %+v", binding) + } + if _, err := os.Stat(filepath.Join(targetDir, ".git")); !os.IsNotExist(err) { + t.Fatalf("expected cloned recipe git metadata removed, got %v", err) + } + + }) + } } } diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 97e222d..1fafbd8 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -431,22 +431,6 @@ func mergeFeatureRequirements(featureGroups ...[]string) ([]string, error) { return ordered, nil } -func (a *App) validateProjectRequiredFeatures(target projectTarget, requiredFeatures []string) error { - for _, feature := range requiredFeatures { - item, err := a.getFeatureItem(feature, target.project, target.region) - if err != nil { - return err - } - if item.Status != "enabled" && item.Status != "included" { - return &cliError{ - Message: fmt.Sprintf("Existing project %q is missing required feature %q. Run `agora project feature enable %s %s` and retry.", target.project.Name, feature, feature, target.project.ProjectID), - Code: "QUICKSTART_REQUIRED_FEATURE_MISSING", - } - } - } - return nil -} - func featureListIncludes(features []string, target string) bool { for _, feature := range features { if feature == target { diff --git a/internal/cli/quickstart.go b/internal/cli/quickstart.go index 72482fa..e1fed71 100644 --- a/internal/cli/quickstart.go +++ b/internal/cli/quickstart.go @@ -18,6 +18,8 @@ import ( const quickstartManifestFileName = "agora.quickstart.json" +var errQuickstartTemplateUndetected = errors.New("could not detect the quickstart type from this directory") + type quickstartManifest struct { SchemaVersion int `json:"schemaVersion"` Template string `json:"template"` @@ -967,47 +969,46 @@ func resolveQuickstartTemplateForPath(root, explicitTemplate, explicitScenario s } foundBinding = foundBinding && bindingRoot == root && strings.TrimSpace(binding.Template) != "" + // Keep missing fields unset until all sources have been reconciled. A + // template-only flag or legacy binding must not invent a default scenario + // that conflicts with the scenario already declared by the workspace. type namedSelection struct { - name string - definition quickstartTemplate + name, template, scenario string } selections := []namedSelection{} if foundBinding { - definition, resolveErr := selectQuickstartDefinition(binding.Template, binding.Scenario) - if resolveErr != nil { - return quickstartTemplate{}, resolveErr - } - selections = append(selections, namedSelection{name: ".agora/project.json", definition: definition}) + selections = append(selections, namedSelection{".agora/project.json", strings.TrimSpace(binding.Template), strings.TrimSpace(binding.Scenario)}) } if foundManifest { - definition, _ := selectQuickstartDefinition(manifest.Template, manifest.Scenario) - selections = append(selections, namedSelection{name: quickstartManifestFileName, definition: definition}) + selections = append(selections, namedSelection{quickstartManifestFileName, strings.TrimSpace(manifest.Template), strings.TrimSpace(manifest.Scenario)}) } - - if strings.TrimSpace(explicitTemplate) != "" { - definition, resolveErr := selectQuickstartDefinition(explicitTemplate, explicitScenario) - if resolveErr != nil { - return quickstartTemplate{}, resolveErr - } - selections = append([]namedSelection{{name: "explicit flags", definition: definition}}, selections...) - } else if strings.TrimSpace(explicitScenario) != "" { - if len(selections) == 0 { - return quickstartTemplate{}, &cliError{Message: "--scenario requires --template when the repository has no binding or manifest.", Code: "QUICKSTART_TEMPLATE_REQUIRED"} - } - definition, resolveErr := selectQuickstartDefinition(selections[0].definition.Template, explicitScenario) - if resolveErr != nil { - return quickstartTemplate{}, resolveErr - } - selections = append([]namedSelection{{name: "explicit flags", definition: definition}}, selections...) + explicitTemplate = strings.TrimSpace(explicitTemplate) + explicitScenario = strings.TrimSpace(explicitScenario) + if explicitTemplate == "" && explicitScenario != "" && len(selections) == 0 { + return quickstartTemplate{}, &cliError{Message: "--scenario requires --template when the repository has no binding or manifest.", Code: "QUICKSTART_TEMPLATE_REQUIRED"} + } + if explicitTemplate != "" || explicitScenario != "" { + selections = append([]namedSelection{{"explicit flags", explicitTemplate, explicitScenario}}, selections...) } if len(selections) > 0 { - for index := 1; index < len(selections); index++ { - if !sameQuickstartSelection(selections[0].definition, selections[index].definition) { - return quickstartTemplate{}, quickstartSelectionMismatch(selections[0].name, selections[0].definition, selections[index].name, selections[index].definition) + var templateID, scenario, templateSource, scenarioSource string + for _, selection := range selections { + if selection.template != "" { + if templateID != "" && templateID != selection.template { + return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("Quickstart template mismatch: %s declares %q, but %s declares %q.", templateSource, templateID, selection.name, selection.template), Code: "QUICKSTART_SELECTION_MISMATCH"} + } + templateID, templateSource = selection.template, selection.name + } + if selection.scenario != "" { + if scenario != "" && scenario != selection.scenario { + return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("Quickstart scenario mismatch: %s declares %q, but %s declares %q.", scenarioSource, scenario, selection.name, selection.scenario), Code: "QUICKSTART_SELECTION_MISMATCH"} + } + scenario, scenarioSource = selection.scenario, selection.name } } - return selections[0].definition, nil + return selectQuickstartDefinition(templateID, scenario) } + for _, template := range quickstartTemplates() { if !template.DefaultScenario { continue @@ -1025,8 +1026,8 @@ func resolveQuickstartTemplateForPath(root, explicitTemplate, explicitScenario s ids = append(ids, t.ID) } return quickstartTemplate{}, fmt.Errorf( - "could not detect the quickstart type from this directory (looked for %s). Pass --template %s to specify explicitly.", - strings.Join(hints, ", "), + "%w (looked for %s). Pass --template %s to specify explicitly.", + errQuickstartTemplateUndetected, strings.Join(hints, ", "), strings.Join(ids, "|"), ) } diff --git a/internal/cli/quickstart_test.go b/internal/cli/quickstart_test.go index 73bd7ce..2ee7b78 100644 --- a/internal/cli/quickstart_test.go +++ b/internal/cli/quickstart_test.go @@ -572,3 +572,39 @@ func TestQuickstartTemplatesIncludeAndroid(t *testing.T) { t.Fatalf("unexpected Android next steps:\n got: %#v\nwant: %#v", got, wantSteps) } } + +func TestResolveQuickstartScenarioInheritance(t *testing.T) { + for _, tc := range []struct { + name string + binding *localProjectBinding + manifest bool + want string + }{ + {name: "manifest", manifest: true, want: "video-call"}, + {name: "binding", binding: &localProjectBinding{ProjectID: "prj_1", Template: "nextjs", Scenario: "video-call"}, want: "video-call"}, + {name: "legacy binding and manifest", binding: &localProjectBinding{ProjectID: "prj_1", Template: "nextjs"}, manifest: true, want: "video-call"}, + {name: "legacy default", binding: &localProjectBinding{ProjectID: "prj_1", Template: "nextjs"}, want: "voice-agent"}, + {name: "no metadata", want: "voice-agent"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + if tc.binding != nil { + if err := writeLocalProjectBinding(root, *tc.binding); err != nil { + t.Fatal(err) + } + } + if tc.manifest { + if err := os.WriteFile(filepath.Join(root, quickstartManifestFileName), []byte(`{"schemaVersion":1,"template":"nextjs","scenario":"video-call"}`), 0600); err != nil { + t.Fatal(err) + } + } + definition, err := resolveQuickstartTemplateForPath(root, "nextjs", "") + if err != nil { + t.Fatal(err) + } + if definition.Scenario != tc.want { + t.Fatalf("scenario=%s want=%s", definition.Scenario, tc.want) + } + }) + } +} diff --git a/internal/cli/recipes.go b/internal/cli/recipes.go index e674a6b..49c7e47 100644 --- a/internal/cli/recipes.go +++ b/internal/cli/recipes.go @@ -218,7 +218,13 @@ func (a *App) initRecipeProject(name, targetDir string, recipe recipeDetail, exi if err != nil { return nil, err } - resolution, err := a.resolveInitProjectForScaffold(name, defaultInitFeatures(), existingProject, features, rtmDataCenter, newProject, promptForReuse, promptOut, promptIn, progress) + // Recipe defaults only control new project creation. They are not + // requirements for reusing an existing project. + createFeatures, err := resolveProjectCreateFeatures("", features) + if err != nil { + return nil, err + } + resolution, err := a.resolveInitProjectForScaffold(name, createFeatures, existingProject, rtmDataCenter, newProject, promptForReuse, promptOut, promptIn, progress) if err != nil { return nil, err }