diff --git a/.changeset/ui-app-action-links.md b/.changeset/ui-app-action-links.md new file mode 100644 index 0000000..9b52a8c --- /dev/null +++ b/.changeset/ui-app-action-links.md @@ -0,0 +1,27 @@ +--- +'@getbrevo/cli': minor +--- + +Add UI app support: author action links and manage their per-account availability (BEX-290). + +A UI app is a new *type* of app rather than a separate entity — it shares the app record, credentials, and version lifecycle with OAuth apps, and adds a `ui_app` block to `app-config.json` describing where and how it renders inside Brevo. The first shippable variant is the **action link**: a partner-authored entry point that appears in a CRM record's action menu and opens an external URL in a new tab with record context. + +The `ui_app` block is the app snapshot the platform stores, field for field — `extensionType`, `surfacePointList`, `heading`, `subheading`, `redirectLink`, `linkTarget` — so what a partner authors is exactly what the platform stores, serves and renders, with no mapping layer in between. `extensionType` uses the camelCase vocabulary the platform settled on in BEX-350 (`actionLink`, and `iframeExtension`/`legacyComponent` for the types the CLI doesn't author); the older snake_case spellings are not accepted. + +`brevo app create` gained a leading "What type of app are you building?" prompt, offering an OAuth integration (the default path) or a UI app. **A UI app is authored entirely through the prompts** — there is no `--type` flag, and no flag for any UI-app field. Every non-interactive run, whether `--json` or piped stdin, creates an OAuth app exactly as before, so existing scripted invocations are unaffected and nothing new becomes scriptable while the feature is still in development. The UI path collects placement and destination instead of OAuth callbacks — which record pages to appear on (`contact`/`company`/`deal`, multi-select, so one action link can serve several), heading, optional subheading, destination URL and link target — and never asks for or defaults a redirect URL, since an action link has no OAuth callback. `redirect_uris` is omitted from the create call entirely for UI apps, which also start from narrower default scopes (`contacts:read`, `contacts:write`). No feature is scaffolded for a UI app; there is no local server to run. + +`brevo app upload` now sends the block under the `snapshot` key for UI apps and validates it locally before the request. The validation is deliberately stricter than the wire, because the platform degrades a bad snapshot silently rather than rejecting it: `extensionType` must be `actionLink` (`iframeExtension` and `legacyComponent` are not CLI-authorable, and the pre-BEX-350 `action_link` spelling is rejected); `surfacePointList` must be non-empty, drawn from the twelve-point extension-point registry, action slots only, and free of duplicates; `heading` must be non-empty; `redirectLink` must be https — `http://` is accepted only for `localhost`/`127.0.0.1` so a partner can point at a local dev server; `linkTarget` must be `_blank` or `_self`; and `modalIframeUrl` is rejected outright, since the UI kit keeps it only for an `iframeExtension` item and would otherwise discard it without a word. + +Extension-point validation is the most load-bearing part of that list. Slot names follow the grammar `..`, the UI kit matches them by exact string equality, and the backend drops any name without a registry row — so a typo, a stale `.region`-era name, or a casing slip produces an empty slot, an HTTP 200, and no error anywhere in the stack. The CLI is the only layer that can tell a partner, so it checks locally against the registry. + +The redirect-URL requirement is now OAuth-only. The upload diff covers the snapshot (ignoring key order), so editing only that block is correctly detected as a change instead of reporting "already up to date". For OAuth apps nothing is sent and the payload is unchanged. + +New `brevo app deploy ` and `brevo app remove ` manage a UI app's availability in a single Brevo account. Both resolve the target app from `--app-id`, the linked `app-config.json`, or an interactive picker, and support `--force` and `--json`. `deploy` refuses until the configuration has been validated by an upload, pointing at `brevo app upload` — detected locally from a missing `version` and mapped from the server's own rejection. `remove` has no such gate and treats "not deployed to this account" as informational, exiting `0`, so teardown scripts stay idempotent. + +`brevo app scaffold` inside a UI-app project now refreshes the base config and reports that there are no features to scaffold. It preserves a hand-edited `ui_app` block through a confirmed config refresh, which would otherwise have overwritten `app-config.json` wholesale from server values that don't include it, and no longer reports phantom redirect-URL drift for an app type that has none. + +Note two fields the CLI deliberately does **not** author, because the platform has no counterpart: a per-action label (the record action menu labels the entry with the *app name*) and partner-declared context properties (the record context an action receives is an allow-list on the platform's extension-point registry row). + +The write path for the snapshot does not exist on the platform yet, so the upload transport (a `snapshot` key on the existing upload endpoint) and the deploy/remove endpoints remain assumptions — marked in code comments and tracked in `RELEASE-CHECKLIST.md`. The field names themselves are confirmed against both of the platform's consumers (BEX-308 / BEX-350). + +UI apps are **not live on the Brevo platform yet**. As with public apps, `agent-context/SKILL.md` and `agent-context/AGENTS.md` carry a notice telling AI agents not to create one or drive the deploy lifecycle, with the same internal-Brevo-account exception so dogfooding still works. diff --git a/AGENTS.md b/AGENTS.md index 7fbc359..f9bad8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,18 @@ Because that surface is shipped and unguarded, `agent-context/SKILL.md` and `age - **This is documentation-level only.** The CLI itself still accepts `--distribution public` without a warning or a guard, by design — a runtime guard is tracked separately (see `RELEASE-CHECKLIST.md`). If one is ever added, it needs the same internal-account escape hatch. - **When public apps go GA**, work through `RELEASE-CHECKLIST.md` → *Before public-apps GA* to remove the notice everywhere in one pass. +## UI apps are not GA either — same deal (BEX-290) + +UI apps (action links that render inside Brevo CRM records) are **not live on the platform**. The CLI ships the surface — the *UI app* choice at `brevo app create`'s app-type prompt, `brevo app deploy `, `brevo app remove ` — and `agent-context/SKILL.md` / `agent-context/AGENTS.md` each carry a **⚠️ UI apps are not available yet** notice reusing the same *Exception — internal Brevo accounts* clause. `README.md` mirrors it. + +A UI app is **prompt-only**: there is no `--type` flag and no per-field flags, so non-interactive runs always create an OAuth app. `extensionType` values are camelCase (`actionLink`, `iframeExtension`, `legacyComponent`) and the old snake_case spellings are rejected. See `CLAUDE.md` for why. + +**Every clause of the public-apps section above applies verbatim**, including that it does **not** restrict work in this repo — building, testing, and QA-ing the UI-app code paths is expected and unblocked. Never refuse or hedge on a UI-app task here. + +The `ui_app` block's **field names are confirmed** against both of the platform's consumers, the manifest read path and the extensibility UI kit (BEX-308 / BEX-350) — it is the stored app snapshot verbatim. What remains **assumed is the transport**: nothing on the platform writes that snapshot yet. See `CLAUDE.md` → *UI apps are not GA either* and `RELEASE-CHECKLIST.md` → *Before UI-apps GA*. + +- **When UI apps go GA**, work through `RELEASE-CHECKLIST.md` → *Before UI-apps GA*. + ## Public repository Repo (`github.com/getbrevo/brevo-cli`) and package (`@getbrevo/cli` on the public npm registry) are **public**. Every commit, PR, and issue is world-readable. diff --git a/BEX-290-ui-app-action-links.md b/BEX-290-ui-app-action-links.md new file mode 100644 index 0000000..26ac2e7 --- /dev/null +++ b/BEX-290-ui-app-action-links.md @@ -0,0 +1,247 @@ +# BEX-290 — UI app support: action links + +Design and review notes for the UI-app authoring surface. **Status: implemented** on this +branch; this file records the shape of the change and the decisions behind it. + +**Branch base:** cut from `features_set_public_cli`, not `main`. The change extends +`applyConditionals` in `src/templates/index.ts`, which the PKCE work introduced on that +branch and which does not exist in `main` yet. + +## Context + +Before this change the CLI could only author OAuth integrations. A **UI app** is a second +app type: it shares the app record, credentials, and version lifecycle with an OAuth app, +and adds a `ui_app` block to `app-config.json` describing where and how it renders inside +Brevo. + +The first shippable variant is the **action link** — an entry in a CRM record's header +"More" (•••) menu that opens the partner's URL, with record context appended. + +The CLI is the partner's authoring surface for it: produce the `ui_app` block, push it to +the platform (which owns validation), and manage per-account availability with `deploy` / +`remove`. Until an in-product enable/disable surface ships, those two commands *are* the +install mechanism. + +## Contract + +The `ui_app` block is the app snapshot the platform stores, **field for field** — the same +names it stores, serves and renders. There is no mapping layer, so nothing can drift. + +```json +{ + "ui_app": { + "extensionType": "actionLink", + "surfacePointList": ["contactDetails.headerMenu.action"], + "heading": "Invoice Manager", + "subheading": "Review invoice history for this contact", + "redirectLink": "https://example.com/brevo", + "linkTarget": "_blank" + } +} +``` + +Verified against both consumers of that snapshot — the manifest read path and the +extensibility UI kit (BEX-308 / BEX-350). + +### Fields that deliberately don't exist + +Two fields an earlier draft of this work included have no counterpart on the platform, so +the CLI does not author them. Worth knowing, because both are things a partner (or a future +contributor) will look for: + +- **A per-action label.** The menu entry is labelled with the **app name**. To change the + label, rename the app. +- **`contextProperties`.** The record context an action receives is an allow-list on the + platform's extension-point registry entry — a property of the *slot*, chosen by the + platform, not declared by the partner. + +`modalIframeUrl` also exists on the wire but only applies to the `iframeExtension` type; +the UI kit discards it for anything else, so the CLI rejects it on an action link rather +than let a partner ship a URL that never opens. + +### Extension-point grammar (BEX-350) + +Slot names are `..`. Twelve are registered: locations +`contactDetails`, `companyDetails`, `dealDetails` × widget places `overviewAttributes`, +`overviewMain`, `overviewSidebar` (kind `widget`), plus `headerMenu` (kind `action`). An +action link may only target `.headerMenu.action` — it renders as a menu entry, so +a `.widget` slot would register it somewhere it never appears. + +**This is why local validation matters more than usual here.** The UI kit matches +`extensionPoint` by exact string equality, and the platform drops a name with no registry +entry. Both failures are silent — an empty slot, an HTTP 200, no error, nothing for +monitoring to catch. The CLI validates against a mirrored copy of the registry +(`EXTENSION_POINTS` in `src/lib/constants.ts`) because it is the only layer that will ever +tell a partner. Keep that copy in lockstep with the registry. + +### Still assumed: the transport, not the shape + +The **write path does not exist on the platform yet** — only the read path that consumes the +snapshot. So while the field names are confirmed, the CLI's choice of where to *put* them is +not: it sends the block under a `snapshot` key on `POST /v3/app-store/apps/{id}/upload`, and +assumes `POST /v3/app-store/apps/{id}/deploy|remove` with `account_id` in the body plus +HTTP 422 for "not uploaded" / "not deployed". All marked in code comments and tracked in +`RELEASE-CHECKLIST.md` → *Before UI-apps GA*. + +**BEX-350 also needs a coordinated release** — UI kit, reseeded extension-point registry, +and backend together. A CLI release ahead of the reseed authors slot names that resolve to +nothing, silently. + +## What changed, flow by flow + +Four changes follow directly from the feature. Five more were forced by the existing code +and are the ones most worth reviewing — they're marked **Non-obvious**. + +### 1. Config schema — `src/lib/config.ts`, `src/types.ts` + +`ProjectConfig` gains an optional `ui_app` typed as the snapshot shape: + +```ts +export type ExtensionType = 'actionLink' | 'iframeExtension' | 'legacyComponent'; +export type LinkTarget = '_blank' | '_self'; + +export interface UiApp { + extensionType: ExtensionType; + surfacePointList: string[]; // `..`, registry-validated + heading?: string; + subheading?: string; + redirectLink?: string; + linkTarget?: LinkTarget; + modalIframeUrl?: string; // iframeExtension only — not authorable + version?: string; // server-managed +} +``` + +`readProjectConfig` already spreads unknown top-level keys through, so `ui_app` survives a +round-trip untouched. Added there: a non-object `ui_app` is dropped so callers can trust the +type, but field-level validation deliberately stays out — an unrelated command that merely +reads the config must not fail on a half-written block. `app upload` is the enforcement +point. + +**The presence of `ui_app` is the app-type discriminator** — there is no `appType` key. +Every branch goes through `isUiAppConfig()` so the discriminator can change in one place. + +### 2. `app create` / `app init` prompts — `src/commands/app/create.ts` + +`init` delegates to `createCommand`, so all prompts live in `create.ts`. A new +`resolveAppType` step runs between name and distribution. It is **prompt-only — there is no +`--type` flag** — so any non-interactive run (piped stdin or `--json`) stays `oauth` and +existing scripted calls are unaffected. UI apps aren't live on the platform, and a +scriptable create surface would invite pipelines to pin to a shape that can still change. + +Then the paths diverge: + +- **`oauth`** → the existing redirect-URL + logo path, byte-for-byte unchanged. +- **`ui`** → `resolveUiApp()` prompts for one or more record pages, then heading, + subheading, redirect link and link target. The multi-select maps `contact|company|deal` + onto `.headerMenu.action`. Defaults: record page `contact`, `linkTarget` + `_blank`, scopes `contacts:read`/`contacts:write` (narrower than the OAuth defaults). No + description length cap — the platform enforces none, and inventing one would reject valid + configs. + - **Non-obvious:** `resolveRedirectUrls` falls back to the default localhost callback in + non-TTY runs. Without an explicit skip, a UI app silently acquires an OAuth callback it + has no flow for. The UI path never calls it, and `redirect_uris` is omitted from the + create call entirely. + +Only reachable interactively, so each field is asked for unconditionally with no flag or +default fallback path; the prompts' own `validate` callbacks are the input check, and +`validateUiApp` re-checks the assembled block. The created-app box +gets a UI-app variant listing extension type, point(s), heading, subheading, redirect link +and link target — and stating that the menu entry is labelled with the app name. + +`ui_app` is **not** sent to `POST /apps`; that call registers the app record and issues +credentials. The block travels on upload. + +### 3. Feature scaffolding is not offered — `src/commands/app/scaffold.ts` + +An action link runs on the partner's own infrastructure, so there is no local server to +generate. The feature prompt is skipped for UI apps, and `app scaffold` in a UI-app project +degrades to a base-config refresh with a message explaining why. + +### 4. Templates — `src/templates/index.ts`, `app-config.json.tmpl` + +`applyConditionals` is generalised from a single distribution value to a flag set +(`public`/`private` plus `oauth`/`ui_app`), so one template can carry both app types. + +- **Non-obvious:** it has a documented byte-for-byte invariant — a private scaffold must + render identically to one written with no markers at all. Preserved, and covered by the + existing tests. + +The `ui_app` block sits before `permittedUrls` in the template rather than last, so both +branches produce valid JSON without a trailing-comma problem. Markers must be whole-line. + +### 5. `app scaffold` refresh would have destroyed a hand-edited `ui_app` block + +When drift is detected, the command rewrites `app-config.json` **wholesale** from server +values — which don't include the block. The local block is now carried into the template +vars so a confirmed refresh preserves it. `ui_app` is also excluded from the drift diff: the +local copy is the author's source of truth, and diffing it against an absent remote value +would report permanent phantom drift and then overwrite it with nothing. + +Redirect-URL drift is skipped for UI apps too, since the server returns none and the context +falls back to a default — otherwise every UI-app scaffold would report a phantom diff. + +### 6. `app upload` — `src/commands/app/upload.ts` + +- The block is sent under the `snapshot` key; `UploadAppResponse` reads it back so the + server's normalisation (notably `linkTarget`) wins on write-back. +- **The redirect-URL requirement is now OAuth-only.** A UI app legitimately has none. +- Local validation runs before the request, deliberately stricter than the wire — the + platform degrades a bad snapshot silently rather than rejecting it, so this is the only + enforcement point. Covers extension type, slot names (registered / action-only / no + duplicates), non-empty heading, https redirect link (loopback exempt), link target, and + the `modalIframeUrl` rejection. +- **Non-obvious:** `hasNoChanges` compared every field *except* the block, so editing only + `ui_app` reported "Already up to date" and never pushed. Now included, with a canonical + key-order-insensitive comparison so a reformatted file isn't reported as drift. +- **Non-obvious:** this inverts a shipped assertion. `upload.test.ts` had + `it('never sends a ui_app field')` and `QA-TESTCASES.md` TC-5.7 said the same. That + guarantee now applies to OAuth apps only; both were updated rather than deleted. + +### 7. New `app deploy` / `app remove` + +Modelled on `withdraw.ts`: positional ``, app resolved as `--app-id` → linked +`app-config.json` → picker, `--force`, `--json`. Shared resolution, the upload gate, and +confirmation live in `account-deployment.ts` so the two mirror commands can't drift. + +`deploy` refuses until the config has been validated by an upload — a local pre-flight on a +missing `version` (fast, offline) plus the server's own rejection mapped to the same message. +`remove` has no gate (removing is always safe, and an app deployed by an older CLI must stay +removable) and treats "not deployed" as informational, exiting `0` so teardown stays +idempotent. + +### 8. The app type is decided locally, never from server data + +- **Non-obvious:** `fetchAppContext` originally fell back to the server's `ui_app`. Since + it's called during `create`, unexpected server data could reclassify an app the user had + just explicitly created as OAuth. Both callers always know the type locally, so the + fallback was removed and pinned with a regression test. + +### 9. Not-GA notice + +UI apps aren't live, so `agent-context/SKILL.md` and `agent-context/AGENTS.md` carry a +**⚠️ UI apps are not available yet** notice mirroring the public-apps one, reusing the same +internal-account exception so dogfooding still works. This is a judgement call — easy to drop +if unwanted. + +## Verification + +**Unit** — 847 tests across 46 suites. New: `deploy.test.ts`, `remove.test.ts`, plus UI-app +blocks in `create` / `upload` / `scaffold` / `validators` / `conditionals`, with a case per +silent-failure slot-name mode (stale grammar, wrong casing, widget slot, unregistered +location). + +**Manual, against a mock API** — UI-app create → config inspection → upload (wire payload +confirmed to carry `snapshot` and no `ui_app`) → deploy → remove; every validation +rejection; the deploy gate; the snapshot-only diff; and an OAuth regression sweep confirming +an unchanged config and payload. + +**Still outstanding** — verification against a real test account: whether the platform +accepts the snapshot, and whether the action link renders once the registry is reseeded. +Tracked in `RELEASE-CHECKLIST.md` → *Per-branch verification*. + +## Follow-ups + +In `TODO.md` (branch-scoped) and `RELEASE-CHECKLIST.md` → *Before UI-apps GA* (durable). The +load-bearing ones: confirm the write-path transport, sequence the BEX-350 coordinated +release, and keep the mirrored registry in lockstep. diff --git a/CLAUDE.md b/CLAUDE.md index 0fada76..c85dd0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,28 @@ Because that surface is shipped and unguarded, `agent-context/SKILL.md` and `age - **This is documentation-level only.** The CLI itself still accepts `--distribution public` without a warning or a guard, by design — a runtime guard is tracked separately (see `RELEASE-CHECKLIST.md`). If one is ever added, it needs the same internal-account escape hatch. - **When public apps go GA**, work through `RELEASE-CHECKLIST.md` → *Before public-apps GA* to remove the notice everywhere in one pass. +## UI apps are not GA either — same deal (BEX-290) + +UI apps (action links that render inside Brevo CRM records) are **not live on the platform**. The CLI ships the surface — the *UI app* choice at `brevo app create`'s app-type prompt, `brevo app deploy `, `brevo app remove ` — and, exactly as with public apps, `agent-context/SKILL.md` and `agent-context/AGENTS.md` each carry a **⚠️ UI apps are not available yet** notice reusing the same *Exception — internal Brevo accounts* clause. `README.md` mirrors it. + +**Every clause of the public-apps section above applies verbatim** — it does not restrict work in this repo, don't remove or soften the notice during unrelated cleanup, keep the internal-account escape hatch, and it's documentation-level only (no runtime guard, by design). + +**The `ui_app` block IS the app snapshot the platform stores, field for field.** Field names are confirmed against both of its consumers — the manifest read path and the extensibility UI kit (BEX-308 / BEX-350). Do **not** reintroduce the UIApp Support Spec's `properties`/`trigger` vocabulary: nothing on the platform reads those names. + +Three consequences worth knowing before touching this code: + +- **Extension-point names fail silently.** `surfacePointList` entries use the BEX-350 grammar `..`. The UI kit matches by exact string equality and the platform *drops* a name with no registry entry — an empty slot, a 200, no error. That is why `validateSurfacePoint` checks against the twelve-point registry mirrored in `src/lib/constants.ts`: the CLI is the only layer that will ever tell a partner. +- **Two spec'd fields don't exist.** There is no per-action label (the menu entry uses the *app name*), and no partner-declared `contextProperties` (record context is an allow-list on the registry row, chosen by the platform). Don't add them back. +- **`modalIframeUrl` is gated on `extensionType`.** The UI kit keeps it only for `iframeExtension`, so the CLI rejects it on an `actionLink` rather than letting a partner ship a URL that never opens. + +**`extensionType` values are camelCase (BEX-350): `actionLink`, `iframeExtension`, `legacyComponent`.** The source of truth is `FEATURE_TYPES` in the extensibility UI kit. The pre-BEX-350 snake_case spellings are **deliberately not accepted** — the kit still maps them for consumers, but that map is slated for removal once every producer emits camelCase, and the CLI is a producer. There's no config in the wild to migrate while the feature is in development, so there is no alias map and no normalize-on-read; `validateUiApp` simply rejects `action_link`. Note the platform's *own* `linkTarget` default is still gated on the literal `"action_link"`, so it no longer fires for CLI-authored apps — harmless, because the CLI always writes `linkTarget` explicitly and the kit defaults it client-side too. + +**A UI app is prompt-only — there is no `--type` flag and no per-field flags.** `brevo app create` asks the app type first; a UI app can only be authored from an interactive terminal, so every non-interactive run (`--json` or piped stdin) creates an OAuth app, exactly as before BEX-290. This is deliberate: UI apps aren't live, and a scriptable create surface would invite pipelines to pin to a shape that can still change. Don't add flags back without revisiting that. + +**What is still assumed: the transport, not the shape.** Nothing on the platform writes the app snapshot yet — only the read path that consumes it exists. The CLI sends the block under a `snapshot` key on `POST /v3/app-store/apps/{id}/upload`; `deploy`/`remove` likewise assume `POST /v3/app-store/apps/{id}/deploy|remove` with `account_id` in the body and HTTP 422 for "not uploaded" / "not deployed". All marked in code comments and tracked in `RELEASE-CHECKLIST.md` → *Before UI-apps GA*. + +**The presence of `ui_app` is the app-type discriminator** — there is no `appType` key in `app-config.json`. Every branch that needs to tell the two types apart goes through `isUiAppConfig()` in `src/lib/config.ts`; use it rather than testing for the key inline, so the discriminator can change in one place. + ## Public repository — review before committing This repo is **public** at `github.com/getbrevo/brevo-cli` and the package publishes to the **public npm registry** under `@getbrevo`. Every commit, PR title, PR description, issue, and review comment is world-readable and indexed by search engines. Treat each commit and PR as a public release. diff --git a/QA-TESTCASES.md b/QA-TESTCASES.md index e5a99e1..f0ad133 100644 --- a/QA-TESTCASES.md +++ b/QA-TESTCASES.md @@ -263,7 +263,7 @@ brevo app create --name "QA Flags App" --distribution private \ **Priority:** High **Preconditions:** Ability to observe the request (proxy/network log) OR verify via server state. **Steps:** Make a change and `brevo app upload --yes`. -**Expected:** Payload has `app_version` (top-level), `name`, `logo_uri`, and `auth: { distribution_type, scopes, redirect_urls }`. Note: **`redirect_urls`** (not `redirect_uris`) and `distribution_type` nested under `auth`. `ui_app` is never sent. +**Expected:** Payload has `app_version` (top-level), `name`, `logo_uri`, and `auth: { distribution_type, scopes, redirect_urls }`. Note: **`redirect_urls`** (not `redirect_uris`) and `distribution_type` nested under `auth`. For an **OAuth** app `ui_app` is never sent — the key must be absent, not `null`. (UI apps do send it; see section 12.) ### TC-5.8 — Legacy `all` scope blocks upload **Priority:** High @@ -579,6 +579,108 @@ Messages match the canned copy per state (e.g. `submitted` → "Your app has bee --- +--- + +## 12 — UI apps / action links (BEX-290) + +> **⚠️ UI apps are not available to end users yet**, so the shipped agent docs tell AI +> agents not to create them. **That does not apply to QA** — same internal-account rule +> as the public-app note at the top. Run these cases directly. +> +> **Field names in the `ui_app` block are confirmed** against the platform +> (its manifest read path and its extensibility UI kit — BEX-308 / BEX-350). What is +> **not** yet built is the write path: nothing on the platform stores that snapshot today. +> So if TC-12.4 or TC-12.7 fails with a 4xx, that is the expected failure mode of an +> unbuilt endpoint, not a CLI bug — record the exact response and flag it against +> `RELEASE-CHECKLIST.md` → *Before UI-apps GA*. +> +> **TC-12.7 also depends on the BEX-350 registry reseed.** Until the platform's +> extension-point registry carries the twelve `.widget`/`.action` entries, an authored slot name resolves to nothing +> and the action link won't render — silently, with a 200. Confirm the reseed has run in +> your environment before treating a non-rendering link as a CLI defect. + +### TC-12.1 — Interactive create asks for the app type first +**Priority:** High +**Preconditions:** Logged in; TTY; cwd has **no** `app-config.json`. +**Steps:** Run `brevo app create`. +**Expected:** After "App name:", the next prompt is "What type of app are you building?" with **OAuth app** and **UI app**. Choosing **OAuth app** reproduces the previous flow exactly (distribution → redirect URL → logo → scaffold prompt). + +### TC-12.2 — Delivery prompt shows unsupported options as disabled +**Priority:** Medium +**Steps:** `brevo app create`, choose **UI app**. +**Expected:** "How should your app be delivered?" lists **Action link** as selectable, and **Iframe modal** / **Inline widget** as visibly disabled ("not yet supported"). Neither can be selected. + +### TC-12.3 — UI-app create writes the snapshot shape and no redirect URLs +**Priority:** High +**Steps:** Complete the UI-app flow — pick one or more record pages, then heading, subheading, redirect link (`https://…`), link target. +**Expected:** A "UI app created" box shows extension type, extension point(s), heading, subheading, redirect link and link target — and **no** `Redirect URL` lines. It also states that the menu entry is labelled with the app name. The generated `app-config.json` is valid JSON with a top-level `ui_app` containing exactly `extensionType: "actionLink"`, `surfacePointList`, `heading`, `subheading`, `redirectLink`, `linkTarget` — **no** `properties`, `trigger`, `surface`, `placement`, `contextProperties` or label keys. `auth.scopes` is `["contacts:read","contacts:write"]` and there is **no** `auth.redirectUrls`. No `src/oauth/` directory, no feature prompt. + +### TC-12.4 — Upload sends the snapshot and is accepted +**Priority:** High +**Preconditions:** TC-12.3 done; ability to observe the request. +**Steps:** `brevo app upload` from the project directory. +**Expected:** The summary includes a `UI app:` block listing extension type / point(s) / heading / subheading / redirect link / link target, and **no** "Redirect URLs" row. The payload carries the block under the **`snapshot`** key (not `ui_app`) alongside `app_version`/`name`/`logo_uri`/`auth`. The server accepts it; `Version:` is printed and written back to `app-config.json`. + +### TC-12.5 — Editing only the snapshot is detected as a change +**Priority:** High +**Steps:** After a successful upload, change only `ui_app.heading`, then `brevo app upload`. +**Expected:** The diff shows the UI-app block as `(changed)` and the upload proceeds. It must **not** say "Already up to date". Reordering keys without changing values must report up to date. + +### TC-12.6 — Extension-point validation (the silent-failure guard) +**Priority:** High +**Why this matters:** the platform *drops* an unregistered slot name and the UI kit matches names by exact string equality — both silently. These rejections are the only place a bad name is ever reported. +**Steps:** For each, set `ui_app.surfacePointList` and run `brevo app upload`: +1. `["contact.header.action"]` — the pre-BEX-350 grammar +2. `["contact.headerMenu.action"]` — record type instead of the page name +3. `["contactdetails.headerMenu.action"]` — wrong casing +4. `["contactDetails.overviewMain.widget"]` — a widget slot for an action link +5. `[]` — empty list +6. `["contactDetails.headerMenu.action","contactDetails.headerMenu.action"]` — duplicates +**Expected:** Each fails before any network call, naming the field; exit `1`. Cases 1–3 report "Unknown extension point" and list the valid registry; case 4 explains an action link renders as a menu action. + +### TC-12.7 — Deploy to an account, and the action link renders +**Priority:** High +**Preconditions:** TC-12.4 succeeded; a test account ID; the BEX-350 registry reseed has run. +**Steps:** `brevo app deploy `, confirm the prompt. Open a contact record in that account and open the header **More** (•••) menu. +**Expected:** "App … deployed to account …". A menu entry appears **labelled with the app name**, and clicking it opens the redirect link in a new tab (or the same tab when `linkTarget` is `_self`), carrying the record context the slot's registry row allows. Then `brevo app remove ` and confirm it disappears. + +### TC-12.8 — Multiple record pages from one app +**Priority:** Medium +**Steps:** `brevo app create`, choose **UI app**, tick **all three** record pages at the multi-select, finish the prompts, then upload and deploy. +**Expected:** `surfacePointList` has all three `.headerMenu.action` names; the entry appears in the More menu on contact, deal **and** company records. + +### TC-12.9 — Deploy refuses before an upload +**Priority:** High +**Steps:** In a UI-app project whose `app-config.json` has no `version` (or a freshly created, never-uploaded app), run `brevo app deploy `. +**Expected:** Refuses with "Please first validate your configuration with `brevo app upload`"; exit `1`; nothing deployed. + +### TC-12.10 — Remove from an account, and idempotency +**Priority:** High +**Steps:** `brevo app remove ` on a deployed app; then run it again. +**Expected:** First run: "App … removed from account …", entry gone from the record. Second run: reports the app is not deployed to that account and exits **`0`** (not an error). Under `--json`: `{"removed": false, "reason": "NOT_DEPLOYED", …}`. + +### TC-12.11 — Field validation and account-ID validation +**Priority:** Medium +**Steps:** (a) set `ui_app.redirectLink` to `http://example.com/x` and upload; (b) set it to `http://localhost:3000/x` and upload; (c) blank `ui_app.heading` and upload; (d) set `ui_app.linkTarget` to `_top` and upload; (e) add `ui_app.modalIframeUrl` and upload; (f) `brevo app deploy abc`; (g) `brevo app deploy` with no argument. +**Expected:** (a) rejected — must use https; (b) **accepted** (loopback exemption); (c) rejected — heading cannot be empty; (d) rejected — invalid linkTarget; (e) rejected — only used by `iframeExtension`; (f) "not a numeric Brevo account ID"; (g) "Missing account ID" + usage. All rejections exit `1` with no API call. + +### TC-12.12 — A UI app cannot be created non-interactively +**Priority:** High +**Steps:** (a) `brevo app create --name "QA Link" --distribution private --json`; (b) the same command piped from `/dev/null` (non-TTY); (c) `brevo app create --type ui`; (d) `brevo app create --surface contact`. +**Expected:** (a) and (b) create an **OAuth** app without ever showing the app-type prompt — JSON reports `appType: "oauth"`, includes `redirectUri`, and has **no** `uiApp` key; no `ui_app` block is written to `app-config.json`. (c) and (d) fail with commander's `unknown option` and exit non-zero — neither flag exists. `brevo app create --help` lists neither, nor `--heading`/`--subheading`/`--redirect-link`/`--link-target`. + +### TC-12.13 — `app scaffold` in a UI-app project +**Priority:** High +**Steps:** From a UI-app project, hand-edit `ui_app.subheading`, then force drift (rename the app locally or on the server) and run `brevo app scaffold`, consenting to the refresh. +**Expected:** No feature-type prompt, no `src/oauth/` files, and a message that there are no features to scaffold. **Critically: the hand-edited `ui_app` block survives the refresh** — still present and unchanged in `app-config.json` afterwards. + +### TC-12.14 — OAuth regression sweep +**Priority:** High +**Steps:** Create a private OAuth app end to end (`brevo app create` → accept the feature prompt → `yarn --cwd src/oauth` → `brevo app start oauth`), then `brevo app upload`. +**Expected:** Byte-for-byte the same experience as before this branch: redirect-URL prompts, four default scopes, `src/oauth/` scaffold, working OAuth flow, and an upload payload with **no** `snapshot` (and no `ui_app`) key. A public OAuth app must still get the PKCE scaffold. + +--- + ## Sign-off | Suite | Owner | Result (Pass/Fail) | Notes | @@ -594,5 +696,6 @@ Messages match the canned copy per state (e.g. `submitted` → "Your app has bee | 9 — backward compat/migration | | | | | 10 — help layout | | | | | 11 — cross-cutting/regression | | | | +| 12 — UI apps / action links | | | | **Overall verdict:** ☐ Ready to merge ☐ Blocked (see notes) diff --git a/README.md b/README.md index 62c592b..05274d3 100644 --- a/README.md +++ b/README.md @@ -93,18 +93,22 @@ Run `brevo --help` or `brevo --help` for full command and option lists | `brevo logout` | Clear stored credentials (`--force` to skip confirmation) | | `brevo whoami` | Show the authenticated user | | `brevo app init` | Guided setup — login, create app, and scaffold in one go | -| `brevo app create` | Create an OAuth app (`--name`, `--distribution private`, repeatable `--redirect-uri`, `--logo-uri`) | -| `brevo app list` | List OAuth apps in your account | +| `brevo app create` | Create an app (`--name`, `--distribution private`, repeatable `--redirect-uri`, `--logo-uri`). Interactively it asks the app type first — an OAuth integration or a UI app; a UI app is prompt-only, so non-interactive runs always create an OAuth app | +| `brevo app list` | List apps in your account | | `brevo app credentials` | Show client ID and secret (`--app-id`, `--reveal-secret`) | -| `brevo app update` | Update app name, redirect URLs, or logo (`--app-id`, `--name`, repeatable `--redirect-uri`, `--logo-uri`, `--yes`) | +| `brevo app upload` | Push `app-config.json` to Brevo after showing a local-vs-server diff (`--yes`) | +| `brevo app deploy` | Make an app available in a Brevo account (``, `--app-id`, `--force`) | +| `brevo app remove` | Remove an app from a Brevo account (``, `--app-id`, `--force`) | | `brevo app delete` | Delete an app (`--app-id`, `--force`) | -| `brevo app scaffold` | Generate starter code for an app (`--app-id`) | +| `brevo app scaffold` | Add a feature to the app in the current directory (`--overwrite`) | | `brevo app start` | Run a scaffolded feature locally (e.g. `brevo app start oauth --port 3000`) | Most commands require a successful `brevo login` first, except authentication/help flows (`brevo login`, `brevo logout`, `brevo app init`, `--help`). Every command accepts `--json` for machine-readable output. > **⚠️ Public apps are not available yet.** Public app distribution isn't live on the Brevo platform. `brevo app create` still accepts `--distribution public`, but create your apps with `--distribution private` — a public app can't be distributed or submitted for review today. +> **⚠️ UI apps are not available yet.** UI apps — action links that render inside Brevo CRM records — aren't live on the Brevo platform. `brevo app create` offers one at its app-type prompt, and `brevo app deploy` / `brevo app remove` ship, but there is no working path behind any of it today. + ### Browser login `brevo login` defaults to a browser-based sign-in. The CLI starts a temporary loopback server, opens your browser to the Brevo CLI login service, and stores the returned tokens in `~/.brevo/credentials.json`. Access tokens refresh automatically on expiry. diff --git a/RELEASE-CHECKLIST.md b/RELEASE-CHECKLIST.md index 51b8823..908bb89 100644 --- a/RELEASE-CHECKLIST.md +++ b/RELEASE-CHECKLIST.md @@ -6,7 +6,8 @@ editing or deleting anything in it. | Section | Lifetime | | --- | --- | | `## Before public-apps GA` | **Durable.** Merges into `main` and stays there until public app distribution ships. Do **not** delete it during branch cleanup. | -| `## Per-branch verification` | **Scratch.** Per-branch working state — clear it before merging the branch into `main`, but keep the file and the section heading. | +| `## Before UI-apps GA` | **Durable.** Same deal for UI apps (action links) — stays until they ship. Do **not** delete it during branch cleanup. | +| `## Per-branch verification` | **Scratch.** Per-branch working state — clear it before merging the branch into `main`, but keep the file and the section headings. | --- @@ -62,13 +63,83 @@ to create a public app or drive the review lifecycle (`app submit` / `app status runtime guard would need the same internal-account escape hatch, and note the domain check is a guardrail, not a security boundary: real enforcement belongs on the API. If a guard lands before GA, add its removal to the list above. -- [ ] `README.md`'s command table is drifted independently of this change: it still - lists `brevo app update` (replaced by `brevo app upload`) and omits - `brevo app status` / `submit` / `withdraw` / `available-scopes`. Worth a +- [x] `README.md`'s command table drift — the stale `brevo app update` row (replaced + by `brevo app upload`) was fixed in the BEX-290 branch. Still omits + `brevo app status` / `submit` / `withdraw` / `available-scopes`; worth a separate pass. --- +## Before UI-apps GA + +UI apps (action links) are not live on the Brevo platform, so the agent-facing docs +carry a **⚠️ UI apps are not available yet** notice telling agents never to create a +UI app or drive the deploy lifecycle (`app deploy` / `app remove`). This mirrors the +public-apps notice above, including its *Exception — internal Brevo accounts* clause. + +**When UI apps go GA, remove the notice everywhere in one pass:** + +- [ ] `agent-context/SKILL.md` + - [ ] Delete the `## ⚠️ UI apps are not available yet` section. + - [ ] Decision tree — drop the **not available yet** prefix from "Create a UI app / + action link", "Make a UI app available in an account", and "Remove a UI app + from an account". + - [ ] Hard rules — delete rule 7 (*Don't create UI apps for real use*). Keep rule 8 + (*Never mix the two app types*) — that one is a correctness rule, not a + pre-GA restriction. +- [ ] `agent-context/AGENTS.md` + - [ ] Delete the `## ⚠️ UI apps are not available yet` section. + - [ ] Common commands table — drop the **⚠️ Not available yet** prefix from the + `brevo app deploy` and `brevo app remove` rows. + - [ ] Conventions — delete the *UI apps are not available yet* bullet. Keep the + *Two app types, one command surface* and *The `ui_app` block* bullets. +- [ ] `README.md` — delete the **⚠️ UI apps are not available yet** blockquote below + the commands table. +- [ ] Verify nothing was missed: + `grep -rn "UI apps are not available yet" --include="*.md" .` + (excluding `node_modules/`, `dist/`, `coverage/`) returns only this file. +- [ ] Delete this whole `## Before UI-apps GA` section. + +**Related follow-ups (not blockers for GA removal):** + +- [x] **`ui_app` field names — RESOLVED.** Confirmed against both of the platform's + consumers — the manifest read path and the extensibility UI kit + (BEX-308 / BEX-350). The block is the stored app snapshot verbatim: `extensionType`, + `surfacePointList`, `heading`, `subheading`, `redirectLink`, `linkTarget`. + The UIApp Support Spec's `properties`/`trigger` vocabulary is not read + anywhere and has been dropped. +- [ ] **Coordinate the BEX-350 registry reseed.** The twelve-point + extension-point registry (three record pages x three widget places + one + action place, `.widget`/`.action` kinds) has to be seeded before a + CLI-authored slot name resolves. An unregistered name is dropped silently, so + a CLI release ahead of the reseed produces action links that render nothing. + The CLI's local registry copy lives in `src/lib/constants.ts` + (`EXTENSION_POINTS`) and must be updated in lockstep if the registry changes. +- [ ] **Build the snapshot write path — still missing.** On + the platform nothing writes the app snapshot; only the + manifest read path parses it, and the existing build endpoint writes a + *separate* config field. The CLI currently sends the block under a + `snapshot` key on `POST /v3/app-store/apps/{id}/upload` — confirm or correct + that once the write endpoint exists (`src/types.ts` `UploadAppPayload` and + `upload.ts` are the only places to change). +- [ ] **Confirm the deploy/remove endpoint contract.** `ENDPOINTS.APP_STORE_APP_DEPLOY` + / `APP_STORE_APP_REMOVE` and `appService.deployApp` / `removeApp` currently + assume `POST /v3/app-store/apps/{id}/deploy|remove` with `account_id` in the + body, and that the "not yet uploaded" / "not deployed" rejections are HTTP 422. + All four assumptions are marked in code comments. +- [ ] Confirm whether `GET /v3/app-store/apps/{id}` returns the snapshot. The upload + diff and the scaffold-refresh path both read `snapshot` opportunistically and + degrade safely when absent (the block reads as new / is carried forward + locally), but the diff is only fully accurate once the server echoes it — + notably `linkTarget`, which the backend defaults to `_blank` server-side. +- [ ] Decide whether the CLI should guard the UI-app path at runtime, the same open + question as `--distribution public` above. There is no `--type ui` flag to guard — + a UI app can only be chosen at the interactive app-type prompt, which is itself a + soft limit on reaching it accidentally — but the prompt choice is offered without + a warning. + +--- + ## Per-branch verification Append an entry per change that needs verifying. Clear this section (keep the @@ -133,3 +204,104 @@ before any submit work. Only a failed fetch blocks; the state value is not a gat in `--json`), and a thrown error is formatted by `withCommandHandler`. - [ ] Manual: point the CLI at an unreachable API and confirm `brevo app submit` exits non-zero with the status-fetch error, not a submit error. + +### BEX-290 — UI app support (action links) + +**Change:** New app type. `brevo app create --type ` with a UI-app prompt +path, a `ui_app` block in `app-config.json`, `ui_app` on the upload payload with +local validation and diffing, and two new commands `brevo app deploy ` / +`brevo app remove `. `applyConditionals` generalised from a single +distribution value to a flag set. + +**Must hold true:** + +- [x] The OAuth path is unchanged end to end. `create` still collects redirect URLs, + still sends `redirect_uris` + the four `DEFAULT_SCOPES`, and the upload payload + for an OAuth app is byte-identical (no `ui_app` key at all). Covered by the + pre-existing `create.test.ts` / `upload.test.ts` cases plus + `never sends a ui_app field for an OAuth app`. +- [x] A private OAuth scaffold renders byte-for-byte as before, despite + `applyConditionals` now taking a flag set — the existing + `templates/conditionals.test.ts` invariants still pass, and + `applyConditionals(tmpl, 'private')` still accepts a bare `Distribution`. +- [x] A UI app never acquires a phantom OAuth callback: `resolveRedirectUrls` (which + falls back to `http://localhost:3009/auth/callback` in non-TTY) is not on the UI + path. Covered by `never prompts for or defaults a redirect URL`. +- [x] Editing only the `ui_app` block is detected as a change, not "already up to + date". Covered by `uploads when only the ui_app block changed`; key-order + insensitivity by `treats a reordered ui_app block as unchanged`. +- [x] A hand-edited `ui_app` block survives a `brevo app scaffold` config refresh + (which rewrites `app-config.json` wholesale from server values). Covered by + `preserves the local ui_app block through a confirmed config refresh`. +- [x] `app deploy` refuses before an upload, and maps the server's 422 to the same + message. `app remove` has no gate and exits `0` when not deployed. Covered by + `deploy.test.ts` / `remove.test.ts`. +- [x] The `ui_app` block matches the platform's stored app-snapshot shape field for + field (`extensionType`, `surfacePointList`, `heading`, `subheading`, + `redirectLink`, `linkTarget`), verified against both of the platform's + consumers (BEX-308 / BEX-350). Covered by + `builds the snapshot shape the platform consumes` and + `sends the block under the snapshot key`. +- [x] An unregistered, mis-cased, stale-grammar, or widget-slot extension point is + rejected locally — the platform would drop it silently. Covered by + `validateSurfacePoint` cases and the upload-level rejections. +- [ ] Manual, **against a real test account**: run `brevo app create`, choose **UI app** + at the prompt, inspect the generated `app-config.json`, then `brevo app upload` + and confirm the backend **accepts** the snapshot. A 4xx here means the write path + isn't built yet (expected — see the follow-ups above), not a shape mismatch. +- [ ] Manual: `brevo app deploy ` against a real account, then confirm the + action link actually renders in that account's contact record action menu, opens + the external URL in a new tab, and carries the declared context properties. + Then `brevo app remove ` and confirm it disappears. +- [ ] Manual: `brevo app deploy ` on a never-uploaded app must refuse with + the `brevo app upload` hint — verify the **server** path too (not just the local + `version` pre-flight) by deleting `version` from a config whose app *was* + uploaded. +- [ ] Manual: `brevo app create` interactively on an existing OAuth project directory + and confirm the new app-type prompt appears first and that choosing *OAuth app* + reproduces the previous flow exactly. +- [ ] Manual: confirm the disabled *Iframe modal* / *Inline widget* choices in the + delivery prompt are visible but unselectable. +- [ ] Manual: confirm the created-app box states that the menu entry is labelled with + the app name — partners will otherwise look for a label field that doesn't exist. +- [ ] Reviewer: `agent-context/SKILL.md` and `agent-context/AGENTS.md` both document + the new commands, the prompt-only UI-app create path (and that no `--type` or + UI-field flags exist), the `ui_app` block, and both carry the + UI-apps-not-available notice with equivalent wording (CLAUDE.md requires those + two stay in sync). +- [ ] Reviewer: the **field names are now verified** against both platform consumers, + but the **snapshot write path does not exist yet** — confirm with the app-store + backend team how the CLI should submit it (the CLI currently sends a `snapshot` + key on the existing upload endpoint) before this ships to users. Same for the + deploy/remove endpoints. See *Before UI-apps GA* → related follow-ups. +- [ ] Reviewer: BEX-350 needs a coordinated release (kit + reseeded extension-point + registry + backend). A CLI release ahead of the reseed authors names that resolve + to nothing, silently. Confirm the sequencing. + +### UI-app create is prompt-only; `extensionType` is camelCase + +- [x] `brevo app create` exposes **no** `--type`, `--surface`, `--heading`, + `--subheading`, `--redirect-link` or `--link-target`. Covered by lint (the dead + imports in `definitions.ts` would fail it) and by TC-12.12 manually; commander + rejects each with `unknown option`. +- [x] Every non-interactive run creates an OAuth app: piped stdin **and** `--json` on a + TTY, neither showing the app-type prompt. Covered by + `creates an OAuth app in a non-TTY run, without prompting for the app type` and + `creates an OAuth app under --json even on a TTY`. +- [x] The prompt `validate` callbacks are still wired now that the flag parsers are + gone — they are the only remaining input check. Covered by + `validates the heading and redirect-link answers at the prompt` and + `requires at least one record page`. +- [x] Only the action link is selectable at the delivery prompt. Covered by + `offers only the action link as a selectable delivery path`. +- [x] The authored `extensionType` is `actionLink`, and `action_link` is rejected on + upload. Covered by `builds the snapshot shape the platform consumes` and the + `validateUiApp` type cases. +- [ ] Manual: with a UI project created via the prompts, confirm `brevo app upload` + renders `Extension type: actionLink` in the diff and sends that value under + `snapshot`. Then hand-edit the file to `action_link` and confirm the upload is + rejected locally with exit `1` and no API call. +- [ ] Reviewer: the platform's server-side `linkTarget` default is gated on the literal + `"action_link"`, so it no longer fires for CLI-authored apps. Confirm this stays + harmless — the CLI always writes `linkTarget` explicitly, and the UI kit defaults + an absent/unrecognised value to `_blank` client-side. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..1bbdcc0 --- /dev/null +++ b/TODO.md @@ -0,0 +1,66 @@ +# TODO + +Running work tracker. Per `CLAUDE.md`, delete this file before merging the branch +into `main` — anything that must outlive the branch belongs in +`RELEASE-CHECKLIST.md` → *Before UI-apps GA* instead. + +## Open + +### BEX-290 follow-ups + +- [x] **`ui_app` field names — resolved.** Aligned with the platform's manifest read path and its + extensibility UI kit (BEX-308 / BEX-350). The block is the stored app snapshot + verbatim. +- [ ] **Blocking before this reaches users:** the snapshot **write path** doesn't exist + yet — only the manifest read path that consumes it exists, and the existing + build endpoint writes a separate config field. So the upload transport is still a guess (the CLI + sends the block as `snapshot` on `POST /v3/app-store/apps/{id}/upload`), as are + the deploy/remove endpoints. See `RELEASE-CHECKLIST.md` → *Before UI-apps GA*. +- [ ] **Keep `EXTENSION_POINTS` in lockstep with the registry.** `src/lib/constants.ts` + hard-codes the twelve-point registry so slot names can be validated offline. If + the platform's registry gains or renames an entry (e.g. a `quoteDetails` + location, or a second action place), the CLI will reject a legitimate name until + it is updated. Worth revisiting if the registry becomes fetchable. +- [ ] **BEX-350 requires a coordinated release.** The kit, the reseeded registry and + the backend have to land together; a CLI authoring `.widget`/`.action` names + against a `.region`-era registry produces extensions that render nothing, with no + error anywhere. +- [ ] `iframeExtension` support (the platform's name for a modal card). The type + round-trips today but `validateUiApp` rejects it on upload, and `modalIframeUrl` + is rejected on an `actionLink` because the UI kit only honours it for an + `iframeExtension`. Needs: authoring for `modalIframeUrl`, `permittedUrls.iframe` + handling (the postMessage origin allowlist is what makes the modal secure), and + the corresponding create prompts. +- [ ] Widget slots (`.overviewAttributes|overviewMain|overviewSidebar.widget`). + The registry and `EXTENSION_POINTS` already carry them and `ExtensionSlot` + renders them, but there is no CLI authoring path — `validateUiApp` rejects a + widget slot for an action link, which is correct, but nothing can author a + widget-type extension yet. The delivery prompt shows it as a disabled choice. +- [ ] `place` selection is currently implicit: an action link always targets + `headerMenu`, the only action place in the registry. If a second action place + lands (a sidebar toolbar, an inline header bar), the record-page prompt needs a + companion prompt rather than deriving the slot name. +- [ ] No local dev story for a UI app. `brevo app start` has no UI-app equivalent, so + a partner can't preview an action link without deploying to a real account. + Worth considering a local harness that renders the action menu and forwards + context params to the external URL. +- [ ] `permittedUrls` is scaffolded empty and never validated or populated from + `ui_app.redirectLink`. Harmless for action links (they open a new tab), but it + becomes load-bearing for `iframeExtension` modals. +- [ ] Consider whether `brevo app list` should show the app type. Right now an OAuth + app and a UI app are indistinguishable in the list output. +- [ ] Record context is an allow-list on the extension-point registry row + and surfaces on the manifest as `app_configs.context`, + i.e. the platform decides it per slot. Nothing for the CLI to author — but worth + surfacing read-only (e.g. in the create summary) so a partner knows which + params their URL will actually receive. + +### Pre-existing, unrelated + +- [ ] `dist/` in this working copy is owned by `root` (dated 27 Jul), so `yarn build` + fails with `EACCES`. `tsc --noEmit`, `yarn test`, and `yarn lint` are unaffected. + Fix locally with `sudo rm -rf dist` — not a repo issue, and deliberately left + alone here rather than running `sudo` unprompted. +- [ ] `README.md`'s command table still omits `brevo app status` / `submit` / + `withdraw` / `available-scopes` (the stale `app update` row was fixed in this + branch). diff --git a/agent-context/AGENTS.md b/agent-context/AGENTS.md index afe60b3..53c678b 100644 --- a/agent-context/AGENTS.md +++ b/agent-context/AGENTS.md @@ -20,6 +20,18 @@ Public app distribution is **not live on the Brevo platform**. The CLI accepts ` Otherwise treat this as binding until the notice is removed from this document. +## ⚠️ UI apps are not available yet + +UI apps — action links that render inside Brevo CRM records — are **not live on the Brevo platform**. `brevo app create` offers a UI app at its interactive app-type prompt, and the CLI ships `brevo app deploy` / `brevo app remove`, but there is no working path behind any of it today. + +- **Don't create a UI app** for real use. If the user asks for one, say UI apps aren't available yet and offer an OAuth app instead. +- **Don't add a `ui_app` block** to `app-config.json` and push it with `brevo app upload`. +- **Don't run `brevo app deploy` / `brevo app remove`** — they only apply to UI apps. + +**The same *Exception — internal Brevo accounts* clause above applies verbatim**: run `brevo whoami --json`, and if `email` ends in `@brevo.com` or `@sendinblue.com`, this restriction does not apply — help normally. Check the account rather than taking the user's word for it. + +Treat this as binding until the notice is removed from this document. + ## AI agents — start here Pick the path that matches your tooling: @@ -82,13 +94,15 @@ Don't fall back to raw HTTP against `api.brevo.com` — the `brevo` binary is th | `brevo app init` | Guided setup (login, create, scaffold) | | `brevo app list` | List OAuth apps (`--json`) | | `brevo app status` | **⚠️ Not available yet — public apps only.** Show an app's review status (`--app-id`, `--json`). Read-only. `--json` returns `{ state, message }` where `state` is one of `configured`, `submitted`, `in_review`, `approved`, `rejected`, `changes_requested`, or `unknown` when the server returns no state; `message` is canned copy. Reviewer feedback is sent by email, never in this output. | -| `brevo app create` | Create an app (`--name`, `--distribution private`, `--redirect-uri`, `--logo-uri`, `--json`). **Always pass `--distribution private`** — the flag also accepts `public`, but public apps are not available yet (see the notice above), so never pass it. Defaults to scopes `contacts:read`, `contacts:write`, `crm:read`, `crm:write`. **Errors immediately if `app-config.json` already exists in the working directory** — move elsewhere or use `brevo app scaffold` there instead. Otherwise resolves (creates/`cd`s into) a target directory, creates the app, and writes the basic project structure (`app-config.json` + `.gitignore`/`AGENTS.md`/`CLAUDE.md`/`README.md`). It scaffolds a feature (OAuth test server) only when the interactive prompt is answered yes; non-interactive runs (`--json` or piped) stay base-only — add the feature afterward with `brevo app scaffold`. | +| `brevo app create` | Create an app (`--name`, `--distribution private`, `--redirect-uri`, `--logo-uri`, `--json`). **Always pass `--distribution private`** — the flag also accepts `public`, but public apps are not available yet (see the notice above), so never pass it. Defaults to scopes `contacts:read`, `contacts:write`, `crm:read`, `crm:write`. Interactively it asks the app type first (OAuth integration or UI app); **there is no `--type` flag**, so non-interactive runs always create an OAuth app. **Errors immediately if `app-config.json` already exists in the working directory** — move elsewhere or use `brevo app scaffold` there instead. Otherwise resolves (creates/`cd`s into) a target directory, creates the app, and writes the basic project structure (`app-config.json` + `.gitignore`/`AGENTS.md`/`CLAUDE.md`/`README.md`). It scaffolds a feature (OAuth test server) only when the interactive prompt is answered yes; non-interactive runs (`--json` or piped) stay base-only — add the feature afterward with `brevo app scaffold`. | | `brevo app upload` | Push `app-config.json` to Brevo (`--yes`, `--json`). No edit flags — change name/redirect URLs/scopes/logo/distribution/version by editing `app-config.json` directly, then run `upload`. Always fetches the remote app first and shows a local-vs-server diff (even under `--yes`/`--json`); exits 0 with no network push if nothing differs. | | `brevo app credentials` | Show client ID / secret (`--app-id`, `--reveal-secret`, `--json`). Also backfills a missing top-level `version` / `distribution_type` into cwd's `app-config.json` when its `appId` matches (fill-only-when-missing, silent). | | `brevo app delete` | Delete an app (`--app-id`, `--force`, `--json`) | | `brevo app withdraw` | **⚠️ Not available yet — public apps only.** Withdraw an app from submission (`--app-id`, `--force`, `--json`); `--app-id` optional inside a scaffolded project (reads `app-config.json`); if the app was never submitted, prints a submit hint and exits `0` | | `brevo app scaffold` | Add a feature to the app in the current directory (`--overwrite`, `--json`). Requires an `app-config.json` in cwd (errors if absent, pointing you to `brevo app create` or the right folder); reads the linked app from it (no `--app-id`). Diffs the local config against the server and, on drift, updates `app-config.json` to match (on consent) before writing the feature files. When feature files already exist, prompts Overwrite / Merge / Cancel (default Merge — existing files kept); `--overwrite` forces overwrite and skips the prompt. The scaffolded OAuth flow branches on `distribution_type`: **public** apps get a PKCE (RFC 7636) flow (`code_challenge`/`code_verifier`, **no client secret** — no `CLIENT_SECRET` in the generated `.env.local`/`.env.example`); **private** apps get the confidential-client flow (token exchange authenticated with `CLIENT_SECRET`). | | `brevo app start oauth` | Run the scaffolded OAuth test server (`--port`) | +| `brevo app deploy ` | **⚠️ Not available yet — UI apps only.** Make an app available in one Brevo account (`--app-id`, `--force`, `--json`). `--app-id` optional inside a project (reads `app-config.json`). Refuses with *"Please first validate your configuration with `brevo app upload`"* until the app has been uploaded — detected locally via a missing top-level `version`, and the server's own rejection maps to the same message. `` must be numeric. | +| `brevo app remove ` | **⚠️ Not available yet — UI apps only.** Remove an app from one Brevo account (`--app-id`, `--force`, `--json`). No upload gate. If the app isn't deployed there it says so and exits `0` — `{"removed": false, "reason": "NOT_DEPLOYED"}` under `--json` — so teardown stays idempotent. | | `brevo app submit` | **⚠️ Not available yet — public apps only.** Submit a **public** app for review (`--app-id`, `--json`). Runs a status preflight first (the same review-state read as `brevo app status`) and aborts if that read fails. Requires the app's `distribution_type` to be `public` and, when `app-config.json` describes the target app, that it matches the server (on drift, either update the local config with the server values or push with `brevo app upload`). Shows the full app definition and asks for confirmation (interactive TTY only; skipped when stdin is not a TTY), then opens the pre-filled submission form in the browser; `--json` prints `{"app_id","form_url"}` instead, with no prompt — use it in CI/headless contexts. The app is only actually submitted once the Google Form is completed and submitted — the command itself changes nothing server-side. | | `brevo app available-scopes` | List OAuth scopes supported by the IdP (`--json`, `--web`) | | `brevo skill:cli install` | Install the brevo-cli Claude Code skill (Claude-only; auto-refreshes on every `brevo` run) | @@ -100,13 +114,18 @@ Run `brevo --help` or `brevo --help` for the full set. - **Every command supports `--json`** — prefer this when parsing output programmatically. - **Public apps are not available yet.** Always create apps with `--distribution private`, never set `distribution_type` to `public` in `app-config.json`, and don't run `brevo app submit` / `brevo app status` / `brevo app withdraw` — unless `brevo whoami --json` shows an `@brevo.com` / `@sendinblue.com` account. See the notice at the top of this file, including its *Exception — internal Brevo accounts* clause. +- **UI apps are not available yet.** Never choose **UI app** at `brevo app create`'s app-type prompt, never add a `ui_app` block to `app-config.json`, and don't run `brevo app deploy` / `brevo app remove` — unless `brevo whoami --json` shows an `@brevo.com` / `@sendinblue.com` account. Same exception clause as above. +- **Two app types, one command surface — but only one is scriptable.** `brevo app create` asks *"What type of app are you building?"* first, offering an OAuth integration or a UI app (action link). **There is no `--type` flag and no flags for any UI-app field**, so a UI app can only be authored from an interactive terminal: every non-interactive run (`--json` or piped stdin) creates an OAuth app. The two types differ in what's collected and stored: an OAuth app has `auth.redirectUrls` and no `ui_app`; a UI app has a `ui_app` block, **no** `auth.redirectUrls` (no OAuth callback exists), narrower default scopes (`contacts:read`, `contacts:write`), and no scaffoldable feature. The UI path prompts for record page(s) (`contact`/`company`/`deal`, multi-select), heading, optional subheading, destination URL and link target (`_blank`/`_self`). The presence of `ui_app` in `app-config.json` is how the CLI tells the types apart — never add it to an OAuth project. - **`brevo app create` refuses to run inside an already-linked directory.** If `app-config.json` exists in cwd, it throws immediately (no confirm, no override) — the error points at moving elsewhere or running `brevo app scaffold` there. - **`brevo app create` resolves its target directory before creating the app**, then writes the **basic project structure only** (`app-config.json` + `.gitignore`/`AGENTS.md`/`CLAUDE.md`/`README.md`) — the OAuth server code is a *feature*, not part of the base. Interactive mode prompts for the target directory (default `./`, `cd`s into it) before the API call, how to handle an existing one (overwrite / merge / choose a different path), and — after the app is created — whether to scaffold a feature (default **yes**) then which kind (today, a single choice: *Test OAuth App*). Non-interactive runs stay base-only: `--json` (and piped, non-TTY) create the app and write the base files but never scaffold a feature — run `brevo app scaffold` afterward for the OAuth code. Under `--json` the same default directory is used and `cd`d into if it doesn't already exist; if it already exists, both directory setup and scaffolding are skipped (the app is still created). The JSON response always includes `directory` (absolute path) alongside the app fields, plus either `scaffolded` (base file count, on success) or `scaffoldSkipped` (a message, when the directory already existed). - **`brevo app scaffold` adds a feature to an already-created project.** It **requires** an `app-config.json` in cwd — with none present it errors (pointing you to run `brevo app create` first or `cd` into the right project folder), it does *not* create a directory. It reads the linked app id from that config (no `--app-id`, no app picker), diffs the local config against the server, and if fields drifted it shows them and asks consent to update `app-config.json` (and the other base files) to match before writing the feature files. When any feature file already exists it prompts **Overwrite / Merge / Cancel** (default **Merge** — existing, e.g. hand-edited, files are kept and only missing files added; Cancel aborts without writing). The `--overwrite` flag forces a full overwrite of feature files and skips that prompt (works interactively and under `--json`). **Under `--json` it never prompts**: a config diff comes back as `{ "cancelled": true, "reason": "...", "diffs": [...] }`; otherwise it writes the feature (merging existing files unless `--overwrite` is passed) and returns `{ "scaffolded": , "directory": "..." }`. - **`app-config.json`** in the working directory pins the linked app — `brevo app upload`, `brevo app start`, and `brevo app withdraw` read from it. `upload` is the *only* command that pushes config changes, and it has no `--app-id` override (it always resolves the app from cwd's `app-config.json`, hard-erroring if that file is missing/invalid/lacks `appId`); `brevo app start` and `brevo app withdraw` accept `--app-id` to target a different app. The top-level `logoUri` string is pushed as `logo_uri`; leave it empty to keep the API value untouched. The top-level `version` string is round-tripped as `app_version` on the wire — `upload` sends the local value (falling back to the server's current value if locally absent) and writes back whatever the server confirms. `brevo app credentials` additionally backfills a missing top-level `version` / `distribution_type` into cwd's `app-config.json` when its `appId` matches the inspected app — fill-only-when-missing (never overwrites an existing local value), silent in all modes — so legacy projects that are never `upload`ed still converge to the current shape. +- **The `ui_app` block (UI apps only).** A UI app's `app-config.json` carries a top-level `ui_app` which is the app snapshot the platform stores **field for field**: `{ extensionType: "actionLink", surfacePointList: ["contactDetails.headerMenu.action"], heading, subheading?, redirectLink, linkTarget?: "_blank"|"_self" }`. `brevo app upload` sends it under the `snapshot` key and validates it locally first — `extensionType` must be `actionLink` (camelCase since BEX-350 — the old snake_case `action_link` is rejected); `surfacePointList` non-empty, drawn from the registry, action-slots only, no duplicates; `heading` non-empty; `redirectLink` an **https** URL (`http://` only for `localhost`/`127.0.0.1`); `linkTarget` one of `_blank`/`_self`; `modalIframeUrl` rejected (the UI kit keeps it only for `iframeExtension`, so it would be silently dropped). `iframeExtension` and `legacyComponent` are not CLI-authorable. For OAuth apps nothing is sent and the OAuth payload is byte-identical to previous releases. Editing only `ui_app` still counts as a change (the diff ignores key order). `brevo app scaffold` in a UI-app project refreshes the base config, reports that there are no features to scaffold, and preserves your hand-edited `ui_app` block even when rewriting `app-config.json` to match the server. +- **Extension-point names are exact and fail silently.** `surfacePointList` entries follow `..`. The registry is twelve names: locations `contactDetails`/`companyDetails`/`dealDetails` × widget places `overviewAttributes`/`overviewMain`/`overviewSidebar` (kind `widget`), plus `headerMenu` (kind `action`). An action link may only target `.headerMenu.action`. A name that isn't registered is **dropped by the platform**, and the UI kit matches by exact string equality — so a typo or a casing slip yields an empty slot, a 200, and no error anywhere. The CLI validates locally because nothing downstream will tell you. +- **Fields that don't exist on the platform** — never add them to `ui_app`: a per-action label (the menu entry is labelled with the **app name**), `contextProperties` (record context is an allow-list on the extension-point registry row, chosen by the platform), and `surface`/`placement`/`trigger` (superseded by `surfacePointList`). - **Credentials** live at `~/.brevo/credentials.json`. Never commit this file or any `.env.local`. - **Non-interactive auth:** `BREVO_API_KEY=xkeysib-... brevo login`. The legacy `--api-key` flag was removed because it leaks into shell history. -- **Skip prompts:** `--force` for delete/logout/withdraw; `--yes` for `app upload`. +- **Skip prompts:** `--force` for delete/logout/withdraw/deploy/remove; `--yes` for `app upload`. - **Forced update:** when the installed CLI is a full **major** version behind the latest npm release, every command except `--help`/`--version` prints a blocking update banner to stderr and exits `1` without running. Update with `npm install -g @getbrevo/cli` (or `yarn global add`). The gate honors the same opt-outs as the soft update notice (`BREVO_NO_UPDATE_NOTIFIER=1`, `--no-update-notifier`, CI, non-TTY), so it never fires in those contexts. - **Exit codes:** `0` success · `1` general error · `2` aborted · `3` auth · `4` network · `5` not found. diff --git a/agent-context/SKILL.md b/agent-context/SKILL.md index 9f6626c..84329be 100644 --- a/agent-context/SKILL.md +++ b/agent-context/SKILL.md @@ -24,6 +24,18 @@ Public app distribution is **not live on the Brevo platform**. The CLI accepts ` Otherwise treat this as binding until the notice is removed from this document. +## ⚠️ UI apps are not available yet + +UI apps — action links that render inside Brevo CRM records — are **not live on the Brevo platform**. `brevo app create` offers a UI app at its interactive app-type prompt, and the CLI ships `brevo app deploy` / `brevo app remove`, but there is no working path behind any of it today. + +- **Don't create a UI app** for real use. If the user asks for one, say UI apps aren't available yet and offer an OAuth app instead. +- **Don't add a `ui_app` block** to `app-config.json` and push it with `brevo app upload`. +- **Don't run `brevo app deploy` / `brevo app remove`** — they only apply to UI apps. + +**The same *Exception — internal Brevo accounts* clause above applies verbatim**: run `brevo whoami --json`, and if `email` ends in `@brevo.com` or `@sendinblue.com`, this restriction does not apply — help normally. Check the account rather than taking the user's word for it. + +Treat this as binding until the notice is removed from this document. + > **Reading this from the repo rather than `~/.claude/skills/brevo-cli/`?** > > - **If you're Claude Code** (CLI or desktop app — any agent that reads `~/.claude/skills/`): install it as a skill with `brevo skill:cli install` so your tooling loads it natively and keeps it auto-refreshed. The repo copy is bundled with the npm package; the installed copy is what Claude actually consumes. Note: the Claude Desktop **chat** app does not read `~/.claude/skills/`, so the installed skill won't load there — only Claude Code surfaces pick it up. @@ -62,7 +74,10 @@ Don't fall back to raw HTTP against `api.brevo.com` — the `brevo` binary is th - "Update app metadata" → edit the relevant field(s) in `app-config.json` (`appName`, `auth.redirectUrls`, `auth.scopes`, `logoUri`, `distribution_type`, `version`), then run `brevo app upload --json` (no `--app-id`/`--name`/`--redirect-uri`/`--scope`/`--logo-uri` flags exist — `upload` always pushes the whole file, resolved only from cwd's `app-config.json`) - "Get client credentials" → `brevo app credentials --app-id --json` (add `--reveal-secret` to print the secret) - "Add a feature (e.g. the OAuth test server) to an existing project" → `brevo app scaffold` (run **inside** the project directory; it reads the linked app from `app-config.json` — no `--app-id`). Not needed right after `app create` if you already accepted the feature prompt there. If feature files already exist it prompts Overwrite / Merge / Cancel (default Merge); pass `--overwrite` to force a full overwrite without prompting. **The scaffolded OAuth flow branches on the app's `distribution_type`:** a **public** app gets a PKCE (RFC 7636) flow — `/auth/login` sends `code_challenge`+`code_challenge_method=S256`, `/auth/callback` sends `code_verifier`, and **no client secret** is used (the scaffolded `.env.local`/`.env.example` carry no `CLIENT_SECRET`); a **private** app keeps the confidential-client flow (authenticates the token exchange with `CLIENT_SECRET`). +- "Create a UI app / action link" → **not available yet** (see the UI-apps notice above). For reference: a UI app is created by running plain `brevo app create` and choosing **UI app** at the *"What type of app are you building?"* prompt, then answering the record-page, heading, subheading, destination-URL and link-target prompts. **There are no flags for any of this and no `--type` flag** — a UI app can only be authored from an interactive terminal, so **every non-interactive run (`--json` or piped stdin) creates an OAuth app**, which is also why an agent cannot create one non-interactively. The UI path **never** collects redirect URLs — an action link has no OAuth callback, so `redirect_uris` is omitted from the create call entirely. Defaults: record page `contact`, link target `_blank`, scopes `contacts:read`/`contacts:write` (narrower than the OAuth defaults). No feature is ever scaffolded for a UI app (there is no local server to run). **There is no per-action label** — the menu entry is labelled with the *app name*, so rename the app to change it. - "Run the OAuth test server" → `brevo app start oauth --port 3009` (must be inside the scaffolded directory) +- "Make a UI app available in an account" → **not available yet** (see the UI-apps notice above). For reference: `brevo app deploy [--app-id ] [--force] [--json]`. Refuses with *"Please first validate your configuration with `brevo app upload`"* until the app has been uploaded (locally detected via a missing `version` in `app-config.json`; the server's rejection maps to the same message). `` must be numeric. +- "Remove a UI app from an account" → **not available yet** (see the UI-apps notice above). For reference: `brevo app remove [--app-id ] [--force] [--json]`. Has no upload gate. If the app isn't deployed to that account it reports so and exits `0` — not an error (`{"removed": false, "reason": "NOT_DEPLOYED"}` under `--json`), so teardown scripts stay idempotent. - "Delete an app" → `brevo app delete --app-id --force` - "Submit a public app for review" → **not available yet** (public apps only — see the notice above). For reference: `brevo app submit --app-id --json` (prints the submission form URL as `{"app_id","form_url"}` without opening a browser; without `--json` it shows the full app definition, asks for confirmation, then opens the form in the user's browser — the prompt is skipped when stdin is not a TTY). Before any of that it runs a status preflight (the same review-state read as `brevo app status`) and aborts if that read fails. The app's `distribution_type` must be `public`, and when `app-config.json` describes the target app it must match the server — if the command reports drift, either update the local config with the server values or push local changes with `brevo app upload`. The app is only actually submitted once the Google Form is completed and submitted; the command itself changes nothing server-side. - "Withdraw an app from submission" → **not available yet** (public apps only — see the notice above). For reference: `brevo app withdraw --app-id --force` (omit `--app-id` inside a scaffolded project to use the app pinned in `app-config.json`; if the app was never submitted, it prints a hint to submit first and exits `0` — not an error) @@ -77,6 +92,8 @@ Don't fall back to raw HTTP against `api.brevo.com` — the `brevo` binary is th 4. **`brevo app create` refuses to run inside an already-linked directory** (`app-config.json` present) — `cd` elsewhere or use `brevo app scaffold` there instead. **`brevo app scaffold` requires an `app-config.json` in the current directory** (it adds a feature to an already-created project); with none present it errors, telling you to run `brevo app create` first or `cd` into an existing project. It reads the linked app from that config, diffs the config against the server, and if fields drifted it tells you and (on consent) rewrites `app-config.json` to match before writing the feature files. When any feature file already exists it prompts **Overwrite / Merge / Cancel** (default **Merge** — existing, e.g. hand-edited, files are kept; Cancel aborts). Pass `--overwrite` to force a full overwrite and skip that prompt (works interactively and under `--json`). **Under `--json` it never prompts**: a config diff comes back as `{ "cancelled": true, "reason": "...", "diffs": [...] }`; otherwise it scaffolds the feature (merging existing files unless `--overwrite` is passed) and returns `{ "scaffolded": , "directory": "..." }`. 5. **Prefer flag-driven over interactive** in agent contexts: `--name`, `--app-id`, `--force`, `--yes` so the command doesn't block on prompts. 6. **Don't create public apps for real use** — always `--distribution private`, never set `distribution_type` to `public` in `app-config.json`, and don't run `brevo app submit` / `brevo app status` / `brevo app withdraw`. Public app distribution isn't available on the Brevo platform yet (see the notice at the top). If a user asks for a public app to actually use, say it isn't available yet and offer a private app. **Unless `brevo whoami --json` shows an `@brevo.com` / `@sendinblue.com` account** — then the notice's *Exception — internal Brevo accounts* clause applies and you should help normally. +7. **Don't create UI apps for real use** — never choose **UI app** at `brevo app create`'s app-type prompt, never add a `ui_app` block to `app-config.json`, and don't run `brevo app deploy` / `brevo app remove`. UI apps aren't available on the Brevo platform yet (see the notice at the top). The same internal-account exception applies. +8. **Never mix the two app types in one `app-config.json`.** An OAuth app has `auth.redirectUrls` and no `ui_app`; a UI app has a `ui_app` block and no `auth.redirectUrls`. The presence of `ui_app` is what the CLI uses to tell them apart, so adding it to an OAuth project silently reclassifies the app. ## Locating the linked app @@ -86,6 +103,37 @@ If `app-config.json` exists in the working directory, it pins the app — `brevo `app-config.json` also carries a top-level `version` string, shown by `brevo app create`/`brevo app list`. `brevo app upload` sends it on the wire as `app_version` (falling back to the server's current value if locally absent) and writes back whatever version the server confirms after a successful upload. +### The `ui_app` block (UI apps only) + +A UI app's `app-config.json` carries a top-level `ui_app` object and **no** `auth.redirectUrls`. Its presence is how the CLI distinguishes the two app types. + +The block is the app snapshot the platform stores **field for field** — the same names it stores, serves and renders. Do not invent alternatives: + +```json +{ + "ui_app": { + "extensionType": "actionLink", + "surfacePointList": ["contactDetails.headerMenu.action"], + "heading": "Invoice Manager", + "subheading": "Review invoice history for this contact", + "redirectLink": "https://example.com/brevo", + "linkTarget": "_blank" + } +} +``` + +**`surfacePointList` entries follow the grammar `..`.** The valid registry is twelve names — three record pages (`contactDetails`, `companyDetails`, `dealDetails`) × three widget places (`overviewAttributes`, `overviewMain`, `overviewSidebar`, kind `widget`) plus one action place (`headerMenu`, kind `action`). + +**An action link may only target `.headerMenu.action`** — it renders as a menu entry, so a `.widget` slot would register it somewhere it never appears. **Get a name even slightly wrong and it fails silently**: the platform drops an unregistered name and the UI kit matches by exact string equality, so you get an empty slot, a 200, and no error anywhere. The CLI validates names locally for exactly this reason — trust its error rather than assuming the server would have complained. + +`brevo app upload` sends the block as `snapshot` and validates it locally first: `extensionType` must be `actionLink` — camelCase since BEX-350; the old snake_case `action_link` is rejected (and `iframeExtension` / `legacyComponent` are not CLI-authorable); `surfacePointList` non-empty, registered, action-only, no duplicates; `heading` non-empty; `redirectLink` an **https** URL (`http://` only for `localhost`/`127.0.0.1`, since the UI kit drops non-http(s) URLs outright); `linkTarget` one of `_blank`/`_self`; and `modalIframeUrl` rejected outright, because the UI kit keeps it only for an `iframeExtension` item and would silently discard it here. For OAuth apps nothing is sent and the OAuth upload payload is unchanged. + +Fields that do **not** exist — don't add them: a per-action label (the app name is the label), `contextProperties` (the record context an action receives is an allow-list on the platform's extension-point registry, not partner-declared), and any `surface`/`placement`/`trigger` keys (superseded by `surfacePointList`). + +Editing only the `ui_app` block still counts as a change — `upload` diffs it (ignoring key order) rather than reporting "already up to date". Redirect URLs are required for OAuth apps only. + +`brevo app scaffold` inside a UI-app project refreshes the base config and reports that there are no features to scaffold. It preserves your hand-edited `ui_app` block even when it rewrites `app-config.json` to match the server. + `brevo app credentials` also backfills a legacy `app-config.json` toward the current shape: when the file exists in cwd and its `appId` matches the app being inspected, any missing top-level `version` / `distribution_type` is filled in from the server (fill-only-when-missing — an existing local value is never overwritten). This runs silently in all modes; human output prints a one-line note when something was written. It's how projects that are never `upload`ed still converge. ## Scopes diff --git a/src/__tests__/commands/app/create.test.ts b/src/__tests__/commands/app/create.test.ts index 823ef6a..16491be 100644 --- a/src/__tests__/commands/app/create.test.ts +++ b/src/__tests__/commands/app/create.test.ts @@ -3,6 +3,9 @@ import { ApiError, ErrorCode } from '../../../lib/errors'; jest.mock('inquirer', () => ({ prompt: jest.fn(), + // The UI-app delivery-path prompt renders a separator between the action link + // and the not-yet-supported choices. + Separator: class {}, })); jest.mock('../../../lib/config', () => ({ @@ -11,6 +14,7 @@ jest.mock('../../../lib/config', () => ({ saveAppName: jest.fn(), hasLocalApp: jest.fn().mockReturnValue(false), readProjectConfig: jest.fn().mockReturnValue(null), + isUiAppConfig: (config: { ui_app?: unknown } | null | undefined) => !!config?.ui_app, })); jest.mock('../../../container', () => ({ @@ -142,6 +146,7 @@ describe('app/create', () => { }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) // app type .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) // redirect URL .mockResolvedValueOnce({ another: false }) // no more URLs .mockResolvedValueOnce({ logoUrl: '' }) // logo @@ -179,6 +184,7 @@ describe('app/create', () => { redirect_uris: ['http://localhost:3009/auth/callback'], }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ another: false }) .mockResolvedValueOnce({ logoUrl: '' }) @@ -213,6 +219,7 @@ describe('app/create', () => { version: '0.0.1', }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ another: false }) .mockResolvedValueOnce({ logoUrl: '' }) @@ -241,6 +248,7 @@ describe('app/create', () => { redirect_uris: ['http://localhost:3009/auth/callback'], }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ another: false }) .mockResolvedValueOnce({ logoUrl: '' }) @@ -383,6 +391,7 @@ describe('app/create', () => { }; }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ another: false }) .mockResolvedValueOnce({ logoUrl: '' }) @@ -417,6 +426,7 @@ describe('app/create', () => { redirect_uris: ['http://localhost:3009/auth/callback'], }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ another: false }) .mockResolvedValueOnce({ logoUrl: '' }) @@ -467,6 +477,7 @@ describe('app/create', () => { return 'oauth'; }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ another: false }) .mockResolvedValueOnce({ logoUrl: '' }) @@ -512,6 +523,7 @@ describe('app/create', () => { }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) // app type .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ another: false }) .mockResolvedValueOnce({ logoUrl: '' }) @@ -576,6 +588,7 @@ describe('app/create', () => { }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) // app type .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ another: false }) .mockResolvedValueOnce({ logoUrl: '' }) @@ -618,7 +631,10 @@ describe('app/create', () => { redirect_uris: ['https://example.com/cb'], }); - mockPrompt.mockResolvedValueOnce({ logoUrl: '' }).mockResolvedValueOnce({ scaffoldRaw: 'y' }); + mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) + .mockResolvedValueOnce({ logoUrl: '' }) + .mockResolvedValueOnce({ scaffoldRaw: 'y' }); await createCommand({ name: 'Flag App', @@ -637,6 +653,7 @@ describe('app/create', () => { ); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) // app type .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ another: false }) .mockResolvedValueOnce({ logoUrl: '' }); @@ -658,6 +675,7 @@ describe('app/create', () => { }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) // app type .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) // redirect URL .mockResolvedValueOnce({ another: false }) // no more URLs .mockResolvedValueOnce({ logoUrl: '' }) // skip logo prompt @@ -707,6 +725,7 @@ describe('app/create', () => { it('should prompt for name when not provided', async () => { mockPrompt .mockResolvedValueOnce({ name: 'Prompted App' }) // name prompt + .mockResolvedValueOnce({ appType: 'oauth' }) // app type .mockResolvedValueOnce({ distribution: 'private' }) // distribution prompt .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) // redirect URL .mockResolvedValueOnce({ another: false }) // no more URLs @@ -732,6 +751,9 @@ describe('app/create', () => { }); it('should throw on invalid distribution', async () => { + // The app-type prompt runs before distribution is validated. + mockPrompt.mockResolvedValueOnce({ appType: 'oauth' }); + await expect(createCommand({ name: 'Test', distribution: 'invalid' })).rejects.toThrow( 'Invalid --distribution', ); @@ -747,6 +769,7 @@ describe('app/create', () => { }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) // app type .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ another: false }) .mockResolvedValueOnce({ logoUrl: '' }) @@ -791,6 +814,7 @@ describe('app/create', () => { }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) // app type .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ another: false }) .mockResolvedValueOnce({ logoUrl: '' }) @@ -816,6 +840,7 @@ describe('app/create', () => { }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) // app type .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) // first URL .mockResolvedValueOnce({ anotherRaw: 'y' }) // add another .mockResolvedValueOnce({ nextUrl: 'https://myapp.com/callback' }) // second URL @@ -842,7 +867,10 @@ describe('app/create', () => { redirect_uris: ['https://myapp.com/callback'], }); - mockPrompt.mockResolvedValueOnce({ logoUrl: '' }).mockResolvedValueOnce({ scaffoldRaw: 'y' }); + mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) + .mockResolvedValueOnce({ logoUrl: '' }) + .mockResolvedValueOnce({ scaffoldRaw: 'y' }); await createCommand({ name: 'Flag App', @@ -856,8 +884,8 @@ describe('app/create', () => { redirect_uris: ['https://myapp.com/callback'], scopes: ['contacts:read', 'contacts:write', 'crm:read', 'crm:write'], }); - // Only the logo prompt and the scaffold-feature prompt — no redirect URL prompts. - expect(mockPrompt).toHaveBeenCalledTimes(2); + // Only the app-type, logo and scaffold-feature prompts — no redirect URL prompts. + expect(mockPrompt).toHaveBeenCalledTimes(3); }); it('should pass multiple --redirect-uri flags to the API', async () => { @@ -940,6 +968,7 @@ describe('app/create', () => { }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) // app type .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ another: false }) .mockResolvedValueOnce({ logoUrl: 'https://example.com/prompted.png' }) @@ -986,6 +1015,7 @@ describe('app/create', () => { redirect_uris: ['http://localhost:3009/auth/callback'], }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) // app type .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ anotherRaw: 'n' }) .mockResolvedValueOnce({ logoUrl: '' }) @@ -1009,6 +1039,7 @@ describe('app/create', () => { redirect_uris: ['http://localhost:3009/auth/callback'], }); mockPrompt + .mockResolvedValueOnce({ appType: 'oauth' }) // app type .mockResolvedValueOnce({ redirectUrl: 'http://localhost:3009/auth/callback' }) .mockResolvedValueOnce({ anotherRaw: 'n' }) .mockResolvedValueOnce({ logoUrl: '' }) @@ -1041,4 +1072,239 @@ describe('app/create', () => { const stdoutCalls = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); expect(stdoutCalls).not.toContain('Default scopes'); }); + + // ──────────────── UI apps (BEX-290) ──────────────── + // A UI app is authored entirely through the prompts — there is no `--type` or + // per-field flag — so every test here drives inquirer. + describe('UI apps', () => { + const CLI_OPTIONS = { name: 'Invoice Manager', distribution: 'private' }; + + /** Every question inquirer was asked, in order. */ + let askedQuestions: Array>; + + /** + * Answer the create prompts by question *name* rather than by call order, so + * that adding or reordering a prompt can't silently shift an answer onto the + * wrong field. + */ + const answerPrompts = (overrides: Record = {}) => { + const answers: Record = { + appType: 'ui', + extensionType: 'actionLink', + surfaces: ['contact'], + heading: 'Invoice Manager', + subheading: '', + redirectLink: 'https://example.com/brevo', + linkTarget: '_blank', + logoUrl: '', + // Only reached when a test forces the OAuth path (non-TTY / --json), but + // kept here so those tests don't need their own mock wiring. + redirectUrl: 'http://localhost:3009/auth/callback', + another: false, + scaffoldRaw: 'n', + ...overrides, + }; + mockPrompt.mockImplementation((questions: Array>) => { + const question = questions[0] ?? {}; + askedQuestions.push(question); + const name = String(question.name ?? ''); + return Promise.resolve(name in answers ? { [name]: answers[name] } : {}); + }); + }; + + const questionNamed = (name: string) => askedQuestions.find((q) => q.name === name); + + beforeEach(() => { + askedQuestions = []; + (appService.createApp as jest.Mock).mockResolvedValue({ + app_id: 42, + name: 'Invoice Manager', + client_id: 'cli-123', + client_secret: 'secret-456', + redirect_uris: [], + }); + answerPrompts(); + }); + + const collectedUiApp = () => (fetchAppContext as jest.Mock).mock.calls[0][2]; + + it('omits redirect_uris from the create payload', async () => { + await createCommand(CLI_OPTIONS); + + const payload = (appService.createApp as jest.Mock).mock.calls[0][0]; + expect(payload).not.toHaveProperty('redirect_uris'); + }); + + it('sends the narrower UI-app scope defaults', async () => { + await createCommand(CLI_OPTIONS); + + const payload = (appService.createApp as jest.Mock).mock.calls[0][0]; + expect(payload.scopes).toEqual(['contacts:read', 'contacts:write']); + }); + + // The regression this guards: resolveRedirectUrls falls back to + // http://localhost:3009/auth/callback, which would silently register an OAuth + // redirect URL on an app that has no OAuth flow. + it('never prompts for or defaults a redirect URL', async () => { + await createCommand(CLI_OPTIONS); + + const payload = (appService.createApp as jest.Mock).mock.calls[0][0]; + expect(JSON.stringify(payload)).not.toContain('localhost:3009'); + expect(questionNamed('redirectUrl')).toBeUndefined(); + }); + + it('does not send the snapshot to POST /apps', async () => { + await createCommand(CLI_OPTIONS); + + const payload = (appService.createApp as jest.Mock).mock.calls[0][0]; + expect(payload).not.toHaveProperty('snapshot'); + expect(payload).not.toHaveProperty('ui_app'); + }); + + // Field names and casing must match the platform's stored app snapshot + // exactly — `extensionType` is camelCase since BEX-350. + it('builds the snapshot shape the platform consumes', async () => { + await createCommand(CLI_OPTIONS); + + expect(collectedUiApp()).toEqual({ + extensionType: 'actionLink', + surfacePointList: ['contactDetails.headerMenu.action'], + heading: 'Invoice Manager', + redirectLink: 'https://example.com/brevo', + linkTarget: '_blank', + }); + }); + + it('maps the picked record pages onto action slot names', async () => { + answerPrompts({ surfaces: ['deal', 'company'] }); + + await createCommand(CLI_OPTIONS); + + expect(collectedUiApp().surfacePointList).toEqual([ + 'dealDetails.headerMenu.action', + 'companyDetails.headerMenu.action', + ]); + }); + + it('deduplicates repeated record pages', async () => { + answerPrompts({ surfaces: ['contact', 'contact'] }); + + await createCommand(CLI_OPTIONS); + + expect(collectedUiApp().surfacePointList).toEqual(['contactDetails.headerMenu.action']); + }); + + it('omits subheading when left blank rather than writing an empty string', async () => { + await createCommand(CLI_OPTIONS); + + expect(collectedUiApp()).not.toHaveProperty('subheading'); + }); + + it('includes subheading when entered', async () => { + answerPrompts({ subheading: 'Review invoice history' }); + + await createCommand(CLI_OPTIONS); + + expect(collectedUiApp().subheading).toBe('Review invoice history'); + }); + + it('honours the link-target answer', async () => { + answerPrompts({ linkTarget: '_self' }); + + await createCommand(CLI_OPTIONS); + + expect(collectedUiApp().linkTarget).toBe('_self'); + }); + + // The per-field flags are gone, so these prompt `validate` callbacks are now + // the only thing standing between a typo and a silently unrenderable action + // link. Assert they're still wired up. + it('validates the heading and redirect-link answers at the prompt', async () => { + await createCommand(CLI_OPTIONS); + + const heading = questionNamed('heading'); + expect(typeof heading?.validate).toBe('function'); + expect((heading?.validate as (v: string) => unknown)(' ')).toMatch(/cannot be empty/i); + + const redirectLink = questionNamed('redirectLink'); + expect(typeof redirectLink?.validate).toBe('function'); + expect((redirectLink?.validate as (v: string) => unknown)('http://example.com')).toMatch( + /must use https/i, + ); + }); + + it('requires at least one record page', async () => { + await createCommand(CLI_OPTIONS); + + const surfaces = questionNamed('surfaces'); + expect((surfaces?.validate as (v: unknown[]) => unknown)([])).toMatch(/at least one/i); + expect((surfaces?.validate as (v: unknown[]) => unknown)(['contact'])).toBe(true); + }); + + it('offers only the action link as a selectable delivery path', async () => { + await createCommand(CLI_OPTIONS); + + const choices = (questionNamed('extensionType')?.choices ?? []) as Array<{ + value?: string; + disabled?: string; + }>; + const selectable = choices.filter((c) => c.value && !c.disabled).map((c) => c.value); + expect(selectable).toEqual(['actionLink']); + }); + + it('never offers the OAuth feature scaffold', async () => { + await createCommand(CLI_OPTIONS); + + expect(promptFeatureType).not.toHaveBeenCalled(); + expect(runFeatureScaffold).not.toHaveBeenCalled(); + }); + + it('renders the UI-app box with the extension point, not redirect URLs', async () => { + await createCommand(CLI_OPTIONS); + + const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(output).toContain('UI app created'); + expect(output).toContain('contactDetails.headerMenu.action'); + expect(output).toContain('https://example.com/brevo'); + expect(output).not.toContain('Redirect URL'); + }); + + // There is no per-action label on the platform — the menu entry uses the app + // name — so the box says so explicitly. + it('explains that the menu label comes from the app name', async () => { + await createCommand(CLI_OPTIONS); + + const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(output).toMatch(/labelled with the app name/i); + }); + + // ──────── A UI app needs an interactive terminal ──────── + // Both of these keep pre-BEX-290 behaviour for scripted callers: without the + // app-type prompt there is no way to ask for a UI app, so they get an OAuth + // app rather than an error. + it('creates an OAuth app in a non-TTY run, without prompting for the app type', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + writable: true, + value: false, + }); + + await createCommand(CLI_OPTIONS); + + expect(questionNamed('appType')).toBeUndefined(); + const payload = (appService.createApp as jest.Mock).mock.calls[0][0]; + expect(payload).toHaveProperty('redirect_uris'); + expect(collectedUiApp()).toBeUndefined(); + }); + + it('creates an OAuth app under --json even on a TTY', async () => { + await createCommand({ ...CLI_OPTIONS, json: true }); + + expect(questionNamed('appType')).toBeUndefined(); + const parsed = JSON.parse(stdoutSpy.mock.calls.map((c) => String(c[0])).join('')); + expect(parsed.appType).toBe('oauth'); + expect(parsed).toHaveProperty('redirectUri'); + expect(parsed).not.toHaveProperty('uiApp'); + }); + }); }); diff --git a/src/__tests__/commands/app/deploy.test.ts b/src/__tests__/commands/app/deploy.test.ts new file mode 100644 index 0000000..0ac3a9a --- /dev/null +++ b/src/__tests__/commands/app/deploy.test.ts @@ -0,0 +1,153 @@ +jest.mock('inquirer', () => ({ prompt: jest.fn() })); + +jest.mock('../../../container', () => ({ + appService: { + deployApp: jest.fn(), + fetchAppsList: jest.fn(), + }, +})); + +jest.mock('../../../lib/config', () => ({ + readProjectConfig: jest.fn(), +})); + +import inquirer from 'inquirer'; +import { deployCommand } from '../../../commands/app/deploy'; +import { appService } from '../../../container'; +import { readProjectConfig } from '../../../lib/config'; +import { ApiError } from '../../../lib/errors'; + +const mockPrompt = inquirer.prompt as unknown as jest.Mock; + +// A project that has been through a successful `app upload` — `version` is only +// ever written by one, which is what the deploy gate keys off. +const UPLOADED_CONFIG = { + appId: '42', + appName: 'Invoice Manager', + distribution_type: 'private' as const, + version: '1.0.0', + auth: { scopes: ['contacts:read'] }, + ui_app: { type: 'link' as const }, +}; + +describe('app/deploy', () => { + let stdoutSpy: jest.SpyInstance; + const originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + + beforeEach(() => { + stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + writable: true, + value: true, + }); + jest.clearAllMocks(); + // clearAllMocks() clears calls but NOT implementations set via + // mockRejectedValue/mockResolvedValue in an earlier test (this repo's jest + // config doesn't enable resetMocks), so re-assert the happy path here. + (appService.deployApp as jest.Mock).mockResolvedValue(undefined); + (readProjectConfig as jest.Mock).mockReturnValue(UPLOADED_CONFIG); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + if (originalIsTTYDescriptor) { + Object.defineProperty(process.stdin, 'isTTY', originalIsTTYDescriptor); + } else { + Reflect.deleteProperty(process.stdin, 'isTTY'); + } + }); + + it('deploys the linked app to the given account', async () => { + await deployCommand({ accountId: '99999', force: true }); + + expect(appService.deployApp).toHaveBeenCalledWith('42', '99999'); + }); + + it('prefers an explicit --app-id over the linked config', async () => { + await deployCommand({ accountId: '99999', appId: '7', force: true }); + + expect(appService.deployApp).toHaveBeenCalledWith('7', '99999'); + }); + + it('errors when the account ID is missing', async () => { + await expect(deployCommand({ force: true })).rejects.toThrow(/Missing account ID/i); + expect(appService.deployApp).not.toHaveBeenCalled(); + }); + + it('rejects a non-numeric account ID', async () => { + await expect(deployCommand({ accountId: 'abc', force: true })).rejects.toThrow( + /not a numeric Brevo account ID/i, + ); + expect(appService.deployApp).not.toHaveBeenCalled(); + }); + + // The spec's installation flow: deploy must refuse until `app upload` has + // validated the configuration. + it('refuses to deploy an app that has never been uploaded', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ ...UPLOADED_CONFIG, version: '' }); + + await expect(deployCommand({ accountId: '99999', force: true })).rejects.toThrow( + /brevo app upload/i, + ); + expect(appService.deployApp).not.toHaveBeenCalled(); + }); + + it('maps the server 422 to the same upload-first message', async () => { + (appService.deployApp as jest.Mock).mockRejectedValue( + new ApiError('Unprocessable', 422, undefined), + ); + + await expect(deployCommand({ accountId: '99999', force: true })).rejects.toThrow( + /brevo app upload/i, + ); + }); + + it('asks for confirmation and does nothing when declined', async () => { + mockPrompt.mockResolvedValueOnce({ confirmed: false }); + + await deployCommand({ accountId: '99999' }); + + expect(appService.deployApp).not.toHaveBeenCalled(); + expect(stdoutSpy.mock.calls.map((c: [string]) => c[0]).join('')).toMatch(/Deploy cancelled/i); + }); + + it('deploys after an accepted confirmation', async () => { + mockPrompt.mockResolvedValueOnce({ confirmed: true }); + + await deployCommand({ accountId: '99999' }); + + expect(appService.deployApp).toHaveBeenCalledWith('42', '99999'); + }); + + it('refuses to prompt in a non-TTY run without --force or --json', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + writable: true, + value: false, + }); + + await expect(deployCommand({ accountId: '99999' })).rejects.toThrow(/non-interactive/i); + expect(appService.deployApp).not.toHaveBeenCalled(); + }); + + it('emits JSON and skips the prompt under --json', async () => { + await deployCommand({ accountId: '99999', json: true }); + + expect(mockPrompt).not.toHaveBeenCalled(); + const parsed = JSON.parse(stdoutSpy.mock.calls.map((c: [string]) => c[0]).join('')); + expect(parsed).toEqual({ deployed: true, appId: '42', accountId: '99999' }); + }); + + it('falls back to the app picker outside a project directory', async () => { + (readProjectConfig as jest.Mock).mockReturnValue(null); + (appService.fetchAppsList as jest.Mock).mockResolvedValue([ + { app_id: '9', name: 'Picked App', client_id: 'cli-9' }, + ]); + mockPrompt.mockResolvedValueOnce({ selectedApp: '9' }); + + await deployCommand({ accountId: '99999', force: true }); + + expect(appService.deployApp).toHaveBeenCalledWith('9', '99999'); + }); +}); diff --git a/src/__tests__/commands/app/remove.test.ts b/src/__tests__/commands/app/remove.test.ts new file mode 100644 index 0000000..0ff46b4 --- /dev/null +++ b/src/__tests__/commands/app/remove.test.ts @@ -0,0 +1,123 @@ +jest.mock('inquirer', () => ({ prompt: jest.fn() })); + +jest.mock('../../../container', () => ({ + appService: { + removeApp: jest.fn(), + fetchAppsList: jest.fn(), + }, +})); + +jest.mock('../../../lib/config', () => ({ + readProjectConfig: jest.fn(), +})); + +import inquirer from 'inquirer'; +import { removeCommand } from '../../../commands/app/remove'; +import { appService } from '../../../container'; +import { readProjectConfig } from '../../../lib/config'; +import { ApiError } from '../../../lib/errors'; + +const mockPrompt = inquirer.prompt as unknown as jest.Mock; + +const LINKED_CONFIG = { + appId: '42', + appName: 'Invoice Manager', + distribution_type: 'private' as const, + version: '1.0.0', + auth: { scopes: ['contacts:read'] }, + ui_app: { type: 'link' as const }, +}; + +describe('app/remove', () => { + let stdoutSpy: jest.SpyInstance; + const originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + + beforeEach(() => { + stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + writable: true, + value: true, + }); + jest.clearAllMocks(); + // See the note in deploy.test.ts — implementations survive clearAllMocks(). + (appService.removeApp as jest.Mock).mockResolvedValue(undefined); + (readProjectConfig as jest.Mock).mockReturnValue(LINKED_CONFIG); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + if (originalIsTTYDescriptor) { + Object.defineProperty(process.stdin, 'isTTY', originalIsTTYDescriptor); + } else { + Reflect.deleteProperty(process.stdin, 'isTTY'); + } + }); + + it('removes the linked app from the given account', async () => { + await removeCommand({ accountId: '99999', force: true }); + + expect(appService.removeApp).toHaveBeenCalledWith('42', '99999'); + }); + + it('errors when the account ID is missing', async () => { + await expect(removeCommand({ force: true })).rejects.toThrow(/Missing account ID/i); + expect(appService.removeApp).not.toHaveBeenCalled(); + }); + + // Unlike deploy, remove has no upload gate — an app deployed by an older CLI + // version must still be removable. + it('does not require the app to have been uploaded', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ ...LINKED_CONFIG, version: '' }); + + await removeCommand({ accountId: '99999', force: true }); + + expect(appService.removeApp).toHaveBeenCalledWith('42', '99999'); + }); + + it('treats "not deployed" (422) as informational, not a failure', async () => { + (appService.removeApp as jest.Mock).mockRejectedValue( + new ApiError('Unprocessable', 422, undefined), + ); + + await expect(removeCommand({ accountId: '99999', force: true })).resolves.toBeUndefined(); + expect(stdoutSpy.mock.calls.map((c: [string]) => c[0]).join('')).toMatch(/is not deployed/i); + }); + + it('reports NOT_DEPLOYED in JSON mode without failing', async () => { + (appService.removeApp as jest.Mock).mockRejectedValue( + new ApiError('Unprocessable', 422, undefined), + ); + + await removeCommand({ accountId: '99999', json: true }); + + const parsed = JSON.parse(stdoutSpy.mock.calls.map((c: [string]) => c[0]).join('')); + expect(parsed).toMatchObject({ removed: false, reason: 'NOT_DEPLOYED', accountId: '99999' }); + }); + + it('propagates errors other than 422', async () => { + (appService.removeApp as jest.Mock).mockRejectedValue( + new ApiError('Server error', 500, undefined), + ); + + await expect(removeCommand({ accountId: '99999', force: true })).rejects.toThrow( + /Server error/, + ); + }); + + it('does nothing when the confirmation is declined', async () => { + mockPrompt.mockResolvedValueOnce({ confirmed: false }); + + await removeCommand({ accountId: '99999' }); + + expect(appService.removeApp).not.toHaveBeenCalled(); + expect(stdoutSpy.mock.calls.map((c: [string]) => c[0]).join('')).toMatch(/Remove cancelled/i); + }); + + it('emits JSON on success', async () => { + await removeCommand({ accountId: '99999', json: true }); + + const parsed = JSON.parse(stdoutSpy.mock.calls.map((c: [string]) => c[0]).join('')); + expect(parsed).toEqual({ removed: true, appId: '42', accountId: '99999' }); + }); +}); diff --git a/src/__tests__/commands/app/scaffold.test.ts b/src/__tests__/commands/app/scaffold.test.ts index 2392ec0..ef3bbb4 100644 --- a/src/__tests__/commands/app/scaffold.test.ts +++ b/src/__tests__/commands/app/scaffold.test.ts @@ -34,6 +34,7 @@ jest.mock('../../../lib/config', () => ({ getAppCredentials: jest.fn(), saveAppCredentials: jest.fn(), readProjectConfig: jest.fn().mockReturnValue(null), + isUiAppConfig: (config: { ui_app?: unknown } | null | undefined) => !!config?.ui_app, })); jest.mock('../../../templates', () => ({ @@ -621,4 +622,112 @@ describe('app/scaffold', () => { expect(result).toBe('oauth'); }); }); + + // ──────────────── UI apps (BEX-290) ──────────────── + describe('UI apps', () => { + const uiApp = { + extensionType: 'actionLink' as const, + surfacePointList: ['contactDetails.headerMenu.action'], + heading: 'Invoice Manager', + // A value the server does not know about — the whole point of the + // preservation test below. + subheading: 'Hand-edited subheading', + redirectLink: 'https://example.com/brevo', + linkTarget: '_blank' as const, + }; + + // Drifts from serverApp on appName so the refresh path (a full overwrite of + // app-config.json) is exercised. + const driftedUiConfig = { + appId: '1', + appName: 'Renamed Locally', + distribution_type: 'private' as const, + logoUri: '', + version: '1.0.0', + auth: { scopes: ['contacts:read'] }, + ui_app: uiApp, + }; + + // This is the regression that matters: on detected drift the command + // rewrites app-config.json wholesale from server values, and the server does + // not return `ui_app`. Without the local block being carried into the + // template vars, a partner's hand-edited action-link config is destroyed. + it('preserves the local ui_app block through a confirmed config refresh', async () => { + (readProjectConfig as jest.Mock).mockReturnValue(driftedUiConfig); + mockPrompt.mockResolvedValueOnce({ confirmed: true }); + + await scaffoldCommand({}); + + const { loadBaseTemplates } = require('../../../templates'); + expect(loadBaseTemplates).toHaveBeenCalled(); + const vars = (loadBaseTemplates as jest.Mock).mock.calls[0][0]; + expect(vars['{{UI_APP_JSON}}']).toContain('Hand-edited subheading'); + expect(JSON.parse(vars['{{UI_APP_JSON}}'].replaceAll('\n ', '\n'))).toEqual(uiApp); + }); + + it('does not report phantom redirect-URL drift for a UI app', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...driftedUiConfig, + appName: 'Test App', // matches the server, so ONLY redirectUrls could differ + }); + + await scaffoldCommand({ json: true }); + + // No drift detected → no cancellation, and the base refresh is skipped. + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0]); + expect(parsed.cancelled).toBeUndefined(); + }); + + it('offers no features and never scaffolds the OAuth server', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...driftedUiConfig, + appName: 'Test App', + }); + + await scaffoldCommand({}); + + const { loadFeatureTemplates } = require('../../../templates'); + expect(loadFeatureTemplates).not.toHaveBeenCalled(); + const output = stdoutSpy.mock.calls.map((c: [string]) => c[0]).join(''); + expect(output).toMatch(/no features to scaffold/i); + }); + + // The app type is decided locally, never by server data. If the server + // returned a `ui_app` for an app the local config says is OAuth, honouring it + // would silently reclassify the project and write a UI config over an OAuth + // one — so `fetchAppContext` takes the block from the caller only. + it('ignores a server-returned snapshot for a project whose local config is OAuth', async () => { + (appService.resolveAppCredentials as jest.Mock).mockResolvedValue({ + diffs: [], + app: { ...serverApp, snapshot: uiApp }, + }); + (readProjectConfig as jest.Mock).mockReturnValue({ + ...matchingLocalConfig, + appName: 'Renamed Locally', // force the base refresh + }); + mockPrompt + .mockResolvedValueOnce({ confirmed: true }) + .mockResolvedValueOnce({ featureType: 'oauth' }); + + await scaffoldCommand({}); + + const { loadBaseTemplates, loadFeatureTemplates } = require('../../../templates'); + const vars = (loadBaseTemplates as jest.Mock).mock.calls[0][0]; + expect(vars['{{UI_APP_JSON}}']).toBe(''); + // Still treated as an OAuth project: the feature scaffold runs. + expect(loadFeatureTemplates).toHaveBeenCalled(); + }); + + it('reports an empty feature list under --json', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...driftedUiConfig, + appName: 'Test App', + }); + + await scaffoldCommand({ json: true }); + + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0]); + expect(parsed.features).toEqual([]); + }); + }); }); diff --git a/src/__tests__/commands/app/upload.test.ts b/src/__tests__/commands/app/upload.test.ts index 06cd98c..bf803fe 100644 --- a/src/__tests__/commands/app/upload.test.ts +++ b/src/__tests__/commands/app/upload.test.ts @@ -15,6 +15,9 @@ jest.mock('../../../lib/config', () => ({ readProjectConfig: jest.fn(), writeProjectConfig: jest.fn(), saveAppName: jest.fn(), + // Pure predicate over the config object — use the real logic rather than a + // jest.fn() so every test doesn't have to stub the app-type branch. + isUiAppConfig: (config: { ui_app?: unknown } | null | undefined) => !!config?.ui_app, })); jest.mock('node:fs'); @@ -193,7 +196,10 @@ describe('app/upload', () => { }); }); - it('never sends a ui_app field', async () => { + // Earlier CLI versions guaranteed `ui_app` was never sent at all. That + // guarantee now applies to OAuth apps only (BEX-290) — the UI-app half of the + // contract is covered in the 'UI apps' block below. + it('never sends a ui_app field for an OAuth app', async () => { const changedConfig = { ...BASE_CONFIG, appName: 'Renamed App' }; (readProjectConfig as jest.Mock).mockReturnValue(changedConfig); (appService.uploadApp as jest.Mock).mockResolvedValue({ @@ -342,4 +348,286 @@ describe('app/upload', () => { const parsed = JSON.parse(output); expect(parsed.upToDate).toBe(true); }); + + // ──────────────── UI apps (BEX-290) ──────────────── + // The block mirrors the platform's stored app snapshot field for field. + describe('UI apps', () => { + const UI_APP = { + extensionType: 'actionLink' as const, + surfacePointList: ['contactDetails.headerMenu.action'], + heading: 'Invoice Manager', + subheading: 'Review invoice history for this contact', + redirectLink: 'https://example.com/brevo', + linkTarget: '_blank' as const, + }; + + // A UI app's config carries no redirectUrls at all — that absence is the + // point of the OAuth-only redirect check. + const UI_CONFIG = { + appId: '1', + appName: 'Invoice Manager', + distribution_type: 'private' as const, + logoUri: '', + version: '1.0.0', + auth: { scopes: ['contacts:read', 'contacts:write'] }, + ui_app: UI_APP, + }; + + // Must match UI_CONFIG on every non-snapshot field, so the tests below + // isolate the snapshot as the only thing that can differ. + const UI_REMOTE = { + ...BASE_REMOTE, + name: 'Invoice Manager', + redirect_uris: [], + scopes: ['contacts:read', 'contacts:write'], + }; + + beforeEach(() => { + (readProjectConfig as jest.Mock).mockReturnValue(UI_CONFIG); + (appService.fetchApp as jest.Mock).mockResolvedValue(UI_REMOTE); + (appService.uploadApp as jest.Mock).mockResolvedValue({ + ...BASE_UPLOAD_RESPONSE, + name: 'Invoice Manager', + auth: { + distribution_type: 'private' as const, + scopes: ['contacts:read', 'contacts:write'], + }, + }); + }); + + // The destination is the platform's app snapshot, so the payload key is `snapshot`. + it('sends the block under the snapshot key', async () => { + await uploadCommand({ yes: true }); + + const payload = (appService.uploadApp as jest.Mock).mock.calls[0][1]; + expect(payload.snapshot).toEqual(UI_APP); + expect(payload).not.toHaveProperty('ui_app'); + }); + + it('does not require redirect URLs', async () => { + await expect(uploadCommand({ yes: true })).resolves.toBeUndefined(); + expect(appService.uploadApp).toHaveBeenCalled(); + }); + + it('still requires redirect URLs for an OAuth app', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...BASE_CONFIG, + auth: { scopes: ['contacts:read'] }, + }); + + await expect(uploadCommand({ yes: true })).rejects.toThrow(/OAuth apps need at least one/i); + expect(appService.uploadApp).not.toHaveBeenCalled(); + }); + + // The regression this guards: `hasNoChanges` compared every field *except* + // the snapshot, so a snapshot-only edit reported "Already up to date" and + // silently never reached the server. + it('uploads when only the ui_app block changed', async () => { + (appService.fetchApp as jest.Mock).mockResolvedValue({ + ...UI_REMOTE, + snapshot: { ...UI_APP, heading: 'Old Heading' }, + }); + + await uploadCommand({ yes: true }); + + expect(appService.uploadApp).toHaveBeenCalled(); + const payload = (appService.uploadApp as jest.Mock).mock.calls[0][1]; + expect(payload.snapshot.heading).toBe('Invoice Manager'); + }); + + it('reports up to date when the snapshot matches the server', async () => { + (appService.fetchApp as jest.Mock).mockResolvedValue({ ...UI_REMOTE, snapshot: UI_APP }); + + await uploadCommand({ json: true }); + + expect(appService.uploadApp).not.toHaveBeenCalled(); + const parsed = JSON.parse(stdoutSpy.mock.calls.map((c: [string]) => c[0]).join('')); + expect(parsed.upToDate).toBe(true); + }); + + // Key order in app-config.json varies with how it was edited; a raw + // stringify comparison would report phantom drift. + it('treats a reordered snapshot as unchanged', async () => { + (appService.fetchApp as jest.Mock).mockResolvedValue({ + ...UI_REMOTE, + snapshot: { + linkTarget: '_blank' as const, + redirectLink: 'https://example.com/brevo', + subheading: 'Review invoice history for this contact', + heading: 'Invoice Manager', + surfacePointList: ['contactDetails.headerMenu.action'], + extensionType: 'actionLink' as const, + }, + }); + + await uploadCommand({ json: true }); + + expect(appService.uploadApp).not.toHaveBeenCalled(); + }); + + // Slot names are matched by exact string equality and an unregistered name is + // silently DROPPED by the backend, so these two are the highest-value checks + // in the whole UI-app flow — nothing downstream would ever report them. + it('rejects an extension point that is not in the registry', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...UI_CONFIG, + ui_app: { ...UI_APP, surfacePointList: ['contact.headerMenu.action'] }, + }); + + await expect(uploadCommand({ yes: true })).rejects.toThrow(/Unknown extension point/i); + expect(appService.uploadApp).not.toHaveBeenCalled(); + }); + + it('rejects a widget slot for an action link', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...UI_CONFIG, + ui_app: { ...UI_APP, surfacePointList: ['contactDetails.overviewMain.widget'] }, + }); + + await expect(uploadCommand({ yes: true })).rejects.toThrow(/renders as a menu action/i); + expect(appService.uploadApp).not.toHaveBeenCalled(); + }); + + it('rejects an empty surfacePointList', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...UI_CONFIG, + ui_app: { ...UI_APP, surfacePointList: [] }, + }); + + await expect(uploadCommand({ yes: true })).rejects.toThrow(/at least one extension point/i); + }); + + it('accepts multiple record pages', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...UI_CONFIG, + ui_app: { + ...UI_APP, + surfacePointList: [ + 'contactDetails.headerMenu.action', + 'dealDetails.headerMenu.action', + 'companyDetails.headerMenu.action', + ], + }, + }); + + await uploadCommand({ yes: true }); + + const payload = (appService.uploadApp as jest.Mock).mock.calls[0][1]; + expect(payload.snapshot.surfacePointList).toHaveLength(3); + }); + + it('rejects duplicate extension points', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...UI_CONFIG, + ui_app: { + ...UI_APP, + surfacePointList: [ + 'contactDetails.headerMenu.action', + 'contactDetails.headerMenu.action', + ], + }, + }); + + await expect(uploadCommand({ yes: true })).rejects.toThrow(/duplicate/i); + }); + + it('rejects an insecure redirect link', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...UI_CONFIG, + ui_app: { ...UI_APP, redirectLink: 'http://example.com/brevo' }, + }); + + await expect(uploadCommand({ yes: true })).rejects.toThrow(/must use https/i); + expect(appService.uploadApp).not.toHaveBeenCalled(); + }); + + it('rejects an empty heading', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...UI_CONFIG, + ui_app: { ...UI_APP, heading: ' ' }, + }); + + await expect(uploadCommand({ yes: true })).rejects.toThrow(/Heading cannot be empty/i); + }); + + it('rejects an invalid linkTarget', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...UI_CONFIG, + ui_app: { ...UI_APP, linkTarget: '_top' }, + }); + + await expect(uploadCommand({ yes: true })).rejects.toThrow(/Invalid ui_app.linkTarget/i); + }); + + it.each([['iframeExtension'], ['legacyComponent']])( + 'rejects the not-yet-authorable %s type', + async (extensionType) => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...UI_CONFIG, + ui_app: { ...UI_APP, extensionType }, + }); + + await expect(uploadCommand({ yes: true })).rejects.toThrow( + /Unsupported ui_app.extensionType/i, + ); + }, + ); + + // The UI kit drops modalIframeUrl for anything that isn't an + // iframeExtension, so authoring one on an action link is a silent no-op. + it('rejects modalIframeUrl on an action link', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ + ...UI_CONFIG, + ui_app: { ...UI_APP, modalIframeUrl: 'https://example.com/modal' }, + }); + + await expect(uploadCommand({ yes: true })).rejects.toThrow(/only used by "iframeExtension"/i); + }); + + it('writes the snapshot back into app-config.json, preferring the server copy', async () => { + // The server defaults linkTarget, so its copy is the authority. + const serverNormalized = { ...UI_APP, linkTarget: '_self' as const }; + (appService.uploadApp as jest.Mock).mockResolvedValue({ + ...BASE_UPLOAD_RESPONSE, + name: 'Invoice Manager', + auth: { distribution_type: 'private' as const, scopes: ['contacts:read'] }, + snapshot: serverNormalized, + }); + + await uploadCommand({ yes: true }); + + expect(writeProjectConfig).toHaveBeenCalledWith( + expect.objectContaining({ ui_app: serverNormalized }), + ); + }); + + it('keeps the locally sent snapshot when the server does not echo one', async () => { + await uploadCommand({ yes: true }); + + expect(writeProjectConfig).toHaveBeenCalledWith(expect.objectContaining({ ui_app: UI_APP })); + }); + + it('does not add a redirectUrls key back into a UI app config', async () => { + await uploadCommand({ yes: true }); + + const written = (writeProjectConfig as jest.Mock).mock.calls[0][0]; + expect(written.auth).not.toHaveProperty('redirectUrls'); + }); + + it('renders the snapshot fields in the diff, without a Redirect URLs row', async () => { + (readProjectConfig as jest.Mock).mockReturnValue({ ...UI_CONFIG, appName: 'Renamed' }); + (appService.uploadApp as jest.Mock).mockResolvedValue({ + ...BASE_UPLOAD_RESPONSE, + name: 'Renamed', + auth: { distribution_type: 'private' as const, scopes: ['contacts:read'] }, + }); + + await uploadCommand({ yes: true }); + + const output = stdoutSpy.mock.calls.map((c: [string]) => c[0]).join(''); + expect(output).toContain('contactDetails.headerMenu.action'); + expect(output).toContain('Link target'); + expect(output).not.toContain('Redirect URLs'); + }); + }); }); diff --git a/src/__tests__/lib/validators.test.ts b/src/__tests__/lib/validators.test.ts index db6c0b7..cb48632 100644 --- a/src/__tests__/lib/validators.test.ts +++ b/src/__tests__/lib/validators.test.ts @@ -6,6 +6,11 @@ import { validateScopes, collectScopes, containsLegacyAllScope, + parseAccountId, + validateUiApp, + validateUiAppHeading, + validateUiAppUrl, + validateSurfacePoint, } from '../../lib/validators'; import { CliError } from '../../lib/errors'; @@ -228,3 +233,146 @@ describe('containsLegacyAllScope', () => { expect(containsLegacyAllScope(scopes)).toBe(expected); }); }); + +// ──────────────── UI apps (BEX-290) ──────────────── + +describe('validateUiAppUrl', () => { + it.each([ + ['https URL', 'https://example.com/brevo'], + ['https with path and query', 'https://example.com/a?b=c'], + // Loopback http is allowed so a partner can point at a local dev server. + ['http on localhost', 'http://localhost:3000/card'], + ['http on 127.0.0.1', 'http://127.0.0.1:3000/card'], + ])('accepts a %s', (_label, url) => { + expect(validateUiAppUrl(url)).toBe(true); + }); + + it.each([ + ['plain http on a public host', 'http://example.com/brevo'], + ['a non-HTTP scheme', 'ftp://example.com'], + ['javascript:', 'javascript:alert(1)'], + ['a non-URL', 'not a url'], + ['an empty value', ''], + ])('rejects %s', (_label, url) => { + expect(validateUiAppUrl(url)).not.toBe(true); + }); +}); + +describe('validateUiAppHeading', () => { + it('accepts a non-empty heading and rejects whitespace-only', () => { + expect(validateUiAppHeading('Invoice Manager')).toBe(true); + expect(validateUiAppHeading(' ')).not.toBe(true); + }); +}); + +// Slot names are matched by exact string equality by the UI kit, and an authored +// name with no registry row is silently dropped by the backend — so this is the +// only place a typo ever surfaces. +describe('validateSurfacePoint', () => { + it.each([ + ['contactDetails.headerMenu.action'], + ['dealDetails.headerMenu.action'], + ['companyDetails.headerMenu.action'], + ['contactDetails.overviewMain.widget'], + ['dealDetails.overviewAttributes.widget'], + ['companyDetails.overviewSidebar.widget'], + ])('accepts the registered point %s', (name) => { + expect(validateSurfacePoint(name)).toBe(true); + }); + + it.each([ + ['the pre-BEX-350 region grammar', 'contact.center.region'], + ['the pre-BEX-350 action grammar', 'contact.header.action'], + ['a bare record type instead of the page', 'contact.headerMenu.action'], + ['a wrong kind for the place', 'contactDetails.headerMenu.widget'], + ['a location not in the registry', 'quoteDetails.headerMenu.action'], + ['wrong casing', 'contactdetails.headerMenu.action'], + ['an empty value', ''], + ])('rejects %s', (_label, name) => { + expect(validateSurfacePoint(name)).not.toBe(true); + }); +}); + +describe('parseAccountId', () => { + it('accepts and trims a numeric account ID', () => { + expect(parseAccountId(' 99999 ')).toBe('99999'); + }); + + it.each([ + ['empty', ''], + ['non-numeric', 'abc'], + ['mixed', '99a'], + ['negative', '-1'], + ])('rejects a %s account ID', (_label, value) => { + expect(() => parseAccountId(value)).toThrow(CliError); + }); +}); + +describe('validateUiApp', () => { + const VALID = { + extensionType: 'actionLink', + surfacePointList: ['contactDetails.headerMenu.action'], + heading: 'Invoice Manager', + subheading: 'Review invoice history for this contact', + redirectLink: 'https://example.com/brevo', + linkTarget: '_blank', + }; + + it('accepts a well-formed action link', () => { + expect(() => validateUiApp(VALID)).not.toThrow(); + }); + + it('accepts one without a subheading or linkTarget', () => { + const { subheading: _s, linkTarget: _l, ...rest } = VALID; + expect(() => validateUiApp(rest)).not.toThrow(); + }); + + it('accepts several action slots', () => { + expect(() => + validateUiApp({ + ...VALID, + surfacePointList: ['contactDetails.headerMenu.action', 'dealDetails.headerMenu.action'], + }), + ).not.toThrow(); + }); + + it.each([ + ['not an object', 'nope'], + ['null', null], + ['a missing extensionType', { ...VALID, extensionType: undefined }], + ['an empty surfacePointList', { ...VALID, surfacePointList: [] }], + ['a missing surfacePointList', { ...VALID, surfacePointList: undefined }], + ['an unregistered point', { ...VALID, surfacePointList: ['contact.header.action'] }], + [ + 'a widget slot for an action link', + { ...VALID, surfacePointList: ['contactDetails.overviewMain.widget'] }, + ], + [ + 'duplicate points', + { + ...VALID, + surfacePointList: ['contactDetails.headerMenu.action', 'contactDetails.headerMenu.action'], + }, + ], + ['an empty heading', { ...VALID, heading: ' ' }], + ['a missing redirectLink', { ...VALID, redirectLink: undefined }], + ['an insecure redirectLink', { ...VALID, redirectLink: 'http://example.com' }], + ['an unknown linkTarget', { ...VALID, linkTarget: '_top' }], + ])('rejects %s', (_label, block) => { + expect(() => validateUiApp(block)).toThrow(CliError); + }); + + // Types beyond the action link exist on the platform but the CLI can't author + // them yet — pushing one would produce a config nothing renders. + it.each([['iframeExtension'], ['legacyComponent']])('rejects the %s type', (extensionType) => { + expect(() => validateUiApp({ ...VALID, extensionType })).toThrow(/Unsupported/i); + }); + + // The UI kit keeps modalIframeUrl only for iframeExtension, so one on an + // action link is silently discarded. + it('rejects modalIframeUrl on an action link', () => { + expect(() => validateUiApp({ ...VALID, modalIframeUrl: 'https://example.com/modal' })).toThrow( + /only used by/i, + ); + }); +}); diff --git a/src/__tests__/templates/conditionals.test.ts b/src/__tests__/templates/conditionals.test.ts index 24ee471..cb01336 100644 --- a/src/__tests__/templates/conditionals.test.ts +++ b/src/__tests__/templates/conditionals.test.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { applyConditionals, applyVars, Distribution } from '../../templates'; +import { applyConditionals, applyVars, Distribution, TemplateFlag } from '../../templates'; const TEMPLATES_DIR = path.resolve(__dirname, '../../templates/files'); function loadTemplate(relativePath: string): string { @@ -97,3 +97,85 @@ describe('.env template branching', () => { expect(pub).toContain('PKCE'); }); }); + +// ──────────────── App-type flags (BEX-290) ──────────────── +describe('app-type conditionals', () => { + it('accepts a flag set alongside the legacy Distribution argument', () => { + const tmpl = [ + '{{#if oauth}}', + 'oauth-only', + '{{/if}}', + '{{#if ui_app}}', + 'ui-only', + '{{/if}}', + ].join('\n'); + + expect(applyConditionals(tmpl, new Set(['private', 'oauth']))).toBe('oauth-only'); + expect(applyConditionals(tmpl, new Set(['private', 'ui_app']))).toBe('ui-only'); + // A bare Distribution still works and matches neither app-type branch. + expect(applyConditionals(tmpl, 'private')).toBe(''); + }); + + it('combines distribution and app-type flags independently', () => { + const tmpl = ['{{#if ui_app}}', 'ui', '{{#if public}}', 'ui-public', '{{/if}}', '{{/if}}'].join( + '\n', + ); + + expect(applyConditionals(tmpl, new Set(['public', 'ui_app']))).toBe( + 'ui\nui-public', + ); + expect(applyConditionals(tmpl, new Set(['private', 'ui_app']))).toBe('ui'); + }); +}); + +describe('app-config.json template branching', () => { + const BASE_VARS = { + '{{APP_ID}}': '42', + '{{APP_NAME}}': 'Invoice Manager', + '{{APP_VERSION}}': '1.0.0', + '{{LOGO_URI}}': '', + '{{CLI_VERSION}}': '2.0.1', + '{{DISTRIBUTION}}': 'private', + '{{SCOPES_JSON}}': '["contacts:read","contacts:write"]', + '{{REDIRECT_URLS_JSON}}': '["http://localhost:3009/auth/callback"]', + }; + + const renderConfig = (extraVars: Record, flags: Set): string => + applyVars(applyConditionals(loadTemplate('app-config.json.tmpl'), flags), { + ...BASE_VARS, + ...extraVars, + }); + + it('renders valid JSON with redirectUrls and no ui_app for an OAuth app', () => { + const out = renderConfig( + { '{{UI_APP_JSON}}': '' }, + new Set(['private', 'oauth']), + ); + const parsed = JSON.parse(out); + + expect(parsed.auth.redirectUrls).toEqual(['http://localhost:3009/auth/callback']); + expect(parsed).not.toHaveProperty('ui_app'); + }); + + it('renders valid JSON with ui_app and no redirectUrls for a UI app', () => { + // The platform's app-snapshot shape — nested one level deep, which is + // what the template's indent handling has to survive. + const uiApp = { + extensionType: 'actionLink', + surfacePointList: ['contactDetails.headerMenu.action', 'dealDetails.headerMenu.action'], + heading: 'Invoice Manager', + subheading: 'Review invoice history', + redirectLink: 'https://example.com/brevo', + linkTarget: '_blank', + }; + const out = renderConfig( + { '{{UI_APP_JSON}}': JSON.stringify(uiApp, null, 2).split('\n').join('\n ') }, + new Set(['private', 'ui_app']), + ); + const parsed = JSON.parse(out); + + expect(parsed.ui_app).toEqual(uiApp); + expect(parsed.auth).not.toHaveProperty('redirectUrls'); + expect(parsed.auth.scopes).toEqual(['contacts:read', 'contacts:write']); + }); +}); diff --git a/src/bin/index.ts b/src/bin/index.ts index be6cb6d..1961910 100644 --- a/src/bin/index.ts +++ b/src/bin/index.ts @@ -73,7 +73,7 @@ program ` brevo app init Quick setup — login, create app, and scaffold`, ` brevo app create [--name] [--distribution private|public]`, ` [--redirect-uri ...] [--logo-uri ] [--json]`, - ` Create a new OAuth app`, + ` Create a new app (OAuth, or a UI app via the prompts)`, ` brevo app list [--json] List all apps in your account`, ` brevo app credentials [--app-id ] [--reveal-secret] [--json]`, ` Show an app's client ID and secret`, @@ -83,6 +83,12 @@ program ` brevo app delete [--app-id ] [--force] [--json]`, ` Delete an app`, ``, + `App-deployment commands (UI apps only):`, + ` brevo app deploy [--app-id ] [--force] [--json]`, + ` Make an app available in an account`, + ` brevo app remove [--app-id ] [--force] [--json]`, + ` Remove an app from an account`, + ``, `App-review commands (public apps only):`, ` brevo app submit [--app-id ] [--json] Submit a public app for review`, ` brevo app status [--app-id ] [--json] Show an app's review status`, diff --git a/src/commands/app/account-deployment.ts b/src/commands/app/account-deployment.ts new file mode 100644 index 0000000..d44c8a0 --- /dev/null +++ b/src/commands/app/account-deployment.ts @@ -0,0 +1,104 @@ +import inquirer from 'inquirer'; +import { logInfo } from '../../lib/logger'; +import { messages } from '../../lang/en'; +import { CliError } from '../../lib/errors'; +import { readProjectConfig } from '../../lib/config'; +import { parseAccountId } from '../../lib/validators'; +import { promptAppSelection } from './select-app'; + +/** + * Shared resolution for `app deploy` and `app remove` (BEX-290). + * + * The two commands are mirror operations on the same target — an (app, account) + * pair — so target resolution, the upload gate, and confirmation live here rather + * than being duplicated (and drifting) across both files. + */ + +export interface DeploymentTarget { + appId: string; + appLabel: string; + accountId: string; +} + +/** + * Resolve which app + account the command acts on. + * + * App resolution follows the same precedence as `app withdraw`: explicit + * `--app-id` flag > the app linked in this directory's app-config.json > an + * interactive picker. + */ +export async function resolveDeploymentTarget( + accountIdArg: string | undefined, + options: { appId?: string }, + selectPrompt: string, + missingAccountIdMessage: string, +): Promise { + if (!accountIdArg) { + throw new CliError(missingAccountIdMessage); + } + const accountId = parseAccountId(accountIdArg); + + if (options.appId) { + return { appId: options.appId, appLabel: options.appId, accountId }; + } + + const projectConfig = readProjectConfig(); + if (projectConfig) { + return { + appId: projectConfig.appId, + appLabel: projectConfig.appName || projectConfig.appId, + accountId, + }; + } + + const selection = await promptAppSelection(selectPrompt); + return { appId: selection.appId, appLabel: selection.appLabel, accountId }; +} + +/** + * Enforce the spec's installation-flow gate: an app must be validated by + * `brevo app upload` before it can be deployed. + * + * `version` in app-config.json is only ever written by a successful upload, so + * its absence is a reliable local signal — and catching it here avoids a wasted + * round-trip. This is a pre-flight only: when the command runs outside a project + * directory there is no local config to check, and the server's own rejection is + * the authority. + */ +export function assertUploadedBeforeDeploy(): void { + const projectConfig = readProjectConfig(); + if (!projectConfig) return; + if (!projectConfig.version?.trim()) { + throw new CliError(messages.APP_DEPLOY_NOT_UPLOADED); + } +} + +/** + * Confirm a deploy/remove unless `--force` was passed. Returns false when the + * user declines, in which case the caller returns without acting (exit 0). + */ +export async function confirmDeployment( + confirmMessage: string, + cancelledMessage: string, + options: { force?: boolean; json?: boolean }, +): Promise { + if (options.force || options.json) return true; + + if (!process.stdin.isTTY) { + throw new CliError(messages.APP_DEPLOY_NON_INTERACTIVE); + } + + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message: confirmMessage, + default: false, + }, + ]); + if (!confirmed) { + logInfo(`\n ${cancelledMessage}\n`); + return false; + } + return true; +} diff --git a/src/commands/app/create.ts b/src/commands/app/create.ts index b78b466..3095aed 100644 --- a/src/commands/app/create.ts +++ b/src/commands/app/create.ts @@ -1,14 +1,34 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import inquirer from 'inquirer'; -import { CLI, DEFAULT_PORT, DEFAULT_REDIRECT_URI, DEFAULT_SCOPES } from '../../lib/constants'; +import { + CLI, + DEFAULT_PORT, + DEFAULT_REDIRECT_URI, + DEFAULT_SCOPES, + DEFAULT_LINK_TARGET, + DEFAULT_UI_APP_SCOPES, + DEFAULT_UI_APP_SURFACE, + EXTENSION_TYPE_ACTION_LINK, + EXTENSION_TYPE_IFRAME, + LINK_TARGETS, + UI_APP_SURFACE_TO_LOCATION, + UI_APP_SURFACES, + actionPointForLocation, +} from '../../lib/constants'; import { findAvailablePort } from '../../lib/port'; import { logInfo, logError } from '../../lib/logger'; import { messages } from '../../lang/en'; import { ApiError, CliError, ErrorCode } from '../../lib/errors'; import { withCommandHandler } from '../../lib/command-handler'; import { jsonOutput } from '../../lib/json-output'; -import { validateEnum, validateAppName } from '../../lib/validators'; +import { + validateEnum, + validateAppName, + validateUiApp, + validateUiAppHeading, + validateUiAppUrl, +} from '../../lib/validators'; import { printBox, createSpinner } from '../../lib/ui'; import { saveAppCredentials, saveAppName, hasLocalApp, readProjectConfig } from '../../lib/config'; import { @@ -24,7 +44,7 @@ import { } from './scaffold'; import { appService } from '../../container'; import { FeatureType } from '../../templates'; -import { CreateAppResponse } from '../../types'; +import { CreateAppResponse, UiApp } from '../../types'; function validateHttpUrl(trimmed: string, invalidMessage: string): true | string { try { @@ -79,7 +99,37 @@ async function resolveAppName(nameFlag: string | undefined): Promise { return answer.name; } -// 2. Distribution type +// 2. App type — OAuth integration vs UI app (BEX-290). +// Asked before distribution because it decides which of the two remaining +// prompt paths runs (OAuth callback URLs vs UI-app placement). +// +// Prompt-only, deliberately: there is no `--type` flag, so a UI app can only +// be authored from an interactive terminal. UI apps aren't live on the +// platform yet, and a scriptable create surface would invite pipelines to pin +// to a shape that can still change. Any non-interactive run — piped stdin or +// `--json` — creates an OAuth app, exactly as it did before BEX-290, so +// existing scripted `app create` calls are unaffected. +export type AppType = 'oauth' | 'ui'; + +async function resolveAppType(interactive: boolean): Promise { + if (!interactive) { + return 'oauth'; + } + const answer = await inquirer.prompt([ + { + type: 'list', + name: 'appType', + message: messages.APP_CREATE_APP_TYPE_PROMPT, + choices: [ + { name: messages.APP_CREATE_APP_TYPE_OAUTH, value: 'oauth' }, + { name: messages.APP_CREATE_APP_TYPE_UI, value: 'ui' }, + ], + }, + ]); + return answer.appType as AppType; +} + +// 3. Distribution type async function resolveDistribution(distributionFlag: string | undefined): Promise { const VALID_DISTRIBUTIONS = ['private', 'public'] as const; validateEnum(distributionFlag, VALID_DISTRIBUTIONS, '--distribution'); @@ -221,6 +271,138 @@ async function resolveLogoUri( return trimmed || undefined; } +// 4b. UI-app configuration (BEX-290) — replaces the redirect-URL step for UI +// apps. Only the action link is buildable today; the other delivery paths +// appear as disabled choices so the roadmap is visible where the decision is +// made. +// +// The collected block is the app snapshot the platform stores, verbatim, so +// there is no vocabulary translation between what a partner authors and what +// the platform renders. +async function promptUiExtensionType(): Promise { + const { extensionType } = await inquirer.prompt([ + { + type: 'list', + name: 'extensionType', + message: messages.APP_CREATE_UI_TRIGGER_PROMPT, + choices: [ + { name: messages.APP_CREATE_UI_TRIGGER_LINK, value: EXTENSION_TYPE_ACTION_LINK }, + new inquirer.Separator(), + { + name: messages.APP_CREATE_UI_TRIGGER_MODAL, + value: EXTENSION_TYPE_IFRAME, + disabled: 'not yet supported', + }, + { + name: messages.APP_CREATE_UI_TRIGGER_WIDGET, + value: 'widget', + disabled: 'not yet supported', + }, + ], + }, + ]); + // Defensive: inquirer won't return a disabled choice, but a future edit that + // enables one before the rest of the pipeline supports it should fail loudly + // rather than push a config the platform silently drops. + if (extensionType !== EXTENSION_TYPE_ACTION_LINK) { + throw new CliError(messages.APP_CREATE_UI_TRIGGER_UNSUPPORTED(String(extensionType))); + } +} + +// Map the friendly record-type answers onto action slot names. The prompt's +// choices come from UI_APP_SURFACES, so an unmapped value is unreachable today — +// the throw exists so a future edit that adds a choice without adding its +// location mapping fails loudly instead of writing a slot name the platform +// silently drops. +function toActionPoints(surfaces: string[]): string[] { + const points: string[] = []; + for (const surface of surfaces) { + const key = String(surface).trim().toLowerCase(); + const location = UI_APP_SURFACE_TO_LOCATION[key]; + if (!location) { + throw new CliError( + `Invalid record page "${surface}". Must be one of: ${UI_APP_SURFACES.join(', ')}.`, + ); + } + const point = actionPointForLocation(location); + if (!points.includes(point)) points.push(point); + } + return points; +} + +/** + * Collect the `ui_app` block interactively. Only reachable when the app-type + * prompt returned `ui`, which already implies an interactive terminal — so every + * field is asked for, with no flag or default fallback path. + */ +async function resolveUiApp(): Promise { + await promptUiExtensionType(); + + const { surfaces } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'surfaces', + message: messages.APP_CREATE_UI_SURFACE_PROMPT, + choices: [...UI_APP_SURFACES], + default: [DEFAULT_UI_APP_SURFACE], + validate: (picked: unknown[]) => picked.length > 0 || messages.APP_CREATE_UI_SURFACE_REQUIRED, + }, + ]); + + const { heading } = await inquirer.prompt([ + { + type: 'input', + name: 'heading', + message: messages.APP_CREATE_UI_HEADING_PROMPT, + validate: validateUiAppHeading, + }, + ]); + + const { subheading } = await inquirer.prompt([ + { + type: 'input', + name: 'subheading', + message: messages.APP_CREATE_UI_SUBHEADING_PROMPT, + }, + ]); + + const { redirectLink } = await inquirer.prompt([ + { + type: 'input', + name: 'redirectLink', + message: messages.APP_CREATE_UI_REDIRECT_LINK_PROMPT, + validate: validateUiAppUrl, + }, + ]); + + const { linkTarget } = await inquirer.prompt([ + { + type: 'list', + name: 'linkTarget', + message: messages.APP_CREATE_UI_LINK_TARGET_PROMPT, + choices: [...LINK_TARGETS], + default: DEFAULT_LINK_TARGET, + }, + ]); + + const uiApp: UiApp = { + extensionType: EXTENSION_TYPE_ACTION_LINK, + surfacePointList: toActionPoints((surfaces as string[]) ?? []), + heading: String(heading ?? '').trim(), + // Omitted rather than written empty: the kit only renders it when set, and an + // empty string would show up as a spurious diff on every upload. + ...(String(subheading ?? '').trim() ? { subheading: String(subheading).trim() } : {}), + redirectLink: String(redirectLink ?? '').trim(), + linkTarget: String(linkTarget ?? DEFAULT_LINK_TARGET).trim() as UiApp['linkTarget'], + }; + + // Belt and braces: the per-prompt validators cover each answer in isolation, + // but nothing else checks the assembled block — in particular the surface → + // slot-name mapping, which is where a silent platform-side drop would come from. + validateUiApp(uiApp); + return uiApp; +} + type CreateDirectoryResult = | { targetDir: string; mergeOnly: boolean; skipped: false } | { targetDir: string; skipped: true }; @@ -261,6 +443,8 @@ interface CreateAppInputs { distribution: string; redirectUrls: string[]; logoUri?: string; + /** Present for UI apps only; drives scope defaults and omits redirect URIs. */ + uiApp?: UiApp; } interface CreatedApp { @@ -269,11 +453,18 @@ interface CreatedApp { } function buildCreatePayload(inputs: CreateAppInputs) { + // The `ui_app` block is deliberately NOT sent here. `POST /apps` registers the + // app record and issues credentials; the extension configuration is validated + // and stored by `app upload`, which is the platform's single validation + // authority for it. Create only writes it to app-config.json. + const isUiApp = !!inputs.uiApp; return { name: inputs.appName, distribution_type: inputs.distribution as 'public' | 'private', - redirect_uris: inputs.redirectUrls, - scopes: [...DEFAULT_SCOPES], + // A UI app has no OAuth callback — sending an empty array (or worse, the + // default localhost URI) would register a redirect URL it never uses. + ...(isUiApp ? {} : { redirect_uris: inputs.redirectUrls }), + scopes: isUiApp ? [...DEFAULT_UI_APP_SCOPES] : [...DEFAULT_SCOPES], ...(inputs.logoUri ? { logo_uri: inputs.logoUri } : {}), }; } @@ -330,7 +521,7 @@ function renderCreatedApp(result: CreateAppResponse, appName: string, logoUri?: `App ID: ${result.app_id}`, `Client ID: ${result.client_id}`, `Client secret: ${messages.CLIENT_SECRET_HIDDEN_HUMAN}`, - ...result.redirect_uris.map((uri, i) => `Redirect URL ${i + 1}: ${uri}`), + ...(result.redirect_uris ?? []).map((uri, i) => `Redirect URL ${i + 1}: ${uri}`), ...(logoUri ? [`Logo URL: ${logoUri}`] : []), ...(result.version ? [`App version: ${result.version}`] : []), `${messages.APP_CREATE_BOX_SCOPES_LABEL} ${[...DEFAULT_SCOPES].join(', ')}`, @@ -340,6 +531,39 @@ function renderCreatedApp(result: CreateAppResponse, appName: string, logoUri?: printBox(messages.APP_CREATE_BOX_TITLE, boxLines); } +// UI apps get their own summary box: there is no OAuth callback to list, and the +// placement/trigger fields are what the partner actually needs to verify. +function renderCreatedUiApp( + result: CreateAppResponse, + appName: string, + uiApp: UiApp, + logoUri?: string, +): void { + const boxLines = [ + `App name: ${appName}`, + `App ID: ${result.app_id}`, + `Client ID: ${result.client_id}`, + `Client secret: ${messages.CLIENT_SECRET_HIDDEN_HUMAN}`, + `Extension type: ${uiApp.extensionType}`, + ...uiApp.surfacePointList.map( + (point, i) => `${i === 0 ? 'Extension point:' : ' '} ${point}`, + ), + `Heading: ${uiApp.heading ?? ''}`, + ...(uiApp.subheading ? [`Subheading: ${uiApp.subheading}`] : []), + `Redirect link: ${uiApp.redirectLink ?? ''}`, + `Link target: ${uiApp.linkTarget ?? DEFAULT_LINK_TARGET}`, + ...(logoUri ? [`Logo URL: ${logoUri}`] : []), + ...(result.version ? [`App version: ${result.version}`] : []), + `${messages.APP_CREATE_BOX_SCOPES_LABEL} ${[...DEFAULT_UI_APP_SCOPES].join(', ')}`, + '', + // The menu entry is labelled with the app name, not a per-action label — + // worth stating, since it's the one place a partner might expect a field. + messages.APP_CREATE_UI_BOX_LABEL_NOTE(appName), + messages.APP_CREATE_UI_BOX_HINT, + ]; + printBox(messages.APP_CREATE_UI_BOX_TITLE, boxLines); +} + export const createCommand = withCommandHandler( async (options: { name?: string; @@ -353,15 +577,27 @@ export const createCommand = withCommandHandler( guardAgainstLinkedApp(); + const interactive = !jsonMode && !!process.stdin.isTTY; + const appName = await resolveAppName(options.name); + const appType = await resolveAppType(interactive); const distribution = await resolveDistribution(options.distribution); - const redirectUrls = await resolveRedirectUrls(options.redirectUri, jsonMode); + + // The two app types diverge here: OAuth apps collect callback URLs, UI apps + // collect placement + destination. Neither path runs the other's prompts. + let redirectUrls: string[] = []; + let uiApp: UiApp | undefined; + if (appType === 'ui') { + uiApp = await resolveUiApp(); + } else { + redirectUrls = await resolveRedirectUrls(options.redirectUri, jsonMode); + } + const logoUri = await resolveLogoUri(options.logoUri, jsonMode); - const interactive = !jsonMode && !!process.stdin.isTTY; const dir = await resolveCreateDirectory(appName, interactive); - const inputs: CreateAppInputs = { appName, distribution, redirectUrls, logoUri }; + const inputs: CreateAppInputs = { appName, distribution, redirectUrls, logoUri, uiApp }; const { result, appName: finalAppName } = await createAppWithRetry(inputs, jsonMode); // Store app credentials locally — client_secret may not be retrievable again @@ -371,27 +607,48 @@ export const createCommand = withCommandHandler( }); if (finalAppName) saveAppName(result.app_id, finalAppName); + // Shared JSON shape for both exits below. `redirectUri` is omitted for UI + // apps rather than emitted as an empty array, so a consumer can distinguish + // "no callbacks by design" from "callbacks not returned". + // + // `--json` implies non-interactive, which implies OAuth, so `appType` is + // always `oauth` here today and the `uiApp` branch is unreachable. Both are + // kept so the field stays meaningful to a consumer, and so this shape doesn't + // have to be rediscovered if UI apps ever gain a non-interactive path. + const jsonBase = { + appId: result.app_id, + appName: finalAppName, + clientId: result.client_id, + clientSecret: messages.CLIENT_SECRET_HIDDEN_JSON, + appType, + ...(uiApp ? { uiApp } : { redirectUri: result.redirect_uris }), + ...(logoUri ? { logoUri } : {}), + ...(result.version ? { version: result.version } : {}), + }; + + const renderBox = (): void => + uiApp + ? renderCreatedUiApp(result, finalAppName, uiApp, logoUri) + : renderCreatedApp(result, finalAppName, logoUri); + if (dir.skipped) { if (jsonMode) { jsonOutput({ - appId: result.app_id, - appName: finalAppName, - clientId: result.client_id, - clientSecret: messages.CLIENT_SECRET_HIDDEN_JSON, - redirectUri: result.redirect_uris, - ...(logoUri ? { logoUri } : {}), - ...(result.version ? { version: result.version } : {}), + ...jsonBase, directory: dir.targetDir, scaffoldSkipped: messages.APP_CREATE_JSON_SCAFFOLD_DIR_EXISTS(dir.targetDir), }); return; } - renderCreatedApp(result, finalAppName, logoUri); + renderBox(); logInfo(messages.APP_CREATE_DIR_EXISTS_SKIPPED(dir.targetDir)); return; } - const ctx = await fetchAppContext(result.app_id, jsonMode); + // Pass the freshly collected `ui_app` block explicitly: the server doesn't + // have it yet (it only learns about it on `app upload`), so the scaffold + // can't read it back from `fetchAppContext`'s server response. + const ctx = await fetchAppContext(result.app_id, jsonMode, uiApp); // Always write the basic project structure (app-config.json + meta files). const base = runBaseScaffold(result.app_id, ctx, dir.targetDir, dir.mergeOnly); @@ -399,13 +656,7 @@ export const createCommand = withCommandHandler( // --json never scaffolds a feature — emit the base result as a single blob. if (jsonMode) { jsonOutput({ - appId: result.app_id, - appName: finalAppName, - clientId: result.client_id, - clientSecret: messages.CLIENT_SECRET_HIDDEN_JSON, - redirectUri: result.redirect_uris, - ...(logoUri ? { logoUri } : {}), - ...(result.version ? { version: result.version } : {}), + ...jsonBase, directory: dir.targetDir, scaffolded: base.written, }); @@ -414,11 +665,19 @@ export const createCommand = withCommandHandler( // Show the created-app box and the base files that were just written, // before asking about features. - renderCreatedApp(result, finalAppName, logoUri); + renderBox(); reportBaseScaffoldSuccess(base); const cdDir = computeCdHint(originalCwd, dir.targetDir); + // UI apps have no scaffoldable feature — an action link runs on the partner's + // own infrastructure, so there is no local server to generate. Point at the + // upload → deploy path instead of offering the OAuth test server. + if (uiApp) { + printBox(messages.APP_SCAFFOLD_NEXT_STEPS_TITLE, messages.APP_CREATE_UI_NEXT(cdDir)); + return; + } + // Then offer to scaffold a feature (default yes → pick a type). Only the // interactive prompt triggers it; a piped (non-TTY) run stays base-only. let feature: FeatureType | null = null; diff --git a/src/commands/app/deploy.ts b/src/commands/app/deploy.ts new file mode 100644 index 0000000..c17e318 --- /dev/null +++ b/src/commands/app/deploy.ts @@ -0,0 +1,66 @@ +import { logSuccess } from '../../lib/logger'; +import { messages } from '../../lang/en'; +import { ApiError, CliError } from '../../lib/errors'; +import { withCommandHandler } from '../../lib/command-handler'; +import { jsonOutput } from '../../lib/json-output'; +import { appService } from '../../container'; +import { createSpinner } from '../../lib/ui'; +import { + assertUploadedBeforeDeploy, + confirmDeployment, + resolveDeploymentTarget, +} from './account-deployment'; + +interface DeployOptions { + /** The `` positional, folded in by the command definition. */ + accountId?: string; + appId?: string; + force?: boolean; + json?: boolean; +} + +/** + * `brevo app deploy ` — make an app available in one Brevo account. + * + * Until an in-product enable/disable surface ships, this (with + * `app remove`) is the only way a UI app becomes visible in an account. + */ +export const deployCommand = withCommandHandler(async (options: DeployOptions): Promise => { + const { appId, appLabel, accountId } = await resolveDeploymentTarget( + options.accountId, + options, + messages.APP_DEPLOY_SELECT, + messages.APP_DEPLOY_MISSING_ACCOUNT_ID, + ); + + assertUploadedBeforeDeploy(); + + const proceed = await confirmDeployment( + messages.APP_DEPLOY_CONFIRM(appLabel, appId, accountId), + messages.APP_DEPLOY_CANCELLED, + options, + ); + if (!proceed) return; + + const spinner = createSpinner('Deploying app...', { silent: options.json }); + try { + await appService.deployApp(appId, accountId); + } catch (err) { + spinner.stop(); + // The server is the authority on whether the config was validated. 422 is + // its "not uploaded yet" rejection — surface the same actionable message + // the local pre-flight uses rather than a raw API error. + if (err instanceof ApiError && err.statusCode === 422) { + throw new CliError(messages.APP_DEPLOY_NOT_UPLOADED, err.exitCode); + } + throw err; + } + spinner.stop(); + + if (options.json) { + jsonOutput({ deployed: true, appId, accountId }); + return; + } + + logSuccess(messages.APP_DEPLOY_SUCCESS(appId, accountId)); +}); diff --git a/src/commands/app/remove.ts b/src/commands/app/remove.ts new file mode 100644 index 0000000..29a8071 --- /dev/null +++ b/src/commands/app/remove.ts @@ -0,0 +1,78 @@ +import { logSuccess, logInfo } from '../../lib/logger'; +import { messages } from '../../lang/en'; +import { ApiError } from '../../lib/errors'; +import { withCommandHandler } from '../../lib/command-handler'; +import { jsonOutput } from '../../lib/json-output'; +import { appService } from '../../container'; +import { createSpinner } from '../../lib/ui'; +import { confirmDeployment, resolveDeploymentTarget } from './account-deployment'; + +interface RemoveOptions { + /** The `` positional, folded in by the command definition. */ + accountId?: string; + appId?: string; + force?: boolean; + json?: boolean; +} + +/** + * "Not deployed to this account" is informational, not a failure: the caller's + * intent (the app is not in that account) already holds. Mirrors how + * `app withdraw` treats a never-submitted app — report and exit 0 so + * teardown scripts stay idempotent. + */ +function reportNotDeployed(appId: string, accountId: string, json?: boolean): void { + if (json) { + jsonOutput({ + removed: false, + appId, + accountId, + reason: 'NOT_DEPLOYED', + message: messages.APP_REMOVE_NOT_DEPLOYED(appId, accountId), + }); + return; + } + logInfo(`\n ${messages.APP_REMOVE_NOT_DEPLOYED(appId, accountId)}\n`); +} + +/** + * `brevo app remove ` — withdraw an app's availability from one + * Brevo account. Counterpart to `app deploy`. + */ +export const removeCommand = withCommandHandler(async (options: RemoveOptions): Promise => { + const { appId, appLabel, accountId } = await resolveDeploymentTarget( + options.accountId, + options, + messages.APP_REMOVE_SELECT, + messages.APP_REMOVE_MISSING_ACCOUNT_ID, + ); + + // Deliberately no upload gate here: removing is always safe, and blocking it + // on an upload would strand an app deployed by an earlier CLI version. + const proceed = await confirmDeployment( + messages.APP_REMOVE_CONFIRM(appLabel, appId, accountId), + messages.APP_REMOVE_CANCELLED, + options, + ); + if (!proceed) return; + + const spinner = createSpinner('Removing app...', { silent: options.json }); + try { + await appService.removeApp(appId, accountId); + } catch (err) { + spinner.stop(); + if (err instanceof ApiError && err.statusCode === 422) { + reportNotDeployed(appId, accountId, options.json); + return; + } + throw err; + } + spinner.stop(); + + if (options.json) { + jsonOutput({ removed: true, appId, accountId }); + return; + } + + logSuccess(messages.APP_REMOVE_SUCCESS(appId, accountId)); +}); diff --git a/src/commands/app/scaffold.ts b/src/commands/app/scaffold.ts index 2770b22..3671695 100644 --- a/src/commands/app/scaffold.ts +++ b/src/commands/app/scaffold.ts @@ -7,6 +7,7 @@ import { OAUTH_BASE, OAUTH_REALM, DEFAULT_SCOPES, + DEFAULT_UI_APP_SCOPES, LEGACY_ALL_SCOPE, } from '../../lib/constants'; import { logSuccess, logInfo, logWarn } from '../../lib/logger'; @@ -18,7 +19,8 @@ import { jsonOutput } from '../../lib/json-output'; import { appService } from '../../container'; import { loadBaseTemplates, loadFeatureTemplates, FeatureType } from '../../templates'; import { containsLegacyAllScope } from '../../lib/validators'; -import { readProjectConfig, ProjectConfig } from '../../lib/config'; +import { readProjectConfig, ProjectConfig, isUiAppConfig } from '../../lib/config'; +import { UiApp } from '../../types'; interface TreeNode { [key: string]: TreeNode; @@ -71,6 +73,14 @@ export interface AppContext { clientSecret: string; redirectUrls: string[]; redirectUri: string; + /** + * The UI-app block to write into app-config.json (BEX-290). Unlike every other + * field here it is not necessarily server-sourced: `app create` collects it + * from prompts before the server knows about it, and `app scaffold` carries the + * *local* block forward so a refresh never destroys hand-edited values. + * Absent for OAuth apps. + */ + uiApp?: UiApp; } export function computeSlug(name: string | undefined): string { @@ -82,7 +92,17 @@ export function computeSlug(name: string | undefined): string { ); } -export async function fetchAppContext(appId: string, silent?: boolean): Promise { +export async function fetchAppContext( + appId: string, + silent?: boolean, + // The UI-app block to scaffold, supplied by the caller — deliberately NOT read + // from the server response. Both callers already know the app type locally + // (`app create` from the type it just prompted for, `app scaffold` from the + // local `app-config.json`), and falling back to `appDetails.ui_app` would let + // stale or unexpected server data reclassify an app the user explicitly created + // as OAuth. Absent here means "OAuth app", authoritatively. + uiApp?: UiApp, +): Promise { const spinner = createSpinner('Fetching app details...', { silent }); const result = await appService.resolveAppCredentials(appId); spinner.stop(); @@ -106,6 +126,7 @@ export async function fetchAppContext(appId: string, silent?: boolean): Promise< clientSecret: appDetails?.client_secret || 'YOUR_CLIENT_SECRET', redirectUrls, redirectUri: localhostUri || DEFAULT_REDIRECT_URI, + ...(uiApp ? { uiApp } : {}), }; } @@ -213,16 +234,21 @@ function diffLocalConfig(localConfig: ProjectConfig, ctx: AppContext): ConfigDif }); } - const localRedirects = [...(localConfig.auth?.redirectUrls ?? [])].sort((a, b) => - a.localeCompare(b), - ); - const serverRedirects = [...ctx.redirectUrls].sort((a, b) => a.localeCompare(b)); - if (JSON.stringify(localRedirects) !== JSON.stringify(serverRedirects)) { - diffs.push({ - field: 'redirectUrls', - local: localRedirects.join(', ') || '(none)', - server: serverRedirects.join(', ') || '(none)', - }); + // UI apps have no OAuth callback, and `ctx.redirectUrls` falls back to the + // default localhost URI when the server returns none — comparing the two would + // report a permanent phantom diff on every UI-app scaffold. Skip it entirely. + if (!isUiAppConfig(localConfig)) { + const localRedirects = [...(localConfig.auth?.redirectUrls ?? [])].sort((a, b) => + a.localeCompare(b), + ); + const serverRedirects = [...ctx.redirectUrls].sort((a, b) => a.localeCompare(b)); + if (JSON.stringify(localRedirects) !== JSON.stringify(serverRedirects)) { + diffs.push({ + field: 'redirectUrls', + local: localRedirects.join(', ') || '(none)', + server: serverRedirects.join(', ') || '(none)', + }); + } } const localScopes = [...(localConfig.auth?.scopes ?? [])].sort((a, b) => a.localeCompare(b)); @@ -253,6 +279,13 @@ function diffLocalConfig(localConfig: ProjectConfig, ctx: AppContext): ConfigDif }); } + // `ui_app` is deliberately NOT diffed. The local block is the author's source + // of truth — the CLI writes it, the server validates it — and not every server + // build echoes it back on reads. Diffing it would report drift against an + // absent remote value and then a confirmed refresh would overwrite the + // partner's hand-edited block with nothing. The local block is instead carried + // through the refresh verbatim (see the ctx override in scaffoldCommand). + return diffs; } @@ -282,16 +315,28 @@ interface TemplateVars { // Build the `{{...}}` substitution map shared by base and feature templates, // plus the resolved scopes and whether the legacy 'all' scope was substituted. +// Render a `ui_app` block for embedding at a 2-space indent inside +// app-config.json.tmpl. JSON.stringify only indents *nested* levels, so every +// line after the first needs the parent's indent added to keep the emitted file +// readable (and diff-friendly against a hand-edited one). +function renderUiAppJson(uiApp: UiApp | undefined): string { + if (!uiApp) return ''; + return JSON.stringify(uiApp, null, 2).split('\n').join('\n '); +} + function buildTemplateVars(appId: string, ctx: AppContext, targetDir: string): TemplateVars { const rawAppName = ctx.appDetails?.name || path.basename(targetDir); const appName = rawAppName.replaceAll(/["\\\n\r\t]/g, '').trim() || 'my-app'; // Never propagate the deprecated legacy 'all' scope into a fresh - // app-config.json — keep the app's granular scopes, fall back to - // DEFAULT_SCOPES when 'all' was the only scope, and tell the user (BEX-214). + // app-config.json — keep the app's granular scopes, fall back to the app + // type's defaults when 'all' was the only scope, and tell the user (BEX-214). const remoteScopes = ctx.appDetails?.scopes; const legacyAllSubstituted = containsLegacyAllScope(remoteScopes); const granularScopes = (remoteScopes ?? []).filter((s) => s !== LEGACY_ALL_SCOPE); - const scopes = granularScopes.length > 0 ? granularScopes : [...DEFAULT_SCOPES]; + // UI apps start from a narrower scope set than OAuth apps — they read record + // context rather than driving a full authorization flow. + const defaultScopes = ctx.uiApp ? DEFAULT_UI_APP_SCOPES : DEFAULT_SCOPES; + const scopes = granularScopes.length > 0 ? granularScopes : [...defaultScopes]; const pkg = JSON.parse( fs.readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf-8'), @@ -314,6 +359,9 @@ function buildTemplateVars(appId: string, ctx: AppContext, targetDir: string): T '{{OAUTH_BASE}}': OAUTH_BASE, '{{OAUTH_REALM}}': OAUTH_REALM, '{{CLI_VERSION}}': cliVersion, + // Empty for OAuth apps — its emptiness is what selects the `oauth` + // conditional branch in templates (see resolveTemplateFlags). + '{{UI_APP_JSON}}': renderUiAppJson(ctx.uiApp), }; return { vars, scopes, legacyAllSubstituted }; @@ -471,7 +519,10 @@ async function resolveScaffoldPlan( jsonMode: boolean, ): Promise { const appId = localConfig.appId; - const ctx = await fetchAppContext(appId, jsonMode); + // Carry the local `ui_app` block into the context so that if the user consents + // to a config refresh, `runBaseScaffold` rewrites app-config.json *with* it + // rather than dropping it (the refresh is a full overwrite, not a merge). + const ctx = await fetchAppContext(appId, jsonMode, localConfig.ui_app); const diffs = diffLocalConfig(localConfig, ctx); // No drift → nothing to refresh; just add the feature. @@ -527,9 +578,33 @@ export const scaffoldCommand = withCommandHandler( } const { appId, ctx, refreshBase } = plan; - const feature = await promptFeatureType(!jsonMode); const targetDir = process.cwd(); + // UI apps have no scaffoldable features — there is no local server to run for + // an action link. `app scaffold` degrades to a base-config refresh so the + // command still has a use inside a UI-app project, instead of offering an + // OAuth test server the app can't use. + if (isUiAppConfig(localConfig)) { + const base = refreshBase ? runBaseScaffold(appId, ctx, targetDir, false) : null; + if (jsonMode) { + jsonOutput({ + scaffolded: base?.written ?? 0, + directory: targetDir, + features: [], + reason: messages.APP_SCAFFOLD_NO_FEATURES_FOR_UI_APP, + }); + return; + } + if (base) { + logSuccess(messages.APP_CREATE_BASE_SUCCESS(base.written)); + logInfo(formatFileTree(base.files.map((f) => f.name))); + } + logInfo(messages.APP_SCAFFOLD_NO_FEATURES_FOR_UI_APP); + return; + } + + const feature = await promptFeatureType(!jsonMode); + // Decide how existing feature files are handled (overwrite/merge/cancel) // before writing anything. const conflict = await resolveFeatureConflict(feature, appId, ctx, targetDir, { diff --git a/src/commands/app/upload.ts b/src/commands/app/upload.ts index 5879431..99af7be 100644 --- a/src/commands/app/upload.ts +++ b/src/commands/app/upload.ts @@ -12,10 +12,11 @@ import { readProjectConfig, writeProjectConfig, saveAppName, + isUiAppConfig, ProjectConfig, } from '../../lib/config'; -import { validateScopes, containsLegacyAllScope } from '../../lib/validators'; -import { OAuthApp, UploadAppResponse } from '../../types'; +import { validateScopes, containsLegacyAllScope, validateUiApp } from '../../lib/validators'; +import { OAuthApp, UiApp, UploadAppResponse } from '../../types'; interface UploadOptions { yes?: boolean; @@ -114,6 +115,12 @@ interface UploadDiff { currentVersion?: string; nextVersion: string; migratingLegacyScopes: boolean; + // UI apps only (BEX-290). `currentUiApp` stays undefined on server builds that + // accept the snapshot on write but don't echo it back on reads — in that case + // the block always reads as new, which is safe: re-sending an identical block is + // idempotent, whereas skipping it could strand a local edit. + currentUiApp?: UiApp; + nextUiApp?: UiApp; } function buildDiff(config: NonNullable, remote: OAuthApp): UploadDiff { @@ -133,9 +140,30 @@ function buildDiff(config: NonNullable, remote: OAuthApp): Upload currentVersion: remote.version, nextVersion: config.version || remote.version || '', migratingLegacyScopes: containsLegacyAllScope(remote.scopes ?? []), + currentUiApp: remote.snapshot, + nextUiApp: config.ui_app, }; } +// Stable serialization for equality checks: key order in app-config.json depends +// on how the file was edited, so a raw JSON.stringify comparison would report +// drift for a semantically identical block. +function canonicalizeUiApp(uiApp: UiApp | undefined): string { + if (!uiApp) return ''; + const sortDeep = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortDeep); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => [k, sortDeep(v)]), + ); + } + return value; + }; + return JSON.stringify(sortDeep(uiApp)); +} + function renderUploadDiff(diff: UploadDiff): void { logInfo(''); logInfo(` ${messages.APP_UPLOAD_SUMMARY}`); @@ -148,7 +176,11 @@ function renderUploadDiff(diff: UploadDiff): void { } else { logInfo(` Distribution: ${diff.nextDistribution}`); } - logAligned(' Redirect URLs: ', diffLines(diff.currentUrls, diff.nextUrls)); + // Only OAuth apps have callbacks — printing an empty "Redirect URLs:" row for a + // UI app would imply something is missing. + if (!diff.nextUiApp) { + logAligned(' Redirect URLs: ', diffLines(diff.currentUrls, diff.nextUrls)); + } if (diff.migratingLegacyScopes) { logInfo(` ${messages.LEGACY_ALL_SCOPE_UPDATE_MIGRATING}`); } @@ -163,9 +195,28 @@ function renderUploadDiff(diff: UploadDiff): void { } else if (diff.nextVersion) { logInfo(` Version: ${diff.nextVersion}`); } + if (diff.nextUiApp) { + renderUiAppDiff(diff.nextUiApp, diff.currentUiApp); + } logInfo(''); } +// Field-by-field so the partner can see exactly what the platform will store — +// this block drives what renders inside Brevo, so a bare "changed" would be +// useless. Values that differ from the server are tagged. +function renderUiAppDiff(next: UiApp, current: UiApp | undefined): void { + const changed = canonicalizeUiApp(next) !== canonicalizeUiApp(current); + logInfo(` ${messages.APP_UPLOAD_UI_APP_SUMMARY}${changed ? ' (changed)' : ''}`); + logInfo(` Extension type: ${next.extensionType}`); + next.surfacePointList.forEach((point, i) => { + logInfo(` ${i === 0 ? 'Extension point:' : ' '} ${point}`); + }); + logInfo(` Heading: ${next.heading ?? ''}`); + if (next.subheading) logInfo(` Subheading: ${next.subheading}`); + logInfo(` Redirect link: ${next.redirectLink ?? ''}`); + logInfo(` Link target: ${next.linkTarget ?? ''}`); +} + function diffToJson(diff: UploadDiff) { return { current: { @@ -175,6 +226,7 @@ function diffToJson(diff: UploadDiff) { logo_uri: diff.currentLogoUri, distribution_type: diff.currentDistribution, version: diff.currentVersion, + ...(diff.currentUiApp ? { ui_app: diff.currentUiApp } : {}), }, next: { name: diff.nextName, @@ -183,6 +235,7 @@ function diffToJson(diff: UploadDiff) { logo_uri: diff.nextLogoUri, distribution_type: diff.nextDistribution, version: diff.nextVersion, + ...(diff.nextUiApp ? { ui_app: diff.nextUiApp } : {}), }, }; } @@ -195,19 +248,32 @@ function hasNoChanges(diff: UploadDiff): boolean { JSON.stringify([...diff.currentScopes].sort()) === JSON.stringify([...diff.nextScopes].sort()) && (diff.currentLogoUri || '') === (diff.nextLogoUri || '') && - (diff.currentVersion || '') === (diff.nextVersion || '') + (diff.currentVersion || '') === (diff.nextVersion || '') && + // Without this, editing only the `ui_app` block reports "Already up to date" + // and the change is never pushed. + canonicalizeUiApp(diff.currentUiApp) === canonicalizeUiApp(diff.nextUiApp) ); } export const uploadCommand = withCommandHandler(async (options: UploadOptions): Promise => { const config = loadUsableConfig(); + const isUiApp = isUiAppConfig(config); + // A UI app has no OAuth callback, so the redirect requirement is OAuth-only. + // Any URLs a UI-app config happens to carry are still format-checked rather + // than silently forwarded. const redirectUrls = config.auth?.redirectUrls ?? []; - if (redirectUrls.length === 0) { - throw new CliError(messages.APP_UPLOAD_NO_REDIRECT_URLS); + if (!isUiApp && redirectUrls.length === 0) { + throw new CliError(messages.APP_UPLOAD_NO_REDIRECT_URLS_OAUTH); } validateRedirectUrls(redirectUrls); + // Local pre-flight so a malformed block fails with a precise message before a + // round-trip. The app-store backend remains the validation authority. + if (isUiApp) { + validateUiApp(config.ui_app); + } + const scopes = config.auth?.scopes ?? []; validateScopes(scopes); if (containsLegacyAllScope(scopes)) { @@ -267,6 +333,9 @@ export const uploadCommand = withCommandHandler(async (options: UploadOptions): scopes, redirect_urls: redirectUrls, }, + // Spread rather than a fixed key so OAuth uploads keep their exact + // historical payload shape — `ui_app` is absent, not `undefined`. + ...(isUiApp && config.ui_app ? { snapshot: config.ui_app } : {}), }); } finally { spinner.stop(); @@ -289,8 +358,16 @@ export const uploadCommand = withCommandHandler(async (options: UploadOptions): version: confirmedVersion, auth: { scopes: response.auth.scopes ?? scopes, - redirectUrls: response.auth.redirect_urls ?? redirectUrls, + // Preserved as-is for UI apps: `redirectUrls` is absent from their config + // by design, and writing back an empty array would add a key the app type + // doesn't use. + ...(isUiApp && !config.auth?.redirectUrls + ? {} + : { redirectUrls: response.auth.redirect_urls ?? redirectUrls }), }, + // Prefer the server's normalized block when it echoes one back, otherwise + // keep what we just sent. + ...(isUiApp ? { ui_app: response.snapshot ?? config.ui_app } : {}), }); if (options.json) { diff --git a/src/commands/definitions.ts b/src/commands/definitions.ts index 5552e05..46e18f6 100644 --- a/src/commands/definitions.ts +++ b/src/commands/definitions.ts @@ -6,6 +6,8 @@ import { loginCommand } from './login'; import { logoutCommand } from './logout'; import { whoamiCommand } from './whoami'; import { createCommand } from './app/create'; +import { deployCommand } from './app/deploy'; +import { removeCommand } from './app/remove'; import { listCommand } from './app/list'; import { credentialsCommand } from './app/credentials'; import { statusCommand } from './app/status'; @@ -63,7 +65,7 @@ export const appCommandGroup: SubcommandGroupDefinition = { }, { name: 'create', - description: 'Create a new OAuth app', + description: 'Create a new app (OAuth integration or UI app)', examples: [ 'brevo app create', 'brevo app create --name "My App" --distribution private', @@ -72,12 +74,16 @@ export const appCommandGroup: SubcommandGroupDefinition = { 'brevo app create --name "My App" --distribution private --redirect-uri http://localhost:3009/auth/callback --redirect-uri https://myapp.com/callback --json', 'brevo app create --name "My App" --distribution private --logo-uri https://example.com/logo.png', ], + // A UI app is authored entirely through the interactive prompts (BEX-290) — + // there is deliberately no `--type` or per-field flag. Every flag below + // applies to an OAuth app, which is what a non-interactive run always + // creates. options: [ { flags: '--name ', description: 'App name' }, { flags: '--distribution ', description: 'Distribution type (private|public)' }, { flags: '--redirect-uri ', - description: 'Redirect URI (repeatable)', + description: 'Redirect URI (repeatable, OAuth apps only)', parser: collectUrls, }, { @@ -162,6 +168,58 @@ export const appCommandGroup: SubcommandGroupDefinition = { json: Boolean(opts.json), }), }, + { + name: 'deploy', + description: 'Make an app available in a Brevo account', + arguments: [{ name: '', description: 'Brevo account (tenant) ID' }], + examples: [ + 'brevo app deploy 99999', + 'brevo app deploy 99999 --app-id 42', + 'brevo app deploy 99999 --force --json', + ], + options: [ + { + flags: '--app-id ', + description: 'App ID (uses app-config.json if omitted)', + parser: (v) => parseAppId(v), + }, + { flags: '--force', description: 'Skip confirmation (for CI)' }, + { flags: '--json', description: 'Output as JSON' }, + ], + handler: (opts, accountId) => + deployCommand({ + accountId: accountId as string | undefined, + appId: opts.appId as string | undefined, + force: Boolean(opts.force), + json: Boolean(opts.json), + }), + }, + { + name: 'remove', + description: 'Remove an app from a Brevo account', + arguments: [{ name: '', description: 'Brevo account (tenant) ID' }], + examples: [ + 'brevo app remove 99999', + 'brevo app remove 99999 --app-id 42', + 'brevo app remove 99999 --force --json', + ], + options: [ + { + flags: '--app-id ', + description: 'App ID (uses app-config.json if omitted)', + parser: (v) => parseAppId(v), + }, + { flags: '--force', description: 'Skip confirmation (for CI)' }, + { flags: '--json', description: 'Output as JSON' }, + ], + handler: (opts, accountId) => + removeCommand({ + accountId: accountId as string | undefined, + appId: opts.appId as string | undefined, + force: Boolean(opts.force), + json: Boolean(opts.json), + }), + }, { name: 'delete', description: 'Delete an app', diff --git a/src/lang/en.ts b/src/lang/en.ts index 9b47da9..6933890 100644 --- a/src/lang/en.ts +++ b/src/lang/en.ts @@ -58,6 +58,10 @@ export const messages = { // App create APP_CREATE_NAME_PROMPT: 'App name:', APP_CREATE_TYPE_PROMPT: 'Distribution type?', + APP_CREATE_APP_TYPE_PROMPT: 'What type of app are you building?', + APP_CREATE_APP_TYPE_OAUTH: + 'OAuth app (Authorize against Brevo and call the API on a user’s behalf)', + APP_CREATE_APP_TYPE_UI: 'UI app (Render inside Brevo — opens your app from a record)', APP_CREATE_SUCCESS: 'App created.', APP_CREATE_NAME_TAKEN: 'That name is already taken. Try a different name.', APP_CREATE_REDIRECT_PROMPT: @@ -94,6 +98,36 @@ export const messages = { `App "${name}" is already linked in this directory (app-config.json found). Move to a different directory to create a new app, or run \`${CLI.APP_SCAFFOLD}\` here to add a feature to this project.`, APP_CREATE_DIR_UNRESOLVED: 'Could not resolve the output directory for scaffolding.', + // App create — UI app (BEX-290) + APP_CREATE_UI_TRIGGER_PROMPT: 'How should your app be delivered?', + APP_CREATE_UI_TRIGGER_LINK: + 'Action link (Adds a menu entry that opens your URL in a new tab)', + // Kept visible but unselectable so the roadmap is discoverable from the prompt + // itself. `iframeExtension` exists on the platform but the CLI can't author it + // yet; widgets have no CLI authoring path at all. + APP_CREATE_UI_TRIGGER_MODAL: 'Iframe modal (Opens your URL in a modal — not yet supported)', + APP_CREATE_UI_TRIGGER_WIDGET: + 'Inline widget (Renders inside the record page — not yet supported)', + APP_CREATE_UI_TRIGGER_UNSUPPORTED: (choice: string) => + `"${choice}" is not available yet. Only an action link can be created today.`, + APP_CREATE_UI_SURFACE_PROMPT: 'Which record pages should it appear on?', + APP_CREATE_UI_SURFACE_REQUIRED: 'Pick at least one record page.', + APP_CREATE_UI_HEADING_PROMPT: 'Heading (primary text shown on the action):', + APP_CREATE_UI_SUBHEADING_PROMPT: 'Subheading (optional secondary text):', + APP_CREATE_UI_REDIRECT_LINK_PROMPT: 'Redirect link (where record context is passed):', + APP_CREATE_UI_LINK_TARGET_PROMPT: 'Where should the link open?', + APP_CREATE_UI_BOX_TITLE: 'UI app created', + // The action-menu entry is labelled with the app name — there is no per-action + // label field on the platform, so say so rather than let a partner hunt for one. + APP_CREATE_UI_BOX_LABEL_NOTE: (appName: string) => + `The menu entry is labelled with the app name ("${appName}") — rename the app to change it.`, + APP_CREATE_UI_BOX_HINT: `Edit the \`ui_app\` block in app-config.json to change any of this, then run \`${CLI.APP_UPLOAD}\`.`, + APP_CREATE_UI_NEXT: (cdDir?: string): string[] => [ + ...(cdDir ? [`1. cd ${cdDir}`] : []), + `${cdDir ? 2 : 1}. ${CLI.APP_UPLOAD} (validate and save your configuration)`, + `${cdDir ? 3 : 2}. ${CLI.APP_DEPLOY()} (make it available in an account)`, + ], + // App list APP_LIST_EMPTY: `No apps found. Create one with: ${CLI.APP_CREATE}`, APP_LIST_HEADER: 'Your OAuth apps:', @@ -121,6 +155,35 @@ export const messages = { APP_UPLOAD_CANCELLED: 'Upload cancelled.', APP_UPLOAD_SUCCESS: 'App uploaded.', APP_UPLOAD_UP_TO_DATE: (version: string) => `Already up to date at version ${version}.`, + // UI apps have no OAuth callback, so the redirect-URL requirement is + // OAuth-only — this message names the app type to make that explicit. + APP_UPLOAD_NO_REDIRECT_URLS_OAUTH: + 'app-config.json has no redirect URLs configured. OAuth apps need at least one — add it to `auth.redirectUrls`.', + APP_UPLOAD_UI_APP_SUMMARY: 'UI app:', + + // App deploy / remove — per-account availability for UI apps (BEX-290) + APP_DEPLOY_SELECT: 'Select an app to deploy:', + APP_DEPLOY_CONFIRM: (name: string, appId: string, accountId: string) => + `Deploy app "${name}" (${appId}) to account ${accountId}?`, + APP_DEPLOY_CANCELLED: 'Deploy cancelled.', + APP_DEPLOY_SUCCESS: (appId: string, accountId: string) => + `App ${appId} deployed to account ${accountId}.`, + APP_DEPLOY_MISSING_ACCOUNT_ID: `Missing account ID.\n\n Usage: ${CLI.APP_DEPLOY()}`, + // The spec's installation flow requires deploy to refuse until the config has + // been validated by an upload. `version` is only ever written by a successful + // upload, so its absence is a reliable local signal. + APP_DEPLOY_NOT_UPLOADED: `Please first validate your configuration with \`${CLI.APP_UPLOAD}\`.`, + APP_REMOVE_SELECT: 'Select an app to remove:', + APP_REMOVE_CONFIRM: (name: string, appId: string, accountId: string) => + `Remove app "${name}" (${appId}) from account ${accountId}?`, + APP_REMOVE_CANCELLED: 'Remove cancelled.', + APP_REMOVE_SUCCESS: (appId: string, accountId: string) => + `App ${appId} removed from account ${accountId}.`, + APP_REMOVE_MISSING_ACCOUNT_ID: `Missing account ID.\n\n Usage: ${CLI.APP_REMOVE()}`, + APP_REMOVE_NOT_DEPLOYED: (appId: string, accountId: string) => + `App ${appId} is not deployed to account ${accountId}.`, + APP_DEPLOY_NON_INTERACTIVE: + 'Cannot prompt for confirmation in non-interactive mode. Use --force or --json to skip.', // App submit (BEX-221) APP_SUBMIT_CHECKING_STATUS: 'Checking app status...', @@ -225,6 +288,7 @@ export const messages = { APP_SCAFFOLD_JSON_DIFF_CANCELLED: 'app-config.json differs from the server and --json cannot prompt for confirmation. Re-run without --json to review and confirm the update.', APP_SCAFFOLD_SUCCESS: (count: number) => `Feature scaffolded (${count} files)`, + APP_SCAFFOLD_NO_FEATURES_FOR_UI_APP: `This is a UI app — there are no features to scaffold (an action link has no local server to run). Edit the \`ui_app\` block in app-config.json, then run \`${CLI.APP_UPLOAD}\` and \`${CLI.APP_DEPLOY()}\`.`, APP_SCAFFOLD_TARGET_IS_CWD: 'Scaffolding into the current directory.', APP_SCAFFOLD_CREATING_DIR: (dir: string) => `Creating ${dir} and moving into it...`, APP_SCAFFOLD_NEXT_STEPS_TITLE: 'Next steps', @@ -285,7 +349,7 @@ export const messages = { INIT_WELCOME: 'Brevo CLI — Quick Setup', INIT_ALREADY_LOGGED_IN: 'Already authenticated.', INIT_STEP_LOGIN: ' Step 1: Authenticate with your Brevo account', - INIT_STEP_CREATE: ' Step 2: Create your first OAuth app', + INIT_STEP_CREATE: ' Step 2: Create your first app', INIT_APPS_EXIST: (count: number) => `You have ${count} app${count === 1 ? '' : 's'} already.`, INIT_APP_LINKED: (name: string) => `App "${name}" is linked to this project (app-config.json).`, INIT_APP_ACTION: 'What would you like to do?', diff --git a/src/lib/config.ts b/src/lib/config.ts index 12a32ee..439e64b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { splitScopes } from './validators'; +import { UiApp } from '../types'; // ──────────────── Directory ──────────────── @@ -411,8 +412,17 @@ export interface ProjectConfig { distribution_type: 'private' | 'public'; auth: { scopes: string[]; + // Absent for UI apps: an action link has no OAuth callback to register. + // OAuth apps still require at least one (enforced in `app upload`). redirectUrls?: string[]; }; + /** + * Present only for UI apps (BEX-290). Its presence is the discriminator + * between the two app types — there is no separate `appType` key, matching + * the UIApp Support Spec's config examples. Use {@link isUiAppConfig} + * rather than testing for the key directly. + */ + ui_app?: UiApp; permittedUrls: { fetch: string[]; img: string[]; @@ -497,6 +507,16 @@ export function readProjectConfig(): ProjectConfig | null { // old projects to the new shape on their next write, instead of // round-tripping the stray key forever. const { distribution: _legacyDistribution, ...rawWithoutLegacyDistribution } = rawRecord; + // `ui_app` (BEX-290) is passed through structurally intact — the spread + // above already carries it — but a non-object value is dropped so callers + // can trust `config.ui_app` is an object whenever it is present. Field-level + // validation is deliberately *not* done here: unrelated commands that merely + // read the config must not fail because the block is half-written. `app + // upload` is the enforcement point (see validateUiApp). + const rawUiApp = rawWithoutLegacyDistribution.ui_app; + if ('ui_app' in rawWithoutLegacyDistribution && (!rawUiApp || typeof rawUiApp !== 'object')) { + delete rawWithoutLegacyDistribution.ui_app; + } return { ...rawWithoutLegacyDistribution, appId, @@ -513,6 +533,18 @@ export function hasLocalApp(): boolean { return cfg?.appId != null && cfg.appId !== ''; } +/** + * Whether a project config describes a UI app rather than an OAuth app. + * + * The presence of the `ui_app` block is the discriminator — there is no separate + * `appType` key. Every branch that needs to distinguish the two app types goes + * through this helper so the discriminator can change in one place if the + * backend later requires an explicit type field. + */ +export function isUiAppConfig(config: Pick | null | undefined): boolean { + return !!config?.ui_app; +} + export function writeProjectConfig(config: ProjectConfig): void { const configPath = path.resolve(process.cwd(), PROJECT_CONFIG_FILE); fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5a72890..70fdb1a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -89,6 +89,16 @@ export const ENDPOINTS = { APP_STORE_APP_UPLOAD: (appId: string) => `/v3/app-store/apps/${encodeURIComponent(appId)}/upload`, APP_STORE_APP_WITHDRAW: (appId: string) => `/v3/app-store/apps/${encodeURIComponent(appId)}/withdraw`, + // Per-account availability for UI apps (BEX-290). Until an in-product + // enable/disable surface ships, these two endpoints *are* the install + // mechanism for an action link. + // + // ⚠️ ASSUMED CONTRACT — pending confirmation from the app-store backend team: + // paths below, and `account_id` carried in the request body rather than as a + // path segment. If the real contract differs, this and appService.deployApp / + // removeApp are the only two places to change. + APP_STORE_APP_DEPLOY: (appId: string) => `/v3/app-store/apps/${encodeURIComponent(appId)}/deploy`, + APP_STORE_APP_REMOVE: (appId: string) => `/v3/app-store/apps/${encodeURIComponent(appId)}/remove`, OAUTH_AUTHORIZE: '/oauth/authorize', OAUTH_TOKEN: '/oauth/token', } as const; @@ -108,6 +118,10 @@ export const CLI = { ? `brevo app credentials --reveal-secret --app-id ${appId}` : 'brevo app credentials --reveal-secret', APP_UPLOAD: 'brevo app upload', + APP_DEPLOY: (accountId?: string) => + accountId ? `brevo app deploy ${accountId}` : 'brevo app deploy ', + APP_REMOVE: (accountId?: string) => + accountId ? `brevo app remove ${accountId}` : 'brevo app remove ', APP_DELETE: 'brevo app delete', APP_WITHDRAW: (appId?: string) => appId ? `brevo app withdraw --app-id ${appId}` : 'brevo app withdraw --app-id ', @@ -140,6 +154,102 @@ export const DEFAULT_SCOPES: readonly string[] = [ 'crm:write', ] as const; +// ──────────────── UI apps (BEX-290) ──────────────── +// An action link reads record context rather than driving an OAuth flow, so it +// starts from a narrower scope set than DEFAULT_SCOPES. Widen via `auth.scopes` +// in app-config.json + `app upload`. +export const DEFAULT_UI_APP_SCOPES: readonly string[] = [ + 'contacts:read', + 'contacts:write', +] as const; + +/** + * Extension-point slot grammar: `..` (BEX-350). + * + * These values are a hard contract with two consumers. The extensibility UI kit + * matches an item's `extensionPoint` against a slot name by **exact string + * equality**, and the platform **drops** an authored name that has no entry in + * its extension-point registry. Both failures are silent — the slot renders + * nothing, with a 200 and no error — so a typo here is invisible in production. + * That is why the CLI validates authored names locally against the registry + * below rather than trusting the server to complain. + * + * Mirrored from the platform's registry and its UI kit's slot helpers + * (BEX-350). Keep in lockstep: see RELEASE-CHECKLIST.md. + */ +export const EXTENSION_LOCATIONS: readonly string[] = [ + 'contactDetails', + 'companyDetails', + 'dealDetails', +] as const; + +// `place` is a ROLE within the page, not a layout coordinate — columns stack on +// mobile, so encoding left/center/right would invalidate every registration on a +// redesign. The owning tab is folded in as a prefix. +export const EXTENSION_WIDGET_PLACES: readonly string[] = [ + 'overviewAttributes', + 'overviewMain', + 'overviewSidebar', +] as const; + +// The only action place in the current registry: the record page's header "More" +// (•••) overflow menu. An action link mounts here. +export const EXTENSION_ACTION_PLACE = 'headerMenu'; + +/** Build an action slot name for a record page. */ +export function actionPointForLocation(location: string): string { + return `${location}.${EXTENSION_ACTION_PLACE}.action`; +} + +/** + * The full twelve-point registry — three record pages x (three widget places + + * one action place). Mirrors the platform's seeded extension-point registry. + */ +export const EXTENSION_POINTS: readonly string[] = EXTENSION_LOCATIONS.flatMap((location) => [ + ...EXTENSION_WIDGET_PLACES.map((place) => `${location}.${place}.widget`), + actionPointForLocation(location), +]); + +/** Action slots only — the subset an action link can target. */ +export const EXTENSION_ACTION_POINTS: readonly string[] = + EXTENSION_LOCATIONS.map(actionPointForLocation); + +/** + * Friendly record-type choices (the values offered at the UI-app record-page + * prompt) mapped to their `location` segment. Partners think in record types; the + * wire wants the page name. + */ +export const UI_APP_SURFACE_TO_LOCATION: Readonly> = { + contact: 'contactDetails', + company: 'companyDetails', + deal: 'dealDetails', +} as const; + +export const UI_APP_SURFACES: readonly string[] = Object.keys(UI_APP_SURFACE_TO_LOCATION); +export const DEFAULT_UI_APP_SURFACE = 'contact'; + +/** + * `extensionType` values, camelCase per BEX-350 — the same casing as the + * extension-point grammar. Source of truth is `FEATURE_TYPES` in the + * extensibility UI kit (integrations-common-frontend + * `shared/constants/global.ts`); the kit routes on an exact match against these + * strings. + * + * The pre-BEX-350 snake_case spellings (`action_link`, `iframe_extension`, + * `legacy_component`) are deliberately NOT accepted. The kit still maps them for + * backward compatibility, but that map is slated for removal once every producer + * emits camelCase — and the CLI is a producer, so it only ever writes canonical + * values. UI apps aren't live yet, so there is no authored config to migrate. + */ +export const EXTENSION_TYPE_ACTION_LINK = 'actionLink'; +export const EXTENSION_TYPE_IFRAME = 'iframeExtension'; +export const EXTENSION_TYPE_LEGACY = 'legacyComponent'; + +export const LINK_TARGETS: readonly string[] = ['_blank', '_self'] as const; +// The CLI always writes linkTarget explicitly, so the authored file is complete +// and never depends on a server- or client-side default being applied. +export const DEFAULT_LINK_TARGET = '_blank'; + export const BREVO_DASHBOARD_API_KEYS_URL = 'https://app.brevo.com/settings/keys/api'; export const BREVO_API_KEY_DOCS_URL = 'https://developers.brevo.com/docs/api-key-authentication'; export const BREVO_STATUS_URL = 'https://status.brevo.com'; diff --git a/src/lib/validators.ts b/src/lib/validators.ts index ee865b2..e72378b 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -1,5 +1,12 @@ import { CliError } from './errors'; -import { LEGACY_ALL_SCOPE } from './constants'; +import { + LEGACY_ALL_SCOPE, + EXTENSION_POINTS, + EXTENSION_ACTION_POINTS, + EXTENSION_TYPE_ACTION_LINK, + EXTENSION_TYPE_IFRAME, + LINK_TARGETS, +} from './constants'; const APP_NAME_MAX_LENGTH = 48; const APP_NAME_REGEX = /^[a-zA-Z0-9 ._\-\u00C0-\u024F]+$/; @@ -140,6 +147,157 @@ export function containsLegacyAllScope(scopes: string[] | undefined): boolean { return scopes?.includes(LEGACY_ALL_SCOPE) ?? false; } +// ──────────────── UI apps (BEX-290) ──────────────── + +/** + * Whether an HTTP(S) URL is safe as a UI-app destination. + * + * The extensibility UI kit drops any non-http(s) `redirectLink` outright + * (`isHttpUrl` in its shared utils), so anything else would be authored and then + * silently ignored. On top of that, Brevo opens this URL from an authenticated + * CRM page, so plain http would downgrade the session — https is required, with + * the loopback exemption the rest of the CLI grants so partners can point at a + * local dev server while building. + */ +function isSafeUiAppUrl(parsed: URL): boolean { + if (parsed.protocol === 'https:') return true; + return ( + parsed.protocol === 'http:' && + (parsed.hostname === 'localhost' || + parsed.hostname === '127.0.0.1' || + parsed.hostname === '::1') + ); +} + +/** + * Validate a UI-app destination URL. Returns `true` or an error string, so it can + * back an inquirer `validate` directly as well as the upload-time check. + */ +export function validateUiAppUrl(value: string): true | string { + const trimmed = value.trim(); + if (!trimmed) return 'URL cannot be empty.'; + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return `Invalid URL: "${trimmed}" is not a valid URL.`; + } + if (!isSafeUiAppUrl(parsed)) { + return `Invalid URL: "${trimmed}" must use https:// (http:// is allowed only for localhost).`; + } + return true; +} + +/** Validate a UI-app heading. Returns `true` or an error string. */ +export function validateUiAppHeading(value: string): true | string { + return value.trim() ? true : 'Heading cannot be empty.'; +} + +/** + * Validate a `surfacePointList` entry against the known registry. + * + * This is the highest-value local check in the UI-app flow. The backend silently + * DROPS an authored value with no registry entry, and the UI kit matches by exact + * string equality — so a near-miss like `contact.headerMenu.action` or + * `contactDetails.headerMenu.widget` produces an empty slot, a 200, and no error + * anywhere. Catching it here is the only place a partner gets told. + */ +export function validateSurfacePoint(point: string): true | string { + const trimmed = String(point ?? '').trim(); + if (!trimmed) return 'Extension point cannot be empty.'; + if (EXTENSION_POINTS.includes(trimmed)) return true; + return `Unknown extension point "${trimmed}". Must be one of: ${EXTENSION_POINTS.join(', ')}.`; +} + +/** + * Fully validate a `ui_app` block before it is sent to the server. + * + * The block is the app snapshot the platform stores, verbatim. + * Every field below is optional on the wire — the backend degrades a malformed or + * absent snapshot to "not yet migrated" rather than erroring — which means the + * server will NOT tell a partner their action link is unrenderable. This + * pre-flight is therefore the only enforcement point, so it is deliberately + * stricter than the wire: it requires the fields an action link actually needs to + * render, and rejects combinations the consumers silently discard. + * + * Throws CliError on the first problem found. + */ +export function validateUiApp(uiApp: unknown): void { + if (!uiApp || typeof uiApp !== 'object') { + throw new CliError( + 'app-config.json has an invalid "ui_app" block — expected an object. Fix the file, or recreate the app with `brevo app create` and choose "UI app".', + ); + } + const block = uiApp as Record; + + // Only action links are authorable. `legacyComponent` is the pre-extensibility + // interpreter path (never CLI-authored) and `iframeExtension` needs the modal + // surface that isn't built yet. The pre-BEX-350 snake_case spellings fail here + // too, by design — the CLI only ever writes canonical camelCase. + if (block.extensionType !== EXTENSION_TYPE_ACTION_LINK) { + throw new CliError( + `Unsupported ui_app.extensionType "${String(block.extensionType)}". Only "${EXTENSION_TYPE_ACTION_LINK}" can be uploaded today.`, + ); + } + + const points = block.surfacePointList; + if (!Array.isArray(points) || points.length === 0) { + throw new CliError( + 'ui_app.surfacePointList must list at least one extension point (e.g. ["contactDetails.headerMenu.action"]). An empty list makes the platform fall back to default widget slots, which an action link cannot render in.', + ); + } + for (const point of points) { + const check = validateSurfacePoint(String(point)); + if (check !== true) throw new CliError(`ui_app.surfacePointList: ${check}`); + // An action link yields a menu descriptor, so it can only occupy an action + // slot; targeting a `.widget` slot registers it somewhere it never renders. + if (!EXTENSION_ACTION_POINTS.includes(String(point).trim())) { + throw new CliError( + `ui_app.surfacePointList: "${String(point)}" is a widget slot, but an ${EXTENSION_TYPE_ACTION_LINK} renders as a menu action. Use one of: ${EXTENSION_ACTION_POINTS.join(', ')}.`, + ); + } + } + if (new Set(points.map((p) => String(p).trim())).size !== points.length) { + throw new CliError('ui_app.surfacePointList contains duplicate extension points.'); + } + + const headingCheck = validateUiAppHeading(String(block.heading ?? '')); + if (headingCheck !== true) throw new CliError(`ui_app.heading: ${headingCheck}`); + + const urlCheck = validateUiAppUrl(String(block.redirectLink ?? '')); + if (urlCheck !== true) throw new CliError(`ui_app.redirectLink: ${urlCheck}`); + + if (block.linkTarget !== undefined && !LINK_TARGETS.includes(String(block.linkTarget))) { + throw new CliError( + `Invalid ui_app.linkTarget "${String(block.linkTarget)}". Must be one of: ${LINK_TARGETS.join(', ')}.`, + ); + } + + // The UI kit keeps `modalIframeUrl` only for an `iframeExtension` item, so one + // carried by an actionLink is dropped without a word. Reject rather than let a + // partner ship a URL that will never open. + if (block.modalIframeUrl !== undefined && String(block.modalIframeUrl).trim()) { + throw new CliError( + `ui_app.modalIframeUrl is only used by "${EXTENSION_TYPE_IFRAME}" extensions and is ignored for "${EXTENSION_TYPE_ACTION_LINK}". Remove it, or use redirectLink instead.`, + ); + } +} + +/** + * Parse and validate an `` argument for `app deploy` / `app remove`. + * Brevo account IDs are numeric; accept a trimmed digit string. + */ +export function parseAccountId(value: string): string { + const trimmed = String(value ?? '').trim(); + if (!trimmed) { + throw new CliError('Invalid account ID: value cannot be empty.'); + } + if (!/^\d+$/.test(trimmed)) { + throw new CliError(`Invalid account ID: "${trimmed}" is not a numeric Brevo account ID.`); + } + return trimmed; +} + /** * Validate that a value is a positive integer. * Throws CliError if the value is not a valid positive integer. diff --git a/src/services/app.ts b/src/services/app.ts index 68d328b..1c27130 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -187,6 +187,36 @@ export function createAppService(client: ApiClient) { } }, + /** + * Make a UI app available in a single Brevo account (BEX-290). + * + * ⚠️ ASSUMED CONTRACT — `account_id` in the body, path from + * ENDPOINTS.APP_STORE_APP_DEPLOY. Pending confirmation from the app-store + * backend team; this and `removeApp` are the only places to change. + * + * 404 becomes a friendly CliError; everything else (notably the "not yet + * uploaded" rejection) propagates for the command to map. + */ + async deployApp(appId: string, accountId: string): Promise { + try { + await client.post(ENDPOINTS.APP_STORE_APP_DEPLOY(appId), { account_id: accountId }); + } catch (err) { + rethrowNotFound(err, appId); + } + }, + + /** + * Withdraw a UI app's availability from a single account. Counterpart to + * {@link deployApp}; same assumed contract caveat. + */ + async removeApp(appId: string, accountId: string): Promise { + try { + await client.post(ENDPOINTS.APP_STORE_APP_REMOVE(appId), { account_id: accountId }); + } catch (err) { + rethrowNotFound(err, appId); + } + }, + async withdrawApp(appId: string): Promise { try { await client.post(ENDPOINTS.APP_STORE_APP_WITHDRAW(appId)); diff --git a/src/templates/files/app-config.json.tmpl b/src/templates/files/app-config.json.tmpl index 763f125..c09568a 100644 --- a/src/templates/files/app-config.json.tmpl +++ b/src/templates/files/app-config.json.tmpl @@ -6,9 +6,17 @@ "cliVersion": "{{CLI_VERSION}}", "distribution_type": "{{DISTRIBUTION}}", "auth": { +{{#if oauth}} "scopes": {{SCOPES_JSON}}, "redirectUrls": {{REDIRECT_URLS_JSON}} +{{/if}} +{{#if ui_app}} + "scopes": {{SCOPES_JSON}} +{{/if}} }, +{{#if ui_app}} + "ui_app": {{UI_APP_JSON}}, +{{/if}} "permittedUrls": { "fetch": [], "img": [], diff --git a/src/templates/index.ts b/src/templates/index.ts index 2f4f2fe..2841ef8 100644 --- a/src/templates/index.ts +++ b/src/templates/index.ts @@ -25,32 +25,51 @@ export function applyVars(template: string, vars: Record): strin export type Distribution = 'public' | 'private'; -const IF_OPEN_RE = /^\s*\{\{#if (public|private)\}\}\s*$/; +/** + * Conditional flags a template can branch on. + * + * - `public` / `private` — the app's distribution type (PKCE vs confidential + * OAuth flow). + * - `oauth` / `ui_app` — the app *type* (BEX-290). Exactly one is always set, + * so a template can carry OAuth-only and UI-app-only sections side by side. + */ +export type TemplateFlag = 'public' | 'private' | 'oauth' | 'ui_app'; + +const IF_OPEN_RE = /^\s*\{\{#if (public|private|oauth|ui_app)\}\}\s*$/; const IF_CLOSE_RE = /^\s*\{\{\/if\}\}\s*$/; /** - * Strip distribution-conditional blocks from a template. + * Strip conditional blocks from a template. * * Blocks are delimited by whole-line markers: * * {{#if public}} * ...lines emitted only for public apps... * {{/if}} - * {{#if private}} - * ...lines emitted only for private apps... + * {{#if ui_app}} + * ...lines emitted only for UI apps... * {{/if}} * - * A block's body is kept only when its condition matches `distribution`; - * otherwise the whole block is dropped. Blocks may nest. The marker lines are - * always removed in full (including their line break), so a template whose only - * markers wrap the *matching* branch renders byte-for-byte identically to one - * written without any markers — this is what keeps private-app scaffolds - * unchanged from before PKCE branching was introduced. + * A block's body is kept only when its flag is in `flags`; otherwise the whole + * block is dropped. Blocks may nest. The marker lines are always removed in full + * (including their line break), so a template whose only markers wrap the + * *matching* branch renders byte-for-byte identically to one written without any + * markers — this is what keeps private-app scaffolds unchanged from before PKCE + * branching was introduced, and what keeps OAuth scaffolds unchanged from before + * UI-app branching was introduced. + * + * Accepts a bare `Distribution` for backwards compatibility with existing + * callers and tests that predate the app-type flags. * * `{{DISTRIBUTION}}` and other `{{VAR}}` placeholders are left untouched here; * they are resolved separately by {@link applyVars}. */ -export function applyConditionals(template: string, distribution: Distribution): string { +export function applyConditionals( + template: string, + flags: Distribution | ReadonlySet, +): string { + const activeFlags: ReadonlySet = + typeof flags === 'string' ? new Set([flags]) : flags; const lines = template.split('\n'); const out: string[] = []; // Stack of block states; `keep` is false once any enclosing block excludes us. @@ -60,7 +79,7 @@ export function applyConditionals(template: string, distribution: Distribution): for (const line of lines) { const open = IF_OPEN_RE.exec(line); if (open) { - stack.push(active() && open[1] === distribution); + stack.push(active() && activeFlags.has(open[1] as TemplateFlag)); continue; } if (IF_CLOSE_RE.test(line)) { @@ -127,18 +146,32 @@ export const FEATURE_TEMPLATE_MANIFESTS: Record<'oauth', TemplateFile[]> = { export type FeatureType = keyof typeof FEATURE_TEMPLATE_MANIFESTS; +/** + * Derive the conditional flag set from the template vars. + * + * Distribution: public apps get a PKCE (RFC 7636) OAuth flow with no client + * secret, private apps keep the confidential-client flow. `{{DISTRIBUTION}}` is + * always set by buildTemplateVars; default to the (unchanged) private flow if + * it's absent. + * + * App type: `{{UI_APP_JSON}}` is non-empty only for UI apps, so its presence is + * the discriminator — mirroring how `ui_app` in app-config.json discriminates + * at runtime (see `isUiAppConfig`). + */ +function resolveTemplateFlags(vars: Record): Set { + const distribution: Distribution = vars['{{DISTRIBUTION}}'] === 'public' ? 'public' : 'private'; + const isUiApp = !!vars['{{UI_APP_JSON}}']; + return new Set([distribution, isUiApp ? 'ui_app' : 'oauth']); +} + function loadManifest( manifest: TemplateFile[], vars: Record, ): Array<{ name: string; content: string }> { - // The scaffold branches on the app's distribution type: public apps get a - // PKCE (RFC 7636) OAuth flow with no client secret, private apps keep the - // confidential-client flow. `{{DISTRIBUTION}}` is always set by - // buildTemplateVars; default to the (unchanged) private flow if it's absent. - const distribution: Distribution = vars['{{DISTRIBUTION}}'] === 'public' ? 'public' : 'private'; + const flags = resolveTemplateFlags(vars); return manifest.map((entry) => ({ name: entry.outputPath, - content: applyVars(applyConditionals(loadTemplate(entry.templatePath), distribution), vars), + content: applyVars(applyConditionals(loadTemplate(entry.templatePath), flags), vars), })); } diff --git a/src/types.ts b/src/types.ts index da0c719..b4b2231 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,106 @@ export interface AccountResponse { user_id: number; } +// ──────────────── UI apps (BEX-290) ──────────────── +// A UI app is a *type* of app, not a separate entity: it shares the app record, +// credentials, and version lifecycle with OAuth apps, and adds a `ui_app` block +// describing where and how it renders inside Brevo. +// +// This block IS the app snapshot the platform stores, field for field: the +// manifest endpoint parses exactly this shape and projects a subset of it into +// each manifest item's `app_configs`, which the extensibility UI kit renders. +// Verified against both of those consumers (BEX-308 / BEX-350). +// +// Deliberately NOT the UIApp Support Spec's `properties`/`trigger` shape: nothing +// on either side of the platform reads those names. Keeping the CLI's authored +// file 1:1 with the consumed shape means there is no mapping layer to drift. +// +// Two fields the spec described have no counterpart in the implementation and are +// therefore not authorable: +// - a per-action label — the menu entry is labelled with the *app name*, so +// there is nothing to author. +// - `contextProperties` — the record context an action receives is an allow-list +// on the extension-point registry entry, i.e. a property of the slot, chosen +// by the platform, not declared by the partner. + +/** + * The delivery path an extension renders through. camelCase per BEX-350, matching + * the extension-point grammar; the pre-BEX-350 snake_case spellings are not + * accepted (see the note on the constants in `lib/constants.ts`). + * + * - `actionLink` — a redirect-only CTA driven by `redirectLink`. The only type + * the CLI authors today. + * - `iframeExtension` — opens `modalIframeUrl` in a modal iframe. The UI kit + * keeps `modalIframeUrl` *only* for this type, so authoring one on any other + * type is silently dropped. + * - `legacyComponent` — the pre-extensibility interpreter path used by earlier + * integrations. Never CLI-authored; listed so a hand-edited config round-trips. + */ +export type ExtensionType = 'actionLink' | 'iframeExtension' | 'legacyComponent'; + +/** + * Where an `actionLink` redirect opens. The UI kit falls back to `_blank` for an + * absent or unrecognised value, but the CLI always writes one explicitly. + */ +export type LinkTarget = '_blank' | '_self'; + +/** + * A CRM record page an extension can mount on — the `location` segment of a slot + * name, and the same value a host fetches its manifest with. + */ +export type ExtensionLocation = 'contactDetails' | 'companyDetails' | 'dealDetails'; + +/** + * The `place` segment of a slot name: the slot's ROLE within a location, not a + * layout coordinate (columns stack on mobile, so a redesign that moved one would + * otherwise invalidate every registration). + */ +export type ExtensionPlace = + | 'overviewAttributes' + | 'overviewMain' + | 'overviewSidebar' + | 'headerMenu'; + +/** The `kind` segment: `widget` mounts a widget, `action` yields a menu entry. */ +export type ExtensionKind = 'widget' | 'action'; + +/** + * A slot in the BEX-350 grammar `..` — e.g. + * `contactDetails.headerMenu.action`. Named for the field it types + * (`surfacePointList`); the platform calls the same value `extensionPoint` when + * it serves it back on the manifest. + * + * Casing and spelling are part of the contract: the UI kit matches + * `extensionPoint` by exact string equality against the platform's + * extension-point registry, and the backend *drops* an authored value with no + * registry entry. Both failures are silent — an empty slot, no error, still a + * 200 — which is why the CLI validates locally against the known registry. + */ +export type SurfacePoint = string; + +export interface UiApp { + extensionType: ExtensionType; + /** + * The slots this app mounts on. An app may target several (e.g. the same action + * link on contact, company and deal pages). An empty/absent list makes the + * backend fall back to a default widget slot list, which is not what an + * action-link author wants — so the CLI always writes at least one. + */ + surfacePointList: SurfacePoint[]; + /** Primary CTA text rendered by the kit. */ + heading?: string; + /** Secondary CTA text rendered beneath the heading. */ + subheading?: string; + /** Destination for an `actionLink`. Non-http(s) values are dropped by the kit. */ + redirectLink?: string; + /** `actionLink` only. Written explicitly rather than relying on any default. */ + linkTarget?: LinkTarget; + /** `iframeExtension` only — dropped by the kit for any other type. Not authorable yet. */ + modalIframeUrl?: string; + /** Snapshot version, surfaced at the manifest item root. Server-managed. */ + version?: string; +} + export interface OAuthApp { app_id: string; name: string; @@ -19,6 +119,9 @@ export interface OAuthApp { version?: string; // Review-submission form for public apps (BEX-221); absent for private apps. google_form_link?: string; + // Present only for UI apps, and only once the server echoes the snapshot back + // on reads. Absent for OAuth apps and on server builds that don't return it. + snapshot?: UiApp; created_at: string; updated_at: string; } @@ -72,6 +175,15 @@ export interface UploadAppPayload { scopes: string[]; redirect_urls: string[]; }; + // Sent only for UI apps (BEX-290). OAuth apps must never carry this key — + // earlier CLI versions guaranteed it was never sent at all, and the OAuth + // payload shape is unchanged from that contract. + // + // Named `snapshot` after the app snapshot the manifest read path parses. + // ⚠️ The write path is the one part of this contract that does not exist on the + // platform yet, so this key is an assumption — the shape is confirmed against + // its consumers, only the transport is not. Tracked in RELEASE-CHECKLIST.md. + snapshot?: UiApp; } export interface UploadAppResponse { @@ -89,4 +201,9 @@ export interface UploadAppResponse { scopes?: string[]; redirect_urls?: string[]; }; + // Echoed back for UI apps so the local config can be reconciled with whatever + // the server normalized (notably `linkTarget`, which it defaults to `_blank`). + // Tolerated as absent: server builds that accept the snapshot on write but + // don't return it leave the locally-sent block in place. + snapshot?: UiApp; }