From 96aca742d4cc2da440b2d1e6228cd6bd263c19e3 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Wed, 21 Oct 2026 13:54:19 +0900 Subject: [PATCH 1/6] feat(contacts): --dry-run and --on-conflict on contacts import and import-csv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both import commands take `--dry-run` (the API's `dryRun: true`: the response the import would return, nothing written) and `--on-conflict upsert|skip` (`skip` leaves existing contacts unchanged). An unknown mode is a usage error. The terminal summary of a dry run says "Dry run — nothing was written" and counts what it would add, update, fail and skip. The SDK pin moves to the commit that types both options (GetBrew/typescript-sdk claude/contact-import-dry-run), and the vendored spec and generated types are synced from GetBrew/brew-v2. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++ bun.lock | 4 +- docs/commands/README.md | 7 ++ openapi/public-api-v1.yaml | 33 ++++++- package.json | 2 +- src/commands/contacts/import-csv.ts | 6 ++ src/commands/contacts/import-modes.ts | 43 ++++++++ src/commands/contacts/import-summary.ts | 23 +++-- src/commands/contacts/import.ts | 5 + src/generated/openapi-types.ts | 31 +++++- .../commands/contacts-import-summary.test.ts | 24 +++++ tests/commands/contacts.test.ts | 99 +++++++++++++++++++ 12 files changed, 267 insertions(+), 17 deletions(-) create mode 100644 src/commands/contacts/import-modes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f5108f2..b2ce06a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ in `RELEASING.md` before release. - `contacts import-csv` names its batch in Import History: `--file-name`, else a `fileName` in `--input`, else the `--file` name. Both imports return `importId`. +- `contacts import` and `contacts import-csv` take `--dry-run` (print what + the import would do and write nothing: no contacts, fields or Import History + batch, no `importId`) and `--on-conflict upsert|skip` (`skip` creates + missing contacts and leaves existing ones unchanged, counted as skipped + with a `CONTACT_EXISTS_SKIPPED` warning). The terminal summary of a dry run + starts "Dry run — nothing was written". These flags require the matching + API deployment. - On a terminal, `contacts import` and `contacts import-csv` print the API's submitted total and outcome counts, plus failed rows and warnings with available row, email and field details. `--json` preserves the API envelope. diff --git a/bun.lock b/bun.lock index c4a1ef3..6e08383 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#f7608fa1d7771b9d023dea5bfc123b983d925408", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#3642cdc874962d921013e32e3508e753e33f7498", "commander": "^14.0.0", }, "devDependencies": { @@ -46,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#f7608fa", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-f7608fa", "sha512-cfc+iV7tYD+j3hoxf9ZXf4caSuhWO7D3VSPN/SvXaadhZIrIfPwECaMap/h/80N0WhavvDymp0OgV58UrAtrDg=="], + "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#3642cdc", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-3642cdc", "sha512-cTzYFWNlhv2k5zSaqeM0CbBWVYucK51MJpP+AI787+Db17kEILy0fFuR82cY0I7ITDG55S7rDNrwQRp/6g0fGg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/docs/commands/README.md b/docs/commands/README.md index e1a0cc3..63c1cd7 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -463,12 +463,15 @@ Bulk-import up to 1000 contacts from a JSON file or stdin as one Import History - `--file-name ` — Name the batch in Brew's Import History (default: the --file name) - `--validate` — Deliverability-check every imported address (2 credits per address) - `--consent-source ` — Default marketing consent record for rows without their own: api, form or import +- `--dry-run` — Show what the import would do and write nothing (no contacts, fields or Import History batch; --validate is skipped and nothing is charged) +- `--on-conflict ` — upsert (default) updates existing contacts; skip only creates missing ones and leaves existing contacts unchanged (counted as skipped) - `--input ` — Full JSON request body, or - to read stdin (flags override it) - `--idempotency-key ` — Idempotency-Key for safe retries (auto-generated otherwise) ```bash brew-cli contacts import --file contacts.json --consent-source import cat contacts.json | brew-cli contacts import --file - --file-name crm-export.json +brew-cli contacts import --file contacts.json --dry-run --on-conflict skip ``` ### brew-cli contacts import-csv @@ -484,6 +487,8 @@ Bulk-import contacts from a CSV file or stdin as one Import History batch (free; - `--date-order ` — month_first or day_first: how to read dates like 03/04/2026 (a day over 12 in the column wins; default month-first, with a DATE_ORDER_ASSUMED warning) - `--validate` — Deliverability-check every imported address (2 credits per CSV row) - `--consent-source ` — Stamp a marketing consent record on every row: api, form or import (sets the source of a record given in --input) +- `--dry-run` — Show what the import would do and write nothing (no contacts, fields or Import History batch; --validate is skipped and nothing is charged) +- `--on-conflict ` — upsert (default) updates existing contacts; skip only creates missing ones and leaves existing contacts unchanged (counted as skipped) - `--input ` — Full JSON request body, or - to read stdin (flags override it) - `--idempotency-key ` — Idempotency-Key for safe retries (auto-generated otherwise) @@ -491,6 +496,8 @@ Bulk-import contacts from a CSV file or stdin as one Import History batch (free; brew-cli contacts import-csv --file contacts.csv cat contacts.csv | brew-cli contacts import-csv --file - --mapping Email=email brew-cli contacts import-csv --file eu-signups.csv --date-order day_first --consent-source import +brew-cli contacts import-csv --file contacts.csv --dry-run +brew-cli contacts import-csv --file contacts.csv --on-conflict skip ``` ### brew-cli fields list diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 6f2ce8d..3731c61 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -2248,6 +2248,7 @@ components: - CORE_FIELD_IGNORED - DATE_ORDER_ASSUMED - CSV_ROW_SKIPPED + - CONTACT_EXISTS_SKIPPED - CSV_COLUMN_IGNORED - RECIPIENTS_EXCLUDED - CONSENT_RECORD_MISSING @@ -14005,7 +14006,7 @@ components: skipped: type: integer minimum: 0 - description: CSV rows omitted for a missing/invalid email or merged into a later duplicate. Up to 100 `CSV_ROW_SKIPPED` warnings name each `row`; a final warning counts further skipped rows. Always 0 for JSON. + description: 'Rows not written and not failed: existing contacts left alone by `onConflict: "skip"` (one `CONTACT_EXISTS_SKIPPED` warning each), and CSV rows omitted for a missing/invalid email or merged into a later duplicate (up to 100 `CSV_ROW_SKIPPED` warnings name each `row`; a final warning counts further skipped rows).' total: type: integer minimum: 0 @@ -14122,13 +14123,17 @@ components: type: string importId: type: string - description: This batch's Import History id. Each contact it wrote carries it as `importId` until a later import touches that contact, so a contact filter on `importId` lists them. + description: This batch's Import History id. Each contact it wrote carries it as `importId` until a later import touches that contact, so a contact filter on `importId` lists them. Absent on a dry run. + dryRun: + type: boolean + enum: + - true + description: "Present on a dry run: the counts say what the import would do, and nothing was written." required: - summary - fieldsCreated - errors - warnings - - importId additionalProperties: false ContactsImportCsvRequest: type: object @@ -14181,6 +14186,15 @@ components: minLength: 1 maxLength: 255 description: Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. + dryRun: + type: boolean + description: "When true, returns what this import would return and writes nothing: no contacts, fields, Import History entry or `importId`; `validate` is skipped and nothing is charged." + onConflict: + type: string + enum: + - upsert + - skip + description: "`upsert` (default) updates existing contacts; `skip` only creates missing ones, leaving each existing contact unchanged (counted in `summary.skipped`, with a `CONTACT_EXISTS_SKIPPED` warning)." required: - csv additionalProperties: false @@ -14266,6 +14280,15 @@ components: minLength: 1 maxLength: 255 description: Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. + dryRun: + type: boolean + description: "When true, returns what this import would return and writes nothing: no contacts, fields, Import History entry or `importId`; `validate` is skipped and nothing is charged." + onConflict: + type: string + enum: + - upsert + - skip + description: "`upsert` (default) updates existing contacts; `skip` only creates missing ones, leaving each existing contact unchanged (counted in `summary.skipped`, with a `CONTACT_EXISTS_SKIPPED` warning)." required: - contacts additionalProperties: false @@ -48683,6 +48706,8 @@ paths: A date column is read in ONE day/month order: the order its own dates prove (a day over 12 anywhere in the column, `13/04/2026`), else `dateOrder` (`day_first` reads `03/04/2026` as 3 April, `month_first` as 4 March), else month/day with a `warnings[]` entry (`code: DATE_ORDER_ASSUMED`, `field`). Dotted dates (`03.04.2026`) are day-first. ISO (`2026-04-03`), Unix seconds or milliseconds, month names (`3 Apr 2026`), year-first (`2026/04/03`) and compact (`20260403`) dates need no order. Convert to `YYYY-MM-DD` to be unambiguous. A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing. A word that is not a subscription status (`maybe`) fails its row (`code: FIELD_TYPE_MISMATCH`, `field: subscribed`) instead of importing the contact subscribed. A subscribed value only applies to new contacts: a row that asks to re-subscribe a contact who opted out keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). + + Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. requestBody: required: true content: @@ -49172,6 +49197,8 @@ paths: Create or update up to 1000 contacts from JSON rows and record them as one Import History batch: the JSON twin of `POST /v1/contacts/import-csv`. Rows take the batch-create shape (`email`, `firstName`, `lastName`, `subscribed`, `customFields`, `consent`) and follow its rules: a missing or invalid email, or an address repeated in the batch, fails its row (`errors[]`, `207`) while the others land. New contacts are subscribed unless the row says `subscribed: false`; re-subscribing a contact who opted out keeps its other fields and adds a `RESUBSCRIBE_SKIPPED` warning. The batch is listed in Import History under `fileName` (optional), and each contact it writes carries the returned `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). `POST /v1/contacts` with `contacts[]` writes the same rows without an Import History entry, for recurring syncs. + + Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. requestBody: required: true content: diff --git a/package.json b/package.json index 7bb5ad3..90f842b 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#f7608fa1d7771b9d023dea5bfc123b983d925408", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#3642cdc874962d921013e32e3508e753e33f7498", "commander": "^14.0.0" }, "devDependencies": { diff --git a/src/commands/contacts/import-csv.ts b/src/commands/contacts/import-csv.ts index 28d417b..0e98840 100644 --- a/src/commands/contacts/import-csv.ts +++ b/src/commands/contacts/import-csv.ts @@ -13,6 +13,7 @@ import { readTextFlag, requestOptions, } from '../../lib/input' +import { DRY_RUN_FLAG, importModeFlags, ON_CONFLICT_FLAG } from './import-modes' import { renderImportResult } from './import-summary' type ImportCsvContactsInput = Parameters[0] @@ -53,6 +54,8 @@ export const contactsImportCsvCommand = defineCommand({ summary: 'Stamp a marketing consent record on every row: api, form or import (sets the source of a record given in --input)', }, + DRY_RUN_FLAG, + ON_CONFLICT_FLAG, INPUT_FLAG, IDEMPOTENCY_FLAG, ], @@ -60,6 +63,8 @@ export const contactsImportCsvCommand = defineCommand({ 'brew-cli contacts import-csv --file contacts.csv', 'cat contacts.csv | brew-cli contacts import-csv --file - --mapping Email=email', 'brew-cli contacts import-csv --file eu-signups.csv --date-order day_first --consent-source import', + 'brew-cli contacts import-csv --file contacts.csv --dry-run', + 'brew-cli contacts import-csv --file contacts.csv --on-conflict skip', ], run: async ({ ctx, flags }) => { if (flags.input === '-' && flags.file === '-') { @@ -72,6 +77,7 @@ export const contactsImportCsvCommand = defineCommand({ mapping: parseKeyValues(flags.mapping, '--mapping'), dateOrder: flagString(flags.dateOrder), validate: flags.validate === true ? true : undefined, + ...importModeFlags(flags), }) const fileName = importFileName(flags, input) if (fileName !== undefined) { diff --git a/src/commands/contacts/import-modes.ts b/src/commands/contacts/import-modes.ts new file mode 100644 index 0000000..8dc37a8 --- /dev/null +++ b/src/commands/contacts/import-modes.ts @@ -0,0 +1,43 @@ +import { CliUsageError } from '../../lib/errors' +import { flagString } from '../../lib/input' + +/** `--dry-run` on both imports. */ +export const DRY_RUN_FLAG = { + flag: '--dry-run', + summary: + 'Show what the import would do and write nothing (no contacts, fields or Import History batch; --validate is skipped and nothing is charged)', +} as const + +/** `--on-conflict` on both imports. */ +export const ON_CONFLICT_FLAG = { + flag: '--on-conflict ', + summary: + 'upsert (default) updates existing contacts; skip only creates missing ones and leaves existing contacts unchanged (counted as skipped)', +} as const + +const ON_CONFLICT_MODES = ['upsert', 'skip'] as const + +/** + * The body keys `--dry-run` and `--on-conflict` set. An absent flag sets + * nothing, so a value from --input or the file stays. + */ +export function importModeFlags(flags: { + readonly dryRun?: unknown + readonly onConflict?: unknown +}): { dryRun?: true; onConflict?: (typeof ON_CONFLICT_MODES)[number] } { + const onConflict = flagString(flags.onConflict) + if ( + onConflict !== undefined && + !(ON_CONFLICT_MODES as ReadonlyArray).includes(onConflict) + ) { + throw new CliUsageError( + `--on-conflict takes upsert or skip, not "${onConflict}".` + ) + } + return { + ...(flags.dryRun === true ? { dryRun: true } : {}), + ...(onConflict !== undefined + ? { onConflict: onConflict as (typeof ON_CONFLICT_MODES)[number] } + : {}), + } +} diff --git a/src/commands/contacts/import-summary.ts b/src/commands/contacts/import-summary.ts index 6351b47..f2cff95 100644 --- a/src/commands/contacts/import-summary.ts +++ b/src/commands/contacts/import-summary.ts @@ -7,23 +7,29 @@ export const MAX_TERMINAL_ROWS = 20 /** * The terminal rendering of `contacts import` and `contacts import-csv`: one * line of counts, then the failures and warnings returned by the API. - * `--json` prints the full envelope. + * `--json` prints the full envelope. A dry run (`--dry-run`) says so first + * and counts what the import would do. */ export function renderImportResult( result: ImportContactsResponse, fileName: string | undefined ): string { const { summary } = result + const isDryRun = result.dryRun === true const counts = [ - `${summary.inserted} added`, - `${summary.updated} updated`, - `${summary.failed} failed`, - ...(summary.skipped > 0 ? [`${summary.skipped} skipped`] : []), + `${summary.inserted} ${isDryRun ? 'to add' : 'added'}`, + `${summary.updated} ${isDryRun ? 'to update' : 'updated'}`, + `${summary.failed} ${isDryRun ? 'to fail' : 'failed'}`, + ...(summary.skipped > 0 + ? [`${summary.skipped} ${isDryRun ? 'to skip' : 'skipped'}`] + : []), ].join(', ') const name = fileName ? `${fileName} ` : '' const rowNoun = summary.total === 1 ? 'row' : 'rows' const lines = [ - `Imported ${name}as ${result.importId}: ${counts} (${summary.total} ${rowNoun} submitted).`, + isDryRun + ? `Dry run${fileName ? ` of ${fileName}` : ''} — nothing was written: ${counts} (${summary.total} ${rowNoun} submitted).` + : `Imported ${name}as ${result.importId}: ${counts} (${summary.total} ${rowNoun} submitted).`, ] if (result.errors.length > 0) { lines.push( @@ -52,7 +58,10 @@ export function renderImportResult( ) } if (result.fieldsCreated.length > 0) { - lines.push('', `New custom fields: ${result.fieldsCreated.join(', ')}.`) + lines.push( + '', + `${isDryRun ? 'Custom fields it would create' : 'New custom fields'}: ${result.fieldsCreated.join(', ')}.` + ) } if (result.validation) { const { valid, risky, invalid, unscored, persistence } = result.validation diff --git a/src/commands/contacts/import.ts b/src/commands/contacts/import.ts index 7c77d22..a0ea920 100644 --- a/src/commands/contacts/import.ts +++ b/src/commands/contacts/import.ts @@ -12,6 +12,7 @@ import { requestOptions, } from '../../lib/input' import { importFileName } from './import-csv' +import { DRY_RUN_FLAG, importModeFlags, ON_CONFLICT_FLAG } from './import-modes' import { renderImportResult } from './import-summary' type ImportContactsInput = Parameters[0] @@ -44,12 +45,15 @@ export const contactsImportCommand = defineCommand({ summary: 'Default marketing consent record for rows without their own: api, form or import', }, + DRY_RUN_FLAG, + ON_CONFLICT_FLAG, INPUT_FLAG, IDEMPOTENCY_FLAG, ], examples: [ 'brew-cli contacts import --file contacts.json --consent-source import', 'cat contacts.json | brew-cli contacts import --file - --file-name crm-export.json', + 'brew-cli contacts import --file contacts.json --dry-run --on-conflict skip', ], run: async ({ ctx, flags }) => { if (flags.input === '-' && flags.file === '-') { @@ -69,6 +73,7 @@ export const contactsImportCommand = defineCommand({ const input = mergeInput(body, { fileName: importFileName(flags, body), validate: flags.validate === true ? true : undefined, + ...importModeFlags(flags), }) if (!Array.isArray(input.contacts) || input.contacts.length === 0) { throw new CliUsageError( diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index 57b2455..95ff288 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -1552,6 +1552,8 @@ export interface paths { * A date column is read in ONE day/month order: the order its own dates prove (a day over 12 anywhere in the column, `13/04/2026`), else `dateOrder` (`day_first` reads `03/04/2026` as 3 April, `month_first` as 4 March), else month/day with a `warnings[]` entry (`code: DATE_ORDER_ASSUMED`, `field`). Dotted dates (`03.04.2026`) are day-first. ISO (`2026-04-03`), Unix seconds or milliseconds, month names (`3 Apr 2026`), year-first (`2026/04/03`) and compact (`20260403`) dates need no order. Convert to `YYYY-MM-DD` to be unambiguous. * * A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing. A word that is not a subscription status (`maybe`) fails its row (`code: FIELD_TYPE_MISMATCH`, `field: subscribed`) instead of importing the contact subscribed. A subscribed value only applies to new contacts: a row that asks to re-subscribe a contact who opted out keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). + * + * Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. */ post: operations["importContactsCsv"]; delete?: never; @@ -1574,6 +1576,8 @@ export interface paths { * @description Create or update up to 1000 contacts from JSON rows and record them as one Import History batch: the JSON twin of `POST /v1/contacts/import-csv`. Rows take the batch-create shape (`email`, `firstName`, `lastName`, `subscribed`, `customFields`, `consent`) and follow its rules: a missing or invalid email, or an address repeated in the batch, fails its row (`errors[]`, `207`) while the others land. New contacts are subscribed unless the row says `subscribed: false`; re-subscribing a contact who opted out keeps its other fields and adds a `RESUBSCRIBE_SKIPPED` warning. * * The batch is listed in Import History under `fileName` (optional), and each contact it writes carries the returned `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). `POST /v1/contacts` with `contacts[]` writes the same rows without an Import History entry, for recurring syncs. + * + * Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. */ post: operations["importContacts"]; delete?: never; @@ -3448,7 +3452,7 @@ export interface components { }; ApiWarning: { /** @enum {string} */ - code: "DRAFT_SAVED_NOT_LIVE" | "TRIGGER_EVENT_NOT_RECEIVED" | "ENFORCEMENT_LOOSENED_WHILE_PUBLISHED" | "REQUIRED_FIELD_SATISFIED_BY_FALLBACK" | "RESUBSCRIBE_SKIPPED" | "CORE_FIELD_IGNORED" | "DATE_ORDER_ASSUMED" | "CSV_ROW_SKIPPED" | "CSV_COLUMN_IGNORED" | "RECIPIENTS_EXCLUDED" | "CONSENT_RECORD_MISSING"; + code: "DRAFT_SAVED_NOT_LIVE" | "TRIGGER_EVENT_NOT_RECEIVED" | "ENFORCEMENT_LOOSENED_WHILE_PUBLISHED" | "REQUIRED_FIELD_SATISFIED_BY_FALLBACK" | "RESUBSCRIBE_SKIPPED" | "CORE_FIELD_IGNORED" | "DATE_ORDER_ASSUMED" | "CSV_ROW_SKIPPED" | "CONTACT_EXISTS_SKIPPED" | "CSV_COLUMN_IGNORED" | "RECIPIENTS_EXCLUDED" | "CONSENT_RECORD_MISSING"; message: string; field?: string; }; @@ -7673,7 +7677,7 @@ export interface components { inserted: number; updated: number; failed: number; - /** @description CSV rows omitted for a missing/invalid email or merged into a later duplicate. Up to 100 `CSV_ROW_SKIPPED` warnings name each `row`; a final warning counts further skipped rows. Always 0 for JSON. */ + /** @description Rows not written and not failed: existing contacts left alone by `onConflict: "skip"` (one `CONTACT_EXISTS_SKIPPED` warning each), and CSV rows omitted for a missing/invalid email or merged into a later duplicate (up to 100 `CSV_ROW_SKIPPED` warnings name each `row`; a final warning counts further skipped rows). */ skipped: number; /** @description Rows submitted (JSON contacts, or CSV data rows). Always `inserted + updated + failed + skipped`. */ total: number; @@ -7711,8 +7715,13 @@ export interface components { }; }; validationJobId?: string; - /** @description This batch's Import History id. Each contact it wrote carries it as `importId` until a later import touches that contact, so a contact filter on `importId` lists them. */ - importId: string; + /** @description This batch's Import History id. Each contact it wrote carries it as `importId` until a later import touches that contact, so a contact filter on `importId` lists them. Absent on a dry run. */ + importId?: string; + /** + * @description Present on a dry run: the counts say what the import would do, and nothing was written. + * @enum {boolean} + */ + dryRun?: true; }; ContactsImportCsvRequest: { csv: string; @@ -7743,6 +7752,13 @@ export interface components { dateOrder?: "month_first" | "day_first"; /** @description Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. */ fileName?: string; + /** @description When true, returns what this import would return and writes nothing: no contacts, fields, Import History entry or `importId`; `validate` is skipped and nothing is charged. */ + dryRun?: boolean; + /** + * @description `upsert` (default) updates existing contacts; `skip` only creates missing ones, leaving each existing contact unchanged (counted in `summary.skipped`, with a `CONTACT_EXISTS_SKIPPED` warning). + * @enum {string} + */ + onConflict?: "upsert" | "skip"; }; ContactsImportRequest: { contacts: { @@ -7788,6 +7804,13 @@ export interface components { }; /** @description Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. */ fileName?: string; + /** @description When true, returns what this import would return and writes nothing: no contacts, fields, Import History entry or `importId`; `validate` is skipped and nothing is charged. */ + dryRun?: boolean; + /** + * @description `upsert` (default) updates existing contacts; `skip` only creates missing ones, leaving each existing contact unchanged (counted in `summary.skipped`, with a `CONTACT_EXISTS_SKIPPED` warning). + * @enum {string} + */ + onConflict?: "upsert" | "skip"; }; ContactsBatchDeleteResponse: { deletedCount: number; diff --git a/tests/commands/contacts-import-summary.test.ts b/tests/commands/contacts-import-summary.test.ts index 58c54e3..b6b33ab 100644 --- a/tests/commands/contacts-import-summary.test.ts +++ b/tests/commands/contacts-import-summary.test.ts @@ -70,6 +70,30 @@ describe('renderImportResult (contacts import / import-csv on a terminal)', () = ) }) + it('renders a dry run as what the import would do, with no importId', () => { + const { importId: _none, ...dry } = result({ + summary: { inserted: 2, updated: 1, failed: 0, skipped: 1, total: 4 }, + errors: [], + warnings: [ + { + code: 'CONTACT_EXISTS_SKIPPED', + email: 'ada@brewmail.dev', + row: 3, + message: + 'ada@brewmail.dev already exists, so onConflict: "skip" left it unchanged.', + }, + ], + fieldsCreated: ['plan'], + }) + const text = renderImportResult({ ...dry, dryRun: true }, undefined) + expect(text.split('\n')[0]).toBe( + 'Dry run — nothing was written: 2 to add, 1 to update, 0 to fail, 1 to skip (4 rows submitted).' + ) + expect(text).toMatch(/3\s+ada@brewmail\.dev\s+CONTACT_EXISTS_SKIPPED/) + expect(text).toContain('Custom fields it would create: plan.') + expect(text).not.toContain('undefined') + }) + it('caps a long table and points at --json for the rest', () => { const errors = Array.from({ length: MAX_TERMINAL_ROWS + 5 }, (_, i) => ({ email: `x${i}@brewmail.dev`, diff --git a/tests/commands/contacts.test.ts b/tests/commands/contacts.test.ts index 0af98a4..fa9c04d 100644 --- a/tests/commands/contacts.test.ts +++ b/tests/commands/contacts.test.ts @@ -447,6 +447,77 @@ describe('contacts import-csv', () => { }) }) + it('sends --dry-run and --on-conflict, and renders the dry run as written nowhere', async () => { + let body: unknown + server.use( + http.post( + 'https://brew.new/api/v1/contacts/import-csv', + async ({ request }) => { + body = await request.json() + return HttpResponse.json({ + summary: { + inserted: 1, + updated: 0, + failed: 0, + skipped: 1, + total: 2, + }, + fieldsCreated: [], + errors: [], + warnings: [ + { + code: 'CONTACT_EXISTS_SKIPPED', + email: 'jane@example.com', + row: 1, + message: + 'jane@example.com already exists, so onConflict: "skip" left it unchanged.', + }, + ], + dryRun: true, + }) + } + ) + ) + const dir = mkdtempSync(join(tmpdir(), 'brew-cli-csv-')) + const csvPath = join(dir, 'contacts.csv') + writeFileSync(csvPath, 'Email\njane@example.com\nnew@example.com\n') + const result = await runCli( + [ + 'contacts', + 'import-csv', + '--file', + csvPath, + '--dry-run', + '--on-conflict', + 'skip', + ], + { env: env(), ttyOut: true } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ + csv: 'Email\njane@example.com\nnew@example.com\n', + dryRun: true, + onConflict: 'skip', + fileName: 'contacts.csv', + }) + expect(result.stdout).toContain( + 'Dry run of contacts.csv — nothing was written: 1 to add, 0 to update, 0 to fail, 1 to skip (2 rows submitted).' + ) + expect(result.stdout).toContain('CONTACT_EXISTS_SKIPPED') + }) + + it('refuses an --on-conflict mode the API does not take', async () => { + const dir = mkdtempSync(join(tmpdir(), 'brew-cli-csv-')) + const csvPath = join(dir, 'contacts.csv') + writeFileSync(csvPath, 'Email\njane@example.com\n') + const result = await runCli( + ['contacts', 'import-csv', '--file', csvPath, '--on-conflict', 'merge'], + { env: env() } + ) + expect(result.code).toBe(2) + expect(result.stderr).toContain('--on-conflict takes upsert or skip') + }) + it('refuses a call with no CSV at all', async () => { const result = await runCli( ['contacts', 'import-csv', '--date-order', 'day_first'], @@ -536,6 +607,34 @@ describe('contacts import-csv', () => { describe('contacts import', () => { const IMPORT_URL = 'https://brew.new/api/v1/contacts/import' + it('sends --dry-run and --on-conflict over a body that sets neither', async () => { + let body: unknown + server.use( + http.post(IMPORT_URL, async ({ request }) => { + body = await request.json() + return HttpResponse.json({ dryRun: true }) + }) + ) + const result = await runCli( + [ + 'contacts', + 'import', + '--file', + '-', + '--dry-run', + '--on-conflict', + 'skip', + ], + { env: env(), stdin: JSON.stringify([{ email: 'a@example.com' }]) } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ + contacts: [{ email: 'a@example.com' }], + dryRun: true, + onConflict: 'skip', + }) + }) + it('keeps validate, consent and fileName a JSON body sets; flags win only when given', async () => { const bodies: Array = [] server.use( From 4b8a38a0bb5d0703fa5adb9e5dfd596b22f14c6d Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Wed, 21 Oct 2026 20:46:36 +0900 Subject: [PATCH 2/6] Pin the SDK at sdk#79 head 1e11f3f (base 25f8e1e merged) Co-Authored-By: Claude Opus 5.5 --- RELEASING.md | 2 +- bun.lock | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index bd109f5..ad2c07d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -27,7 +27,7 @@ lockfile checksums must never be invented. The canvas tests must preserve The new `contacts import` command requires `contacts.import`, which the published SDK 13.0.0 does not expose. This branch pins reviewed SDK source -commit `3ff0329543d920d73b8719b57b0b0f1bb77e104f` for coordinated PR validation. +commit `1e11f3fc58cc0b72756e23c416da1f881df4b14f` for coordinated PR validation. It preserves SDK 13.0.0's features and version while adding the import method and its `dryRun` and `onConflict` options. diff --git a/bun.lock b/bun.lock index 09344c3..597712b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#3ff0329543d920d73b8719b57b0b0f1bb77e104f", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#1e11f3fc58cc0b72756e23c416da1f881df4b14f", "commander": "^14.0.0", }, "devDependencies": { @@ -46,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#3ff0329", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-3ff0329", "sha512-lDCIIq3SuJqi8s9OLXLibQtRpPqO6tPUOn68FptDGXOuHCKuTaUbwaD3BrrCzBd8KQguxb1zKu93fnnMfUr/bw=="], + "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#1e11f3f", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-1e11f3f", "sha512-/wBmSj7R3zm+O3NGdLBYDVFLi/SiQxFTUSS/THbrUnj6Lnj4V/TXuK59Gohn6kzzv8zYe4T4Kzja4F5IJ86eUg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index 4ad4a31..a86089f 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#3ff0329543d920d73b8719b57b0b0f1bb77e104f", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#1e11f3fc58cc0b72756e23c416da1f881df4b14f", "commander": "^14.0.0" }, "devDependencies": { From 7af8748a26be5b05ca616d600176a666d3b24b72 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Wed, 21 Oct 2026 21:11:23 +0900 Subject: [PATCH 3/6] test(contacts): pin dryRun and onConflict on the wire for both imports A body's dryRun/onConflict reach the request unchanged when no flag is given, and --on-conflict overrides a conflicting body value (contacts import and import-csv); the --input dry-run test asserts the request too. Greptile on 2bec9ce. Co-Authored-By: Claude Opus 5.5 --- tests/commands/contacts.test.ts | 51 ++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/tests/commands/contacts.test.ts b/tests/commands/contacts.test.ts index 565e8bb..42f97bd 100644 --- a/tests/commands/contacts.test.ts +++ b/tests/commands/contacts.test.ts @@ -644,7 +644,13 @@ describe('contacts import', () => { }) it('renders a dry run that --input asked for, with no flag', async () => { - server.use(http.post(IMPORT_URL, () => HttpResponse.json(DRY_RUN_ENVELOPE))) + let body: unknown + server.use( + http.post(IMPORT_URL, async ({ request }) => { + body = await request.json() + return HttpResponse.json(DRY_RUN_ENVELOPE) + }) + ) const result = await runCli( [ 'contacts', @@ -658,11 +664,54 @@ describe('contacts import', () => { { env: env(), ttyOut: true } ) expect(result.code).toBe(0) + // The preview the body asked for is still a preview on the wire. + expect(body).toEqual({ + contacts: [{ email: 'a@example.com' }], + dryRun: true, + }) expect(result.stdout).toContain( 'Dry run — nothing was written: 1 to add, 0 to update, 0 to fail (1 row submitted).' ) }) + it.each([ + ['import', IMPORT_URL, { contacts: [{ email: 'a@example.com' }] }], + [ + 'import-csv', + 'https://brew.new/api/v1/contacts/import-csv', + { csv: 'Email\na@example.com\n' }, + ], + ] as const)( + 'contacts %s keeps dryRun and onConflict from the body, and a flag wins over it', + async (command, url, rows) => { + const bodies: Array = [] + server.use( + http.post(url, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json(DRY_RUN_ENVELOPE) + }) + ) + const input = JSON.stringify({ + ...rows, + dryRun: true, + onConflict: 'upsert', + }) + const kept = await runCli(['contacts', command, '--input', input], { + env: env(), + }) + const overridden = await runCli( + ['contacts', command, '--input', input, '--on-conflict', 'skip'], + { env: env() } + ) + expect(kept.code).toBe(0) + expect(overridden.code).toBe(0) + expect(bodies).toEqual([ + { ...rows, dryRun: true, onConflict: 'upsert' }, + { ...rows, dryRun: true, onConflict: 'skip' }, + ]) + } + ) + it('refuses an --on-conflict mode the API does not take', async () => { const result = await runCli( ['contacts', 'import', '--file', '-', '--on-conflict', 'merge'], From 6ee3b1a13e1ebdab13c6587523fe2bcd9d2a9dc0 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sat, 24 Oct 2026 22:56:51 +0900 Subject: [PATCH 4/6] feat(contacts): sync --dry-run/--on-conflict with the merged app; flows get --kind - Re-vendor the spec from brew-v2 main 87ac2c6242 (#2220 merged) and pin the SDK at its #79 head (a02b304) until the release. - flows get takes a / slug and --kind, via the SDK. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + bun.lock | 4 +- docs/commands/README.md | 4 +- openapi/public-api-v1.yaml | 308 +++++++++++++++++++++++++++++++-- package.json | 2 +- src/commands/flows/get.ts | 16 +- src/generated/openapi-types.ts | 57 ++++-- 7 files changed, 369 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc51b30..f87455c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,12 @@ in `RELEASING.md` before release. - `contacts upsert-many` says what it is for: up to 1000 contacts per call (its summary said 100) with no Import History entry, for recurring syncs. + +### Fixed + +- `flows get` takes a `/` slug from `flows list` (one send + stream's flow) and a `--kind` flag for a bare-domain slug. + ## 0.17.0 Requires `@brew.new/sdk` `^13.0.0`. This is a minor release because it adds diff --git a/bun.lock b/bun.lock index 597712b..6acc25f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#1e11f3fc58cc0b72756e23c416da1f881df4b14f", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#a02b304ef8942a19dcaa437a097d33b49ad78fe8", "commander": "^14.0.0", }, "devDependencies": { @@ -46,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#1e11f3f", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-1e11f3f", "sha512-/wBmSj7R3zm+O3NGdLBYDVFLi/SiQxFTUSS/THbrUnj6Lnj4V/TXuK59Gohn6kzzv8zYe4T4Kzja4F5IJ86eUg=="], + "@brew.new/sdk": ["@brew.new/sdk@github:GetBrew/typescript-sdk#a02b304", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "GetBrew-typescript-sdk-a02b304", "sha512-//MoNZi2gjeM9c7M6f7JORWg8X/HJ/nE1G8hsXI9JCNYTx1IKFH53X9XGf6GSyqlCsLIarNj+fm+dIh3FBzD7Q=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/docs/commands/README.md b/docs/commands/README.md index 5230a42..f2b3193 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2500,12 +2500,14 @@ Fetch one public email flow by brand domain, with every step (day offset, wait, - Route: `GET /v1/flows/{slug}` - Class: read - SDK: `brew.flows.get(...)` -- Argument `slug` — The brand domain a `flows list` card carries (e.g. brew.new) +- Argument `slug` — The slug a `flows list` card carries: a brand domain (brew.new) or / (brew.new/welcome) - `--include ` — Comma-separated expansions: html (each step’s rendered HTML, best-effort per step) +- `--kind ` — One send stream’s flow for a bare-domain slug (e.g. welcome); a / slug already names it ```bash brew-cli flows get brew.new brew-cli flows get brew.new --include html --json +brew-cli flows get brew.new/welcome ``` ### brew-cli integrations list diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 881c4cf..70a50a3 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -114,6 +114,55 @@ components: previewText: type: string description: The design's inbox preview line, read directly from the latest version's JSX (its single source of truth) — what a send delivers when no explicit `previewText` override is passed to POST /v1/sends. Detail-only; absent when the design has no . + lang: + type: string + enum: + - en + - de + - fr + - es + - pt + - it + - nl + - pl + - sv + - da + - 'no' + - fi + - cs + - sk + - hu + - ro + - bg + - el + - tr + - ru + - uk + - hr + - sr + - sl + - lt + - lv + - et + - ca + - ar + - he + - fa + - ur + - hi + - bn + - th + - vi + - id + - ms + - tl + - ja + - ko + - zh-Hans + - zh-Hant + - sw + - af + description: The language the email's copy is written in (ISO 639-1, or zh-Hans / zh-Hant), read by Brew's language model after each content change unless a caller set it. Rendered as the HTML document's lang. Detail-only; absent until it has been read. html: type: string versions: @@ -196,6 +245,55 @@ components: previewText: type: string description: The design's inbox preview line, read directly from the latest version's JSX (its single source of truth) — what a send delivers when no explicit `previewText` override is passed to POST /v1/sends. Detail-only; absent when the design has no . + lang: + type: string + enum: + - en + - de + - fr + - es + - pt + - it + - nl + - pl + - sv + - da + - 'no' + - fi + - cs + - sk + - hu + - ro + - bg + - el + - tr + - ru + - uk + - hr + - sr + - sl + - lt + - lv + - et + - ca + - ar + - he + - fa + - ur + - hi + - bn + - th + - vi + - id + - ms + - tl + - ja + - ko + - zh-Hans + - zh-Hant + - sw + - af + description: The language the email's copy is written in (ISO 639-1, or zh-Hans / zh-Hant), read by Brew's language model after each content change unless a caller set it. Rendered as the HTML document's lang. Detail-only; absent until it has been read. html: type: string versions: @@ -3564,6 +3662,14 @@ components: enum: - newsletter - signup + kind: + type: string + enum: + - onboarding + - trial + - events + - product_updates + - promotions category: type: string minLength: 1 @@ -4673,6 +4779,7 @@ components: - send_review_rejected - comment_mention - comment_reply + - integration_attention status: type: string enum: @@ -4900,6 +5007,55 @@ components: previewText: type: string description: The design's inbox preview line, read directly from the latest version's JSX (its single source of truth) — what a send delivers when no explicit `previewText` override is passed to POST /v1/sends. Detail-only; absent when the design has no . + lang: + type: string + enum: + - en + - de + - fr + - es + - pt + - it + - nl + - pl + - sv + - da + - 'no' + - fi + - cs + - sk + - hu + - ro + - bg + - el + - tr + - ru + - uk + - hr + - sr + - sl + - lt + - lv + - et + - ca + - ar + - he + - fa + - ur + - hi + - bn + - th + - vi + - id + - ms + - tl + - ja + - ko + - zh-Hans + - zh-Hant + - sw + - af + description: The language the email's copy is written in (ISO 639-1, or zh-Hans / zh-Hant), read by Brew's language model after each content change unless a caller set it. Rendered as the HTML document's lang. Detail-only; absent until it has been read. html: type: string versions: @@ -5245,6 +5401,55 @@ components: minLength: 1 maxLength: 250 description: Inbox subject line to set on the design (`subjectLine` — distinct from `title`, the canvas name). Sends still take an explicit per-send `subject`; this is the design's default, seeded into the send dialog and returned by `GET /v1/emails/{emailId}`. + lang: + type: string + enum: + - en + - de + - fr + - es + - pt + - it + - nl + - pl + - sv + - da + - 'no' + - fi + - cs + - sk + - hu + - ro + - bg + - el + - tr + - ru + - uk + - hr + - sr + - sl + - lt + - lv + - et + - ca + - ar + - he + - fa + - ur + - hi + - bn + - th + - vi + - id + - ms + - tl + - ja + - ko + - zh-Hans + - zh-Hant + - sw + - af + description: "ISO 639-1 language code of the email's copy (or zh-Hans / zh-Hant). Omit and Brew's language model reads it from the finished body; a value you set is kept and never re-detected. Becomes the HTML document's lang (and dir for right-to-left languages) on every send and export. One of: en, de, fr, es, pt, it, nl, pl, sv, da, no, fi, cs, sk, hu, ro, bg, el, tr, ru, uk, hr, sr, sl, lt, lv, et, ca, ar, he, fa, ur, hi, bn, th, vi, id, ms, tl, ja, ko, zh-Hans, zh-Hant, sw, af." groupId: type: string minLength: 1 @@ -5567,7 +5772,7 @@ components: prompt: type: string minLength: 1 - description: The edit to make, in plain language — e.g. "swap the hero for the spring campaign image and tighten the CTA copy". Scoped edits beat full rewrites. Omit to only patch `title`, `subjectLine` or `groupId` (at least one of the four is required). + description: The edit to make, in plain language — e.g. "swap the hero for the spring campaign image and tighten the CTA copy". Scoped edits beat full rewrites. Omit to only patch `title`, `subjectLine`, `lang` or `groupId` (at least one of the five is required). emailVersionId: type: string minLength: 1 @@ -5590,6 +5795,58 @@ components: minLength: 1 maxLength: 250 description: Inbox subject line to set on the design (`subjectLine` — distinct from `title`, the canvas name). Sends still take an explicit per-send `subject`; this is the design's default, seeded into the send dialog and returned by `GET /v1/emails/{emailId}`. + lang: + type: + - string + - "null" + enum: + - en + - de + - fr + - es + - pt + - it + - nl + - pl + - sv + - da + - 'no' + - fi + - cs + - sk + - hu + - ro + - bg + - el + - tr + - ru + - uk + - hr + - sr + - sl + - lt + - lv + - et + - ca + - ar + - he + - fa + - ur + - hi + - bn + - th + - vi + - id + - ms + - tl + - ja + - ko + - zh-Hans + - zh-Hant + - sw + - af + - null + description: "Set the language of the email (ISO 639-1, or zh-Hans / zh-Hant). Part of the free envelope patch: no AI run, no new version. Once set it is kept: Brew no longer re-detects it after edits. null clears a language you set, and Brew reads it again from the email." groupId: type: - string @@ -16707,6 +16964,14 @@ components: enum: - newsletter - signup + kind: + type: string + enum: + - onboarding + - trial + - events + - product_updates + - promotions category: type: string minLength: 1 @@ -18135,6 +18400,7 @@ components: - send_review_rejected - comment_mention - comment_reply + - integration_attention status: type: string enum: @@ -18799,7 +19065,7 @@ paths: **Use when** you have a brief and want Brew to author on brand. For existing markup use `importEmail`; for a Figma frame use `importFigmaDesign`. - **Input** `prompt`; optional `category` (steers the design treatment only: transactional categories get receipt or reset conventions, while delivery for those flows is a triggered automation on a transactional-purpose domain), `contentUrls`, `referenceEmailId`, `subjectLine` (the design-default inbox subject; `createSend` still takes a per-send `subject`), and `groupId` (`grp_…` or `ungrouped`) or `groupName` (resolve or create), not both; omit to land Ungrouped. Supports `Idempotency-Key`. + **Input** `prompt`; optional `category` (steers the design treatment only: transactional categories get receipt or reset conventions, while delivery for those flows is a triggered automation on a transactional-purpose domain), `contentUrls`, `referenceEmailId`, `subjectLine` (the design-default inbox subject; `createSend` still takes a per-send `subject`), `lang` (the language of the copy, ISO 639-1; omit and Brew detects it from the finished body), and `groupId` (`grp_…` or `ungrouped`) or `groupName` (resolve or create), not both; omit to land Ungrouped. Supports `Idempotency-Key`. **Returns** `201 { emailId, emailVersionId, html, previewImage?, group, subjectLine? }` when a design was persisted, or `200 { response }` when the agent answered in prose without writing a design. @@ -19363,7 +19629,7 @@ paths: - Emails summary: Get an email design description: |- - Reads one design as the bare `EmailDetail` row: `emailId`, the `emailVersionId` (the pin for `POST /v1/sends` and sendEmail nodes), `title`, `status`, `previewStatus`, `subjectLine`, `previewText`, `previewImage`, `updatedAt`, `group` and `createdBy` / `createdByUserId` (the design’s creator, shared by every version), plus `errorMessage` when a generation failed. + Reads one design as the bare `EmailDetail` row: `emailId`, the `emailVersionId` (the pin for `POST /v1/sends` and sendEmail nodes), `title`, `status`, `previewStatus`, `subjectLine`, `previewText`, `lang`, `previewImage`, `updatedAt`, `group` and `createdBy` / `createdByUserId` (the design’s creator, shared by every version), plus `errorMessage` when a generation failed. **Use when** inspecting a design before sending it, pinning a version, polling a generation you started, or pulling rendered HTML or version history. @@ -19816,11 +20082,11 @@ paths: - Emails summary: Edit an email design description: |- - Updates a design: an AI edit (`prompt`), its envelope (`title`, `subjectLine`, `groupId`), or both in one call. At least one of the four is required. A design renames and moves between groups here exactly like an audience, trigger, automation or group renames through its own PATCH. + Updates a design: an AI edit (`prompt`), its envelope (`title`, `subjectLine`, `lang`, `groupId`), or both in one call. At least one of the five is required. A design renames and moves between groups here exactly like an audience, trigger, automation or group renames through its own PATCH. **Use when** changing an existing design: rewording or restyling it with a prompt, renaming it (a clone starts as `Copy of …`), setting the inbox subject the design carries by default, or filing it in another group. - **Input** `prompt` runs the Brew email agent against the current latest version (or the `emailVersionId` pin, optionally grounded by `contentUrls`) and writes a new `version: "latest"` row on the same `emailId` (the previous head becomes a numeric historical version); usage-metered by actual token spend. `title` renames the design (its canvas name, 1 to 200 characters), `subjectLine` sets the default inbox subject and `groupId` moves it into an existing group (`null` = Ungrouped); any of them without `prompt` is ONE atomic in-place patch (no AI run, no new version, free). With `prompt`, the rename and move land first so the new version inherits them and the subject is applied to the new head. `emailVersionId` requires `prompt`. + **Input** `prompt` runs the Brew email agent against the current latest version (or the `emailVersionId` pin, optionally grounded by `contentUrls`) and writes a new `version: "latest"` row on the same `emailId` (the previous head becomes a numeric historical version); usage-metered by actual token spend. `title` renames the design (its canvas name, 1 to 200 characters), `subjectLine` sets the default inbox subject, `lang` sets the language of the copy (kept from then on; Brew stops re-detecting it) and `groupId` moves it into an existing group (`null` = Ungrouped); any of them without `prompt` is ONE atomic in-place patch (no AI run, no new version, free). With `prompt`, the rename and move land first so the new version inherits them and the subject is applied to the new head. `emailVersionId` requires `prompt`. **Returns** `200` with the generated-email shape: the NEW head after a prompt edit, or the CURRENT head after an envelope patch (with its `title`, `subjectLine` and `group` after the patch); a text response when the agent answered in prose. @@ -19851,7 +20117,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg requestBody: required: true - description: A natural-language `prompt` edit (optional `emailVersionId` source pin, optional `contentUrls`) and/or the envelope fields `title`, `subjectLine` and `groupId` — at least one of the four. Identity lives on the path. + description: A natural-language `prompt` edit (optional `emailVersionId` source pin, optional `contentUrls`) and/or the envelope fields `title`, `subjectLine`, `lang` and `groupId` — at least one of the five. Identity lives on the path. content: application/json: schema: @@ -48705,7 +48971,7 @@ paths: A date column is read in ONE day/month order: the order its own dates prove (a day over 12 anywhere in the column, `13/04/2026`), else `dateOrder` (`day_first` reads `03/04/2026` as 3 April, `month_first` as 4 March), else month/day with a `warnings[]` entry (`code: DATE_ORDER_ASSUMED`, `field`). Dotted dates (`03.04.2026`) are day-first. ISO (`2026-04-03`), Unix seconds or milliseconds, month names (`3 Apr 2026`), year-first (`2026/04/03`) and compact (`20260403`) dates need no order. Convert to `YYYY-MM-DD` to be unambiguous. - A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing. A word that is not a subscription status (`maybe`) fails its row (`code: FIELD_TYPE_MISMATCH`, `field: subscribed`) instead of importing the contact subscribed. A subscribed value only applies to new contacts: a row that asks to re-subscribe a contact who opted out keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). + A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing (with `onConflict: "skip"` an existing contact is left unchanged, and its `CONTACT_EXISTS_SKIPPED` warning says so). A word that is not a subscription status (`maybe`) fails its row (`code: FIELD_TYPE_MISMATCH`, `field: subscribed`) instead of importing the contact subscribed. A subscribed value only applies to new contacts: a row that asks to re-subscribe a contact who opted out keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. A dry run cannot foresee failures that only happen while writing (a concurrent change, a database refusal), so the real run can still fail where the preview succeeded. With `validate: true`, inline checks are still metered per submitted row, rows `skip` left alone included; a request that leaves no address to check is not charged. requestBody: @@ -60089,6 +60355,19 @@ paths: description: "Filter LIST by how the sequence starts: `signup` (after creating an account) or `newsletter` (after subscribing)." name: type in: query + - schema: + type: string + enum: + - onboarding + - trial + - events + - product_updates + - promotions + description: Filter LIST by send-stream kind. + required: false + description: Filter LIST by send-stream kind. + name: kind + in: query - schema: type: string minLength: 1 @@ -60426,12 +60705,20 @@ paths: type: string minLength: 1 maxLength: 253 - description: The flow’s brand domain, as returned in every `GET /v1/flows` row. Case-insensitive. + description: The flow’s brand domain. A bare domain returns the brand’s primary flow; one send stream’s flow is `?kind=` on it (or the path `/v1/flows/{domain}/{kind}`), as each `GET /v1/flows` row’s `slug` (`/`) names it. Case-insensitive. example: brew.new required: true - description: The flow’s brand domain, as returned in every `GET /v1/flows` row. Case-insensitive. + description: The flow’s brand domain. A bare domain returns the brand’s primary flow; one send stream’s flow is `?kind=` on it (or the path `/v1/flows/{domain}/{kind}`), as each `GET /v1/flows` row’s `slug` (`/`) names it. Case-insensitive. name: slug in: path + - schema: + type: string + pattern: ^[a-z][a-z-]*[a-z](?:-[0-9]{1,3})?$ + description: Stream segment for a bare-domain `slug`. + required: false + description: Stream segment for a bare-domain `slug`. + name: kind + in: query - schema: type: string description: "Detail-only expansions, comma-separated. `html` adds each step’s rendered HTML. Rendered email is bulky — over MCP only the first step or two fit one response; the rest arrive without `html` and the summary says how many were kept. A step’s `emailId` is already a valid `referenceEmailId`, so reuse needs no body; for the bodies themselves call `GET /v1/flows/{slug}?include=html` over REST, which has no cap. Best-effort per step: a step whose body is no longer servable comes back without `html` rather than failing the flow." @@ -68847,7 +69134,7 @@ paths: - `domain_status`, `domain_score_run`: `domains` (`emails` implies it). - `import_job`, `validation_job`: `contacts`. - `automation_pause_window_closed`: `automations`. - - `brand_extracted`, `brand_image_import`: every credential on the brand. + - `brand_extracted`, `brand_image_import`, `integration_attention` (a connected integration such as Stripe needs a reconnect, moved to another brand, or lost a permission): every credential on the brand. - `api_key_created`: the `all` scope (a signed-in member, or a key or connection holding `all`). - `send_limit_reached`: organization admins only (a signed-in admin, an admin’s organization-wide connection, or an organization-wide key holding `all`); never a brand key. - `comment_mention`, `comment_reply` (`isPersonal: true`): only the person addressed, signed in or on a personal connection; never an API key. @@ -68884,6 +69171,7 @@ paths: - send_review_rejected - comment_mention - comment_reply + - integration_attention description: Only notifications of this type. A type this credential cannot see (its scopes, or a person-only or admin-only type) is an empty page. required: false description: Only notifications of this type. A type this credential cannot see (its scopes, or a person-only or admin-only type) is an empty page. diff --git a/package.json b/package.json index a86089f..c158a7b 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#1e11f3fc58cc0b72756e23c416da1f881df4b14f", + "@brew.new/sdk": "git+https://github.com/GetBrew/typescript-sdk.git#a02b304ef8942a19dcaa437a097d33b49ad78fe8", "commander": "^14.0.0" }, "devDependencies": { diff --git a/src/commands/flows/get.ts b/src/commands/flows/get.ts index ebfd53b..1414c82 100644 --- a/src/commands/flows/get.ts +++ b/src/commands/flows/get.ts @@ -18,7 +18,8 @@ export const flowsGetCommand = defineCommand({ args: [ { name: 'slug', - summary: 'The brand domain a `flows list` card carries (e.g. brew.new)', + summary: + 'The slug a `flows list` card carries: a brand domain (brew.new) or / (brew.new/welcome)', isRequired: true, }, ], @@ -28,18 +29,29 @@ export const flowsGetCommand = defineCommand({ summary: 'Comma-separated expansions: html (each step’s rendered HTML, best-effort per step)', }, + { + flag: '--kind ', + summary: + 'One send stream’s flow for a bare-domain slug (e.g. welcome); a / slug already names it', + }, ], examples: [ 'brew-cli flows get brew.new', 'brew-cli flows get brew.new --include html --json', + 'brew-cli flows get brew.new/welcome', ], run: async ({ ctx, args, flags }) => { const include = flagString(flags.include) + const kind = flagString(flags.kind) + const options = { + ...(include === undefined ? {} : { include }), + ...(kind === undefined ? {} : { kind }), + } const flow = await ctx .client() .flows.get( args.slug ?? '', - include === undefined ? undefined : { include } + Object.keys(options).length > 0 ? options : undefined ) return { data: flow, human: renderSteps(flow) } }, diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index c994ff6..7d8dc5d 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -31,7 +31,7 @@ export interface paths { * * **Use when** you have a brief and want Brew to author on brand. For existing markup use `importEmail`; for a Figma frame use `importFigmaDesign`. * - * **Input** `prompt`; optional `category` (steers the design treatment only: transactional categories get receipt or reset conventions, while delivery for those flows is a triggered automation on a transactional-purpose domain), `contentUrls`, `referenceEmailId`, `subjectLine` (the design-default inbox subject; `createSend` still takes a per-send `subject`), and `groupId` (`grp_…` or `ungrouped`) or `groupName` (resolve or create), not both; omit to land Ungrouped. Supports `Idempotency-Key`. + * **Input** `prompt`; optional `category` (steers the design treatment only: transactional categories get receipt or reset conventions, while delivery for those flows is a triggered automation on a transactional-purpose domain), `contentUrls`, `referenceEmailId`, `subjectLine` (the design-default inbox subject; `createSend` still takes a per-send `subject`), `lang` (the language of the copy, ISO 639-1; omit and Brew detects it from the finished body), and `groupId` (`grp_…` or `ungrouped`) or `groupName` (resolve or create), not both; omit to land Ungrouped. Supports `Idempotency-Key`. * * **Returns** `201 { emailId, emailVersionId, html, previewImage?, group, subjectLine? }` when a design was persisted, or `200 { response }` when the agent answered in prose without writing a design. * @@ -55,7 +55,7 @@ export interface paths { }; /** * Get an email design - * @description Reads one design as the bare `EmailDetail` row: `emailId`, the `emailVersionId` (the pin for `POST /v1/sends` and sendEmail nodes), `title`, `status`, `previewStatus`, `subjectLine`, `previewText`, `previewImage`, `updatedAt`, `group` and `createdBy` / `createdByUserId` (the design’s creator, shared by every version), plus `errorMessage` when a generation failed. + * @description Reads one design as the bare `EmailDetail` row: `emailId`, the `emailVersionId` (the pin for `POST /v1/sends` and sendEmail nodes), `title`, `status`, `previewStatus`, `subjectLine`, `previewText`, `lang`, `previewImage`, `updatedAt`, `group` and `createdBy` / `createdByUserId` (the design’s creator, shared by every version), plus `errorMessage` when a generation failed. * * **Use when** inspecting a design before sending it, pinning a version, polling a generation you started, or pulling rendered HTML or version history. * @@ -79,11 +79,11 @@ export interface paths { head?: never; /** * Edit an email design - * @description Updates a design: an AI edit (`prompt`), its envelope (`title`, `subjectLine`, `groupId`), or both in one call. At least one of the four is required. A design renames and moves between groups here exactly like an audience, trigger, automation or group renames through its own PATCH. + * @description Updates a design: an AI edit (`prompt`), its envelope (`title`, `subjectLine`, `lang`, `groupId`), or both in one call. At least one of the five is required. A design renames and moves between groups here exactly like an audience, trigger, automation or group renames through its own PATCH. * * **Use when** changing an existing design: rewording or restyling it with a prompt, renaming it (a clone starts as `Copy of …`), setting the inbox subject the design carries by default, or filing it in another group. * - * **Input** `prompt` runs the Brew email agent against the current latest version (or the `emailVersionId` pin, optionally grounded by `contentUrls`) and writes a new `version: "latest"` row on the same `emailId` (the previous head becomes a numeric historical version); usage-metered by actual token spend. `title` renames the design (its canvas name, 1 to 200 characters), `subjectLine` sets the default inbox subject and `groupId` moves it into an existing group (`null` = Ungrouped); any of them without `prompt` is ONE atomic in-place patch (no AI run, no new version, free). With `prompt`, the rename and move land first so the new version inherits them and the subject is applied to the new head. `emailVersionId` requires `prompt`. + * **Input** `prompt` runs the Brew email agent against the current latest version (or the `emailVersionId` pin, optionally grounded by `contentUrls`) and writes a new `version: "latest"` row on the same `emailId` (the previous head becomes a numeric historical version); usage-metered by actual token spend. `title` renames the design (its canvas name, 1 to 200 characters), `subjectLine` sets the default inbox subject, `lang` sets the language of the copy (kept from then on; Brew stops re-detecting it) and `groupId` moves it into an existing group (`null` = Ungrouped); any of them without `prompt` is ONE atomic in-place patch (no AI run, no new version, free). With `prompt`, the rename and move land first so the new version inherits them and the subject is applied to the new head. `emailVersionId` requires `prompt`. * * **Returns** `200` with the generated-email shape: the NEW head after a prompt edit, or the CURRENT head after an envelope patch (with its `title`, `subjectLine` and `group` after the patch); a text response when the agent answered in prose. * @@ -1551,7 +1551,7 @@ export interface paths { * * A date column is read in ONE day/month order: the order its own dates prove (a day over 12 anywhere in the column, `13/04/2026`), else `dateOrder` (`day_first` reads `03/04/2026` as 3 April, `month_first` as 4 March), else month/day with a `warnings[]` entry (`code: DATE_ORDER_ASSUMED`, `field`). Dotted dates (`03.04.2026`) are day-first. ISO (`2026-04-03`), Unix seconds or milliseconds, month names (`3 Apr 2026`), year-first (`2026/04/03`) and compact (`20260403`) dates need no order. Convert to `YYYY-MM-DD` to be unambiguous. * - * A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing. A word that is not a subscription status (`maybe`) fails its row (`code: FIELD_TYPE_MISMATCH`, `field: subscribed`) instead of importing the contact subscribed. A subscribed value only applies to new contacts: a row that asks to re-subscribe a contact who opted out keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). + * A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing (with `onConflict: "skip"` an existing contact is left unchanged, and its `CONTACT_EXISTS_SKIPPED` warning says so). A word that is not a subscription status (`maybe`) fails its row (`code: FIELD_TYPE_MISMATCH`, `field: subscribed`) instead of importing the contact subscribed. A subscribed value only applies to new contacts: a row that asks to re-subscribe a contact who opted out keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). * * Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. A dry run cannot foresee failures that only happen while writing (a concurrent change, a database refusal), so the real run can still fail where the preview succeeded. With `validate: true`, inline checks are still metered per submitted row, rows `skip` left alone included; a request that leaves no address to check is not charged. */ @@ -2602,7 +2602,7 @@ export interface paths { * - `domain_status`, `domain_score_run`: `domains` (`emails` implies it). * - `import_job`, `validation_job`: `contacts`. * - `automation_pause_window_closed`: `automations`. - * - `brand_extracted`, `brand_image_import`: every credential on the brand. + * - `brand_extracted`, `brand_image_import`, `integration_attention` (a connected integration such as Stripe needs a reconnect, moved to another brand, or lost a permission): every credential on the brand. * - `api_key_created`: the `all` scope (a signed-in member, or a key or connection holding `all`). * - `send_limit_reached`: organization admins only (a signed-in admin, an admin’s organization-wide connection, or an organization-wide key holding `all`); never a brand key. * - `comment_mention`, `comment_reply` (`isPersonal: true`): only the person addressed, signed in or on a personal connection; never an API key. @@ -2677,6 +2677,11 @@ export interface components { subjectLine?: string; /** @description The design's inbox preview line, read directly from the latest version's JSX (its single source of truth) — what a send delivers when no explicit `previewText` override is passed to POST /v1/sends. Detail-only; absent when the design has no . */ previewText?: string; + /** + * @description The language the email's copy is written in (ISO 639-1, or zh-Hans / zh-Hant), read by Brew's language model after each content change unless a caller set it. Rendered as the HTML document's lang. Detail-only; absent until it has been read. + * @enum {string} + */ + lang?: "en" | "de" | "fr" | "es" | "pt" | "it" | "nl" | "pl" | "sv" | "da" | "no" | "fi" | "cs" | "sk" | "hu" | "ro" | "bg" | "el" | "tr" | "ru" | "uk" | "hr" | "sr" | "sl" | "lt" | "lv" | "et" | "ca" | "ar" | "he" | "fa" | "ur" | "hi" | "bn" | "th" | "vi" | "id" | "ms" | "tl" | "ja" | "ko" | "zh-Hans" | "zh-Hant" | "sw" | "af"; html?: string; versions?: { version: number | "latest"; @@ -2708,6 +2713,11 @@ export interface components { subjectLine?: string; /** @description The design's inbox preview line, read directly from the latest version's JSX (its single source of truth) — what a send delivers when no explicit `previewText` override is passed to POST /v1/sends. Detail-only; absent when the design has no . */ previewText?: string; + /** + * @description The language the email's copy is written in (ISO 639-1, or zh-Hans / zh-Hant), read by Brew's language model after each content change unless a caller set it. Rendered as the HTML document's lang. Detail-only; absent until it has been read. + * @enum {string} + */ + lang?: "en" | "de" | "fr" | "es" | "pt" | "it" | "nl" | "pl" | "sv" | "da" | "no" | "fi" | "cs" | "sk" | "hu" | "ro" | "bg" | "el" | "tr" | "ru" | "uk" | "hr" | "sr" | "sl" | "lt" | "lv" | "et" | "ca" | "ar" | "he" | "fa" | "ur" | "hi" | "bn" | "th" | "vi" | "id" | "ms" | "tl" | "ja" | "ko" | "zh-Hans" | "zh-Hant" | "sw" | "af"; html?: string; versions?: { version: number | "latest"; @@ -3934,6 +3944,8 @@ export interface components { title: string; /** @enum {string} */ type: "newsletter" | "signup"; + /** @enum {string} */ + kind?: "onboarding" | "trial" | "events" | "product_updates" | "promotions"; category: string; categoryLabel: string; emailCount: number; @@ -4316,7 +4328,7 @@ export interface components { /** @description `ntf_` + 20 hex characters: a one-way hash, stable for the row’s life. */ notificationId: string; /** @enum {string} */ - type: "chat_stream" | "import_job" | "brand_image_import" | "validation_job" | "email_sent" | "email_scheduled" | "preview_email" | "brand_extracted" | "domain_status" | "domain_score_run" | "api_key_created" | "email_send_failed" | "gradual_send_paused" | "gradual_send_completed" | "send_limit_reached" | "automation_pause_window_closed" | "send_review_rejected" | "comment_mention" | "comment_reply"; + type: "chat_stream" | "import_job" | "brand_image_import" | "validation_job" | "email_sent" | "email_scheduled" | "preview_email" | "brand_extracted" | "domain_status" | "domain_score_run" | "api_key_created" | "email_send_failed" | "gradual_send_paused" | "gradual_send_completed" | "send_limit_reached" | "automation_pause_window_closed" | "send_review_rejected" | "comment_mention" | "comment_reply" | "integration_attention"; /** @enum {string} */ status: "uploading" | "preparing" | "processing" | "finalizing" | "streaming" | "background_finalizing" | "completed" | "partial" | "failed" | "interrupted" | "stopped" | "persistence_failed"; title: string; @@ -4410,6 +4422,11 @@ export interface components { subjectLine?: string; /** @description The design's inbox preview line, read directly from the latest version's JSX (its single source of truth) — what a send delivers when no explicit `previewText` override is passed to POST /v1/sends. Detail-only; absent when the design has no . */ previewText?: string; + /** + * @description The language the email's copy is written in (ISO 639-1, or zh-Hans / zh-Hant), read by Brew's language model after each content change unless a caller set it. Rendered as the HTML document's lang. Detail-only; absent until it has been read. + * @enum {string} + */ + lang?: "en" | "de" | "fr" | "es" | "pt" | "it" | "nl" | "pl" | "sv" | "da" | "no" | "fi" | "cs" | "sk" | "hu" | "ro" | "bg" | "el" | "tr" | "ru" | "uk" | "hr" | "sr" | "sl" | "lt" | "lv" | "et" | "ca" | "ar" | "he" | "fa" | "ur" | "hi" | "bn" | "th" | "vi" | "id" | "ms" | "tl" | "ja" | "ko" | "zh-Hans" | "zh-Hant" | "sw" | "af"; html?: string; versions?: { version: number | "latest"; @@ -4478,6 +4495,11 @@ export interface components { category?: "welcome" | "onboarding" | "newsletter" | "content" | "event-invitation" | "event-reminder" | "event-recap" | "product-launch" | "product-update" | "product-pitch" | "promotional" | "cart-abandonment" | "re-engagement" | "subscription" | "feedback-request" | "referral" | "company-news" | "fundraising" | "order-confirmation" | "shipping-update" | "receipt" | "password-reset" | "verification" | "security-alert" | "account-update" | "notification" | "support" | "internal" | "business" | "general"; /** @description Inbox subject line to set on the design (`subjectLine` — distinct from `title`, the canvas name). Sends still take an explicit per-send `subject`; this is the design's default, seeded into the send dialog and returned by `GET /v1/emails/{emailId}`. */ subjectLine?: string; + /** + * @description ISO 639-1 language code of the email's copy (or zh-Hans / zh-Hant). Omit and Brew's language model reads it from the finished body; a value you set is kept and never re-detected. Becomes the HTML document's lang (and dir for right-to-left languages) on every send and export. One of: en, de, fr, es, pt, it, nl, pl, sv, da, no, fi, cs, sk, hu, ro, bg, el, tr, ru, uk, hr, sr, sl, lt, lv, et, ca, ar, he, fa, ur, hi, bn, th, vi, id, ms, tl, ja, ko, zh-Hans, zh-Hant, sw, af. + * @enum {string} + */ + lang?: "en" | "de" | "fr" | "es" | "pt" | "it" | "nl" | "pl" | "sv" | "da" | "no" | "fi" | "cs" | "sk" | "hu" | "ro" | "bg" | "el" | "tr" | "ru" | "uk" | "hr" | "sr" | "sl" | "lt" | "lv" | "et" | "ca" | "ar" | "he" | "fa" | "ur" | "hi" | "bn" | "th" | "vi" | "id" | "ms" | "tl" | "ja" | "ko" | "zh-Hans" | "zh-Hant" | "sw" | "af"; /** @description File the design under an existing group (`grp_…`), or `ungrouped`. Mutually exclusive with `groupName`. */ groupId?: string; /** @description File the design under a group resolved (or created) by this name. Mutually exclusive with `groupId`. */ @@ -4586,7 +4608,7 @@ export interface components { response: string; }; EmailEditRequest: { - /** @description The edit to make, in plain language — e.g. "swap the hero for the spring campaign image and tighten the CTA copy". Scoped edits beat full rewrites. Omit to only patch `title`, `subjectLine` or `groupId` (at least one of the four is required). */ + /** @description The edit to make, in plain language — e.g. "swap the hero for the spring campaign image and tighten the CTA copy". Scoped edits beat full rewrites. Omit to only patch `title`, `subjectLine`, `lang` or `groupId` (at least one of the five is required). */ prompt?: string; /** @description Pin the edit to a specific source version (from `GET /v1/emails/{emailId}?include=versions`). Requires `prompt`. Omit to edit the current latest. */ emailVersionId?: string; @@ -4596,6 +4618,11 @@ export interface components { title?: string; /** @description Inbox subject line to set on the design (`subjectLine` — distinct from `title`, the canvas name). Sends still take an explicit per-send `subject`; this is the design's default, seeded into the send dialog and returned by `GET /v1/emails/{emailId}`. */ subjectLine?: string; + /** + * @description Set the language of the email (ISO 639-1, or zh-Hans / zh-Hant). Part of the free envelope patch: no AI run, no new version. Once set it is kept: Brew no longer re-detects it after edits. null clears a language you set, and Brew reads it again from the email. + * @enum {string|null} + */ + lang?: "en" | "de" | "fr" | "es" | "pt" | "it" | "nl" | "pl" | "sv" | "da" | "no" | "fi" | "cs" | "sk" | "hu" | "ro" | "bg" | "el" | "tr" | "ru" | "uk" | "hr" | "sr" | "sl" | "lt" | "lv" | "et" | "ca" | "ar" | "he" | "fa" | "ur" | "hi" | "bn" | "th" | "vi" | "id" | "ms" | "tl" | "ja" | "ko" | "zh-Hans" | "zh-Hant" | "sw" | "af" | null; /** @description Move the design into this existing group (`grp_…`); `null` moves it to Ungrouped; omit to leave its group alone. Part of the free envelope patch: no AI run, no new version. */ groupId?: string | null; }; @@ -8524,6 +8551,8 @@ export interface components { title: string; /** @enum {string} */ type: "newsletter" | "signup"; + /** @enum {string} */ + kind?: "onboarding" | "trial" | "events" | "product_updates" | "promotions"; category: string; categoryLabel: string; emailCount: number; @@ -8988,7 +9017,7 @@ export interface components { /** @description `ntf_` + 20 hex characters: a one-way hash, stable for the row’s life. */ notificationId: string; /** @enum {string} */ - type: "chat_stream" | "import_job" | "brand_image_import" | "validation_job" | "email_sent" | "email_scheduled" | "preview_email" | "brand_extracted" | "domain_status" | "domain_score_run" | "api_key_created" | "email_send_failed" | "gradual_send_paused" | "gradual_send_completed" | "send_limit_reached" | "automation_pause_window_closed" | "send_review_rejected" | "comment_mention" | "comment_reply"; + type: "chat_stream" | "import_job" | "brand_image_import" | "validation_job" | "email_sent" | "email_scheduled" | "preview_email" | "brand_extracted" | "domain_status" | "domain_score_run" | "api_key_created" | "email_send_failed" | "gradual_send_paused" | "gradual_send_completed" | "send_limit_reached" | "automation_pause_window_closed" | "send_review_rejected" | "comment_mention" | "comment_reply" | "integration_attention"; /** @enum {string} */ status: "uploading" | "preparing" | "processing" | "finalizing" | "streaming" | "background_finalizing" | "completed" | "partial" | "failed" | "interrupted" | "stopped" | "persistence_failed"; title: string; @@ -9854,7 +9883,7 @@ export interface operations { }; cookie?: never; }; - /** @description A natural-language `prompt` edit (optional `emailVersionId` source pin, optional `contentUrls`) and/or the envelope fields `title`, `subjectLine` and `groupId` — at least one of the four. Identity lives on the path. */ + /** @description A natural-language `prompt` edit (optional `emailVersionId` source pin, optional `contentUrls`) and/or the envelope fields `title`, `subjectLine`, `lang` and `groupId` — at least one of the five. Identity lives on the path. */ requestBody: { content: { "application/json": components["schemas"]["EmailEditRequest"]; @@ -27209,6 +27238,8 @@ export interface operations { category?: "verification" | "transactional" | "direct_sales" | "welcome" | "promotion" | "newsletter" | "education" | "abandoned_cart" | "winback" | "other"; /** @description Filter LIST by how the sequence starts: `signup` (after creating an account) or `newsletter` (after subscribing). */ type?: "newsletter" | "signup"; + /** @description Filter LIST by send-stream kind. */ + kind?: "onboarding" | "trial" | "events" | "product_updates" | "promotions"; /** @description Semantic search over what the sequences are about — “developer onboarding drip”, “trial expiry win-back”. Results come back in relevance order and `sort` is ignored. */ semantic?: string; /** @description LIST order: `newest` (default), `emails` (longest sequences first), `span` (sequences that run the longest first), `remixes` (most remixed first). */ @@ -27364,12 +27395,14 @@ export interface operations { getFlow: { parameters: { query?: { + /** @description Stream segment for a bare-domain `slug`. */ + kind?: string; /** @description Detail-only expansions, comma-separated. `html` adds each step’s rendered HTML. Rendered email is bulky — over MCP only the first step or two fit one response; the rest arrive without `html` and the summary says how many were kept. A step’s `emailId` is already a valid `referenceEmailId`, so reuse needs no body; for the bodies themselves call `GET /v1/flows/{slug}?include=html` over REST, which has no cap. Best-effort per step: a step whose body is no longer servable comes back without `html` rather than failing the flow. */ include?: string; }; header?: never; path: { - /** @description The flow’s brand domain, as returned in every `GET /v1/flows` row. Case-insensitive. */ + /** @description The flow’s brand domain. A bare domain returns the brand’s primary flow; one send stream’s flow is `?kind=` on it (or the path `/v1/flows/{domain}/{kind}`), as each `GET /v1/flows` row’s `slug` (`/`) names it. Case-insensitive. */ slug: string; }; cookie?: never; @@ -31163,7 +31196,7 @@ export interface operations { parameters: { query?: { /** @description Only notifications of this type. A type this credential cannot see (its scopes, or a person-only or admin-only type) is an empty page. */ - type?: "chat_stream" | "import_job" | "brand_image_import" | "validation_job" | "email_sent" | "email_scheduled" | "preview_email" | "brand_extracted" | "domain_status" | "domain_score_run" | "api_key_created" | "email_send_failed" | "gradual_send_paused" | "gradual_send_completed" | "send_limit_reached" | "automation_pause_window_closed" | "send_review_rejected" | "comment_mention" | "comment_reply"; + type?: "chat_stream" | "import_job" | "brand_image_import" | "validation_job" | "email_sent" | "email_scheduled" | "preview_email" | "brand_extracted" | "domain_status" | "domain_score_run" | "api_key_created" | "email_send_failed" | "gradual_send_paused" | "gradual_send_completed" | "send_limit_reached" | "automation_pause_window_closed" | "send_review_rejected" | "comment_mention" | "comment_reply" | "integration_attention"; /** * @description Page size (1-100). Defaults to 100. * @example 50 From 6bdf1534fbfa4e670083d901c8596576ab9718fd Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sat, 24 Oct 2026 22:57:09 +0900 Subject: [PATCH 5/6] docs(release): the contact import SDK gate names the current pin Co-Authored-By: Claude Opus 5.5 --- RELEASING.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index ad2c07d..fb8e367 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -26,10 +26,10 @@ lockfile checksums must never be invented. The canvas tests must preserve ### Contact Import SDK Gate The new `contacts import` command requires `contacts.import`, which the -published SDK 13.0.0 does not expose. This branch pins reviewed SDK source -commit `1e11f3fc58cc0b72756e23c416da1f881df4b14f` for coordinated PR validation. -It preserves SDK 13.0.0's features and version while adding the import method -and its `dryRun` and `onConflict` options. +published SDK 13.2.0 does not expose. This branch pins reviewed SDK source +commit `a02b304ef8942a19dcaa437a097d33b49ad78fe8` for coordinated PR validation. +It preserves SDK 13.2.0's features and version while adding the import method, +its `dryRun` and `onConflict` options, and `flows.get`'s `kind`. A fresh frozen install must build that source dependency without relying on a locally packed SDK. Do not release the CLI with this temporary source pin. From 2aaecf957181e54bf6bc201fee3fd30cd793d294 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 25 Oct 2026 03:40:53 +0900 Subject: [PATCH 6/6] fix(contacts): an empty --on-conflict stops the import flagString read an empty value as unset, so --on-conflict "" (an unset shell variable) ran an upsert. Every supplied value is checked first. Co-Authored-By: Claude Opus 5.5 --- src/commands/contacts/import-modes.ts | 13 +++++++++---- tests/commands/contacts.test.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/commands/contacts/import-modes.ts b/src/commands/contacts/import-modes.ts index 8dc37a8..24ccdf8 100644 --- a/src/commands/contacts/import-modes.ts +++ b/src/commands/contacts/import-modes.ts @@ -1,5 +1,4 @@ import { CliUsageError } from '../../lib/errors' -import { flagString } from '../../lib/input' /** `--dry-run` on both imports. */ export const DRY_RUN_FLAG = { @@ -25,13 +24,19 @@ export function importModeFlags(flags: { readonly dryRun?: unknown readonly onConflict?: unknown }): { dryRun?: true; onConflict?: (typeof ON_CONFLICT_MODES)[number] } { - const onConflict = flagString(flags.onConflict) + // Any supplied value is checked before it is normalized: an empty one + // (an unset shell variable) must stop the import, not run an upsert. + const raw = flags.onConflict + const onConflict = typeof raw === 'string' ? raw.trim() : raw if ( onConflict !== undefined && - !(ON_CONFLICT_MODES as ReadonlyArray).includes(onConflict) + !( + typeof onConflict === 'string' && + (ON_CONFLICT_MODES as ReadonlyArray).includes(onConflict) + ) ) { throw new CliUsageError( - `--on-conflict takes upsert or skip, not "${onConflict}".` + `--on-conflict takes upsert or skip, not "${String(raw)}".` ) } return { diff --git a/tests/commands/contacts.test.ts b/tests/commands/contacts.test.ts index 42f97bd..d9599de 100644 --- a/tests/commands/contacts.test.ts +++ b/tests/commands/contacts.test.ts @@ -518,6 +518,18 @@ describe('contacts import-csv', () => { expect(result.stderr).toContain('--on-conflict takes upsert or skip') }) + it('refuses an empty --on-conflict instead of running an upsert', async () => { + const dir = mkdtempSync(join(tmpdir(), 'brew-cli-csv-')) + const csvPath = join(dir, 'contacts.csv') + writeFileSync(csvPath, 'Email\njane@example.com\n') + const result = await runCli( + ['contacts', 'import-csv', '--file', csvPath, '--on-conflict', ''], + { env: env() } + ) + expect(result.code).toBe(2) + expect(result.stderr).toContain('--on-conflict takes upsert or skip, not') + }) + it('refuses a call with no CSV at all', async () => { const result = await runCli( ['contacts', 'import-csv', '--date-order', 'day_first'],