From 21a5cc213caa6a127b70069eb0f9cdfe142eca8c Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Mon, 9 Mar 2026 10:05:03 -0400 Subject: [PATCH] feat(audience): add new and archive commands Add audience creation and deletion commands: - audience new: Scaffold a new audience with interactive prompts for type (static/dynamic) - audience archive: Archive an audience (affects ALL environments, requires confirmation) Supporting changes: - Add audience generator for scaffolding new audiences Includes full test coverage for both commands. --- src/commands/audience/archive.ts | 68 ++++++++ src/commands/audience/new.ts | 222 +++++++++++++++++++++++++ src/lib/marshal/audience/generator.ts | 70 ++++++++ src/lib/marshal/audience/index.ts | 1 + test/commands/audience/archive.test.ts | 112 +++++++++++++ test/commands/audience/new.test.ts | 153 +++++++++++++++++ 6 files changed, 626 insertions(+) create mode 100644 src/commands/audience/archive.ts create mode 100644 src/commands/audience/new.ts create mode 100644 src/lib/marshal/audience/generator.ts create mode 100644 test/commands/audience/archive.test.ts create mode 100644 test/commands/audience/new.test.ts diff --git a/src/commands/audience/archive.ts b/src/commands/audience/archive.ts new file mode 100644 index 00000000..bba0e52e --- /dev/null +++ b/src/commands/audience/archive.ts @@ -0,0 +1,68 @@ +import { Args, Flags, ux } from "@oclif/core"; + +import BaseCommand from "@/lib/base-command"; +import { ApiError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; +import { promptToConfirm, spinner } from "@/lib/helpers/ux"; + +export default class AudienceArchive extends BaseCommand< + typeof AudienceArchive +> { + static summary = "Archive an audience (affects ALL environments)."; + + static description = ` +WARNING: Archiving an audience affects ALL environments and cannot be undone. +Use this command with caution. +`; + + static flags = { + environment: Flags.string({ + required: true, + summary: "The environment to use.", + }), + branch: CustomFlags.branch, + force: Flags.boolean({ + summary: "Skip confirmation prompt.", + }), + }; + + static args = { + audienceKey: Args.string({ + required: true, + description: "The key of the audience to archive.", + }), + }; + + async run(): Promise { + const { audienceKey } = this.props.args; + const { force } = this.props.flags; + + // Confirm before archiving since this affects all environments + if (!force) { + const confirmed = await promptToConfirm( + `WARNING: Archiving audience \`${audienceKey}\` will affect ALL environments.\n` + + `This action cannot be undone. Continue?`, + ); + if (!confirmed) { + this.log("Archive cancelled."); + return; + } + } + + spinner.start(`‣ Archiving audience \`${audienceKey}\``); + + const resp = await this.apiV1.archiveAudience(this.props); + + spinner.stop(); + + if (!isSuccessResp(resp)) { + const message = formatErrorRespMessage(resp); + ux.error(new ApiError(message)); + } + + this.log( + `‣ Successfully archived audience \`${audienceKey}\` across all environments.`, + ); + } +} diff --git a/src/commands/audience/new.ts b/src/commands/audience/new.ts new file mode 100644 index 00000000..6524af88 --- /dev/null +++ b/src/commands/audience/new.ts @@ -0,0 +1,222 @@ +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, spinner } from "@/lib/helpers/ux"; +import * as Audience from "@/lib/marshal/audience"; +import { AudienceType } from "@/lib/marshal/audience"; +import { + AudienceDirContext, + ensureResourceDirForTarget, + ResourceTarget, +} from "@/lib/run-context"; + +import AudiencePush from "./push"; + +const AUDIENCE_TYPE_CHOICES = [ + { name: AudienceType.Static, message: "Static (manual membership)" }, + { name: AudienceType.Dynamic, message: "Dynamic (rule-based membership)" }, +] as const; + +export default class AudienceNew extends BaseCommand { + static summary = "Create a new audience with a minimal configuration."; + + static flags = { + name: Flags.string({ + summary: "The name of the audience", + char: "n", + }), + key: Flags.string({ + summary: "The key of the audience", + char: "k", + }), + type: Flags.string({ + summary: "The type of the audience (static, dynamic).", + char: "t", + options: [AudienceType.Static, AudienceType.Dynamic], + }), + description: Flags.string({ + summary: "The description of the audience", + char: "d", + }), + environment: Flags.string({ + summary: + "The environment to create the audience in. Defaults to development.", + default: KnockEnv.Development, + }), + branch: CustomFlags.branch, + force: Flags.boolean({ + summary: + "Force the creation of the audience directory without confirmation.", + }), + push: Flags.boolean({ + summary: "Whether or not to push the audience to Knock after creation.", + default: false, + char: "p", + }), + }; + + static args = {}; + + async run(): Promise { + const { flags } = this.props; + const { resourceDir } = this.runContext; + + // 1. Ensure we aren't in any existing resource directory already. + if (resourceDir) { + return this.error( + `Cannot create a new audience inside an existing ${resourceDir.type} directory`, + ); + } + + // 2. Prompt for name and key if not provided + let name = flags.name; + let key = flags.key; + + if (!name) { + const nameResponse = await prompt<{ name: string }>({ + type: "input", + name: "name", + message: "Audience name", + validate: (value: string) => { + if (!value || value.trim().length === 0) { + return "Audience name is required"; + } + + return true; + }, + }); + name = nameResponse.name; + } + + if (!key) { + const keyResponse = await prompt<{ key: string }>({ + type: "input", + name: "key", + message: "Audience key (immutable slug)", + initial: slugify(name), + validate: (value: string) => { + if (!value || value.trim().length === 0) { + return "Audience key is required"; + } + + const keyError = Audience.validateAudienceKey(value); + if (keyError) { + return `Invalid audience key: ${keyError}`; + } + + return true; + }, + }); + key = keyResponse.key; + } + + // Validate the audience key + const audienceKeyError = Audience.validateAudienceKey(key); + if (audienceKeyError) { + return this.error( + `Invalid audience key \`${key}\` (${audienceKeyError})`, + ); + } + + const audienceDirCtx = await this.getAudienceDirContext(key); + + const promptMessage = audienceDirCtx.exists + ? `Found \`${audienceDirCtx.key}\` at ${audienceDirCtx.abspath}, overwrite?` + : `Create a new audience directory \`${audienceDirCtx.key}\` at ${audienceDirCtx.abspath}?`; + + // Check if the audience directory already exists, and prompt to confirm if not. + const input = flags.force || (await promptToConfirm(promptMessage)); + if (!input) return; + + // 3. Prompt for type if not provided + let audienceType: AudienceType; + + if (flags.type) { + audienceType = flags.type as AudienceType; + } else { + const typeResponse = await prompt<{ type: string }>({ + type: "select", + name: "type", + message: "Select audience type", + choices: AUDIENCE_TYPE_CHOICES.map((choice) => ({ + name: choice.name, + message: choice.message, + })), + }); + + audienceType = typeResponse.type as AudienceType; + } + + // Generate the audience directory with scaffolded content + await Audience.generateAudienceDir(audienceDirCtx, { + name, + type: audienceType, + description: flags.description, + }); + + if (flags.push) { + spinner.start("‣ Pushing audience to Knock"); + + try { + await AudiencePush.run([key]); + } catch (error) { + this.error(`Failed to push audience to Knock: ${error}`); + } finally { + spinner.stop(); + } + } + + this.log(`‣ Successfully created audience \`${key}\``); + } + + async getAudienceDirContext( + audienceKey?: string, + ): Promise { + const { resourceDir, cwd: runCwd } = this.runContext; + + // Inside an existing resource dir, use it if valid for the target audience. + if (resourceDir) { + const target: ResourceTarget = { + commandId: BaseCommand.id, + type: "audience", + key: audienceKey, + }; + + return ensureResourceDirForTarget( + resourceDir, + target, + ) as AudienceDirContext; + } + + // Default to knock project config first if present, otherwise cwd. + const dirCtx = await resolveResourceDir( + this.projectConfig, + "audience", + runCwd, + ); + + // Not inside any existing audience directory, which means either create a + // new audience directory in the cwd, or update it if there is one already. + if (audienceKey) { + const dirPath = path.resolve(dirCtx.abspath, audienceKey); + const exists = await Audience.isAudienceDir(dirPath); + + return { + type: "audience", + key: audienceKey, + abspath: dirPath, + exists, + }; + } + + // Not in any audience directory, nor an audience key arg was given so error. + return this.error("Missing 1 required arg:\naudienceKey"); + } +} diff --git a/src/lib/marshal/audience/generator.ts b/src/lib/marshal/audience/generator.ts new file mode 100644 index 00000000..1da9c382 --- /dev/null +++ b/src/lib/marshal/audience/generator.ts @@ -0,0 +1,70 @@ +import { AudienceDirContext } from "@/lib/run-context"; + +import { AUDIENCE_JSON, AudienceDirBundle } from "./processor.isomorphic"; +import { AudienceType } from "./types"; +import { writeAudienceDirFromBundle } from "./writer"; + +/* + * Attributes for creating a new audience from scratch. + */ +type NewAudienceAttrs = { + name: string; + type: AudienceType; + description?: string; +}; + +/* + * Returns the default scaffolded segments for a dynamic audience. + */ +const defaultSegmentsForDynamic = () => [ + { + conditions: [ + { + property: "recipient.plan", + operator: "equal_to", + argument: "premium", + }, + ], + }, +]; + +/* + * Scaffolds a new audience directory bundle with default content. + */ +const scaffoldAudienceDirBundle = ( + attrs: NewAudienceAttrs, +): AudienceDirBundle => { + const audienceJson: Record = { + name: attrs.name, + type: attrs.type, + }; + + if (attrs.description) { + audienceJson.description = attrs.description; + } + + // For dynamic audiences, include example segments + if (attrs.type === AudienceType.Dynamic) { + audienceJson.segments = defaultSegmentsForDynamic(); + } + + return { + [AUDIENCE_JSON]: audienceJson, + }; +}; + +/* + * Generates a new audience directory with a scaffolded audience.json file. + * Assumes the given audience directory context is valid and correct. + */ +export const generateAudienceDir = async ( + audienceDirCtx: AudienceDirContext, + attrs: NewAudienceAttrs, +): Promise => { + const bundle = scaffoldAudienceDirBundle(attrs); + + return writeAudienceDirFromBundle(audienceDirCtx, bundle); +}; + +// Exported for tests. +export { scaffoldAudienceDirBundle }; diff --git a/src/lib/marshal/audience/index.ts b/src/lib/marshal/audience/index.ts index 80ce102b..43ea86e0 100644 --- a/src/lib/marshal/audience/index.ts +++ b/src/lib/marshal/audience/index.ts @@ -1,3 +1,4 @@ +export * from "./generator"; export * from "./helpers"; export * from "./processor.isomorphic"; export * from "./reader"; diff --git a/test/commands/audience/archive.test.ts b/test/commands/audience/archive.test.ts new file mode 100644 index 00000000..16231b05 --- /dev/null +++ b/test/commands/audience/archive.test.ts @@ -0,0 +1,112 @@ +import { expect, 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/audience/archive", () => { + describe("given no audience key arg", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command(["audience archive", "--environment", "development"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given no environment flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command(["audience archive", "vip-users"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given an audience key arg with --force flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "archiveAudience", (stub) => + stub.resolves( + factory.resp({ + data: factory.audience({ key: "vip-users" }), + }), + ), + ) + .stdout() + .command([ + "audience archive", + "vip-users", + "--environment", + "development", + "--force", + ]) + .it("calls apiV1 archiveAudience with correct props", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.archiveAudience as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { + audienceKey: "vip-users", + }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + force: true, + }), + ), + ); + }); + }); + + describe("given confirmation prompt is declined", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "archiveAudience", (stub) => + stub.resolves(factory.resp({ data: factory.audience() })), + ) + .stub(enquirer.prototype, "prompt", (stub) => + stub.resolves({ input: false }), + ) + .stdout() + .command([ + "audience archive", + "vip-users", + "--environment", + "development", + ]) + .it("does not call archiveAudience when declined", (ctx) => { + expect(ctx.stdout).to.contain("Archive cancelled"); + sinon.assert.notCalled(KnockApiV1.prototype.archiveAudience as any); + }); + }); + + describe("given archive fails", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "archiveAudience", (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([ + "audience archive", + "vip-users", + "--environment", + "development", + "--force", + ]) + .catch("The resource you requested does not exist") + .it("throws an error for resource not found"); + }); +}); diff --git a/test/commands/audience/new.test.ts b/test/commands/audience/new.test.ts new file mode 100644 index 00000000..eda7f1d0 --- /dev/null +++ b/test/commands/audience/new.test.ts @@ -0,0 +1,153 @@ +import * as path from "node:path"; + +import { expect, test } from "@oclif/test"; +import enquirer from "enquirer"; +import * as fs from "fs-extra"; + +import { sandboxDir } from "@/lib/helpers/const"; +import { AUDIENCE_JSON } from "@/lib/marshal/audience"; + +const currCwd = process.cwd(); + +describe("commands/audience/new", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given all flags provided for static audience", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(enquirer.prototype, "prompt", (stub) => + stub.resolves({ input: true }), + ) + .stdout() + .command([ + "audience new", + "--name", + "VIP Users", + "--key", + "vip-users", + "--type", + "static", + "--force", + ]) + .it("creates the audience directory with static audience.json", () => { + const audienceJsonPath = path.resolve( + sandboxDir, + "vip-users", + AUDIENCE_JSON, + ); + expect(fs.existsSync(audienceJsonPath)).to.be.true; + + const audienceJson = fs.readJsonSync(audienceJsonPath); + expect(audienceJson.name).to.equal("VIP Users"); + expect(audienceJson.type).to.equal("static"); + expect(audienceJson.segments).to.be.undefined; + }); + }); + + describe("given all flags provided for dynamic audience", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(enquirer.prototype, "prompt", (stub) => + stub.resolves({ input: true }), + ) + .stdout() + .command([ + "audience new", + "--name", + "Beta Testers", + "--key", + "beta-testers", + "--type", + "dynamic", + "--description", + "Users in beta program", + "--force", + ]) + .it("creates the audience directory with dynamic audience.json", () => { + const audienceJsonPath = path.resolve( + sandboxDir, + "beta-testers", + AUDIENCE_JSON, + ); + expect(fs.existsSync(audienceJsonPath)).to.be.true; + + const audienceJson = fs.readJsonSync(audienceJsonPath); + expect(audienceJson.name).to.equal("Beta Testers"); + expect(audienceJson.type).to.equal("dynamic"); + expect(audienceJson.description).to.equal("Users in beta program"); + // Dynamic audiences should have segments scaffolded + expect(audienceJson.segments).to.be.an("array"); + }); + }); + + describe("given an invalid audience key", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .command([ + "audience new", + "--name", + "Invalid Audience", + "--key", + "INVALID_KEY", + "--type", + "static", + "--force", + ]) + .catch((error) => expect(error.message).to.match(/Invalid audience key/)) + .it("throws an error for invalid key format"); + }); + + describe("given inside an existing resource directory", () => { + beforeEach(() => { + // Create an existing workflow directory + const workflowJsonPath = path.resolve( + sandboxDir, + "my-workflow", + "workflow.json", + ); + fs.outputJsonSync(workflowJsonPath, { name: "My Workflow" }); + + process.chdir(path.resolve(sandboxDir, "my-workflow")); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .command([ + "audience new", + "--name", + "Test", + "--key", + "test", + "--type", + "static", + "--force", + ]) + .catch((error) => + expect(error.message).to.match( + /Cannot create a new audience inside an existing/, + ), + ) + .it("throws an error"); + }); +});