Skip to content

fix: harmonize app-create prompt copy (distribution & UI-app fields) - #73

Merged
piyushsarin-sib merged 1 commit into
getbrevo:mainfrom
mourraille:fix/ui-app-create-prompt-copy
Aug 25, 2026
Merged

fix: harmonize app-create prompt copy (distribution & UI-app fields)#73
piyushsarin-sib merged 1 commit into
getbrevo:mainfrom
mourraille:fix/ui-app-create-prompt-copy

Conversation

@mourraille

Copy link
Copy Markdown
Contributor

Summary

  • Harmonizes brevo app create's prompt copy: the distribution-type question was a terse noun phrase with a ? stapled on (Distribution type?) while every other choice prompt in the same flow is phrased as a full question — now reads What distribution type should this app use?.
  • Drops 12 hand-typed padding spaces on the UI-app integration-type choice (Link (Opens...)Link (Opens...)) that existed only to align against the Iframe choice removed on 2026-08-19 — dead weight now that Link is the only entry.
  • Tightens the Label / More info / Redirect link prompts in the UI-app ("action link") flow from dense three-clause sentences into shorter copy, while keeping each prompt self-contained. An earlier draft moved the "renders as" explanation to a separate Tip: line above the prompt (mirroring APP_CREATE_REDIRECT_HINT); reverted, because that hint is genuinely supplementary for the OAuth callback flow (a pre-filled default), but here it carried the question's actual meaning — a partner who scrolled past the tip was left answering a bare Label: with nothing to go on.
  • Redirect-link prompt no longer repeats its own query-param example inline, since the created-app box already prints a live one (renderExampleContextUrlLines).
  • Extends the existing 80-column prompt-width test to cover all three UI-app field prompts — APP_CREATE_UI_REDIRECT_LINK_PROMPT had drifted to ~100 characters with nothing catching it, which is exactly the regression that test exists to prevent.

Copy-only change; no command behavior, flags, or exit codes affected.

Test Plan

  • yarn build (tsc + esbuild) — clean
  • yarn test — 1364/1364 passing
  • yarn lint — clean
  • Manual: yarn link:dev && brevo login && brevo app create, pick UI app, walk the flow to eyeball the new copy (not run in this session — no live Brevo account available here)

🤖 Generated with Claude Code

- APP_CREATE_TYPE_PROMPT: 'Distribution type?' -> 'What distribution type
  should this app use?', matching the full-question convention every other
  list/choice prompt in the flow already uses (APP_CREATE_APP_TYPE_PROMPT,
  APP_CREATE_UI_INTEGRATION_PROMPT, etc).
- APP_CREATE_UI_INTEGRATION_EXTERNAL_LINK: drop 12 hand-typed padding spaces
  that existed only to align against the removed Iframe choice (2026-08-19).
  Dead weight now that Link is the only entry.
- Label / More info / Redirect link prompts: tightened from three dense
  compound sentences into shorter inline copy, still self-contained (an
  earlier draft moved the "renders as" explanation to a separate `Tip:`
  line above the prompt; reverted -- that line is genuinely supplementary
  for APP_CREATE_REDIRECT_HINT's use case, but essential here, so hiding it
  above a bare `Label:` left the question meaningless once scrolled past).
  Redirect-link prompt no longer repeats its own query-param example since
  the created-app box already prints a live one.
- Extended the 80-column prompt-width test to cover all three UI-app field
  prompts -- APP_CREATE_UI_REDIRECT_LINK_PROMPT had drifted to ~100 chars
  with nothing catching it, which is exactly what this test exists to
  prevent.

No behavior change; copy only. Verified: yarn build, yarn test (1364/1364),
yarn lint all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@mourraille
mourraille force-pushed the fix/ui-app-create-prompt-copy branch from 5b7802c to b8afc78 Compare August 25, 2026 11:48
@sonarqubecloud

Copy link
Copy Markdown

