From 60f5d504909edba3505f149313a8907517994626 Mon Sep 17 00:00:00 2001 From: Chris Bell Date: Mon, 16 Feb 2026 14:03:34 -0500 Subject: [PATCH 1/3] feat: introduce broadcast commands --- my-new-broadcast/broadcast.json | 13 + package.json | 3 + src/commands/broadcast/get.ts | 204 +++++++++++++++ src/commands/broadcast/list.ts | 108 ++++++++ src/commands/broadcast/new.ts | 155 ++++++++++++ src/commands/broadcast/open.ts | 55 ++++ src/commands/broadcast/pull.ts | 206 +++++++++++++++ src/commands/broadcast/push.ts | 122 +++++++++ src/commands/broadcast/send.ts | 59 +++++ src/commands/broadcast/validate.ts | 112 +++++++++ src/lib/api-v1.ts | 98 ++++++++ src/lib/helpers/project-config.ts | 1 + src/lib/marshal/broadcast/generator.ts | 33 +++ src/lib/marshal/broadcast/helpers.ts | 152 +++++++++++ src/lib/marshal/broadcast/index.ts | 6 + .../marshal/broadcast/processor.isomorphic.ts | 58 +++++ src/lib/marshal/broadcast/reader.ts | 186 ++++++++++++++ src/lib/marshal/broadcast/types.ts | 37 +++ src/lib/marshal/broadcast/writer.ts | 158 ++++++++++++ src/lib/marshal/index.isomorphic.ts | 1 + src/lib/marshal/shared/helpers.isomorphic.ts | 2 + src/lib/marshal/shared/helpers.ts | 2 + .../marshal/workflow/processor.isomorphic.ts | 14 +- src/lib/resources.ts | 5 +- src/lib/run-context/loader.ts | 6 + src/lib/run-context/types.ts | 10 +- src/lib/urls.ts | 8 + test/commands/broadcast/get.test.ts | 80 ++++++ test/commands/broadcast/list.test.ts | 32 +++ test/commands/broadcast/new.test.ts | 126 ++++++++++ test/commands/broadcast/open.test.ts | 73 ++++++ test/commands/broadcast/pull.test.ts | 128 ++++++++++ test/commands/broadcast/push.test.ts | 134 ++++++++++ test/commands/broadcast/send.test.ts | 72 ++++++ test/commands/broadcast/validate.test.ts | 235 ++++++++++++++++++ test/lib/marshal/broadcast/helpers.test.ts | 69 +++++ test/lib/marshal/broadcast/processor.test.ts | 27 ++ test/support/factory.ts | 21 ++ 38 files changed, 2800 insertions(+), 11 deletions(-) create mode 100644 my-new-broadcast/broadcast.json create mode 100644 src/commands/broadcast/get.ts create mode 100644 src/commands/broadcast/list.ts create mode 100644 src/commands/broadcast/new.ts create mode 100644 src/commands/broadcast/open.ts create mode 100644 src/commands/broadcast/pull.ts create mode 100644 src/commands/broadcast/push.ts create mode 100644 src/commands/broadcast/send.ts create mode 100644 src/commands/broadcast/validate.ts create mode 100644 src/lib/marshal/broadcast/generator.ts create mode 100644 src/lib/marshal/broadcast/helpers.ts create mode 100644 src/lib/marshal/broadcast/index.ts create mode 100644 src/lib/marshal/broadcast/processor.isomorphic.ts create mode 100644 src/lib/marshal/broadcast/reader.ts create mode 100644 src/lib/marshal/broadcast/types.ts create mode 100644 src/lib/marshal/broadcast/writer.ts create mode 100644 test/commands/broadcast/get.test.ts create mode 100644 test/commands/broadcast/list.test.ts create mode 100644 test/commands/broadcast/new.test.ts create mode 100644 test/commands/broadcast/open.test.ts create mode 100644 test/commands/broadcast/pull.test.ts create mode 100644 test/commands/broadcast/push.test.ts create mode 100644 test/commands/broadcast/send.test.ts create mode 100644 test/commands/broadcast/validate.test.ts create mode 100644 test/lib/marshal/broadcast/helpers.test.ts create mode 100644 test/lib/marshal/broadcast/processor.test.ts diff --git a/my-new-broadcast/broadcast.json b/my-new-broadcast/broadcast.json new file mode 100644 index 00000000..9acca995 --- /dev/null +++ b/my-new-broadcast/broadcast.json @@ -0,0 +1,13 @@ +{ + "key": "my-new-broadcast", + "name": "", + "description": "", + "categories": [], + "target_audience_key": "", + "status": "draft", + "settings": { + "is_commercial": false, + "override_preferences": false + }, + "steps": [] +} diff --git a/package.json b/package.json index 8e57ea1c..4530a1dc 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,9 @@ "branch": { "description": "Manage branches." }, + "broadcast": { + "description": "Manage broadcasts." + }, "commit": { "description": "Commit or promote changes." }, diff --git a/src/commands/broadcast/get.ts b/src/commands/broadcast/get.ts new file mode 100644 index 00000000..399ac128 --- /dev/null +++ b/src/commands/broadcast/get.ts @@ -0,0 +1,204 @@ +import { Args, Flags, ux } from "@oclif/core"; + +import * as ApiV1 from "@/lib/api-v1"; +import BaseCommand from "@/lib/base-command"; +import { formatCommandScope } from "@/lib/helpers/command"; +import { formatDateTime } from "@/lib/helpers/date"; +import { ApiError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; +import { indentString } from "@/lib/helpers/string"; +import { spinner } from "@/lib/helpers/ux"; +import * as Broadcast from "@/lib/marshal/broadcast"; +import * as Conditions from "@/lib/marshal/conditions"; +import * as Workflow from "@/lib/marshal/workflow"; +import { viewBroadcastUrl } from "@/lib/urls"; + +export default class BroadcastGet extends BaseCommand { + static summary = "Display a single broadcast from an environment."; + + static flags = { + environment: Flags.string({ + default: "development", + summary: "The environment to use.", + }), + branch: CustomFlags.branch, + "hide-uncommitted-changes": Flags.boolean({ + summary: "Hide any uncommitted changes.", + }), + }; + + static args = { + broadcastKey: Args.string({ + required: true, + }), + }; + + static enableJsonFlag = true; + + async run(): Promise { + spinner.start("‣ Loading"); + + const { broadcast, whoami } = await this.loadBroadcast(); + + spinner.stop(); + + const { flags } = this.props; + if (flags.json) return broadcast; + + this.render(broadcast, whoami); + } + + private async loadBroadcast(): Promise<{ + broadcast: ApiV1.GetBroadcastResp; + whoami: ApiV1.WhoamiResp; + }> { + const broadcastResp = await this.apiV1.getBroadcast(this.props); + + if (!isSuccessResp(broadcastResp)) { + const message = formatErrorRespMessage(broadcastResp); + ux.error(new ApiError(message)); + } + + const whoamiResp = await this.apiV1.whoami(); + + if (!isSuccessResp(whoamiResp)) { + const message = formatErrorRespMessage(whoamiResp); + ux.error(new ApiError(message)); + } + + return { + broadcast: broadcastResp.data, + whoami: whoamiResp.data, + }; + } + + render(broadcast: ApiV1.GetBroadcastResp, whoami: ApiV1.WhoamiResp): void { + const { broadcastKey } = this.props.args; + const { + environment: env, + branch, + "hide-uncommitted-changes": commitedOnly, + } = this.props.flags; + + const qualifier = + env === "development" && !commitedOnly ? "(including uncommitted)" : ""; + + const scope = formatCommandScope(this.props.flags); + this.log( + `‣ Showing broadcast \`${broadcastKey}\` in ${scope} ${qualifier}\n`, + ); + + const rows = [ + { + key: "Status", + value: Broadcast.formatStatus(broadcast), + }, + { + key: "Name", + value: broadcast.name, + }, + { + key: "Key", + value: broadcast.key, + }, + { + key: "Description", + value: broadcast.description || "-", + }, + { + key: "Categories", + value: Broadcast.formatCategories(broadcast, { emptyDisplay: "-" }), + }, + { + key: "Target audience", + value: broadcast.target_audience_key || "-", + }, + { + key: "Scheduled at", + value: broadcast.scheduled_at + ? formatDateTime(broadcast.scheduled_at) + : "-", + }, + { + key: "Sent at", + value: broadcast.sent_at ? formatDateTime(broadcast.sent_at) : "-", + }, + { + key: "Created at", + value: formatDateTime(broadcast.created_at), + }, + { + key: "Updated at", + value: formatDateTime(broadcast.updated_at), + }, + ]; + + ux.table(rows, { + key: { + header: "Broadcast", + minWidth: 24, + }, + value: { + header: "", + minWidth: 24, + }, + }); + + this.log(""); + + if (broadcast.steps.length === 0) { + return ux.log(" This broadcast has no steps to display."); + } + + const steps = broadcast.steps.map((step, index) => ({ ...step, index })); + + ux.table(steps, { + index: { + header: "Steps", + get: (step) => step.index + 1, + }, + ref: { + header: "Ref", + minWidth: 18, + get: (step) => step.ref, + }, + type: { + header: "Type", + minWidth: 12, + get: (step) => step.type, + }, + summary: { + header: "Summary", + get: (step) => Workflow.formatStepSummary(step), + }, + conditions: { + header: "Conditions", + get: (step) => { + if (step.type === Workflow.StepType.Branch) return "-"; + if (!step.conditions) return "-"; + + return Conditions.formatConditions(step.conditions); + }, + }, + }); + + const hasTopLevelBranchStep = broadcast.steps.some( + (step) => step.type === Workflow.StepType.Branch, + ); + + const dashboardLinkMessage = hasTopLevelBranchStep + ? `\n‣ This broadcast has branches with nested steps, view the full broadcast tree in the Knock Dashboard:` + : `\n‣ View the full broadcast in the Knock Dashboard:`; + + const url = viewBroadcastUrl( + this.sessionContext.dashboardOrigin, + whoami.account_slug, + branch ?? env, + broadcast.key, + ); + + this.log(dashboardLinkMessage); + this.log(indentString(url, 2)); + } +} diff --git a/src/commands/broadcast/list.ts b/src/commands/broadcast/list.ts new file mode 100644 index 00000000..63b43038 --- /dev/null +++ b/src/commands/broadcast/list.ts @@ -0,0 +1,108 @@ +import { Flags, ux } from "@oclif/core"; +import { AxiosResponse } from "axios"; + +import * as ApiV1 from "@/lib/api-v1"; +import BaseCommand from "@/lib/base-command"; +import { formatCommandScope } from "@/lib/helpers/command"; +import { formatDate } from "@/lib/helpers/date"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { merge } from "@/lib/helpers/object.isomorphic"; +import { + maybePromptPageAction, + pageFlags, + paramsForPageAction, +} from "@/lib/helpers/page"; +import { withSpinner } from "@/lib/helpers/request"; +import * as Broadcast from "@/lib/marshal/broadcast"; + +export default class BroadcastList extends BaseCommand { + static summary = "Display all broadcasts for an environment."; + + static flags = { + environment: Flags.string({ + default: "development", + summary: "The environment to use.", + }), + branch: CustomFlags.branch, + "hide-uncommitted-changes": Flags.boolean({ + summary: "Hide any uncommitted changes.", + }), + ...pageFlags, + }; + + static enableJsonFlag = true; + + async run(): Promise { + const resp = await this.request(); + + const { flags } = this.props; + if (flags.json) return resp.data; + + this.render(resp.data); + } + + async request( + pageParams = {}, + ): Promise> { + const props = merge(this.props, { flags: { ...pageParams } }); + + return withSpinner(() => + this.apiV1.listBroadcasts(props), + ); + } + + async render(data: ApiV1.ListBroadcastResp): Promise { + const { entries } = data; + const { environment: env, "hide-uncommitted-changes": commitedOnly } = + this.props.flags; + + const qualifier = + env === "development" && !commitedOnly ? "(including uncommitted)" : ""; + + const scope = formatCommandScope(this.props.flags); + this.log( + `‣ Showing ${entries.length} broadcasts in ${scope} ${qualifier}\n`, + ); + + ux.table(entries, { + key: { + header: "Key", + }, + name: { + header: "Name", + }, + status: { + header: "Status", + get: (entry) => Broadcast.formatStatus(entry), + }, + categories: { + header: "Categories", + get: (entry) => Broadcast.formatCategories(entry, { truncateAfter: 3 }), + }, + target_audience_key: { + header: "Target audience", + get: (entry) => entry.target_audience_key || "-", + }, + updated_at: { + header: "Updated at", + get: (entry) => formatDate(entry.updated_at), + }, + }); + + return this.prompt(data); + } + + async prompt(data: ApiV1.ListBroadcastResp): Promise { + const { page_info } = data; + + const pageAction = await maybePromptPageAction(page_info); + const pageParams = pageAction && paramsForPageAction(pageAction, page_info); + + if (pageParams) { + this.log("\n"); + + const resp = await this.request(pageParams); + return this.render(resp.data); + } + } +} diff --git a/src/commands/broadcast/new.ts b/src/commands/broadcast/new.ts new file mode 100644 index 00000000..480b95ae --- /dev/null +++ b/src/commands/broadcast/new.ts @@ -0,0 +1,155 @@ +import * as path from "node:path"; + +import { Flags } from "@oclif/core"; +import { prompt } from "enquirer"; + +import BaseCommand from "@/lib/base-command"; +import { KnockEnv } from "@/lib/helpers/const"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { resolveResourceDir } from "@/lib/helpers/project-config"; +import { slugify } from "@/lib/helpers/string"; +import { promptToConfirm } from "@/lib/helpers/ux"; +import * as Broadcast from "@/lib/marshal/broadcast"; +import { + BroadcastDirContext, + ensureResourceDirForTarget, + ResourceTarget, +} from "@/lib/run-context"; + +export default class BroadcastNew extends BaseCommand { + static summary = "Create a new broadcast with a minimal configuration."; + + static flags = { + name: Flags.string({ + summary: "The name of the broadcast", + char: "n", + }), + key: Flags.string({ + summary: "The key of the broadcast", + char: "k", + }), + environment: Flags.string({ + summary: + "The environment to create the broadcast in. Defaults to development.", + default: KnockEnv.Development, + }), + branch: CustomFlags.branch, + force: Flags.boolean({ + summary: + "Force the creation of the broadcast directory without confirmation.", + }), + }; + + static args = {}; + + async run(): Promise { + const { flags } = this.props; + const { resourceDir } = this.runContext; + + if (resourceDir) { + return this.error( + `Cannot create a new broadcast inside an existing ${resourceDir.type} directory`, + ); + } + + let name = flags.name; + let key = flags.key; + + if (!name) { + const nameResponse = await prompt<{ name: string }>({ + type: "input", + name: "name", + message: "Broadcast name", + validate: (value: string) => { + if (!value || value.trim().length === 0) { + return "Broadcast name is required"; + } + + return true; + }, + }); + name = nameResponse.name; + } + + if (!key) { + const keyResponse = await prompt<{ key: string }>({ + type: "input", + name: "key", + message: "Broadcast key (immutable slug)", + initial: slugify(name), + validate: (value: string) => { + if (!value || value.trim().length === 0) { + return "Broadcast key is required"; + } + + const keyError = Broadcast.validateBroadcastKey(value); + if (keyError) { + return `Invalid broadcast key: ${keyError}`; + } + + return true; + }, + }); + key = keyResponse.key; + } + + const broadcastKeyError = Broadcast.validateBroadcastKey(key); + if (broadcastKeyError) { + return this.error( + `Invalid broadcast key \`${key}\` (${broadcastKeyError})`, + ); + } + + const broadcastDirCtx = await this.getBroadcastDirContext(key); + + const promptMessage = broadcastDirCtx.exists + ? `Found \`${broadcastDirCtx.key}\` at ${broadcastDirCtx.abspath}, overwrite?` + : `Create a new broadcast directory \`${broadcastDirCtx.key}\` at ${broadcastDirCtx.abspath}?`; + + const input = flags.force || (await promptToConfirm(promptMessage)); + if (!input) return; + + await Broadcast.generateBroadcastDir(broadcastDirCtx, key); + + this.log(`‣ Successfully created broadcast \`${key}\``); + } + + async getBroadcastDirContext( + broadcastKey?: string, + ): Promise { + const { resourceDir, cwd: runCwd } = this.runContext; + + if (resourceDir) { + const target: ResourceTarget = { + commandId: this.id ?? "broadcast:new", + type: "broadcast", + key: broadcastKey, + }; + + return ensureResourceDirForTarget( + resourceDir, + target, + ) as BroadcastDirContext; + } + + const dirCtx = await resolveResourceDir( + this.projectConfig, + "broadcast", + runCwd, + ); + + if (broadcastKey) { + const dirPath = path.resolve(dirCtx.abspath, broadcastKey); + const exists = await Broadcast.isBroadcastDir(dirPath); + + return { + type: "broadcast", + key: broadcastKey, + abspath: dirPath, + exists, + }; + } + + return this.error("Missing 1 required arg:\nbroadcastKey"); + } +} diff --git a/src/commands/broadcast/open.ts b/src/commands/broadcast/open.ts new file mode 100644 index 00000000..ac4ce72d --- /dev/null +++ b/src/commands/broadcast/open.ts @@ -0,0 +1,55 @@ +import { Args, Flags, ux } from "@oclif/core"; + +import BaseCommand from "@/lib/base-command"; +import { browser } from "@/lib/helpers/browser"; +import { ApiError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; +import { viewBroadcastUrl } from "@/lib/urls"; + +export default class BroadcastOpen extends BaseCommand { + static summary = "Open a broadcast in the Knock dashboard."; + + static flags = { + environment: Flags.string({ + default: "development", + summary: "The environment to use.", + }), + branch: CustomFlags.branch, + }; + + static args = { + broadcastKey: Args.string({ + required: true, + }), + }; + + async run(): Promise { + const whoamiResp = await this.apiV1.whoami(); + + if (!isSuccessResp(whoamiResp)) { + const message = formatErrorRespMessage(whoamiResp); + ux.error(new ApiError(message)); + } + + const { account_slug } = whoamiResp.data; + const { broadcastKey } = this.props.args; + const { environment, branch } = this.props.flags; + + const envOrBranch = branch ?? environment; + + const url = viewBroadcastUrl( + this.sessionContext.dashboardOrigin, + account_slug, + envOrBranch, + broadcastKey, + ); + + this.log( + `‣ Opening broadcast \`${broadcastKey}\` in the Knock dashboard...`, + ); + this.log(` ${url}`); + + await browser.openUrl(url); + } +} diff --git a/src/commands/broadcast/pull.ts b/src/commands/broadcast/pull.ts new file mode 100644 index 00000000..b22e5e03 --- /dev/null +++ b/src/commands/broadcast/pull.ts @@ -0,0 +1,206 @@ +import * as path from "node:path"; + +import { Args, Flags } from "@oclif/core"; + +import * as ApiV1 from "@/lib/api-v1"; +import BaseCommand from "@/lib/base-command"; +import { formatCommandScope } from "@/lib/helpers/command"; +import { ApiError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { merge } from "@/lib/helpers/object.isomorphic"; +import { MAX_PAGINATION_LIMIT } from "@/lib/helpers/page"; +import { resolveResourceDir } from "@/lib/helpers/project-config"; +import { + formatErrorRespMessage, + isSuccessResp, + withSpinner, +} from "@/lib/helpers/request"; +import { promptToConfirm, spinner } from "@/lib/helpers/ux"; +import * as Broadcast from "@/lib/marshal/broadcast"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; +import { + BroadcastDirContext, + ensureResourceDirForTarget, + ResourceTarget, +} from "@/lib/run-context"; + +export default class BroadcastPull extends BaseCommand { + static summary = + "Pull one or more broadcasts from an environment into a local file system."; + + static flags = { + environment: Flags.string({ + default: "development", + summary: "The environment to use.", + }), + branch: CustomFlags.branch, + all: Flags.boolean({ + summary: "Whether to pull all broadcasts from the specified environment.", + }), + "broadcasts-dir": CustomFlags.dirPath({ + summary: "The target directory path to pull all broadcasts into.", + dependsOn: ["all"], + }), + "hide-uncommitted-changes": Flags.boolean({ + summary: "Hide any uncommitted changes.", + }), + force: Flags.boolean({ + summary: "Remove the confirmation prompt.", + }), + }; + + static args = { + broadcastKey: Args.string({ + required: false, + }), + }; + + async run(): Promise { + const { args, flags } = this.props; + + if (flags.all && args.broadcastKey) { + return this.error( + `broadcastKey arg \`${args.broadcastKey}\` cannot also be provided when using --all`, + ); + } + + return flags.all ? this.pullAllBroadcasts() : this.pullOneBroadcast(); + } + + async pullOneBroadcast(): Promise { + const { flags } = this.props; + + const dirContext = await this.getBroadcastDirContext(); + + if (dirContext.exists) { + this.log(`‣ Found \`${dirContext.key}\` at ${dirContext.abspath}`); + } else { + const prompt = `Create a new broadcast directory \`${dirContext.key}\` at ${dirContext.abspath}?`; + const input = flags.force || (await promptToConfirm(prompt)); + if (!input) return; + } + + const resp = await withSpinner>( + () => { + const props = merge(this.props, { + args: { broadcastKey: dirContext.key }, + flags: { annotate: true }, + }); + return this.apiV1.getBroadcast(props); + }, + ); + + if (!isSuccessResp(resp)) { + const message = formatErrorRespMessage(resp); + this.error(new ApiError(message)); + } + + await Broadcast.writeBroadcastDirFromData(dirContext, resp.data, { + withSchema: true, + }); + + const action = dirContext.exists ? "updated" : "created"; + const scope = formatCommandScope(flags); + this.log( + `‣ Successfully ${action} \`${dirContext.key}\` at ${dirContext.abspath} using ${scope}`, + ); + } + + async pullAllBroadcasts(): Promise { + const { flags } = this.props; + + const broadcastsIndexDirCtx = await resolveResourceDir( + this.projectConfig, + "broadcast", + this.runContext.cwd, + ); + + const targetDirCtx = flags["broadcasts-dir"] || broadcastsIndexDirCtx; + + const prompt = targetDirCtx.exists + ? `Pull latest broadcasts into ${targetDirCtx.abspath}?\n This will overwrite the contents of this directory.` + : `Create a new broadcasts directory at ${targetDirCtx.abspath}?`; + + const input = flags.force || (await promptToConfirm(prompt)); + if (!input) return; + + spinner.start(`‣ Loading`); + + const broadcasts = await this.listAllBroadcasts(); + + await Broadcast.writeBroadcastsIndexDir(targetDirCtx, broadcasts, { + withSchema: true, + }); + spinner.stop(); + + const action = targetDirCtx.exists ? "updated" : "created"; + const scope = formatCommandScope(flags); + this.log( + `‣ Successfully ${action} the broadcasts directory at ${targetDirCtx.abspath} using ${scope}`, + ); + } + + async listAllBroadcasts( + pageParams: Record = {}, + broadcastsFetchedSoFar: Broadcast.BroadcastData[] = [], + ): Promise[]> { + const props = merge(this.props, { + flags: { + ...pageParams, + annotate: true, + limit: MAX_PAGINATION_LIMIT, + }, + }); + + const resp = await this.apiV1.listBroadcasts(props); + if (!isSuccessResp(resp)) { + const message = formatErrorRespMessage(resp); + this.error(new ApiError(message)); + } + + const { entries, page_info: pageInfo } = resp.data; + const broadcasts = [...broadcastsFetchedSoFar, ...entries]; + + return pageInfo.after + ? this.listAllBroadcasts({ after: pageInfo.after }, broadcasts) + : broadcasts; + } + + async getBroadcastDirContext(): Promise { + const { broadcastKey } = this.props.args; + const { resourceDir, cwd: runCwd } = this.runContext; + + if (resourceDir) { + const target: ResourceTarget = { + commandId: this.id ?? "broadcast:pull", + type: "broadcast", + key: broadcastKey, + }; + + return ensureResourceDirForTarget( + resourceDir, + target, + ) as BroadcastDirContext; + } + + const broadcastsIndexDirCtx = await resolveResourceDir( + this.projectConfig, + "broadcast", + runCwd, + ); + + if (broadcastKey) { + const dirPath = path.resolve(broadcastsIndexDirCtx.abspath, broadcastKey); + const exists = await Broadcast.isBroadcastDir(dirPath); + + return { + type: "broadcast", + key: broadcastKey, + abspath: dirPath, + exists, + }; + } + + return this.error("Missing 1 required arg:\nbroadcastKey"); + } +} diff --git a/src/commands/broadcast/push.ts b/src/commands/broadcast/push.ts new file mode 100644 index 00000000..2c1bb52e --- /dev/null +++ b/src/commands/broadcast/push.ts @@ -0,0 +1,122 @@ +import { Args, Flags } from "@oclif/core"; + +import BaseCommand from "@/lib/base-command"; +import { formatCommandScope } from "@/lib/helpers/command"; +import { KnockEnv } from "@/lib/helpers/const"; +import { formatError, formatErrors, SourceError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { merge } from "@/lib/helpers/object.isomorphic"; +import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; +import { indentString } from "@/lib/helpers/string"; +import { spinner } from "@/lib/helpers/ux"; +import * as Broadcast from "@/lib/marshal/broadcast"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; + +import BroadcastValidate from "./validate"; + +export default class BroadcastPush extends BaseCommand { + static summary = + "Push one or more broadcasts from a local file system to Knock."; + + static flags = { + environment: Flags.string({ + summary: + "The environment to push the broadcast to. Defaults to development.", + default: KnockEnv.Development, + }), + branch: CustomFlags.branch, + all: Flags.boolean({ + summary: "Whether to push all broadcasts from the target directory.", + }), + "broadcasts-dir": CustomFlags.dirPath({ + summary: "The target directory path to find all broadcasts to push.", + dependsOn: ["all"], + }), + }; + + static args = { + broadcastKey: Args.string({ + required: false, + }), + }; + + async run(): Promise { + const { flags } = this.props; + + const target = await Broadcast.ensureValidCommandTarget( + this.props, + this.runContext, + this.projectConfig, + ); + + const [broadcasts, readErrors] = await Broadcast.readAllForCommandTarget( + target, + { + withExtractedFiles: true, + }, + ); + + if (readErrors.length > 0) { + this.error(formatErrors(readErrors, { prependBy: "\n\n" })); + } + + if (broadcasts.length === 0) { + this.error(`No broadcast directories found in ${target.context.abspath}`); + } + + spinner.start(`‣ Validating`); + + const apiErrors = await BroadcastValidate.validateAll( + this.apiV1, + this.props as any, + broadcasts, + ); + + if (apiErrors.length > 0) { + this.error(formatErrors(apiErrors, { prependBy: "\n\n" })); + } + + spinner.stop(); + + spinner.start(`‣ Pushing`); + + for (const broadcast of broadcasts) { + const props = merge(this.props, { flags: { annotate: true } }); + + // eslint-disable-next-line no-await-in-loop + const resp = await this.apiV1.upsertBroadcast(props, { + ...broadcast.content, + key: broadcast.key, + }); + + if (isSuccessResp(resp)) { + // eslint-disable-next-line no-await-in-loop + await Broadcast.writeBroadcastDirFromData( + broadcast, + resp.data.broadcast!, + { + withSchema: true, + }, + ); + continue; + } + + const error = new SourceError( + formatErrorRespMessage(resp), + Broadcast.broadcastJsonPath(broadcast), + "ApiError", + ); + + this.error(formatError(error)); + } + + spinner.stop(); + + const broadcastKeys = broadcasts.map((b) => b.key); + const scope = formatCommandScope(flags); + this.log( + `‣ Successfully pushed ${broadcasts.length} broadcast(s) to ${scope}:\n` + + indentString(broadcastKeys.join("\n"), 4), + ); + } +} diff --git a/src/commands/broadcast/send.ts b/src/commands/broadcast/send.ts new file mode 100644 index 00000000..f00360c8 --- /dev/null +++ b/src/commands/broadcast/send.ts @@ -0,0 +1,59 @@ +import { Args, Flags } from "@oclif/core"; + +import * as ApiV1 from "@/lib/api-v1"; +import BaseCommand from "@/lib/base-command"; +import { formatCommandScope } from "@/lib/helpers/command"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { withSpinner } from "@/lib/helpers/request"; +import { promptToConfirm } from "@/lib/helpers/ux"; + +export default class BroadcastSend extends BaseCommand { + static summary = "Send or schedule a broadcast in a given environment."; + + static description = ` +Sends a broadcast immediately or schedules it for a future time. Use the +--send-at flag to schedule the broadcast for a specific time. +`.trim(); + + static flags = { + environment: Flags.string({ + required: true, + summary: "The environment to use.", + }), + branch: CustomFlags.branch, + "send-at": Flags.string({ + summary: + "ISO 8601 datetime to schedule the broadcast. Omit to send immediately.", + }), + force: Flags.boolean({ + summary: "Remove the confirmation prompt.", + }), + }; + + static args = { + broadcastKey: Args.string({ + required: true, + }), + }; + + async run(): Promise { + const { args, flags } = this.props; + + const action = flags["send-at"] ? "Schedule" : "Send"; + const scope = formatCommandScope(flags); + const prompt = `${action} \`${args.broadcastKey}\` broadcast in ${scope}?`; + const input = flags.force || (await promptToConfirm(prompt)); + if (!input) return; + + const actioning = flags["send-at"] ? "Scheduling" : "Sending"; + await withSpinner( + () => this.apiV1.sendBroadcast(this.props), + { action: `‣ ${actioning}` }, + ); + + const actioned = flags["send-at"] ? "scheduled" : "sent"; + this.log( + `‣ Successfully ${actioned} \`${args.broadcastKey}\` broadcast in ${scope}`, + ); + } +} diff --git a/src/commands/broadcast/validate.ts b/src/commands/broadcast/validate.ts new file mode 100644 index 00000000..18557910 --- /dev/null +++ b/src/commands/broadcast/validate.ts @@ -0,0 +1,112 @@ +import { Args, Flags } from "@oclif/core"; + +import * as ApiV1 from "@/lib/api-v1"; +import BaseCommand, { Props } from "@/lib/base-command"; +import { formatCommandScope } from "@/lib/helpers/command"; +import { KnockEnv } from "@/lib/helpers/const"; +import { formatErrors, SourceError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; +import { indentString } from "@/lib/helpers/string"; +import { spinner } from "@/lib/helpers/ux"; +import * as Broadcast from "@/lib/marshal/broadcast"; + +export default class BroadcastValidate extends BaseCommand< + typeof BroadcastValidate +> { + static summary = "Validate one or more broadcasts from a local file system."; + + static flags = { + environment: Flags.string({ + summary: + "The environment to validate the broadcast in. Defaults to development.", + default: KnockEnv.Development, + }), + branch: CustomFlags.branch, + all: Flags.boolean({ + summary: "Whether to validate all broadcasts from the target directory.", + }), + "broadcasts-dir": CustomFlags.dirPath({ + summary: "The target directory path to find all broadcasts to validate.", + dependsOn: ["all"], + }), + }; + + static args = { + broadcastKey: Args.string({ + required: false, + }), + }; + + async run(): Promise { + const target = await Broadcast.ensureValidCommandTarget( + this.props, + this.runContext, + this.projectConfig, + ); + + const [broadcasts, readErrors] = await Broadcast.readAllForCommandTarget( + target, + { + withExtractedFiles: true, + }, + ); + + if (readErrors.length > 0) { + this.error(formatErrors(readErrors, { prependBy: "\n\n" })); + } + + if (broadcasts.length === 0) { + this.error(`No broadcast directories found in ${target.context.abspath}`); + } + + spinner.start(`‣ Validating`); + + const apiErrors = await BroadcastValidate.validateAll( + this.apiV1, + this.props, + broadcasts, + ); + + if (apiErrors.length > 0) { + this.error(formatErrors(apiErrors, { prependBy: "\n\n" })); + } + + spinner.stop(); + + const broadcastKeys = broadcasts.map((b) => b.key); + const scope = formatCommandScope(this.props.flags); + this.log( + `‣ Successfully validated ${broadcasts.length} broadcast(s) using ${scope}:\n` + + indentString(broadcastKeys.join("\n"), 4), + ); + } + + static async validateAll( + api: ApiV1.T, + props: Props, + broadcasts: Broadcast.BroadcastDirData[], + ): Promise { + const errorPromises = broadcasts.map(async (broadcast) => { + const resp = await api.validateBroadcast(props, { + ...broadcast.content, + key: broadcast.key, + }); + + if (isSuccessResp(resp)) return; + + const error = new SourceError( + formatErrorRespMessage(resp), + Broadcast.broadcastJsonPath(broadcast), + "ApiError", + ); + return error; + }); + + const errors = (await Promise.all(errorPromises)).filter( + (e): e is Exclude => Boolean(e), + ); + + return errors; + } +} diff --git a/src/lib/api-v1.ts b/src/lib/api-v1.ts index ba3c299f..6146e182 100644 --- a/src/lib/api-v1.ts +++ b/src/lib/api-v1.ts @@ -11,6 +11,7 @@ import { Props } from "@/lib/base-command"; import { InputError } from "@/lib/helpers/error"; import { prune } from "@/lib/helpers/object.isomorphic"; import { PaginatedResp, toPageParams } from "@/lib/helpers/page"; +import * as Broadcast from "@/lib/marshal/broadcast"; import * as EmailLayout from "@/lib/marshal/email-layout"; import * as Guide from "@/lib/marshal/guide"; import * as MessageType from "@/lib/marshal/message-type"; @@ -473,6 +474,82 @@ export default class ApiV1 { }); } + // By resources: Broadcasts + + async listBroadcasts({ + flags, + }: Props): Promise>> { + const params = prune({ + environment: flags.environment, + branch: flags.branch, + annotate: flags.annotate, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + ...toPageParams(flags), + }); + + return this.get("/broadcasts", { params }); + } + + async getBroadcast({ + args, + flags, + }: Props): Promise>> { + const params = prune({ + environment: flags.environment, + branch: flags.branch, + annotate: flags.annotate, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + }); + + return this.get(`/broadcasts/${args.broadcastKey}`, { params }); + } + + async upsertBroadcast( + { flags }: Props, + broadcast: Broadcast.BroadcastInput, + ): Promise>> { + const params = prune({ + environment: flags.environment, + branch: flags.branch, + annotate: flags.annotate, + }); + const data = { broadcast }; + + return this.put(`/broadcasts/${broadcast.key}`, data, { params }); + } + + async validateBroadcast( + { flags }: Props, + broadcast: Broadcast.BroadcastInput, + ): Promise> { + const params = prune({ + environment: flags.environment, + branch: flags.branch, + }); + const data = { broadcast }; + + return this.put(`/broadcasts/${broadcast.key}/validate`, data, { + params, + }); + } + + async sendBroadcast({ + args, + flags, + }: Props): Promise> { + const params = prune({ + environment: flags.environment, + branch: flags.branch, + }); + const data = prune({ + send_at: flags["send-at"], + }); + + return this.put(`/broadcasts/${args.broadcastKey}/send`, data, { + params, + }); + } + // By resources: Guides async listGuides({ @@ -731,4 +808,25 @@ export type ActivateGuideResp = { errors?: InputError[]; }; +export type ListBroadcastResp = + PaginatedResp>; + +export type GetBroadcastResp = + Broadcast.BroadcastData; + +export type UpsertBroadcastResp = { + broadcast?: Broadcast.BroadcastData; + errors?: InputError[]; +}; + +export type ValidateBroadcastResp = { + broadcast?: Broadcast.BroadcastData; + errors?: InputError[]; +}; + +export type SendBroadcastResp = { + broadcast?: Broadcast.BroadcastData; + errors?: InputError[]; +}; + export type ListBranchResp = PaginatedResp; diff --git a/src/lib/helpers/project-config.ts b/src/lib/helpers/project-config.ts index 3b2a7bc2..a517ff63 100644 --- a/src/lib/helpers/project-config.ts +++ b/src/lib/helpers/project-config.ts @@ -123,6 +123,7 @@ export const ResourceDirectoriesByType: Record< email_layout: "layouts", message_type: "message-types", translation: "translations", + broadcast: "broadcasts", } as const; type ValidResourceType = Exclude; diff --git a/src/lib/marshal/broadcast/generator.ts b/src/lib/marshal/broadcast/generator.ts new file mode 100644 index 00000000..3fa5f249 --- /dev/null +++ b/src/lib/marshal/broadcast/generator.ts @@ -0,0 +1,33 @@ +import { BroadcastDirContext } from "@/lib/run-context"; + +import { BROADCAST_JSON, BroadcastDirBundle } from "./processor.isomorphic"; +import { writeBroadcastDirFromBundle } from "./writer"; + +const scaffoldBroadcastDirBundle = (key: string): BroadcastDirBundle => { + const broadcastJson = { + key, + name: "", + description: "", + categories: [], + target_audience_key: "", + status: "draft" as const, + settings: { + is_commercial: false, + override_preferences: false, + }, + steps: [], + }; + + return { + [BROADCAST_JSON]: broadcastJson, + }; +}; + +export const generateBroadcastDir = async ( + broadcastDirCtx: BroadcastDirContext, + key: string, +): Promise => { + const bundle = scaffoldBroadcastDirBundle(key); + + return writeBroadcastDirFromBundle(broadcastDirCtx, bundle); +}; diff --git a/src/lib/marshal/broadcast/helpers.ts b/src/lib/marshal/broadcast/helpers.ts new file mode 100644 index 00000000..fe7a0e7b --- /dev/null +++ b/src/lib/marshal/broadcast/helpers.ts @@ -0,0 +1,152 @@ +import * as path from "node:path"; + +import { ux } from "@oclif/core"; +import * as fs from "fs-extra"; +import { take } from "lodash"; + +import { DirContext } from "@/lib/helpers/fs"; +import { + ProjectConfig, + resolveResourceDir, +} from "@/lib/helpers/project-config"; +import { checkSlugifiedFormat } from "@/lib/helpers/string"; +import { BroadcastDirContext, RunContext } from "@/lib/run-context"; + +import { BROADCAST_JSON } from "./processor.isomorphic"; +import type { BroadcastData } from "./types"; + +export const broadcastJsonPath = ( + broadcastDirCtx: BroadcastDirContext, +): string => path.resolve(broadcastDirCtx.abspath, BROADCAST_JSON); + +export const validateBroadcastKey = (input: string): string | undefined => { + if (!checkSlugifiedFormat(input, { onlyLowerCase: true })) { + return "must include only lowercase alphanumeric, dash, or underscore characters"; + } + + return undefined; +}; + +export const lsBroadcastJson = async ( + dirPath: string, +): Promise => { + const broadcastJsonPath = path.resolve(dirPath, BROADCAST_JSON); + + const exists = await fs.pathExists(broadcastJsonPath); + return exists ? broadcastJsonPath : undefined; +}; + +export const isBroadcastDir = async (dirPath: string): Promise => + Boolean(await lsBroadcastJson(dirPath)); + +type FormatCategoriesOpts = { + truncateAfter?: number; + emptyDisplay?: string; +}; + +export const formatCategories = ( + broadcast: BroadcastData, + opts: FormatCategoriesOpts = {}, +): string => { + const { categories } = broadcast; + const { truncateAfter: limit, emptyDisplay = "" } = opts; + + if (!categories) return emptyDisplay; + + const count = categories.length; + if (!limit || limit >= count) return categories.join(", "); + + return take(categories, limit).join(", ") + ` (+ ${count - limit} more)`; +}; + +export const formatStatus = (broadcast: BroadcastData): string => + broadcast.status; + +type CommandTargetProps = { + flags: { + all: boolean | undefined; + "broadcasts-dir": DirContext | undefined; + }; + args: { + broadcastKey: string | undefined; + }; +}; + +type BroadcastDirTarget = { + type: "broadcastDir"; + context: BroadcastDirContext; +}; + +type BroadcastsIndexDirTarget = { + type: "broadcastsIndexDir"; + context: DirContext; +}; + +export type BroadcastCommandTarget = + | BroadcastDirTarget + | BroadcastsIndexDirTarget; + +export const ensureValidCommandTarget = async ( + props: CommandTargetProps, + runContext: RunContext, + projectConfig?: ProjectConfig, +): Promise => { + const { args, flags } = props; + const { commandId, resourceDir: resourceDirCtx, cwd: runCwd } = runContext; + + if (resourceDirCtx && resourceDirCtx.type !== "broadcast") { + return ux.error( + `Cannot run ${commandId} inside a ${resourceDirCtx.type} directory`, + ); + } + + if (flags.all && args.broadcastKey) { + return ux.error( + `broadcastKey arg \`${args.broadcastKey}\` cannot also be provided when using --all`, + ); + } + + const broadcastsIndexDirCtx = await resolveResourceDir( + projectConfig, + "broadcast", + runCwd, + ); + + if (flags.all) { + if (resourceDirCtx && !flags["broadcasts-dir"]) { + return ux.error("Missing required flag broadcasts-dir"); + } + + return { + type: "broadcastsIndexDir", + context: flags["broadcasts-dir"] || broadcastsIndexDirCtx, + }; + } + + if (args.broadcastKey) { + if (resourceDirCtx && resourceDirCtx.key !== args.broadcastKey) { + return ux.error( + `Cannot run ${commandId} \`${args.broadcastKey}\` inside another broadcast directory:\n${resourceDirCtx.key}`, + ); + } + + const targetDirPath = resourceDirCtx + ? resourceDirCtx.abspath + : path.resolve(broadcastsIndexDirCtx.abspath, args.broadcastKey); + + const broadcastDirCtx: BroadcastDirContext = { + type: "broadcast", + key: args.broadcastKey, + abspath: targetDirPath, + exists: await isBroadcastDir(targetDirPath), + }; + + return { type: "broadcastDir", context: broadcastDirCtx }; + } + + if (resourceDirCtx) { + return { type: "broadcastDir", context: resourceDirCtx }; + } + + return ux.error("Missing 1 required arg:\nbroadcastKey"); +}; diff --git a/src/lib/marshal/broadcast/index.ts b/src/lib/marshal/broadcast/index.ts new file mode 100644 index 00000000..43ea86e0 --- /dev/null +++ b/src/lib/marshal/broadcast/index.ts @@ -0,0 +1,6 @@ +export * from "./generator"; +export * from "./helpers"; +export * from "./processor.isomorphic"; +export * from "./reader"; +export * from "./types"; +export * from "./writer"; diff --git a/src/lib/marshal/broadcast/processor.isomorphic.ts b/src/lib/marshal/broadcast/processor.isomorphic.ts new file mode 100644 index 00000000..9ec8e2e7 --- /dev/null +++ b/src/lib/marshal/broadcast/processor.isomorphic.ts @@ -0,0 +1,58 @@ +/* + * IMPORTANT: + * + * This file is suffixed with `.isomorphic` because the code in this file is + * meant to run not just in a nodejs environment but also in a browser. For this + * reason there are some restrictions for which nodejs imports are allowed in + * this module. See `.eslintrc.json` for more details. + */ +import { cloneDeep, set } from "lodash"; + +import { AnyObj } from "@/lib/helpers/object.isomorphic"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; + +import { prepareResourceJson } from "../shared/helpers.isomorphic"; +import { + keyLocalStepsByRef, + recursivelyBuildStepsDirBundle, + WorkflowDirBundle, +} from "../workflow/processor.isomorphic"; +import type { BroadcastData } from "./types"; + +export const BROADCAST_JSON = "broadcast.json"; + +export type BroadcastDirBundle = { + [relpath: string]: string | AnyObj; +}; + +/* + * For a given broadcast payload (and its local broadcast reference), this + * function builds a "broadcast directory bundle", which is an obj made up of all + * the relative file paths (within the broadcast directory) and its file content + * to write the broadcast directory. + * + * Broadcasts share the same step/template structure as workflows, so we reuse + * the workflow's content extraction logic for steps. + */ +export const buildBroadcastDirBundle = ( + remoteBroadcast: BroadcastData, + localBroadcast?: AnyObj, + $schema?: string, +): BroadcastDirBundle => { + const bundle: WorkflowDirBundle = {}; + localBroadcast = localBroadcast || {}; + const mutBroadcast = cloneDeep(remoteBroadcast); + const localStepsByRef = keyLocalStepsByRef(localBroadcast.steps); + + recursivelyBuildStepsDirBundle( + bundle, + mutBroadcast.steps as any, + localStepsByRef, + ); + + return set( + bundle, + [BROADCAST_JSON], + prepareResourceJson(mutBroadcast, $schema), + ); +}; diff --git a/src/lib/marshal/broadcast/reader.ts b/src/lib/marshal/broadcast/reader.ts new file mode 100644 index 00000000..5bac6205 --- /dev/null +++ b/src/lib/marshal/broadcast/reader.ts @@ -0,0 +1,186 @@ +import * as path from "node:path"; + +import { ux } from "@oclif/core"; +import * as fs from "fs-extra"; +import { hasIn, set } from "lodash"; + +import { formatErrors, JsonDataError, SourceError } from "@/lib/helpers/error"; +import { ParseJsonResult, readJson } from "@/lib/helpers/json"; +import { + AnyObj, + mapValuesDeep, + ObjPath, + omitDeep, +} from "@/lib/helpers/object.isomorphic"; +import { FILEPATH_MARKED_RE } from "@/lib/marshal/shared/const.isomorphic"; +import { + readExtractedFileSync, + validateExtractedFilePath, +} from "@/lib/marshal/shared/helpers"; +import { BroadcastDirContext } from "@/lib/run-context"; + +import { + BroadcastCommandTarget, + isBroadcastDir, + lsBroadcastJson, +} from "./helpers"; +import { BROADCAST_JSON } from "./processor.isomorphic"; + +export type BroadcastDirData = BroadcastDirContext & { + content: AnyObj; +}; + +type ReadBroadcastDirOpts = { + withExtractedFiles?: boolean; +}; + +const joinExtractedFiles = async ( + broadcastDirCtx: BroadcastDirContext, + broadcastJson: AnyObj, +): Promise<[AnyObj, JsonDataError[]]> => { + const errors: JsonDataError[] = []; + const uniqueFilePaths: Record = {}; + + mapValuesDeep(broadcastJson, (relpath: string, key: string, parts) => { + if (!FILEPATH_MARKED_RE.test(key)) return; + + const objPathToFieldStr = ObjPath.stringify(parts); + const inlinObjPathStr = objPathToFieldStr.replace(FILEPATH_MARKED_RE, ""); + + if (hasIn(broadcastJson, inlinObjPathStr)) return; + + const invalidFilePathError = validateExtractedFilePath( + relpath, + path.resolve(broadcastDirCtx.abspath, BROADCAST_JSON), + uniqueFilePaths, + objPathToFieldStr, + ); + if (invalidFilePathError) { + errors.push(invalidFilePathError); + set(broadcastJson, objPathToFieldStr, undefined); + set(broadcastJson, inlinObjPathStr, undefined); + return; + } + + const [content, readExtractedFileError] = readExtractedFileSync( + relpath, + broadcastDirCtx, + objPathToFieldStr, + ); + + if (readExtractedFileError) { + errors.push(readExtractedFileError); + set(broadcastJson, objPathToFieldStr, relpath); + set(broadcastJson, inlinObjPathStr, undefined); + return; + } + + set(broadcastJson, objPathToFieldStr, relpath); + set(broadcastJson, inlinObjPathStr, content); + }); + + return [broadcastJson, errors]; +}; + +const readBroadcastDirs = async ( + broadcastDirCtxs: BroadcastDirContext[], + opts: ReadBroadcastDirOpts = {}, +): Promise<[BroadcastDirData[], SourceError[]]> => { + const broadcasts: BroadcastDirData[] = []; + const errors: SourceError[] = []; + + for (const broadcastDirCtx of broadcastDirCtxs) { + // eslint-disable-next-line no-await-in-loop + const [broadcast, readErrors] = await readBroadcastDir( + broadcastDirCtx, + opts, + ); + + if (readErrors.length > 0) { + const broadcastJsonPath = path.resolve( + broadcastDirCtx.abspath, + BROADCAST_JSON, + ); + const e = new SourceError(formatErrors(readErrors), broadcastJsonPath); + errors.push(e); + continue; + } + + broadcasts.push({ ...broadcastDirCtx, content: broadcast! }); + } + + return [broadcasts, errors]; +}; + +export const readBroadcastDir = async ( + broadcastDirCtx: BroadcastDirContext, + opts: ReadBroadcastDirOpts = {}, +): Promise => { + const { abspath } = broadcastDirCtx; + const { withExtractedFiles = false } = opts; + + const dirExists = await fs.pathExists(abspath); + if (!dirExists) throw new Error(`${abspath} does not exist`); + + const broadcastJsonPath = await lsBroadcastJson(abspath); + if (!broadcastJsonPath) + throw new Error(`${abspath} is not a broadcast directory`); + + const result = await readJson(broadcastJsonPath); + if (!result[0]) return result; + + let [broadcastJson] = result; + + broadcastJson = omitDeep(broadcastJson, ["__readonly"]); + + return withExtractedFiles + ? joinExtractedFiles(broadcastDirCtx, broadcastJson) + : [broadcastJson, []]; +}; + +export const readAllForCommandTarget = async ( + target: BroadcastCommandTarget, + opts: ReadBroadcastDirOpts = {}, +): Promise<[BroadcastDirData[], SourceError[]]> => { + const { type: targetType, context: targetCtx } = target; + + if (!targetCtx.exists) { + const subject = + targetType === "broadcastDir" + ? "a broadcast directory at" + : "broadcast directories in"; + + return ux.error(`Cannot locate ${subject} \`${targetCtx.abspath}\``); + } + + switch (targetType) { + case "broadcastDir": { + return readBroadcastDirs([targetCtx], opts); + } + + case "broadcastsIndexDir": { + const dirents = await fs.readdir(targetCtx.abspath, { + withFileTypes: true, + }); + + const promises = dirents.map(async (dirent) => { + const abspath = path.resolve(targetCtx.abspath, dirent.name); + const broadcastDirCtx: BroadcastDirContext = { + type: "broadcast", + key: dirent.name, + abspath, + exists: await isBroadcastDir(abspath), + }; + return broadcastDirCtx; + }); + + const broadcastDirCtxs = (await Promise.all(promises)).filter( + (broadcastDirCtx) => broadcastDirCtx.exists, + ); + return readBroadcastDirs(broadcastDirCtxs, opts); + } + + default: + throw new Error(`Invalid broadcast command target: ${target}`); + } +}; diff --git a/src/lib/marshal/broadcast/types.ts b/src/lib/marshal/broadcast/types.ts new file mode 100644 index 00000000..a440388d --- /dev/null +++ b/src/lib/marshal/broadcast/types.ts @@ -0,0 +1,37 @@ +import { AnyObj } from "@/lib/helpers/object.isomorphic"; +import type { MaybeWithAnnotation } from "@/lib/marshal/shared/types"; + +import type { WorkflowStepData } from "../workflow/types"; + +export type BroadcastStatus = "draft" | "scheduled" | "sent"; + +export type BroadcastSettings = { + is_commercial?: boolean; + override_preferences?: boolean; +}; + +// Broadcasts reuse workflow step types (channel, branch, delay only per MAPI spec) +export type BroadcastStepData = + WorkflowStepData; + +export type BroadcastData = A & { + key: string; + name: string; + valid: boolean; + status: BroadcastStatus; + description?: string; + categories?: string[]; + target_audience_key?: string; + scheduled_at?: string | null; + sent_at?: string | null; + settings?: BroadcastSettings; + steps: BroadcastStepData[]; + created_at: string; + updated_at: string; + environment: string; + sha: string; +}; + +export type BroadcastInput = AnyObj & { + key: string; +}; diff --git a/src/lib/marshal/broadcast/writer.ts b/src/lib/marshal/broadcast/writer.ts new file mode 100644 index 00000000..f522b457 --- /dev/null +++ b/src/lib/marshal/broadcast/writer.ts @@ -0,0 +1,158 @@ +import * as path from "node:path"; + +import * as fs from "fs-extra"; +import { uniqueId } from "lodash"; + +import { sandboxDir } from "@/lib/helpers/const"; +import { DirContext } from "@/lib/helpers/fs"; +import { DOUBLE_SPACES } from "@/lib/helpers/json"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; +import { BroadcastDirContext } from "@/lib/run-context"; + +import { isBroadcastDir } from "./helpers"; +import { + BROADCAST_JSON, + BroadcastDirBundle, + buildBroadcastDirBundle, +} from "./processor.isomorphic"; +import { readBroadcastDir } from "./reader"; +import type { BroadcastData } from "./types"; + +type WriteOpts = { + withSchema?: boolean; +}; + +const BROADCAST_SCHEMA = "https://schemas.knock.app/cli/broadcast.json"; + +export const writeBroadcastDirFromData = async ( + broadcastDirCtx: BroadcastDirContext, + remoteBroadcast: BroadcastData, + options?: WriteOpts, +): Promise => { + const { withSchema = false } = options || {}; + + const [localBroadcast] = broadcastDirCtx.exists + ? await readBroadcastDir(broadcastDirCtx, { withExtractedFiles: true }) + : []; + + const bundle = buildBroadcastDirBundle( + remoteBroadcast, + localBroadcast, + withSchema ? BROADCAST_SCHEMA : undefined, + ); + + return writeBroadcastDirFromBundle(broadcastDirCtx, bundle); +}; + +export const writeBroadcastDirFromBundle = async ( + broadcastDirCtx: BroadcastDirContext, + broadcastDirBundle: BroadcastDirBundle, +): Promise => { + const backupDirPath = path.resolve(sandboxDir, uniqueId("backup")); + + try { + if (broadcastDirCtx.exists) { + await fs.copy(broadcastDirCtx.abspath, backupDirPath); + await fs.emptyDir(broadcastDirCtx.abspath); + } + + const promises = Object.entries(broadcastDirBundle).map( + ([relpath, fileContent]) => { + const filePath = path.resolve(broadcastDirCtx.abspath, relpath); + + return relpath === BROADCAST_JSON + ? fs.outputJson(filePath, fileContent, { spaces: DOUBLE_SPACES }) + : fs.outputFile( + filePath, + typeof fileContent === "string" ? fileContent : "", + ); + }, + ); + await Promise.all(promises); + } catch (error) { + if (broadcastDirCtx.exists) { + await fs.emptyDir(broadcastDirCtx.abspath); + await fs.copy(backupDirPath, broadcastDirCtx.abspath); + } else { + await fs.remove(broadcastDirCtx.abspath); + } + + throw error; + } finally { + await fs.remove(backupDirPath); + } +}; + +const pruneBroadcastsIndexDir = async ( + indexDirCtx: DirContext, + remoteBroadcasts: BroadcastData[], +): Promise => { + const broadcastsByKey = Object.fromEntries( + remoteBroadcasts.map((b) => [b.key.toLowerCase(), b]), + ); + + const dirents = await fs.readdir(indexDirCtx.abspath, { + withFileTypes: true, + }); + + const promises = dirents.map(async (dirent) => { + const direntName = dirent.name.toLowerCase(); + const direntPath = path.resolve(indexDirCtx.abspath, direntName); + + if ((await isBroadcastDir(direntPath)) && broadcastsByKey[direntName]) { + return; + } + + await fs.remove(direntPath); + }); + + await Promise.all(promises); +}; + +export const writeBroadcastsIndexDir = async ( + indexDirCtx: DirContext, + remoteBroadcasts: BroadcastData[], + options?: WriteOpts, +): Promise => { + const backupDirPath = path.resolve(sandboxDir, uniqueId("backup")); + + try { + if (indexDirCtx.exists) { + await fs.copy(indexDirCtx.abspath, backupDirPath); + await pruneBroadcastsIndexDir(indexDirCtx, remoteBroadcasts); + } + + const writeBroadcastDirPromises = remoteBroadcasts.map( + async (broadcast) => { + const broadcastDirPath = path.resolve( + indexDirCtx.abspath, + broadcast.key, + ); + + const broadcastDirCtx: BroadcastDirContext = { + type: "broadcast", + key: broadcast.key, + abspath: broadcastDirPath, + exists: indexDirCtx.exists + ? await isBroadcastDir(broadcastDirPath) + : false, + }; + + return writeBroadcastDirFromData(broadcastDirCtx, broadcast, options); + }, + ); + + await Promise.all(writeBroadcastDirPromises); + } catch (error) { + if (indexDirCtx.exists) { + await fs.emptyDir(indexDirCtx.abspath); + await fs.copy(backupDirPath, indexDirCtx.abspath); + } else { + await fs.remove(indexDirCtx.abspath); + } + + throw error; + } finally { + await fs.remove(backupDirPath); + } +}; diff --git a/src/lib/marshal/index.isomorphic.ts b/src/lib/marshal/index.isomorphic.ts index 76984cf7..96eef809 100644 --- a/src/lib/marshal/index.isomorphic.ts +++ b/src/lib/marshal/index.isomorphic.ts @@ -1,6 +1,7 @@ /* * IMPORTANT: You must only expose exports from isomorphic modules. */ +export { buildBroadcastDirBundle } from "./broadcast/processor.isomorphic"; export { buildEmailLayoutDirBundle } from "./email-layout/processor.isomorphic"; export { buildGuideDirBundle } from "./guide/processor.isomorphic"; export { buildMessageTypeDirBundle } from "./message-type/processor.isomorphic"; diff --git a/src/lib/marshal/shared/helpers.isomorphic.ts b/src/lib/marshal/shared/helpers.isomorphic.ts index 8da8c248..6747e73e 100644 --- a/src/lib/marshal/shared/helpers.isomorphic.ts +++ b/src/lib/marshal/shared/helpers.isomorphic.ts @@ -3,6 +3,7 @@ import { omit } from "lodash"; import { AnyObj, omitDeep, split } from "@/lib/helpers/object.isomorphic"; import { WithAnnotation } from "@/lib/marshal/shared/types"; +import type { BroadcastData } from "../broadcast/types"; import { EmailLayoutData } from "../email-layout"; import { GuideData } from "../guide"; import { MessageTypeData } from "../message-type"; @@ -16,6 +17,7 @@ import { WorkflowData } from "../workflow"; * fields. */ type ResourceData = + | BroadcastData | EmailLayoutData | PartialData | WorkflowData diff --git a/src/lib/marshal/shared/helpers.ts b/src/lib/marshal/shared/helpers.ts index d7b5a55f..947f8dec 100644 --- a/src/lib/marshal/shared/helpers.ts +++ b/src/lib/marshal/shared/helpers.ts @@ -7,6 +7,7 @@ import { ParsedJson, parseJson } from "@/lib/helpers/json"; import { validateLiquidSyntax } from "@/lib/helpers/liquid"; import { VISUAL_BLOCKS_JSON } from "@/lib/marshal/workflow"; import { + BroadcastDirContext, EmailLayoutDirContext, GuideDirContext, MessageTypeDirContext, @@ -31,6 +32,7 @@ const DECODABLE_JSON_FILES = new Set([VISUAL_BLOCKS_JSON]); export const readExtractedFileSync = ( relpath: string, dirCtx: + | BroadcastDirContext | WorkflowDirContext | EmailLayoutDirContext | PartialDirContext diff --git a/src/lib/marshal/workflow/processor.isomorphic.ts b/src/lib/marshal/workflow/processor.isomorphic.ts index 9e0e8c6e..cbd6d237 100644 --- a/src/lib/marshal/workflow/processor.isomorphic.ts +++ b/src/lib/marshal/workflow/processor.isomorphic.ts @@ -196,7 +196,7 @@ const compileExtractionSettings = ( ); }; -const keyLocalWorkflowStepsByRef = ( +export const keyLocalStepsByRef = ( steps: unknown, result: AnyObj = {}, ): AnyObj => { @@ -212,7 +212,7 @@ const keyLocalWorkflowStepsByRef = ( for (const branch of step.branches) { if (!isPlainObject(branch)) continue; - result = keyLocalWorkflowStepsByRef(branch.steps as AnyObj[], result); + result = keyLocalStepsByRef(branch.steps as AnyObj[], result); } } } @@ -220,7 +220,7 @@ const keyLocalWorkflowStepsByRef = ( return result; }; -const recursivelyBuildWorkflowDirBundle = ( +export const recursivelyBuildStepsDirBundle = ( bundle: WorkflowDirBundle, steps: WorkflowStepData[], localWorkflowStepsByRef: AnyObj, @@ -303,7 +303,7 @@ const recursivelyBuildWorkflowDirBundle = ( // Lastly, recurse thru any branches that exist in the workflow tree if (step.type === StepType.Branch) { for (const branch of step.branches) { - recursivelyBuildWorkflowDirBundle( + recursivelyBuildStepsDirBundle( bundle, branch.steps, localWorkflowStepsByRef, @@ -346,13 +346,11 @@ export const buildWorkflowDirBundle = ( const bundle: WorkflowDirBundle = {}; localWorkflow = localWorkflow || {}; const mutWorkflow = cloneDeep(remoteWorkflow); - const localWorkflowStepsByRef = keyLocalWorkflowStepsByRef( - localWorkflow.steps, - ); + const localWorkflowStepsByRef = keyLocalStepsByRef(localWorkflow.steps); // Recursively traverse the workflow step tree, mutating it and the bundle // along the way - recursivelyBuildWorkflowDirBundle( + recursivelyBuildStepsDirBundle( bundle, mutWorkflow.steps, localWorkflowStepsByRef, diff --git a/src/lib/resources.ts b/src/lib/resources.ts index 56ba7b27..a67c9b61 100644 --- a/src/lib/resources.ts +++ b/src/lib/resources.ts @@ -1,7 +1,10 @@ import type { ResourceType } from "./run-context"; // TODO Remove this once hidden option is removed from message types / guides -export type NonHiddenResourceType = Exclude; +export type NonHiddenResourceType = Exclude< + ResourceType, + "reusable_step" | "broadcast" +>; /** * An ordered array of all resource types. diff --git a/src/lib/run-context/loader.ts b/src/lib/run-context/loader.ts index 8a06dc7f..637c7605 100644 --- a/src/lib/run-context/loader.ts +++ b/src/lib/run-context/loader.ts @@ -5,6 +5,7 @@ */ import * as path from "node:path"; +import * as Broadcast from "@/lib/marshal/broadcast"; import * as EmailLayout from "@/lib/marshal/email-layout"; import * as Guide from "@/lib/marshal/guide"; import * as MessageType from "@/lib/marshal/message-type"; @@ -39,6 +40,11 @@ const evaluateRecursively = async ( ctx.resourceDir = buildResourceDirContext("guide", currDir); } + const isBroadcastDir = await Broadcast.isBroadcastDir(currDir); + if (isBroadcastDir) { + ctx.resourceDir = buildResourceDirContext("broadcast", currDir); + } + const isEmailLayoutDir = await EmailLayout.isEmailLayoutDir(currDir); if (isEmailLayoutDir) { ctx.resourceDir = buildResourceDirContext("email_layout", currDir); diff --git a/src/lib/run-context/types.ts b/src/lib/run-context/types.ts index 9fa26959..e9241dae 100644 --- a/src/lib/run-context/types.ts +++ b/src/lib/run-context/types.ts @@ -23,7 +23,8 @@ export type ResourceType = | "partial" | "message_type" | "guide" - | "reusable_step"; + | "reusable_step" + | "broadcast"; type ResourceDirContextBase = DirContext & { type: ResourceType; @@ -58,6 +59,10 @@ export type ReusableStepDirContext = ResourceDirContextBase & { type: "reusable_step"; }; +export type BroadcastDirContext = ResourceDirContextBase & { + type: "broadcast"; +}; + export type ResourceDirContext = | WorkflowDirContext | EmailLayoutDirContext @@ -65,7 +70,8 @@ export type ResourceDirContext = | PartialDirContext | MessageTypeDirContext | GuideDirContext - | ReusableStepDirContext; + | ReusableStepDirContext + | BroadcastDirContext; export type ResourceTarget = { commandId: string; diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 0842efb7..c11311bf 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -47,3 +47,11 @@ export const viewPartialUrl = ( partialKey: string, ): string => `${dashboardUrl}/${accountSlug}/${envOrBranchSlug.toLowerCase()}/partials/${partialKey}`; + +export const viewBroadcastUrl = ( + dashboardUrl: string, + accountSlug: string, + envOrBranchSlug: string, + broadcastKey: string, +): string => + `${dashboardUrl}/${accountSlug}/${envOrBranchSlug.toLowerCase()}/broadcasts/${broadcastKey}`; diff --git a/test/commands/broadcast/get.test.ts b/test/commands/broadcast/get.test.ts new file mode 100644 index 00000000..2acfcf03 --- /dev/null +++ b/test/commands/broadcast/get.test.ts @@ -0,0 +1,80 @@ +import { test } from "@oclif/test"; +import { isEqual } from "lodash"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; + +describe("commands/broadcast/get", () => { + const whoami = { + account_name: "Collab.io", + account_slug: "collab-io", + service_token_name: "My cool token", + }; + + describe("given no broadcast key arg", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command(["broadcast get"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given a broadcast key arg, and no flags", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(KnockApiV1.prototype, "getBroadcast", (stub) => + stub.resolves( + factory.resp({ + data: factory.broadcast(), + }), + ), + ) + .stdout() + .command(["broadcast get", "foo"]) + .it("calls apiV1 getBroadcast with correct props", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.getBroadcast as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { + broadcastKey: "foo", + }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + }), + ), + ); + }); + }); + + describe("given a broadcast key that does not exist", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(KnockApiV1.prototype, "getBroadcast", (stub) => + stub.resolves( + factory.resp({ + status: 404, + statusText: "Not found", + data: { + code: "resource_missing", + message: "The resource you requested does not exist", + status: 404, + type: "api_error", + }, + }), + ), + ) + .stdout() + .command(["broadcast get", "foo"]) + .catch("The resource you requested does not exist") + .it("throws an error for resource not found"); + }); +}); diff --git a/test/commands/broadcast/list.test.ts b/test/commands/broadcast/list.test.ts new file mode 100644 index 00000000..c98c3c49 --- /dev/null +++ b/test/commands/broadcast/list.test.ts @@ -0,0 +1,32 @@ +import { test } from "@oclif/test"; +import { isEqual } from "lodash"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; + +describe("commands/broadcast/list", () => { + describe("given no flags", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "listBroadcasts", (stub) => + stub.resolves( + factory.resp({ + data: factory.paginatedResp([factory.broadcast()]), + }), + ), + ) + .stdout() + .command(["broadcast list"]) + .it("calls apiV1 listBroadcasts with correct params", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.listBroadcasts as any, + sinon.match( + ({ flags }) => + isEqual(flags.environment, "development") && + isEqual(flags["service-token"], "valid-token"), + ), + ); + }); + }); +}); diff --git a/test/commands/broadcast/new.test.ts b/test/commands/broadcast/new.test.ts new file mode 100644 index 00000000..adf4fae6 --- /dev/null +++ b/test/commands/broadcast/new.test.ts @@ -0,0 +1,126 @@ +import * as path from "node:path"; + +import { expect, test } from "@oclif/test"; +import * as fs from "fs-extra"; + +import { sandboxDir } from "@/lib/helpers/const"; +import { BROADCAST_JSON } from "@/lib/marshal/broadcast"; + +const currCwd = process.cwd(); + +describe("commands/broadcast/new", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + process.chdir(sandboxDir); + }); + + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given name and key flags with --force", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .command([ + "broadcast new", + "--name", + "My New Broadcast", + "--key", + "my-new-broadcast", + "--force", + ]) + .it("creates a broadcast directory with broadcast.json", () => { + const broadcastPath = path.resolve( + sandboxDir, + "my-new-broadcast", + BROADCAST_JSON, + ); + expect(fs.pathExistsSync(broadcastPath)).to.equal(true); + + const content = fs.readJsonSync(broadcastPath); + expect(content.key).to.equal("my-new-broadcast"); + expect(content.name).to.equal(""); + expect(content.status).to.equal("draft"); + expect(content.steps).to.eql([]); + }); + }); + + describe("given an invalid broadcast key, with uppercase chars", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command([ + "broadcast new", + "--name", + "My New Broadcast", + "--key", + "My-New-Broadcast", + "--force", + ]) + .catch((error) => + expect(error.message).to.match(/^Invalid broadcast key/), + ) + .it("throws an error"); + }); + + describe("if invoked inside another broadcast directory", () => { + beforeEach(() => { + const broadcastDir = path.resolve(sandboxDir, "existing-broadcast"); + fs.ensureDirSync(broadcastDir); + fs.outputJsonSync(path.resolve(broadcastDir, BROADCAST_JSON), { + key: "existing-broadcast", + name: "Existing", + steps: [], + }); + process.chdir(broadcastDir); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command([ + "broadcast new", + "--name", + "My New Broadcast", + "--key", + "my-new-broadcast", + "--force", + ]) + .catch( + "Cannot create a new broadcast inside an existing broadcast directory", + ) + .it("throws an error"); + }); + + describe("given a broadcast key for an existing directory with --force", () => { + beforeEach(() => { + const broadcastDir = path.resolve(sandboxDir, "my-broadcast"); + fs.ensureDirSync(broadcastDir); + fs.outputJsonSync(path.resolve(broadcastDir, BROADCAST_JSON), { + key: "my-broadcast", + name: "Existing Broadcast", + steps: [], + }); + process.chdir(sandboxDir); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .command([ + "broadcast new", + "--name", + "Overwrite", + "--key", + "my-broadcast", + "--force", + ]) + .it("overwrites the existing broadcast directory", () => { + const content = fs.readJsonSync( + path.resolve(sandboxDir, "my-broadcast", BROADCAST_JSON), + ); + expect(content.key).to.equal("my-broadcast"); + }); + }); +}); diff --git a/test/commands/broadcast/open.test.ts b/test/commands/broadcast/open.test.ts new file mode 100644 index 00000000..399a92c5 --- /dev/null +++ b/test/commands/broadcast/open.test.ts @@ -0,0 +1,73 @@ +import { test } from "@oclif/test"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; +import { browser } from "@/lib/helpers/browser"; + +describe("commands/broadcast/open", () => { + const whoami = { + account_name: "Collab.io", + account_slug: "collab-io", + service_token_name: "My cool token", + }; + + describe("given no broadcast key arg", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command(["broadcast open"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given a broadcast key arg, and no flags", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(browser, "openUrl", (stub) => stub.resolves()) + .stdout() + .command(["broadcast open", "foo"]) + .it("opens the correct URL", () => { + sinon.assert.calledWith( + browser.openUrl as any, + "https://dashboard.knock.app/collab-io/development/broadcasts/foo", + ); + }); + }); + + describe("given a broadcast key arg and environment flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(browser, "openUrl", (stub) => stub.resolves()) + .stdout() + .command(["broadcast open", "foo", "--environment", "production"]) + .it("opens the correct URL with production environment", () => { + sinon.assert.calledWith( + browser.openUrl as any, + "https://dashboard.knock.app/collab-io/production/broadcasts/foo", + ); + }); + }); + + describe("given a broadcast key arg and branch flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(browser, "openUrl", (stub) => stub.resolves()) + .stdout() + .command(["broadcast open", "foo", "--branch", "my-feature-branch-123"]) + .it("opens the correct URL with branch", () => { + sinon.assert.calledWith( + browser.openUrl as any, + "https://dashboard.knock.app/collab-io/my-feature-branch-123/broadcasts/foo", + ); + }); + }); +}); diff --git a/test/commands/broadcast/pull.test.ts b/test/commands/broadcast/pull.test.ts new file mode 100644 index 00000000..248c47b1 --- /dev/null +++ b/test/commands/broadcast/pull.test.ts @@ -0,0 +1,128 @@ +import * as path from "node:path"; + +import { expect, test } from "@oclif/test"; +import enquirer from "enquirer"; +import * as fs from "fs-extra"; +import { isEqual } from "lodash"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; +import { sandboxDir } from "@/lib/helpers/const"; +import type { BroadcastData } from "@/lib/marshal/broadcast"; + +const currCwd = process.cwd(); + +const setupWithGetBroadcastStub = (broadcastAttrs = {}) => + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "getBroadcast", (stub) => + stub.resolves( + factory.resp({ + data: factory.broadcast(broadcastAttrs), + }), + ), + ) + .stub(enquirer.prototype, "prompt", (stub) => + stub.onFirstCall().resolves({ input: true }), + ); + +const setupWithListBroadcastsStub = ( + ...manyBroadcastsAttrs: Partial[] +) => + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "listBroadcasts", (stub) => + stub.resolves( + factory.resp({ + data: factory.paginatedResp( + manyBroadcastsAttrs.map((attrs) => factory.broadcast(attrs)), + ), + }), + ), + ) + .stub(enquirer.prototype, "prompt", (stub) => + stub.onFirstCall().resolves({ input: true }), + ); + +describe("commands/broadcast/pull", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + process.chdir(sandboxDir); + }); + + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given a broadcast key arg", () => { + setupWithGetBroadcastStub({ key: "onboarding" }) + .stdout() + .command(["broadcast pull", "onboarding"]) + .it("calls apiV1 getBroadcast with an annotate param", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.getBroadcast as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { + broadcastKey: "onboarding", + }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + annotate: true, + }), + ), + ); + }); + + setupWithGetBroadcastStub({ key: "welcome" }) + .stdout() + .command(["broadcast pull", "welcome"]) + .it("writes a broadcast dir to the file system", () => { + const exists = fs.pathExistsSync( + path.resolve(sandboxDir, "welcome", "broadcast.json"), + ); + + expect(exists).to.equal(true); + }); + }); + + describe("given a --all flag", () => { + setupWithListBroadcastsStub({ key: "onboarding" }, { key: "welcome" }) + .stdout() + .command(["broadcast pull", "--all", "--broadcasts-dir", "./broadcasts"]) + .it("calls apiV1 listBroadcasts with an annotate param", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.listBroadcasts as any, + sinon.match( + ({ flags }) => + isEqual(flags.annotate, true) && + isEqual(flags["service-token"], "valid-token"), + ), + ); + }); + }); + + describe("given both broadcast key arg and --all flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .command(["broadcast pull", "foo", "--all"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given no broadcast key arg nor --all flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .command(["broadcast pull"]) + .catch((error) => + expect(error.message).to.match(/Missing 1 required arg/), + ) + .it("throws an error"); + }); +}); diff --git a/test/commands/broadcast/push.test.ts b/test/commands/broadcast/push.test.ts new file mode 100644 index 00000000..326e0a16 --- /dev/null +++ b/test/commands/broadcast/push.test.ts @@ -0,0 +1,134 @@ +import * as path from "node:path"; + +import { expect, test } from "@oclif/test"; +import * as fs from "fs-extra"; +import { isEqual } from "lodash"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import BroadcastValidate from "@/commands/broadcast/validate"; +import KnockApiV1 from "@/lib/api-v1"; +import { sandboxDir } from "@/lib/helpers/const"; + +const broadcastJsonFile = "welcome-broadcast/broadcast.json"; + +const setupWithStub = (attrs = {}) => + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(BroadcastValidate, "validateAll", (stub) => stub.resolves([])) + .stub(KnockApiV1.prototype, "upsertBroadcast", (stub) => + stub.resolves( + factory.resp({ + data: { broadcast: factory.broadcast(attrs) }, + }), + ), + ); + +const currCwd = process.cwd(); + +describe("commands/broadcast/push", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given a broadcast directory exists, for the broadcast key", () => { + beforeEach(() => { + const abspath = path.resolve(sandboxDir, broadcastJsonFile); + fs.outputJsonSync(abspath, { + key: "welcome-broadcast", + name: "Welcome Broadcast", + steps: [], + }); + + process.chdir(sandboxDir); + }); + + setupWithStub({ key: "welcome-broadcast" }) + .stdout() + .command(["broadcast push", "welcome-broadcast"]) + .it("calls apiV1 upsertBroadcast with expected props", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.upsertBroadcast as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { broadcastKey: "welcome-broadcast" }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + annotate: true, + }), + ), + sinon.match((broadcast) => + isEqual(broadcast, { + key: "welcome-broadcast", + name: "Welcome Broadcast", + steps: [], + }), + ), + ); + }); + + describe("given a branch flag", () => { + setupWithStub({ key: "welcome-broadcast" }) + .stdout() + .command([ + "broadcast push", + "welcome-broadcast", + "--branch", + "my-feature-branch-123", + ]) + .it("calls apiV1 upsertBroadcast with expected params", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.upsertBroadcast as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { broadcastKey: "welcome-broadcast" }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + branch: "my-feature-branch-123", + annotate: true, + }), + ), + sinon.match((broadcast) => + isEqual(broadcast, { + key: "welcome-broadcast", + name: "Welcome Broadcast", + steps: [], + }), + ), + ); + }); + }); + }); + + describe("given a nonexistent broadcast directory", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["broadcast push", "does-not-exist"]) + .catch((error) => + expect(error.message).to.match( + /^Cannot locate a broadcast directory at/, + ), + ) + .it("throws an error"); + }); + + describe("given no broadcast key arg nor --all flag", () => { + setupWithStub() + .stdout() + .command(["broadcast push"]) + .exit(2) + .it("exits with status 2"); + }); +}); diff --git a/test/commands/broadcast/send.test.ts b/test/commands/broadcast/send.test.ts new file mode 100644 index 00000000..54e1afc2 --- /dev/null +++ b/test/commands/broadcast/send.test.ts @@ -0,0 +1,72 @@ +import { test } from "@oclif/test"; +import enquirer from "enquirer"; +import { isEqual } from "lodash"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; + +describe("commands/broadcast/send", () => { + describe("given confirmation accepted", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "sendBroadcast", (stub) => + stub.resolves( + factory.resp({ + data: { broadcast: factory.broadcast({ status: "sent" }) }, + }), + ), + ) + .stub(enquirer.prototype, "prompt", (stub) => + stub.onFirstCall().resolves({ input: true }), + ) + .stdout() + .command([ + "broadcast send", + "my-broadcast", + "--environment", + "development", + ]) + .it("calls apiV1 sendBroadcast with correct params", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.sendBroadcast as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { broadcastKey: "my-broadcast" }) && + isEqual(flags.environment, "development"), + ), + ); + }); + }); + + describe("given --force flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "sendBroadcast", (stub) => + stub.resolves( + factory.resp({ + data: { broadcast: factory.broadcast({ status: "sent" }) }, + }), + ), + ) + .stdout() + .command([ + "broadcast send", + "my-broadcast", + "--environment", + "development", + "--force", + ]) + .it("calls sendBroadcast without prompting for confirmation", () => { + sinon.assert.calledOnce(KnockApiV1.prototype.sendBroadcast as any); + }); + }); + + describe("given no environment flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command(["broadcast send", "my-broadcast"]) + .exit(2) + .it("exits with status 2"); + }); +}); diff --git a/test/commands/broadcast/validate.test.ts b/test/commands/broadcast/validate.test.ts new file mode 100644 index 00000000..38f60150 --- /dev/null +++ b/test/commands/broadcast/validate.test.ts @@ -0,0 +1,235 @@ +import * as path from "node:path"; + +import { expect, test } from "@oclif/test"; +import * as fs from "fs-extra"; +import { isEqual } from "lodash"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; +import { sandboxDir } from "@/lib/helpers/const"; +import { BROADCAST_JSON } from "@/lib/marshal/broadcast"; + +const broadcastJsonFile = "welcome-broadcast/broadcast.json"; + +const setupWithStub = (attrs = {}) => + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "validateBroadcast", (stub) => + stub.resolves(factory.resp(attrs)), + ); + +const currCwd = process.cwd(); + +describe("commands/broadcast/validate (a single broadcast)", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given a broadcast directory exists, for the broadcast key", () => { + beforeEach(() => { + const abspath = path.resolve(sandboxDir, broadcastJsonFile); + fs.outputJsonSync(abspath, { + key: "welcome-broadcast", + name: "Welcome Broadcast", + steps: [], + }); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["broadcast validate", "welcome-broadcast"]) + .it("calls apiV1 validateBroadcast with expected props", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.validateBroadcast as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { broadcastKey: "welcome-broadcast" }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + }), + ), + sinon.match((broadcast) => + isEqual(broadcast, { + key: "welcome-broadcast", + name: "Welcome Broadcast", + steps: [], + }), + ), + ); + }); + + describe("given a branch flag", () => { + setupWithStub() + .stdout() + .command([ + "broadcast validate", + "welcome-broadcast", + "--branch", + "my-feature-branch-123", + ]) + .it("calls apiV1 validateBroadcast with expected params", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.validateBroadcast as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { broadcastKey: "welcome-broadcast" }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + branch: "my-feature-branch-123", + }), + ), + sinon.match((broadcast) => + isEqual(broadcast, { + key: "welcome-broadcast", + name: "Welcome Broadcast", + steps: [], + }), + ), + ); + }); + }); + }); + + describe("given a nonexistent broadcast directory", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["broadcast validate", "does-not-exist"]) + .catch((error) => + expect(error.message).to.match( + /^Cannot locate a broadcast directory at/, + ), + ) + .it("throws an error"); + }); + + describe("given no broadcast key arg nor --all flag", () => { + setupWithStub() + .stdout() + .command(["broadcast validate"]) + .exit(2) + .it("exits with status 2"); + }); +}); + +describe("commands/broadcast/validate (all broadcasts)", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given a nonexistent broadcasts index directory", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command([ + "broadcast validate", + "--all", + "--broadcasts-dir", + "broadcasts", + ]) + .catch((error) => + expect(error.message).to.match( + /Cannot locate broadcast directories in/, + ), + ) + .it("throws an error"); + }); + + describe("given a broadcasts index directory with 2 valid broadcasts", () => { + const indexDirPath = path.resolve(sandboxDir, "broadcasts"); + + beforeEach(() => { + const fooBroadcastJson = path.resolve( + indexDirPath, + "foo", + BROADCAST_JSON, + ); + fs.outputJsonSync(fooBroadcastJson, { + key: "foo", + name: "Foo Broadcast", + steps: [], + }); + + const barBroadcastJson = path.resolve( + indexDirPath, + "bar", + BROADCAST_JSON, + ); + fs.outputJsonSync(barBroadcastJson, { + key: "bar", + name: "Bar Broadcast", + steps: [], + }); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command([ + "broadcast validate", + "--all", + "--broadcasts-dir", + "broadcasts", + ]) + .it("calls apiV1 validateBroadcast with expected props twice", () => { + const stub = KnockApiV1.prototype.validateBroadcast as any; + sinon.assert.calledTwice(stub); + + const expectedFlags = { + "service-token": "valid-token", + environment: "development", + all: true, + "broadcasts-dir": { + abspath: indexDirPath, + exists: true, + }, + }; + + sinon.assert.calledWith( + stub.firstCall, + sinon.match(({ flags }) => isEqual(flags, expectedFlags)), + sinon.match((broadcast) => + isEqual(broadcast, { + key: "bar", + name: "Bar Broadcast", + steps: [], + }), + ), + ); + + sinon.assert.calledWith( + stub.secondCall, + sinon.match(({ flags }) => isEqual(flags, expectedFlags)), + sinon.match((broadcast) => + isEqual(broadcast, { + key: "foo", + name: "Foo Broadcast", + steps: [], + }), + ), + ); + }); + }); +}); diff --git a/test/lib/marshal/broadcast/helpers.test.ts b/test/lib/marshal/broadcast/helpers.test.ts new file mode 100644 index 00000000..735e86af --- /dev/null +++ b/test/lib/marshal/broadcast/helpers.test.ts @@ -0,0 +1,69 @@ +import { expect } from "@oclif/test"; + +import { factory } from "@/../test/support"; +import { + formatCategories, + formatStatus, + validateBroadcastKey, +} from "@/lib/marshal/broadcast/helpers"; + +describe("lib/marshal/broadcast/helpers", () => { + describe("formatCategories", () => { + describe("given a broadcast with no categories", () => { + it("returns an empty display string, default or configured", () => { + const broadcast = factory.broadcast({ categories: undefined }); + + expect(formatCategories(broadcast)).to.equal(""); + expect(formatCategories(broadcast, { emptyDisplay: "-" })).to.equal( + "-", + ); + }); + }); + + describe("given a broadcast with categories without truncating", () => { + it("returns a string of categories joined by commas", () => { + const broadcast = factory.broadcast({ categories: ["a", "b", "c"] }); + + expect(formatCategories(broadcast)).to.equal("a, b, c"); + }); + }); + + describe("given a broadcast with categories above a truncate threshold", () => { + it("returns a string of categories joined by commas, plus the remaining count", () => { + const broadcast = factory.broadcast({ + categories: ["a", "b", "c", "d"], + }); + + const result = formatCategories(broadcast, { truncateAfter: 2 }); + expect(result).to.equal("a, b (+ 2 more)"); + }); + }); + }); + + describe("formatStatus", () => { + it("returns the broadcast status", () => { + expect(formatStatus(factory.broadcast({ status: "draft" }))).to.equal( + "draft", + ); + expect(formatStatus(factory.broadcast({ status: "scheduled" }))).to.equal( + "scheduled", + ); + expect(formatStatus(factory.broadcast({ status: "sent" }))).to.equal( + "sent", + ); + }); + }); + + describe("validateBroadcastKey", () => { + it("returns undefined for valid keys", () => { + expect(validateBroadcastKey("valid-key")).to.be.undefined; + expect(validateBroadcastKey("valid_key")).to.be.undefined; + expect(validateBroadcastKey("valid123")).to.be.undefined; + }); + + it("returns error message for invalid keys", () => { + expect(validateBroadcastKey("Invalid-Key")).to.not.be.undefined; + expect(validateBroadcastKey("invalid key")).to.not.be.undefined; + }); + }); +}); diff --git a/test/lib/marshal/broadcast/processor.test.ts b/test/lib/marshal/broadcast/processor.test.ts new file mode 100644 index 00000000..c3783b6c --- /dev/null +++ b/test/lib/marshal/broadcast/processor.test.ts @@ -0,0 +1,27 @@ +import { expect } from "@oclif/test"; + +import { factory } from "@/../test/support"; +import { + BROADCAST_JSON, + buildBroadcastDirBundle, +} from "@/lib/marshal/broadcast/processor.isomorphic"; + +describe("lib/marshal/broadcast/processor", () => { + describe("buildBroadcastDirBundle", () => { + it("returns a bundle with broadcast.json", () => { + const broadcast = factory.broadcast({ + key: "test-broadcast", + name: "Test Broadcast", + steps: [], + }); + + const bundle = buildBroadcastDirBundle(broadcast); + + expect(bundle).to.have.property(BROADCAST_JSON); + expect(bundle[BROADCAST_JSON]).to.have.property("key", "test-broadcast"); + expect(bundle[BROADCAST_JSON]).to.have.property("name", "Test Broadcast"); + expect(bundle[BROADCAST_JSON]).to.have.property("steps"); + expect(bundle[BROADCAST_JSON].steps).to.eql([]); + }); + }); +}); diff --git a/test/support/factory.ts b/test/support/factory.ts index 45912b22..c3d63a18 100644 --- a/test/support/factory.ts +++ b/test/support/factory.ts @@ -10,6 +10,7 @@ import { type WhoamiResp } from "@/lib/api-v1"; import { AuthenticatedSession } from "@/lib/auth"; import { BFlags, Props } from "@/lib/base-command"; import { PageInfo, PaginatedResp } from "@/lib/helpers/page"; +import type { BroadcastData } from "@/lib/marshal/broadcast"; import { EmailLayoutData } from "@/lib/marshal/email-layout"; import { GuideData } from "@/lib/marshal/guide"; import { MessageTypeData } from "@/lib/marshal/message-type"; @@ -100,6 +101,26 @@ export const whoami = (attrs: Partial = {}): WhoamiResp => { }; }; +export const broadcast = ( + attrs: Partial = {}, +): BroadcastData => { + return { + key: "welcome-broadcast", + name: "Welcome Broadcast", + valid: true, + status: "draft", + description: "", + categories: [], + target_audience_key: "", + steps: [], + created_at: "2022-12-31T12:00:00.000000Z", + updated_at: "2022-12-31T12:00:00.000000Z", + environment: "development", + sha: "", + ...attrs, + }; +}; + export const workflow = (attrs: Partial = {}): WorkflowData => { return { name: "New comment", From 7494e1e82cfff108c47af85a39777a2c55fd053c Mon Sep 17 00:00:00 2001 From: Chris Bell Date: Mon, 16 Feb 2026 14:07:07 -0500 Subject: [PATCH 2/3] chore: remove commits from broadcasts --- src/commands/broadcast/get.ts | 16 +--------------- src/commands/broadcast/list.ts | 13 +------------ src/commands/broadcast/pull.ts | 3 --- src/lib/api-v1.ts | 2 -- 4 files changed, 2 insertions(+), 32 deletions(-) diff --git a/src/commands/broadcast/get.ts b/src/commands/broadcast/get.ts index 399ac128..dffdf06b 100644 --- a/src/commands/broadcast/get.ts +++ b/src/commands/broadcast/get.ts @@ -23,9 +23,6 @@ export default class BroadcastGet extends BaseCommand { summary: "The environment to use.", }), branch: CustomFlags.branch, - "hide-uncommitted-changes": Flags.boolean({ - summary: "Hide any uncommitted changes.", - }), }; static args = { @@ -75,19 +72,8 @@ export default class BroadcastGet extends BaseCommand { render(broadcast: ApiV1.GetBroadcastResp, whoami: ApiV1.WhoamiResp): void { const { broadcastKey } = this.props.args; - const { - environment: env, - branch, - "hide-uncommitted-changes": commitedOnly, - } = this.props.flags; - - const qualifier = - env === "development" && !commitedOnly ? "(including uncommitted)" : ""; - const scope = formatCommandScope(this.props.flags); - this.log( - `‣ Showing broadcast \`${broadcastKey}\` in ${scope} ${qualifier}\n`, - ); + this.log(`‣ Showing broadcast \`${broadcastKey}\` in ${scope}\n`); const rows = [ { diff --git a/src/commands/broadcast/list.ts b/src/commands/broadcast/list.ts index 63b43038..e947b0e5 100644 --- a/src/commands/broadcast/list.ts +++ b/src/commands/broadcast/list.ts @@ -24,9 +24,6 @@ export default class BroadcastList extends BaseCommand { summary: "The environment to use.", }), branch: CustomFlags.branch, - "hide-uncommitted-changes": Flags.boolean({ - summary: "Hide any uncommitted changes.", - }), ...pageFlags, }; @@ -53,16 +50,8 @@ export default class BroadcastList extends BaseCommand { async render(data: ApiV1.ListBroadcastResp): Promise { const { entries } = data; - const { environment: env, "hide-uncommitted-changes": commitedOnly } = - this.props.flags; - - const qualifier = - env === "development" && !commitedOnly ? "(including uncommitted)" : ""; - const scope = formatCommandScope(this.props.flags); - this.log( - `‣ Showing ${entries.length} broadcasts in ${scope} ${qualifier}\n`, - ); + this.log(`‣ Showing ${entries.length} broadcasts in ${scope}\n`); ux.table(entries, { key: { diff --git a/src/commands/broadcast/pull.ts b/src/commands/broadcast/pull.ts index b22e5e03..8dd51665 100644 --- a/src/commands/broadcast/pull.ts +++ b/src/commands/broadcast/pull.ts @@ -41,9 +41,6 @@ export default class BroadcastPull extends BaseCommand { summary: "The target directory path to pull all broadcasts into.", dependsOn: ["all"], }), - "hide-uncommitted-changes": Flags.boolean({ - summary: "Hide any uncommitted changes.", - }), force: Flags.boolean({ summary: "Remove the confirmation prompt.", }), diff --git a/src/lib/api-v1.ts b/src/lib/api-v1.ts index 6146e182..cb0f86fc 100644 --- a/src/lib/api-v1.ts +++ b/src/lib/api-v1.ts @@ -483,7 +483,6 @@ export default class ApiV1 { environment: flags.environment, branch: flags.branch, annotate: flags.annotate, - hide_uncommitted_changes: flags["hide-uncommitted-changes"], ...toPageParams(flags), }); @@ -498,7 +497,6 @@ export default class ApiV1 { environment: flags.environment, branch: flags.branch, annotate: flags.annotate, - hide_uncommitted_changes: flags["hide-uncommitted-changes"], }); return this.get(`/broadcasts/${args.broadcastKey}`, { params }); From 027e8b34b9dece8dc7fa1433bb0fd64d6e906fc5 Mon Sep 17 00:00:00 2001 From: Chris Bell Date: Mon, 16 Feb 2026 14:12:18 -0500 Subject: [PATCH 3/3] chore: fix types --- src/commands/broadcast/get.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/broadcast/get.ts b/src/commands/broadcast/get.ts index dffdf06b..927ab3d6 100644 --- a/src/commands/broadcast/get.ts +++ b/src/commands/broadcast/get.ts @@ -72,6 +72,7 @@ export default class BroadcastGet extends BaseCommand { render(broadcast: ApiV1.GetBroadcastResp, whoami: ApiV1.WhoamiResp): void { const { broadcastKey } = this.props.args; + const { environment, branch } = this.props.flags; const scope = formatCommandScope(this.props.flags); this.log(`‣ Showing broadcast \`${broadcastKey}\` in ${scope}\n`); @@ -180,7 +181,7 @@ export default class BroadcastGet extends BaseCommand { const url = viewBroadcastUrl( this.sessionContext.dashboardOrigin, whoami.account_slug, - branch ?? env, + branch ?? environment, broadcast.key, );