@piyushsarin-sib piyushsarin-sib left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@piyushsarin-sib
piyushsarin-sib merged commit 890b19e into getbrevo:main Aug 25, 2026
4 checks passed
piyushsarin-sib added a commit that referenced this pull request Aug 26, 2026
…opy (#77)

* fix(smoke): repair stale UI-app prompt patterns and pin them to the copy

The `ui` suite failed on main at step 24 with "pty run timed out waiting for
prompt 6/9" after 122s. The pty was fine — it cleared five prompts, list
navigation and two live registry reads. It was waiting for prompt copy that
no longer exists.

890b19e (#73) reworded three UI-app create prompts and did not touch this
suite. All three expect patterns were stale; the run died at the first:

  waiting for  /the menu entry text, and the button text on a card/
  CLI prints   Label — the menu entry’s text, and the card’s button text:

The new patterns anchor on short, punctuation-free openings. The old ones
spanned a curly apostrophe and an em dash, and were long enough to wrap in a
fixed-width pty transcript — two independent ways to break on a reword.

The patterns become a named export pinned by a test against `messages`. The
smoke must duplicate this copy: it drives the real binary, and under
`--against=published` that binary legitimately lags this repo, so no
assertion inside the suite can catch a reword. Verified in both directions —
restoring the old pattern fails the test.

Also fixes a skip message that named the wrong layer. With `suite=all` the
runner builds PREVIEW=1, so the public commands ARE in the binary and the API
is what refuses them — yet every dependent step reported
"public-distribution not available in this build". The runtime reason is now
recorded and quoted, because "not in this binary" and "the server said no"
send a reader to different repos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(smoke): add clarifying comment on placement prompt fixture

Addresses PR #77 review comment — clarifies that 'contact' is a
representative, non-load-bearing page name in the smoke pattern test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
shubham773 added a commit that referenced this pull request Aug 31, 2026
* feat: per-entry size [BEX-416], CTA fields [BEX-426], extension-type-filtered registry reads [BEX-422]; rename deploy/rollback to install/uninstall; warn on app delete (#56)

* feat: accept per-entry card size in ui_app.surface_point_list [BEX-416]

Each entry may author size {width, height} (px, both required, positive
integers) at the same level as context. Shape-only validation, like the
rest of validateUiApp — bounds and registry concerns stay the platform's.
Absent size keeps today's behaviour: the host page's per-slot default
applies. Partner-authored, so it round-trips through the upload diff and
write-back untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: size axes are CSS lengths — px or percent, at least one axis [BEX-416]

Redesign from review on the platform PR: a size axis is '<positive
integer>px' (absolute) or '<1-100>%' (relative to the host slot's box),
and a single-axis object is valid — the omitted axis stays on the host
slot's default. An empty size object is refused. Matches the platform's
validateEntrySizeShape grammar exactly, so nothing valid here 400s at
app upload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: both size axes are optional — an empty size object authors nothing [BEX-416]

validateSurfacePointSize no longer requires at least one of width or
height: an empty object is valid and means the host slot's default, the
same as omitting the key — the platform normalizes it to no size at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: move label, more_info and the destination URL into each surface_point_list entry [BEX-426]

Each placement now carries its own label, more_info and redirect_link /
modal_iframe_url; the ui_app root keeps only extension_type (and the
injected link_target). Root CTA spellings are refused by name with a
migration hint, per-entry violations name their entry, and the create
flow authors exactly one placement (single-select page prompt) — more
placements are hand-authored in app-config.json and uploaded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: rename app deploy/rollback to install/uninstall; warn on delete

`brevo app deploy` / `brevo app rollback` become `brevo app install` /
`brevo app uninstall`, matching the platform resource they act on
(POST/DELETE .../installs). Full internal rename: files, message keys,
CLI.* constants, service methods, help screen, and JSON output keys
(`installed`/`uninstalled`, reason NOT_INSTALLED). Both commands are
pre-GA and absent from published builds, so no shipped surface changes.
The wire contract is untouched — `deploy_client_id` is the server's
field name and stays. The command exports are `appInstallCommand` /
`appUninstallCommand` because the bare names collide with the
`skill:cli install`/`uninstall` exports, which would blind the build's
leak-marker guard.

`brevo app delete` now warns before the confirmation prompt that
deleting an app also removes it from every account where it is
installed or published, and that installs and credentials cannot be
recovered. `--force` still skips both the warning and the prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(ui-app): update surface_point_name examples to the dot-notation slugs (#64)

The extension_points registry's surface_point_name slugs are being renamed
from kebab-case (contact-details-header-menu) to dot notation
(contactDetails.header.menu). The CLI validates shape only and fetches the
registry live, so no behavior changes — but the error-message examples and
the comments describing the slug format taught the old kebab spelling.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat: narrow surface-point reads by the chosen extension type [BEX-422] (#63)

Both registry reads in `app create` now pass `?extension_type=` — the
locations read so the page prompt cannot offer a page whose every slot
the catalogue hides for the chosen type, and the row read as the
server-side half of the type check. The unfiltered retry drops both
parameters, and rowSupportsExtensionType stays as the client-side check
for servers predating the filter.

fetchSurfacePoints normalizes the endpoint's enabled_extension_types
(what app-store-bo-be serves since BEX-422) onto extension_type_list,
newest spelling first, alongside the two older namings.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(app-create): label UI placements from the registry, not a local map [BEX-426]

The placement prompt showed a CLI-owned prettified name per slot —
`headerMenu` as `Header "More" (•••) menu`, the kind segment as
`menu entry` / `card`. It now shows the registry's own values joined
verbatim: `section_name — component_type`, e.g. `headerMenu — action`.

Same reasoning that removed the record-page map earlier: a local map gives
every server-supplied identifier a second name to keep in step with the
platform, needs a guess for anything it doesn't know (so the prompt mixed
two vocabularies), and lets the prompt show something the API never said.

Choice VALUES are unchanged — still the row's `surface_point_name` slug —
so nothing about what gets authored into app-config.json changes.

Removes `EXTENSION_PLACE_LABELS` plus the now-unused `EXTENSION_KIND_ACTION`
/ `EXTENSION_KIND_WIDGET`, and the two placement-suffix strings. Folds the
"no friendly-name map" note in constants.ts to cover placements as well as
pages so neither map gets reintroduced.

Pre-GA (UI apps are absent from published builds), so nothing shipped changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: move ui_app link_target into each surface_point_list entry [BEX-426]

`link_target` was the last CTA-adjacent field left at the `ui_app` root. It
qualifies a per-entry destination — where THAT entry's `redirect_link` opens —
so a root copy could only ever say one thing about every placement, and the
platform's read path already treats each entry as the only source for it.

`extension_type` is now the only field at the block root.

- `brevo app upload` injects `_blank` onto every `actionLink` entry instead of
  the block root (`withInjectedLinkTargets`). An `iframeExtension` is untouched,
  and an entry that already carries a value keeps it.
- `validateUiApp` refuses a leftover root `link_target` by name, with a hint to
  remove it rather than relocate it: the field is not authored in
  `app-config.json` at all. A per-entry value is checked against the `_blank`
  pin and refused outright on an `iframeExtension` entry, naming the entry.
- The wire-only key strip needed no change — it already strips at every depth,
  so the upload diff, the write-back and the scaffold pull followed the field
  one level down on their own. `app-config.json` still never carries it.

The field is echoed per entry now, so the response no longer withholds it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app): align the next-steps hints in the UI-app box

The two step lines carried hard-coded padding — 14 spaces after
`brevo app upload`, 3 after `brevo app install` — for commands one
character apart in length, so the parenthetical notes landed ten
columns apart inside the same box.

Replace both with a `numberedSteps` helper that derives the note
column from the widest command in the list, and route
`APP_CREATE_BASE_ONLY_NEXT` through it as well: its single-step
output is byte-identical, so the OAuth path is untouched, and a
second step added there later cannot reintroduce the drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(app): gate install/uninstall on app type, and name the target account

Only a UI app is installed into an account. The capability matrix has
encoded that since it was written — `account-install` is its example of
a type-driven capability — but nothing on the install path consulted it,
and the installs endpoint has no app-type check of its own, so an OAuth
app installed with a 201 and rendered nothing. Refuse both verbs via
`assertCapability`, so a third app type answers by appearing in the
table rather than by editing a comparison.

The refusal is biased to fire only when it is sure: a record carrying
OAuth material is definitely an OAuth app, one carrying none reads as a
UI app and is allowed through, so the failure mode is a missed refusal
rather than a wrongly refused UI app. A read failure does not block —
same policy the upload gate already had, since guarding a silent no-op
must not become a new way to fail.

`assertUploadedBeforeInstall` becomes `assertInstallable` and answers
both questions off one app read. It also stops trusting the local config
unconditionally: `app-config.json` may only answer for the app it
describes, so `--app-id <other-app>` run inside a linked project is now
gated against the app it names. Previously the directory's config
answered, and an OAuth app named explicitly from inside a UI-app project
passed both checks on the strength of a config that was not about it.

`uninstall` takes the type check and still skips the upload check: an
app installed by an older CLI must stay removable whatever its version
says, while an OAuth app never had an install to remove. The one case
this strands is an OAuth app installed by a CLI predating the check.

Both verbs now name the account rather than printing a bare ID. Two of
the three resolution paths choose the account for the user, who then has
no number of their own to check it against — a picked sub-account reads
`Company2 (account 4043630)`, the caller's own account reads
`Acme Retail (your own account, ID 12345)`, and that identifier may be a
UUID they have never seen. Both names come off responses those paths
already read, so nothing costs an extra request. An explicit
`[account-id]` is deliberately not looked up — its whole purpose is
reaching an account the listing will not show — and its wording is
unchanged, so CI output is untouched. `--json` gains an additive
`accountName`; `accountId` stays the raw identifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(cli): show a UUID in the --app-id examples

An app ID is a UUID — `app create` issues one and `app-config.json`
stores it — but every `--help` example said `--app-id 42`, which reads
as a database row. Eight commands taught the wrong shape identically.

Route them all through one `EXAMPLE_APP_ID` constant so they cannot
drift into showing two shapes, and update the README's one occurrence.
The agent docs already use `--app-id <id>`, so they need no change.

Also folds this branch's three changes into the existing changeset
rather than adding a second file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(app-create): drop the disabled Iframe choice from the UI integration prompt

The integration-type prompt showed Iframe as a disabled "coming soon"
choice so partners could see the roadmap where the decision is made.
Removed for now: Link (actionLink) is the only choice offered until
iframe authoring is ready. The question is still asked with its one
choice — same pattern as the gated app-type and distribution prompts —
and a hand-authored iframeExtension block still validates and uploads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Revert "feat: per-entry size [BEX-416], CTA fields [BEX-426], extension-type-filtered registry reads [BEX-422]; rename deploy/rollback to install/uninstall; warn on app delete (#56)" (#66)

This reverts commit 7e5c208777844594fcbb69cff4a8b1a953a20354.

* feat: UI apps GA [BEX-290]; per-entry ui_app fields [BEX-416/422/426]; install/uninstall rename + delete warning [BEX-427] (#68)

* feat: accept per-entry card size in ui_app.surface_point_list [BEX-416]

Each entry may author size {width, height} (px, both required, positive
integers) at the same level as context. Shape-only validation, like the
rest of validateUiApp — bounds and registry concerns stay the platform's.
Absent size keeps today's behaviour: the host page's per-slot default
applies. Partner-authored, so it round-trips through the upload diff and
write-back untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: size axes are CSS lengths — px or percent, at least one axis [BEX-416]

Redesign from review on the platform PR: a size axis is '<positive
integer>px' (absolute) or '<1-100>%' (relative to the host slot's box),
and a single-axis object is valid — the omitted axis stays on the host
slot's default. An empty size object is refused. Matches the platform's
validateEntrySizeShape grammar exactly, so nothing valid here 400s at
app upload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: both size axes are optional — an empty size object authors nothing [BEX-416]

validateSurfacePointSize no longer requires at least one of width or
height: an empty object is valid and means the host slot's default, the
same as omitting the key — the platform normalizes it to no size at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: move label, more_info and the destination URL into each surface_point_list entry [BEX-426]

Each placement now carries its own label, more_info and redirect_link /
modal_iframe_url; the ui_app root keeps only extension_type (and the
injected link_target). Root CTA spellings are refused by name with a
migration hint, per-entry violations name their entry, and the create
flow authors exactly one placement (single-select page prompt) — more
placements are hand-authored in app-config.json and uploaded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: rename app deploy/rollback to install/uninstall; warn on delete

`brevo app deploy` / `brevo app rollback` become `brevo app install` /
`brevo app uninstall`, matching the platform resource they act on
(POST/DELETE .../installs). Full internal rename: files, message keys,
CLI.* constants, service methods, help screen, and JSON output keys
(`installed`/`uninstalled`, reason NOT_INSTALLED). Both commands are
pre-GA and absent from published builds, so no shipped surface changes.
The wire contract is untouched — `deploy_client_id` is the server's
field name and stays. The command exports are `appInstallCommand` /
`appUninstallCommand` because the bare names collide with the
`skill:cli install`/`uninstall` exports, which would blind the build's
leak-marker guard.

`brevo app delete` now warns before the confirmation prompt that
deleting an app also removes it from every account where it is
installed or published, and that installs and credentials cannot be
recovered. `--force` still skips both the warning and the prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(ui-app): update surface_point_name examples to the dot-notation slugs (#64)

The extension_points registry's surface_point_name slugs are being renamed
from kebab-case (contact-details-header-menu) to dot notation
(contactDetails.header.menu). The CLI validates shape only and fetches the
registry live, so no behavior changes — but the error-message examples and
the comments describing the slug format taught the old kebab spelling.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat: narrow surface-point reads by the chosen extension type [BEX-422] (#63)

Both registry reads in `app create` now pass `?extension_type=` — the
locations read so the page prompt cannot offer a page whose every slot
the catalogue hides for the chosen type, and the row read as the
server-side half of the type check. The unfiltered retry drops both
parameters, and rowSupportsExtensionType stays as the client-side check
for servers predating the filter.

fetchSurfacePoints normalizes the endpoint's enabled_extension_types
(what app-store-bo-be serves since BEX-422) onto extension_type_list,
newest spelling first, alongside the two older namings.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(app-create): label UI placements from the registry, not a local map [BEX-426]

The placement prompt showed a CLI-owned prettified name per slot —
`headerMenu` as `Header "More" (•••) menu`, the kind segment as
`menu entry` / `card`. It now shows the registry's own values joined
verbatim: `section_name — component_type`, e.g. `headerMenu — action`.

Same reasoning that removed the record-page map earlier: a local map gives
every server-supplied identifier a second name to keep in step with the
platform, needs a guess for anything it doesn't know (so the prompt mixed
two vocabularies), and lets the prompt show something the API never said.

Choice VALUES are unchanged — still the row's `surface_point_name` slug —
so nothing about what gets authored into app-config.json changes.

Removes `EXTENSION_PLACE_LABELS` plus the now-unused `EXTENSION_KIND_ACTION`
/ `EXTENSION_KIND_WIDGET`, and the two placement-suffix strings. Folds the
"no friendly-name map" note in constants.ts to cover placements as well as
pages so neither map gets reintroduced.

Pre-GA (UI apps are absent from published builds), so nothing shipped changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: move ui_app link_target into each surface_point_list entry [BEX-426]

`link_target` was the last CTA-adjacent field left at the `ui_app` root. It
qualifies a per-entry destination — where THAT entry's `redirect_link` opens —
so a root copy could only ever say one thing about every placement, and the
platform's read path already treats each entry as the only source for it.

`extension_type` is now the only field at the block root.

- `brevo app upload` injects `_blank` onto every `actionLink` entry instead of
  the block root (`withInjectedLinkTargets`). An `iframeExtension` is untouched,
  and an entry that already carries a value keeps it.
- `validateUiApp` refuses a leftover root `link_target` by name, with a hint to
  remove it rather than relocate it: the field is not authored in
  `app-config.json` at all. A per-entry value is checked against the `_blank`
  pin and refused outright on an `iframeExtension` entry, naming the entry.
- The wire-only key strip needed no change — it already strips at every depth,
  so the upload diff, the write-back and the scaffold pull followed the field
  one level down on their own. `app-config.json` still never carries it.

The field is echoed per entry now, so the response no longer withholds it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app): align the next-steps hints in the UI-app box

The two step lines carried hard-coded padding — 14 spaces after
`brevo app upload`, 3 after `brevo app install` — for commands one
character apart in length, so the parenthetical notes landed ten
columns apart inside the same box.

Replace both with a `numberedSteps` helper that derives the note
column from the widest command in the list, and route
`APP_CREATE_BASE_ONLY_NEXT` through it as well: its single-step
output is byte-identical, so the OAuth path is untouched, and a
second step added there later cannot reintroduce the drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(app): gate install/uninstall on app type, and name the target account

Only a UI app is installed into an account. The capability matrix has
encoded that since it was written — `account-install` is its example of
a type-driven capability — but nothing on the install path consulted it,
and the installs endpoint has no app-type check of its own, so an OAuth
app installed with a 201 and rendered nothing. Refuse both verbs via
`assertCapability`, so a third app type answers by appearing in the
table rather than by editing a comparison.

The refusal is biased to fire only when it is sure: a record carrying
OAuth material is definitely an OAuth app, one carrying none reads as a
UI app and is allowed through, so the failure mode is a missed refusal
rather than a wrongly refused UI app. A read failure does not block —
same policy the upload gate already had, since guarding a silent no-op
must not become a new way to fail.

`assertUploadedBeforeInstall` becomes `assertInstallable` and answers
both questions off one app read. It also stops trusting the local config
unconditionally: `app-config.json` may only answer for the app it
describes, so `--app-id <other-app>` run inside a linked project is now
gated against the app it names. Previously the directory's config
answered, and an OAuth app named explicitly from inside a UI-app project
passed both checks on the strength of a config that was not about it.

`uninstall` takes the type check and still skips the upload check: an
app installed by an older CLI must stay removable whatever its version
says, while an OAuth app never had an install to remove. The one case
this strands is an OAuth app installed by a CLI predating the check.

Both verbs now name the account rather than printing a bare ID. Two of
the three resolution paths choose the account for the user, who then has
no number of their own to check it against — a picked sub-account reads
`Company2 (account 4043630)`, the caller's own account reads
`Acme Retail (your own account, ID 12345)`, and that identifier may be a
UUID they have never seen. Both names come off responses those paths
already read, so nothing costs an extra request. An explicit
`[account-id]` is deliberately not looked up — its whole purpose is
reaching an account the listing will not show — and its wording is
unchanged, so CI output is untouched. `--json` gains an additive
`accountName`; `accountId` stays the raw identifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(cli): show a UUID in the --app-id examples

An app ID is a UUID — `app create` issues one and `app-config.json`
stores it — but every `--help` example said `--app-id 42`, which reads
as a database row. Eight commands taught the wrong shape identically.

Route them all through one `EXAMPLE_APP_ID` constant so they cannot
drift into showing two shapes, and update the README's one occurrence.
The agent docs already use `--app-id <id>`, so they need no change.

Also folds this branch's three changes into the existing changeset
rather than adding a second file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(app-create): drop the disabled Iframe choice from the UI integration prompt

The integration-type prompt showed Iframe as a disabled "coming soon"
choice so partners could see the roadmap where the decision is made.
Removed for now: Link (actionLink) is the only choice offered until
iframe authoring is ready. The question is still asked with its one
choice — same pattern as the gated app-type and distribution prompts —
and a hand-authored iframeExtension block still validates and uploads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: take UI apps out of the pre-GA gate [BEX-290]

UI apps now ship in every build. FEATURE_STAGE['ui-app-type'] and
FEATURE_STAGE['account-install'] flip to 'ga', and everything the
build-time gate held back moves to the live surface:

- `app install` / `app uninstall` command definitions move from
  preview-definitions.ts into definitions.ts, and their names leave
  LEAK_MARKERS in scripts/build.mjs (only submit/status/withdraw
  remain gated).
- The UI-app authoring and install/uninstall strings move from
  lang/preview-messages.ts back into lang/en.ts.
- `app create` drops its `__BREVO_PREVIEW__` guards: the UI-app
  choice, resolveUiApp and the created-UI-app box are reachable in
  published builds (still prompt-only — no --type flag).
- The App-install section of root help renders unconditionally.

Docs follow: CLAUDE.md / AGENTS.md sections retitled to "UI apps are
GA", README command table gains install/uninstall, and
agent-context/SKILL.md + AGENTS.md document the UI-app flow, the
ui_app config block, and install semantics. The pending changeset
records the GA note. Public app distribution stays pre-GA and
eliminated from published builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: close the gaps the UI-apps GA flip left open [BEX-290]

The GA flip shipped with loose ends of its own, found in review:

- flip APP_TYPES.ui.availability to 'ga' and update the test that
  cemented the stale 'preview' value
- document the per-entry size key in SKILL.md / AGENTS.md, which told
  agents every undocumented key is rejected at upload
- state the corporate-account exception in README's install row
- pad the gate test's presence assertion so 'uninstall' can no longer
  satisfy the check that 'app install' survived a public build
- assert the GA install surface is PRESENT in both builds (GA_MARKERS
  in scripts/build.mjs), the mirror image of LEAK_MARKERS
- restore gatedSection('account-install') around the root-help section
  so FEATURE_STAGE stays the one decision help and runtime both read;
  GA removed only the __BREVO_PREVIEW__ wrapper
- retire the "UI apps aren't live" premises in comments (validators,
  constants, contract, ui/index) and the one-edit-GA claim in
  preview.ts's header
- re-home RELEASE-CHECKLIST.md / docs.md pointers to the docs branch
  (docs/public-cli-ui-apps-feature-changes), where the still-open
  corporate-discriminator assumption is now tracked

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: uninstall's sub-account picker asked "install into" [BEX-290]

The corporate sub-account picker hardcoded install's prompt, so
`brevo app uninstall` asked which account to "install into". The
wording now travels per command through resolveInstallTarget:
uninstall asks "Select the account to uninstall from:". The shared
error strings stay shared — both correctly point at `app install`.
Tests pin each command's prompt so the strings can't be re-merged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: label the caller's own-account identifier as org ID, not ID [BEX-290]

The install/uninstall confirmation and result messages named the caller's
own-account identifier as plain "ID", which reads as a generic identifier
even though it's specifically the organization ID.

* docs(changeset): condense the pending changeset to user-facing feature notes [BEX-416]

The changeset had grown into a per-commit log — internal file moves,
pre-GA caveats mooted by the GA flip shipping in the same release, and
implementation rationale that belongs in the PR, not the CHANGELOG.
Rewritten as concise feature-level notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(validators): extract per-entry context/size checks from validateSurfacePointList

Sonar S3776 flagged the function at cognitive complexity 20 (limit 15).
The per-entry context and size blocks move into validateEntryContext /
validateEntrySize, mirroring the existing validateEntryCtaFields
pattern, leaving the list function with the list-level rules only.
No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: add branch-local UI-apps release status; guard both status docs from main [BEX-290]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: surface_point_name slugs are dot notation, not kebab-case [BEX-290]

The registry renamed its surface_point_name slugs from kebab-case to dot
notation (contact-details-header-menu -> contactDetails.header.menu) in a
2026-08-18 platform migration, verified applied on prod 2026-08-24. PR #64
updated most CLI examples; this catches the stragglers: CLAUDE.md's two-names
note, the slug bullets in agent-context/SKILL.md and AGENTS.md, the
rejectRootCtaFields migration-hint example, and a comment in authoring.ts.

Also records the type === 'corporate' discriminator on /v3/account/info as
verified (confirmed against a live corporate account, 2026-08-21) in
CLAUDE.md and the AccountResponse.type comment - it was the last assumed
wire contract, and the docs-branch open-questions log is closing it out in
a companion commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: UI-only install picker; credentials refuses UI apps [BEX-290]

`brevo app credentials` on a UI app used to print an empty credential
form (blank client ID, "(none)" everywhere) and cache the emptiness.
It now refuses with a typed message before any side effect, routed
through the capability matrix ('oauth-flow'), which has named this
command as OAuth-only since it was written.

The `app install` / `app uninstall` picker now offers only UI apps —
an OAuth row was a choice whose only outcome was the type-gate refusal
one step later. Rows classify by the same OAuth-material bias as
`assertInstallable`, the full-record check after the pick backstops
it, and an emptied list errors pointing at `brevo app create`. The
delete/withdraw/scaffold pickers stay unfiltered.

Also scrub the two test fixtures using 'email' as a context value:
the platform vocabulary is exactly recordId/recordName/userId/locale/
accountId, and email stays out pending PII sign-off (decision
2026-08-24: not adding it for now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: retire the docs branch - working docs live on this branch now [BEX-290]

CLAUDE.md pointed at docs/public-cli-ui-apps-feature-changes as the durable
home of RELEASE-CHECKLIST.md / docs.md; that split is retired. The three
working docs (plus QA-TESTCASES.md) now sit at this branch's root, covered
by the same never-merge-into-main rule as the release-status docs, and
CLAUDE.md's guard section, working-docs section and runbook pointer say so.

Also closes out the references and claims that went stale with the move and
with recent verifications:
- src/types.ts: the surface-point alias comment no longer claims BEX-361 is
  unbuilt (shipped; aliases are cleanup-ready per docs.md), and the absent
  auth-block comment records the contract as confirmed live (2026-08-12)
  instead of assumed - same fix in upload.test.ts.
- QA-TESTCASES.md: the private-app half was deleted from
  features_set_public_cli once worked, not kept there - the header now says
  where to recover it from history.
- docs.md: the app-credentials-on-a-UI-app UX decision is recorded as
  decided and shipped (refuse via the capability matrix).
- UI-APPS-RELEASE-STATUS.md: the docs-debt section and the kebab-slug /
  corporate-discriminator follow-ups are marked resolved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: split the working docs by feature - UI-apps halves stay here [BEX-290]

Companion to feature_set-brevo-cli-v2's adoption commit (59ee376): the
public-apps halves of RELEASE-CHECKLIST.md, docs.md and QA-TESTCASES.md
(release copy, the Before public-apps GA runbook, QA suites 2/5/6/7/10/13,
the BEX-405 guard explainer and build-gate residue note) moved to that
branch. This branch keeps the UI-apps halves:

- RELEASE-CHECKLIST.md - the UI-apps GA record (worked through at BEX-290;
  stays until PR #68 + the Version Packages PR publish).
- docs.md - the UI-apps background narrative and Part 2 (ship steps,
  platform asks, CLI cleanups, UX decisions, parked items, Resolved).
- QA-TESTCASES.md - Suite 12 with its own entry conditions and sign-off.

CLAUDE.md's guard list, runbook pointer and working-docs section now
describe the split. The docs/public-cli-ui-apps-feature-changes branch is
deleted; its final pre-split state is preserved in closed PR #53.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(validators): report a missing surface_point_name key by name, not as a blank slot [BEX-416]

An entry carrying the pre-rename `surface_point` spelling (or no name key at
all) was reported as 'Surface point cannot be empty', which points at the wrong
thing — the name IS there, under the wrong key. Missing key now gets its own
message, with a rename hint when `surface_point` is present. Found by QA
sweep 3 (TC-12.6 case 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record QA sweep 3 results — terminal-only Suite 12 gaps closed [BEX-416]

Sweep 3 (2026-08-24, public build, production) closes TC-12.3 file half,
TC-12.4 write-back, TC-12.5(a)-(c), TC-12.5b, TC-12.6 both halves, TC-12.12,
TC-12.13, and settles the PKCE question from the templates. Records the new
502-retry duplicate-create finding (fixed on fix/post-502-retry-duplicate-create).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(qa): port sweep-3 Result lines into Suite 12; record finding dispositions [BEX-416]

Result lines for TC-12.3/3b/4/5/5b/6/8/12/13 and the TC-12.14 PKCE settlement;
sign-off table updated — terminal-only Suite 12 is closed, the browser-bound
install set (BEX-438) and the prompt-flow family remain. Status doc records the
502-retry fix (PR #70), the accepted same-name-apps decision (no uniqueness ask),
and the fixed TC-12.6 message nit (62b380e).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): retry a 502 only for idempotent methods, never POST/PATCH (#70)

A 502 comes from a gateway, not the origin, so the origin may have processed
the request before the response was lost. The blind one-shot replay duplicated
a create in live QA: POST /v3/app-store/apps landed, its response surfaced as
a 502, and the retry created the same app twice (the CLI reported only the
second ID, orphaning the first). GET/PUT/DELETE replays can only repeat a
result, so they keep the retry; POST and PATCH now surface the 502 instead.
The 401/429 retries are unchanged — those statuses mean the origin refused
the request without processing it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* docs: PR #70 merged — 502-retry fix is on main [BEX-416]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: drop the branch-local working docs before #68 merges [BEX-290]

QA-TESTCASES.md, RELEASE-CHECKLIST.md, docs.md and UI-APPS-RELEASE-STATUS.md
are branch-local release-state notes (see CLAUDE.md) and must never land on
the public main. They stay on disk (git-excluded locally) for the remaining
release steps; their full content is preserved in this branch's history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(smoke): add opt-in ui suite — pty-driven UI-app lifecycle [BEX-416]

New scripts/smoke/ui-app.ts (yarn smoke --suite=ui / --with-ui): interactive
create driven through a real pty, upload no-op after create, per-entry
label/more_info edit round-tripped through upload, install, uninstall,
repeat-uninstall (informational not-installed), delete. Runs on the published
surface — UI apps are GA — and skips itself against a build whose app-type
prompt offers no UI app.

Core gains execExpectPty: expect-driven answers (each sent only after its
prompt renders, since registry reads happen between prompts) into script(1)
behind `cat |` — macOS script dies on node's socketpair stdio and on
socket-backed FIFOs, but tolerates a real pipe(2), and keeps its child running
past our post-last-answer stdin EOF. install/uninstall join GATED_COMMANDS so
--against=published skips them on older npm builds; the ui app is wired into
both cleanup paths.

The suite's edited label is plain words: the upload endpoint refuses special
characters in a per-entry label (found live — the CLI validator only checks
blank + length; the server rule is the divergence, tracked in the branch-local
docs). Verified live 2026-08-24: 13/13 green, install/uninstall exercised for
the first time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): print the delete consequence line under --force [BEX-427]

`brevo app delete --force` still deletes without prompting, but now
prints the install-loss warning first so a scripted delete leaves a
record of what it did. The line is kept out of --json runs: logWarn
writes to stdout, and the --json contract is a single parseable JSON
document.

The confirm copy ("This cannot be undone") is deliberately unchanged —
the 30-day soft-delete window is an internal operator affordance we do
not surface to users (decision recorded on BEX-427).

Changeset, SKILL.md and AGENTS.md updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(smoke): pin the pty shell and render numeric --json fields [BEX-416]

Three fixes from a Sonar/robustness pass over the pty driver:

- `spawn('sh', …)` resolved the shell through PATH, so a writable PATH entry
  could decide what runs (S4036). It is `/bin/sh` by absolute path now. The
  pipeline that shell interprets (`cat`, `script`) stays on PATH deliberately:
  `script`'s location differs across the platforms the driver supports, and
  the command string is built only from this file's literals plus an already
  resolved brevo path.
- The single-quote-in-single-quote escape is hoisted to `SQ_IN_SQ` so the
  template that uses it is no longer nested (S4624).
- `optStr()` returned '(none)' for a finite number, which is why a step detail
  line had to pre-stringify its value — and `String(res.accountId ?? '')` in
  the install step turned an object into "[object Object]" rather than
  '(none)'. `optStr()` now renders finite numbers and refuses everything else,
  so the caller passes the raw value. An account ID is a string on some
  resolution paths and a number on others, which is what made this reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(validators): quote the matched axis in the size range error [BEX-416]

The out-of-range message interpolated `String(value)`, where `value` is
`unknown` — so a non-string axis would have rendered as its default
stringification. It can't reach that line today (the regex test fails first
for anything but a string), but the pattern is anchored, so `match[0]` IS the
authored string and is typed `string`. Quote that instead, and the message can
never stringify an `unknown`.

Also pins the wording in a test — entry name included — so the quoted value
cannot silently become "[object Object]" again. The existing table asserts only
that the throw happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(changeset): fold the 502-retry patch into the branch changeset [BEX-416]

CLAUDE.md is one changeset file per branch — append, don't multiply. The
502-retry fix arrived on its own `patch` file via PR #70, which merged into
this branch rather than main, so both files were pending together and would
have produced two entries for one release. Its summary is now a paragraph in
`surface-point-entry-size.md`; the bump stays `minor`, which already outranks
the patch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: repoint working-doc references at the branches that hold them [BEX-290]

Six source comments pointed readers at `docs/public-cli-ui-apps-feature-changes`,
which was deleted on 2026-08-24 when the working docs were split across the two
feature branches — and CLAUDE.md in this same branch already says so, so the tree
contradicted itself.

The public-apps GA runbook references now name `feature_set-brevo-cli-v2`, which
holds that half and persists until public apps ship. The UI-apps `docs.md`
references drop the branch pointer entirely and point at CLAUDE.md's "Working
docs" section instead: that half is branch-local and goes away when this branch
merges, so naming any branch there would go stale again.

Comment-only — no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app): refuse the install/uninstall app picker when it cannot be drawn [BEX-290]

`resolveInstallTarget` fell through to `promptAppSelection` with no guard, so
`brevo app install --json` (or any run off a TTY) outside a linked project and
without `--app-id` reached the interactive picker: the spinner and choice list
rendered into stdout — breaking the single-parseable-document contract `--json`
promises and leaking app IDs into what a script is parsing — and inquirer then
aborted with a raw ERR_USE_AFTER_CLOSE readline stack rather than anything a
caller can act on.

`app delete`, `app credentials` and `app withdraw` have all carried
`assertAppSelectionAllowed` in front of their pickers since it was written; the
two commands that went GA at BEX-290 were the ones missing it. Added there,
before the apps-list round trip, with the `--app-id` form of the calling command
so the refusal names a copy-pasteable replacement.

Only the picker path is affected: a run that names its app with `--app-id` or
through a linked app-config.json is unchanged, which is what keeps --json/CI
working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(build): stop gated command strings shipping in the public bundle [BEX-405]

`CLI.APP_SUBMIT`/`APP_STATUS`/`APP_WITHDRAW` and the `/withdraw` + `/state`
endpoints were referenced only from preview-gated modules, but survived into the
published bundle because esbuild cannot prune properties from an object literal.
No command reached them, yet `strings` on the published tarball named three
unreleased commands — which is what the build-level gate exists to prevent.

Apply the treatment `lang/preview-messages.ts` already uses: move them into
`lib/preview-constants.ts` and spread that in behind `__BREVO_PREVIEW__`, so the
whole object is unreachable on a public build and the bundler drops it.

That makes the residue checkable for the first time, so add both guards:
`previewCli`/`previewEndpoints` to LEAK_MARKERS (module bindings), and a new
LEAK_STRINGS check on the user-facing command strings themselves — the bindings
answer "did a gated module survive", the strings answer "what does the tarball
reveal". Verified in three directions: absent from the public build, present in
preview, and a deliberately reintroduced leak fails the build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(smoke): pace API calls and skip the public suite when the server refuses [BEX-416]

Two fixes to the run's signal-to-noise, both found by a full --suite=all sweep.

Pacing: the harness made ~40 API-backed calls back to back, which is what pushed
a busy account over the throttle in the first place. The existing retry is
reactive — it discovers the limit by tripping it — and in that run the teardown
delete exhausted its retries and leaked an app. Add paceBrevoCall(), a minimum
interval between `brevo` invocations, measured start-to-start so a command that
already took longer than the gap adds no delay and only bursts are slowed. New
--gap=<ms> flag, default 1000, --gap=0 restores the old behaviour. Reported in
the summary and the JSON report so a run's wall-clock stays explicable.

Public-suite skip: detectCapabilities can only read the client — it greps
--help, so it answers "does this build offer --distribution public", never "will
the server accept it". Those come apart on an account where Brevo refuses the
create, and the whole lifecycle then reported nine hard failures. Add
markFeatureUnavailable() so the create step can downgrade the capability on the
server's refusal and skip; the eight steps that need that app then skip through
the existing requireFeature rather than each raising "no public app from the
create step".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(smoke): pin the pty pipeline's binaries to absolute paths [BEX-416]

SonarCloud raised S4036 on PR #68 — "OS commands should not rely on PATH
resolution" — against the pty driver. Fair: the shell was pinned to
/bin/sh precisely so no PATH-writable directory could decide what runs,
but the pipeline it interprets still invoked `cat` and `script` by bare
name, so two of the three words were doing the thing the pin exists to
prevent.

Resolve both from fixed absolute candidates (/usr/bin/script or
/bin/script, /bin/cat or /usr/bin/cat), first hit wins, with no bare-name
fallback — a fallback would hand the decision back to PATH on exactly the
machine where the absolute lookup failed. A missing binary now fails with
a message naming what was looked for and where, instead of surfacing as a
pty allocation error later.

The old comment argued for leaving them on PATH because `script` sits in
different places per platform and the command string holds only this
file's literals. The candidate list covers the platform difference, so
that argument no longer buys anything.

Verified behaviour-preserving: the pinned pipeline still allocates a pty
(isTTY=true through /bin/sh -c '/bin/cat | /usr/bin/script …').

Per CLAUDE.md, Sonar hotspots get fixed rather than marked Safe — test
and script files included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(app): show the ui_app config on install, diff it on upload, and branch project docs on app type [BEX-409] (#72)

* fix(templates): branch project docs on app type so UI apps get UI-app docs [BEX-290]

`templates/index.ts` has shipped `oauth` / `ui_app` template flags since
BEX-290, but only `app-config.json.tmpl` ever used them. The three project
docs were written from the OAuth templates for every app, so a scaffolded UI
app was handed a `CLAUDE.md`, `AGENTS.md` and `README.md` describing an OAuth
integration it does not have — including instructions to
`yarn --cwd src/oauth install` and `brevo app start oauth` against a
`src/oauth/` directory a UI app never receives (`finishProject` returns before
the feature offer, and `app scaffold` refuses with
APP_SCAFFOLD_NO_FEATURES_FOR_UI_APP).

Each of the three templates now carries an `{{#if oauth}}` branch holding the
existing body verbatim and an `{{#if ui_app}}` branch documenting what a UI app
actually is: the `ui_app` block as the app-type discriminator, `extension_type`
as the only root field, the per-entry `surface_point_list` contract
(`surface_point_name` slug vs. the dotted extension-point name, `label`,
`more_info`, `redirect_link` / `modal_iframe_url`, `context`, `size` with their
real limits), the server-owned keys not to author, and the
`upload` -> `install` / `uninstall` flow in place of a local server.

`gitignore.tmpl` is deliberately left unbranched: the credentials line is
harmless for a UI app and still covers a hand-added OAuth directory.

OAuth projects are unaffected — the marker lines are stripped in full, so both
distributions render byte-identical files to before. Tests assert that, that
neither branch can render empty, and that the UI branch never names the OAuth
feature, quotes the authorable slug rather than the dotted name, and presents
no server-owned key as a field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(app): show the ui_app config on install and diff it on upload [BEX-290]

`brevo app install` now prints the configuration it is about to install —
app ID, name, version, extension type and every placement — read from the
server, because an install makes the stored snapshot visible in an account
and that is not necessarily what the local app-config.json says. The read
is shared with the type/upload gates via `serverApp`, so it costs one
request. A linked config that has drifted from the stored block is
reported as a notice (not a refusal) naming `brevo app upload`; `--json`
gains additive `version` and `ui_app` keys.

`brevo app upload` now diffs the ui_app block instead of printing only its
desired state with a bare `(changed)` marker: each changed value renders
as `before -> after`, added placements are tagged `(new)`, dropped ones
trail `(removed)`, and entries match by slot slug so a reordered
surface_point_list is not a change. For a UI app the command then warns
that the app may already be installed in Brevo accounts — the block is
shared by every install and an upload is live there immediately, with no
re-install — and asks a confirmation that names that consequence. `--yes`
skips the question but still prints the warning, mirroring
`app delete --force`; `--json` prints neither and stays parseable.

The warning is unconditional for a UI app because the platform exposes no
install-listing read, so a count either way would be invented.

Block equality moves to src/app-types/ui/compare.ts so the upload diff and
the install drift check share one normalization, and the per-entry value
rows are table-driven in ui/fields.ts so a new field cannot appear in the
plain renderer without appearing in the diff (per-entry `size` now renders
in both, having rendered nowhere before).

Docs updated in the same change: README, agent-context/SKILL.md,
agent-context/AGENTS.md, AGENTS.md and CLAUDE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(smoke): consolidate the smoke test into one reusable workflow

The steps, timeout and concurrency lived twice — once in
smoke-pre-merge.yml and once in smoke-post-merge.yml — so an ad-hoc run
could drift from the run that actually gates a release. They now live once
in smoke.yml, called by both triggers through `workflow_call`, and the two
trigger workflows keep only their events and their `if:` guard.

smoke.yml also owns the single manual "Run workflow" button (`against:
published` or `local`, with the ref picked in "Use workflow from"), which
is why the dispatch triggers are gone from the trigger workflows. The
npm-propagation wait is guarded on `against: published` with an expected
version, and non-blocking behaviour is now an input the caller asks for
rather than a hard-coded `continue-on-error` — so only the pre-merge
release PR is non-blocking.

Checkout keeps `persist-credentials: false`; the run steps read their
inputs through `env:` rather than interpolating into the shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(smoke): install with --ignore-scripts

SonarCloud raises "Lifecycle scripts are enabled by default" on the new
install step (new code, so the identical line in the older workflows is not
flagged). Fixed rather than dismissed, per the repo's Sonar policy: the job
needs nothing a lifecycle script provides — the only one in the tree is
husky's `prepare`, which installs git hooks — and it holds an API-key
secret while it runs them.

Verified against this lockfile in a clean install: `yarn build` (esbuild,
which ships a postinstall) and `yarn smoke` (tsx) both work, and the full
Jest suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: harmonize app-create prompt copy (distribution & UI-app fields) (#73)

- APP_CREATE_TYPE_PROMPT: 'Distribution type?' -> 'What distribution type
  should this app use?', matching the full-question convention every other
  list/choice prompt in the flow already uses (APP_CREATE_APP_TYPE_PROMPT,
  APP_CREATE_UI_INTEGRATION_PROMPT, etc).
- APP_CREATE_UI_INTEGRATION_EXTERNAL_LINK: drop 12 hand-typed padding spaces
  that existed only to align against the removed Iframe choice (2026-08-19).
  Dead weight now that Link is the only entry.
- Label / More info / Redirect link prompts: tightened from three dense
  compound sentences into shorter inline copy, still self-contained (an
  earlier draft moved the "renders as" explanation to a separate `Tip:`
  line above the prompt; reverted -- that line is genuinely supplementary
  for APP_CREATE_REDIRECT_HINT's use case, but essential here, so hiding it
  above a bare `Label:` left the question meaningless once scrolled past).
  Redirect-link prompt no longer repeats its own query-param example since
  the created-app box already prints a live one.
- Extended the 80-column prompt-width test to cover all three UI-app field
  prompts -- APP_CREATE_UI_REDIRECT_LINK_PROMPT had drifted to ~100 chars
  with nothing catching it, which is exactly what this test exists to
  prevent.

No behavior change; copy only. Verified: yarn build, yarn test (1364/1364),
yarn lint all clean.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* chore(release): @getbrevo/cli@2.2.0 (#71)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* feat(ci): pre/post-publish release gates, smoke-dispatch fix + clear the SonarCloud findings [BEX-444] (#75)

* feat(ci): gate the release with pre- and post-publish checks

The release path had no assertion about the artifact itself. `yarn build`
guards the gated public-app surface and `yarn test` runs jest against src/,
but nothing looked at the tarball: `files:` is an allow-list, so a dropped
`agent-context/SKILL.md` or a template that stopped being copied into dist/
publishes silently, and a dependency that drifts into devDependencies packs
fine and then fails on the first user's machine with MODULE_NOT_FOUND.
Nothing verified the OIDC posture either — if provenance quietly stopped
attaching, no run would have noticed.

`scripts/release-check.mjs` has two modes over one set of assertions:

- `pre` (before the publish) inspects the tarball `npm pack` produces.
- `post` (after it) inspects the tarball the registry serves.

Both assert the same shape — required files present, forbidden files absent
(the branch-local working docs, `.env`, `credentials.json`, `.brevo.json`,
keys), every `src/templates/files/*.tmpl` shipped, no secret-shaped string
in packed content, and the tarball installs into an empty tree where the
installed `brevo --version` runs. Checking the same artifact from both sides
is the point; a pre-publish gate on a different artifact isn't a gate.

`post` adds what only exists after a publish: the version resolves (retried
for 5 minutes of propagation), `latest` moved to it, a SLSA provenance
attestation is attached, the publisher is the OIDC identity, and the
download matches the advertised `dist.integrity`.

It deliberately does not re-check the leak markers (build.mjs owns those and
prepublishOnly reruns it) and is not the smoke test (that authenticates and
drives real commands; this only proves the artifact).

Wired in as `Pre-publish checks` before the changesets step and
`Post-publish checks` right after it, ahead of the Slack announcement.
Also runnable locally: `yarn release:check pre|post --version=x.y.z`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ci): clear the four SonarCloud findings on release-check.mjs

Fixed rather than dismissed, per CLAUDE.md.

- Two nested template literals (S4624) hoisted into named locals.
- `split('\n').join(…)` → `replaceAll` (S7781).
- The propagation wait no longer shells out to `sleep`, which Sonar flags as
  a PATH lookup (S4036). It was a subprocess to get a blocking sleep; making
  `waitForRegistry`/`postflight` async and awaiting a timer removes the
  process entirely, so there is nothing to resolve off PATH.

Re-verified after the change: `pre` 7/7, `post --version=2.2.0` 12/12,
`post --version=2.1.0` still fails on the dist-tag as it should, and the
retry loop still prints and paces its attempts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: trim the commentary on the release gates

Cut the comments back to what the code can't say for itself: 85 comment
lines down to 29 in release-check.mjs, the section banners gone, the two
release.yaml step comments down to four lines each, and the CLAUDE.md entry
from five paragraphs to three. Behaviour untouched — `pre` 7/7, `post
--version=2.2.0` 12/12, and the stale-version negative still fails on the
dist-tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ci): pin the scheme on the gate's tarball fetch

The URL comes from registry metadata, so `-L` could in principle follow a
redirect down to http. Same hardening as the Homebrew fetch. Re-verified:
`post --version=2.2.0` still 12/12, integrity unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: clear the nine SonarCloud findings on main

Fixed, not marked Safe, per CLAUDE.md. Six vulnerabilities and three code
smells.

Lifecycle scripts on credentialed runners (5 findings) — `--ignore-scripts`
on every dependency install in `release.yaml`, `push.yaml` and
`pre-release.yaml`'s publish job, and on the `npm install -g npm@11.5.1`
step in both publish workflows. `pre-release.yaml`'s smoke job already did
this and its comment already records why it's safe; this brings the rest in
line. The `HUSKY: 0` on the prerelease install is folded in — husky's
`prepare` is the only lifecycle script in the tree, and `--ignore-scripts`
supersedes it.

Verified in a clean clone rather than assumed: `yarn install
--frozen-lockfile --ignore-scripts` then lint, `yarn build` (esbuild resolves
its binary from the `@esbuild/<platform>` optional dependency, not a
postinstall), all 1403 tests, and `yarn smoke --help` (tsx). Also confirmed
`npm install -g npm@11.5.1 --ignore-scripts` yields a working npm 11.5.1 —
the OIDC publish needs that version, so a broken upgrade would have been a
broken release.

Protocol downgrade over redirects (1) — the Homebrew tarball fetch now
passes `--proto '=https' --proto-redir '=https'`, so `-L` cannot be walked
down to http. Confirmed it returns the same sha256 for 2.2.0 that the
formula carries.

Code smells (3) — a useless `?? {}` inside a spread in `scripts/smoke/core.ts`
(spreading undefined is already a no-op), an `a && a.b` chain in
`src/services/cli-info.ts` restructured into a guarded block rather than
optional-chained (TypeScript doesn't narrow through `?.` comparisons, so the
`lastChecked` arithmetic needs the guard), and `expect(x.length).toBe(n)` →
`toHaveLength(n)` in the skill tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ci): dispatch the post-publish smoke at smoke.yml, not the trigger workflow

The release job's smoke dispatch still pointed at `smoke-post-merge.yml`,
which lost its `workflow_dispatch` trigger (and its `version` input) when
the smoke workflows were split into a reusable `smoke.yml` plus two thin
trigger workflows. `gh workflow run` on a `workflow_call`-only workflow
answers `HTTP 422: Workflow does not have 'workflow_dispatch' trigger`, so
the step failed after the publish had already succeeded — the 2.2.0 release
run went red, no smoke ran, and the Homebrew bump step was skipped.

- release.yaml dispatches `smoke.yml` with `against=published` and
  `expected_version=<tag>`, the same inputs smoke-post-merge.yml passes on
  the `release` path.
- smoke.yml's `workflow_dispatch` gains `expected_version` so the dispatched
  run still waits for npm to serve the new version (blank on a manual run).
- The Homebrew bump is gated on `!cancelled() && …published == 'true'`
  instead of the bare condition. It stays behind the published gate and
  stays last; it just no longer gets skipped when a step between it and the
  publish fails, which is what left npm on 2.2.0 with the formula on 2.1.0.
- Comments in all three files record why the dispatch target is smoke.yml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: trim the commentary on the smoke-dispatch fix

Cut the added comments to what the code can't say for itself. No behaviour
change — the dispatch target, inputs and `!cancelled()` gate are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(build): drop sourcesContent and scan every published file for gated strings

`package.json` `files:` ships the whole of `dist/`, so `index.js.map` is as
public as the bundle. esbuild's `sourcesContent` embedded the untouched
TypeScript of all 69 surviving modules, comments included — which undid what
`minifyWhitespace` is there to do: `brevo app submit` and `brevo app withdraw`
were absent from `index.js` and one grep away in `index.js.map`, while
`LEAK_STRINGS` (whose stated job is that `strings` on a published artifact must
not name an unreleased feature) read only the bundle and reported it clean.

No gated module was ever in the map — `preview-definitions.ts` is eliminated and
absent from `sources` — and this repo is public, so nothing secret was exposed.
What leaked was the prose the gate exists to remove, in the one file the gate
did not look at.

Two changes:

- `sourcesContent: false`. The map still resolves a stack trace to
  `src/lib/foo.ts:123` — that is `sources` + `mappings`, not the text. It also
  drops the map from 725 kB to 135 kB.
- The `LEAK_STRINGS` scan now walks every file under `dist/` and names the
  offending file in the error. `LEAK_MARKERS` stays on the bundle: it asks
  whether a gated module survived elimination, which is answerable only there.

Verified by re-enabling `sourcesContent` and watching the build fail, naming
`dist/bin/index.js.map` for all three strings. Preview build unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(smoke): add suite + account inputs, pin every release lane

The manual "Run workflow" button now selects which app types to smoke and
which test account to authenticate as.

`suite` maps straight onto the runner's `--suite`, so the dropdown values
are the argument — no label-to-flag mapping to drift. `account` names a
configured SECRET rather than accepting a key: workflow_dispatch inputs are
recorded on the run page and this repo is public, so a pasted key would be
world-readable and permanent. A selection whose secret is missing fails the
run instead of falling back to the default account, which would silently
report on an account nobody chose.

A suite containing the public-app (preview) surface is now refused unless
`against=local`. Previously that pairing ran, auto-skipped the gated steps
and went green — coverage the run never had.

Every automated lane pins `suite`/`account` explicitly so retuning a
dropdown cannot change what a gate verifies:

  pre-merge   local     / all         (non-blocking, widest, PREVIEW=1)
  post-merge  published / private,ui
  release.yaml dispatch  / private,ui

post-merge and the release dispatch move from private to private,ui: the two
suites are disjoint, so `app install` / `app uninstall` and the whole
`ui_app` path were unverified against the published package despite UI apps
shipping in every build since 2.2.0. Both are pinned identically because the
dispatch is the path that actually runs after a changesets publish.

The account-resolution step sits under the same `continue-on-error` as the
smoke itself, so a misconfigured secret cannot turn the deliberately
non-blocking pre-merge check red.

CLAUDE.md: a feature leaving preview must join the smoke test's live suites
in the same PR — a GA'd feature the smoke never exercises is the mirror of
shipping commands without docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(smoke): repair stale UI-app prompt patterns and pin them to the copy (#77)

* fix(smoke): repair stale UI-app prompt patterns and pin them to the copy

The `ui` suite failed on main at step 24 with "pty run timed out waiting for
prompt 6/9" after 122s. The pty was fine — it cleared five prompts, list
navigation and two live registry reads. It was waiting for prompt copy that
no longer exists.

890b19e (#73) reworded three UI-app create prompts and did not touch this
suite. All three expect patterns were stale; the run died at the first:

  waiting for  /the menu entry text, and the button text on a card/
  CLI prints   Label — the menu entry’s text, and the card’s button text:

The new patterns anchor on short, punctuation-free openings. The old ones
spanned a curly apostrophe and an em dash, and were long enough to wrap in a
fixed-width pty transcript — two independent ways to break on a reword.

The patterns become a named export pinned by a test against `messages`. The
smoke must duplicate this copy: it drives the real binary, and under
`--against=published` that binary legitimately lags this repo, so no
assertion inside the suite can catch a reword. Verified in both directions —
restoring the old pattern fails the test.

Also fixes a skip message that named the wrong layer. With `suite=all` the
runner builds PREVIEW=1, so the public commands ARE in the binary and the API
is what refuses them — yet every dependent step reported
"public-distribution not available in this build". The runtime reason is now
recorded and quoted, because "not in this binary" and "the server said no"
send a reader to different repos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(smoke): add clarifying comment on placement prompt fixture

Addresses PR #77 review comment — clarifies that 'contact' is a
representative, non-load-bearing page name in the smoke pattern test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: add BREVO_OAUTH_BASE_URL and staging smoke environment (#79)

Make OAUTH_BASE overridable via BREVO_OAUTH_BASE_URL, matching the
existing BREVO_API_URL / BREVO_OAUTH_PROXY_URL pattern, so scope
lookups and scaffolded p…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants