diff --git a/package.json b/package.json index be819224..89533ab2 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "/oclif.manifest.json" ], "dependencies": { - "@knocklabs/mgmt": "^0.16.1", + "@knocklabs/mgmt": "0.21.0", "@oclif/core": "^3", "@oclif/plugin-help": "^6", "@prantlf/jsonlint": "^14.1.0", diff --git a/src/commands/audience/archive.ts b/src/commands/audience/archive.ts new file mode 100644 index 00000000..4516e5e6 --- /dev/null +++ b/src/commands/audience/archive.ts @@ -0,0 +1,69 @@ +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 { 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, environment } = 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}\``); + + try { + await this.apiV1.mgmtClient.audiences.archive(audienceKey, { + environment, + }); + + spinner.stop(); + + this.log( + `‣ Successfully archived audience \`${audienceKey}\` across all environments.`, + ); + } catch (error) { + spinner.stop(); + ux.error(new ApiError((error as Error).message)); + } + } +} diff --git a/src/commands/audience/get.ts b/src/commands/audience/get.ts new file mode 100644 index 00000000..642dbbfa --- /dev/null +++ b/src/commands/audience/get.ts @@ -0,0 +1,129 @@ +import { Audience } from "@knocklabs/mgmt/resources/audiences"; +import { Args, Flags, ux } from "@oclif/core"; + +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 { spinner } from "@/lib/helpers/ux"; + +export default class AudienceGet extends BaseCommand { + static summary = "Display a single audience 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 = { + audienceKey: Args.string({ + required: true, + }), + }; + + static enableJsonFlag = true; + + async run(): Promise { + spinner.start("‣ Loading"); + + const { audience } = await this.loadAudience(); + + spinner.stop(); + + const { flags } = this.props; + if (flags.json) return audience; + + this.render(audience); + } + + private async loadAudience(): Promise<{ + audience: Audience; + }> { + const { audienceKey } = this.props.args; + const { flags } = this.props; + + try { + const audience = await this.apiV1.mgmtClient.audiences.retrieve( + audienceKey, + { + environment: flags.environment, + branch: flags.branch, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + }, + ); + + return { audience }; + } catch (error) { + ux.error(new ApiError((error as Error).message)); + } + } + + render(audience: Audience): void { + const { audienceKey } = this.props.args; + const { environment: env, "hide-uncommitted-changes": committedOnly } = + this.props.flags; + + const qualifier = + env === "development" && !committedOnly ? "(including uncommitted)" : ""; + + const scope = formatCommandScope(this.props.flags); + this.log( + `‣ Showing audience \`${audienceKey}\` in ${scope} ${qualifier}\n`, + ); + + /* + * Audience table + */ + + const rows = [ + { + key: "Name", + value: audience.name, + }, + { + key: "Key", + value: audience.key, + }, + { + key: "Type", + value: audience.type, + }, + { + key: "Description", + value: audience.description || "-", + }, + { + key: "Created at", + value: formatDateTime(audience.created_at), + }, + { + key: "Updated at", + value: formatDateTime(audience.updated_at), + }, + ]; + + ux.table(rows, { + key: { + header: "Audience", + minWidth: 24, + }, + value: { + header: "", + minWidth: 24, + }, + }); + + // Show segments for dynamic audiences + if (audience.type === "dynamic" && audience.segments) { + this.log("\nSegments:"); + this.log(JSON.stringify(audience.segments, null, 2)); + } + } +} diff --git a/src/commands/audience/list.ts b/src/commands/audience/list.ts new file mode 100644 index 00000000..e6c48a9f --- /dev/null +++ b/src/commands/audience/list.ts @@ -0,0 +1,129 @@ +import { Audience } from "@knocklabs/mgmt/resources/audiences"; +import { Flags, ux } from "@oclif/core"; + +import BaseCommand from "@/lib/base-command"; +import { formatCommandScope } from "@/lib/helpers/command"; +import { formatDate } from "@/lib/helpers/date"; +import { ApiError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { + maybePromptPageAction, + pageFlags, + PageInfo, + paramsForPageAction, +} from "@/lib/helpers/page"; +import { spinner } from "@/lib/helpers/ux"; + +type ListAudienceData = { + entries: Audience[]; + page_info: PageInfo; +}; + +export default class AudienceList extends BaseCommand { + static summary = "Display all audiences 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 data = await this.request(); + + const { flags } = this.props; + if (flags.json) return data; + + return this.render(data); + } + + async request( + pageParams: { after?: string; before?: string } = {}, + ): Promise { + const { flags } = this.props; + + spinner.start("‣ Loading"); + + try { + const page = await this.apiV1.mgmtClient.audiences.list({ + environment: flags.environment, + branch: flags.branch, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + limit: flags.limit, + after: pageParams.after ?? flags.after, + before: pageParams.before ?? flags.before, + }); + + spinner.stop(); + + return { + entries: page.entries, + page_info: page.page_info, + }; + } catch (error) { + spinner.stop(); + throw new ApiError((error as Error).message); + } + } + + async render(data: ListAudienceData): Promise { + const { entries } = data; + const { environment: env, "hide-uncommitted-changes": committedOnly } = + this.props.flags; + + const qualifier = + env === "development" && !committedOnly ? "(including uncommitted)" : ""; + + const scope = formatCommandScope(this.props.flags); + this.log( + `‣ Showing ${entries.length} audiences in ${scope} ${qualifier}\n`, + ); + + /* + * Audiences list table + */ + + ux.table(entries as unknown as Record[], { + key: { + header: "Key", + }, + name: { + header: "Name", + }, + description: { + header: "Description", + }, + type: { + header: "Type", + }, + updated_at: { + header: "Updated at", + get: (entry) => formatDate(entry.updated_at as string), + }, + }); + + return this.prompt(data); + } + + async prompt(data: ListAudienceData): Promise { + const { page_info } = data; + + const pageAction = await maybePromptPageAction(page_info); + const pageParams = pageAction && paramsForPageAction(pageAction, page_info); + + if (pageParams) { + this.log("\n"); + + const nextData = await this.request(pageParams); + return this.render(nextData); + } + } +} diff --git a/src/commands/audience/new.ts b/src/commands/audience/new.ts new file mode 100644 index 00000000..f7ef967c --- /dev/null +++ b/src/commands/audience/new.ts @@ -0,0 +1,209 @@ +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 } 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"); + + const pushArgs = [key, "--environment", flags.environment]; + if (flags.branch) { + pushArgs.push("--branch", flags.branch); + } + + try { + await AudiencePush.run(pushArgs); + } 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 { cwd: runCwd } = this.runContext; + + // 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/commands/audience/open.ts b/src/commands/audience/open.ts new file mode 100644 index 00000000..802d8eb3 --- /dev/null +++ b/src/commands/audience/open.ts @@ -0,0 +1,53 @@ +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 { viewAudienceUrl } from "@/lib/urls"; + +export default class AudienceOpen extends BaseCommand { + static summary = "Open an audience in the Knock dashboard."; + + static flags = { + environment: Flags.string({ + default: "development", + summary: "The environment to use.", + }), + branch: CustomFlags.branch, + }; + + static args = { + audienceKey: 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 { audienceKey } = this.props.args; + const { environment, branch } = this.props.flags; + + const envOrBranch = branch ?? environment; + + const url = viewAudienceUrl( + this.sessionContext.dashboardOrigin, + account_slug, + envOrBranch, + audienceKey, + ); + + this.log(`‣ Opening audience \`${audienceKey}\` in the Knock dashboard...`); + this.log(` ${url}`); + + await browser.openUrl(url); + } +} diff --git a/src/commands/audience/pull.ts b/src/commands/audience/pull.ts new file mode 100644 index 00000000..8586ab7b --- /dev/null +++ b/src/commands/audience/pull.ts @@ -0,0 +1,212 @@ +import * as path from "node:path"; + +import { Args, Flags, ux } from "@oclif/core"; + +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 { resolveResourceDir } from "@/lib/helpers/project-config"; +import { promptToConfirm, spinner } from "@/lib/helpers/ux"; +import * as AudienceMarshal from "@/lib/marshal/audience"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; +import { + AudienceDirContext, + ensureResourceDirForTarget, + ResourceTarget, +} from "@/lib/run-context"; + +export default class AudiencePull extends BaseCommand { + static summary = + "Pull one or more audiences 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 audiences from the specified environment.", + }), + "audiences-dir": CustomFlags.dirPath({ + summary: "The target directory path to pull all audiences into.", + dependsOn: ["all"], + }), + "hide-uncommitted-changes": Flags.boolean({ + summary: "Hide any uncommitted changes.", + }), + force: Flags.boolean({ + summary: "Remove the confirmation prompt.", + }), + }; + + static args = { + audienceKey: Args.string({ + required: false, + }), + }; + + async run(): Promise { + const { args, flags } = this.props; + + if (flags.all && args.audienceKey) { + return this.error( + `audienceKey arg \`${args.audienceKey}\` cannot also be provided when using --all`, + ); + } + + return flags.all ? this.pullAllAudiences() : this.pullOneAudience(); + } + + // Pull one audience + async pullOneAudience(): Promise { + const { flags } = this.props; + + const dirContext = await this.getAudienceDirContext(); + + if (dirContext.exists) { + this.log(`‣ Found \`${dirContext.key}\` at ${dirContext.abspath}`); + } else { + const prompt = `Create a new audience directory \`${dirContext.key}\` at ${dirContext.abspath}?`; + const input = flags.force || (await promptToConfirm(prompt)); + if (!input) return; + } + + spinner.start("‣ Loading"); + + try { + const audience = await this.apiV1.mgmtClient.audiences.retrieve( + dirContext.key, + { + environment: flags.environment, + branch: flags.branch, + annotate: true, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + }, + ); + + spinner.stop(); + + // The SDK doesn't include annotation types, but the API returns them when annotate=true + await AudienceMarshal.writeAudienceDirFromData( + dirContext, + audience as AudienceMarshal.AudienceData, + { withSchema: true }, + ); + + const action = dirContext.exists ? "updated" : "created"; + const scope = formatCommandScope(flags); + this.log( + `‣ Successfully ${action} \`${dirContext.key}\` at ${dirContext.abspath} using ${scope}`, + ); + } catch (error) { + spinner.stop(); + ux.error(new ApiError((error as Error).message)); + } + } + + // Pull all audiences + async pullAllAudiences(): Promise { + const { flags } = this.props; + + const audiencesIndexDirCtx = await resolveResourceDir( + this.projectConfig, + "audience", + this.runContext.cwd, + ); + + const targetDirCtx = flags["audiences-dir"] || audiencesIndexDirCtx; + + const prompt = targetDirCtx.exists + ? `Pull latest audiences into ${targetDirCtx.abspath}?\n This will overwrite the contents of this directory.` + : `Create a new audiences directory at ${targetDirCtx.abspath}?`; + + const input = flags.force || (await promptToConfirm(prompt)); + if (!input) return; + + spinner.start(`‣ Loading`); + + try { + const audiences = await this.listAllAudiences(); + + // The SDK doesn't include annotation types, but the API returns them when annotate=true + await AudienceMarshal.writeAudiencesIndexDir( + targetDirCtx, + audiences as AudienceMarshal.AudienceData[], + { withSchema: true }, + ); + spinner.stop(); + + const action = targetDirCtx.exists ? "updated" : "created"; + const scope = formatCommandScope(flags); + this.log( + `‣ Successfully ${action} the audiences directory at ${targetDirCtx.abspath} using ${scope}`, + ); + } catch (error) { + spinner.stop(); + ux.error(new ApiError((error as Error).message)); + } + } + + async listAllAudiences(): Promise< + AudienceMarshal.AudienceData[] + > { + const { flags } = this.props; + const audiences: AudienceMarshal.AudienceData[] = []; + + for await (const audience of this.apiV1.mgmtClient.audiences.list({ + environment: flags.environment, + branch: flags.branch, + annotate: true, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + })) { + // The SDK doesn't include annotation types, but the API returns them when annotate=true + audiences.push(audience as AudienceMarshal.AudienceData); + } + + return audiences; + } + + async getAudienceDirContext(): Promise { + const { audienceKey } = this.props.args; + 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; + } + + const audiencesIndexDirCtx = 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(audiencesIndexDirCtx.abspath, audienceKey); + const exists = await AudienceMarshal.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/commands/audience/push.ts b/src/commands/audience/push.ts new file mode 100644 index 00000000..f631f1f6 --- /dev/null +++ b/src/commands/audience/push.ts @@ -0,0 +1,138 @@ +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 { indentString } from "@/lib/helpers/string"; +import { spinner } from "@/lib/helpers/ux"; +import * as Audience from "@/lib/marshal/audience"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; + +import AudienceValidate from "./validate"; + +export default class AudiencePush extends BaseCommand { + static summary = + "Push one or more audiences from a local file system to Knock."; + + static flags = { + environment: Flags.string({ + summary: + "The environment to push the audience to. Defaults to development.", + default: KnockEnv.Development, + }), + branch: CustomFlags.branch, + all: Flags.boolean({ + summary: "Whether to push all audiences from the target directory.", + }), + "audiences-dir": CustomFlags.dirPath({ + summary: "The target directory path to find all audiences to push.", + dependsOn: ["all"], + }), + commit: Flags.boolean({ + summary: "Push and commit the audience(s) at the same time", + }), + "commit-message": Flags.string({ + summary: "Use the given value as the commit message", + char: "m", + dependsOn: ["commit"], + }), + }; + + static args = { + audienceKey: Args.string({ + required: false, + }), + }; + + async run(): Promise { + const { flags } = this.props; + + // 1. First read all audience directories found for the given command. + const target = await Audience.ensureValidCommandTarget( + this.props, + this.runContext, + this.projectConfig, + ); + + const [audiences, readErrors] = await Audience.readAllForCommandTarget( + target, + ); + + if (readErrors.length > 0) { + this.error(formatErrors(readErrors, { prependBy: "\n\n" })); + } + + if (audiences.length === 0) { + this.error(`No audience directories found in ${target.context.abspath}`); + } + + // 2. Then validate them all ahead of pushing them. + spinner.start(`‣ Validating`); + + const apiErrors = await AudienceValidate.validateAll( + this.apiV1, + this.props, + audiences, + ); + + if (apiErrors.length > 0) { + this.error(formatErrors(apiErrors, { prependBy: "\n\n" })); + } + + spinner.stop(); + + // 3. Finally push up each audience, abort on the first error. + spinner.start(`‣ Pushing`); + + for (const audience of audiences) { + try { + // eslint-disable-next-line no-await-in-loop + const resp = await this.apiV1.mgmtClient.audiences.upsert( + audience.key, + { + environment: flags.environment, + branch: flags.branch, + annotate: true, + commit: flags.commit, + commit_message: flags["commit-message"], + audience: audience.content as { + name: string; + type: "static" | "dynamic"; + }, + }, + ); + + // Update the audience directory with the successfully pushed audience + // payload from the server. + // The SDK doesn't include annotation types, but the API returns them when annotate=true + // eslint-disable-next-line no-await-in-loop + await Audience.writeAudienceDirFromData( + audience, + resp.audience as Audience.AudienceData, + { withSchema: true }, + ); + } catch (error) { + const sourceError = new SourceError( + (error as Error).message, + Audience.audienceJsonPath(audience), + "ApiError", + ); + this.error(formatError(sourceError)); + } + } + + spinner.stop(); + + // 4. Display a success message. + const audienceKeys = audiences.map((a) => a.key); + const actioned = flags.commit ? "pushed and committed" : "pushed"; + + const scope = formatCommandScope(flags); + this.log( + `‣ Successfully ${actioned} ${audiences.length} audience(s) to ${scope}:\n` + + indentString(audienceKeys.join("\n"), 4), + ); + } +} diff --git a/src/commands/audience/validate.ts b/src/commands/audience/validate.ts new file mode 100644 index 00000000..d228f963 --- /dev/null +++ b/src/commands/audience/validate.ts @@ -0,0 +1,120 @@ +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 { indentString } from "@/lib/helpers/string"; +import { spinner } from "@/lib/helpers/ux"; +import * as Audience from "@/lib/marshal/audience"; + +import AudiencePush from "./push"; + +export default class AudienceValidate extends BaseCommand< + typeof AudienceValidate +> { + static summary = "Validate one or more audiences from a local file system."; + + static flags = { + environment: Flags.string({ + summary: + "The environment to validate the audience against. Defaults to development.", + default: KnockEnv.Development, + }), + branch: CustomFlags.branch, + all: Flags.boolean({ + summary: "Whether to validate all audiences from the target directory.", + }), + "audiences-dir": CustomFlags.dirPath({ + summary: "The target directory path to find all audiences to validate.", + dependsOn: ["all"], + }), + }; + + static args = { + audienceKey: Args.string({ + required: false, + }), + }; + + async run(): Promise { + // 1. Read all audience directories found for the given command. + const target = await Audience.ensureValidCommandTarget( + this.props, + this.runContext, + this.projectConfig, + ); + + const [audiences, readErrors] = await Audience.readAllForCommandTarget( + target, + ); + + if (readErrors.length > 0) { + this.error(formatErrors(readErrors, { prependBy: "\n\n" })); + } + + if (audiences.length === 0) { + this.error(`No audience directories found in ${target.context.abspath}`); + } + + // 2. Validate each audience data. + spinner.start(`‣ Validating`); + + const apiErrors = await AudienceValidate.validateAll( + this.apiV1, + this.props, + audiences, + ); + + if (apiErrors.length > 0) { + this.error(formatErrors(apiErrors, { prependBy: "\n\n" })); + } + + spinner.stop(); + + // 3. Display a success message. + const audienceKeys = audiences.map((a) => a.key); + const scope = formatCommandScope({ ...this.props.flags }); + this.log( + `‣ Successfully validated ${audiences.length} audience(s) using ${scope}:\n` + + indentString(audienceKeys.join("\n"), 4), + ); + } + + static async validateAll( + api: ApiV1.T, + props: Props, + audiences: Audience.AudienceDirData[], + ): Promise { + const { flags } = props; + + const errorPromises = audiences.map(async (audience) => { + try { + await api.mgmtClient.audiences.validate(audience.key, { + environment: flags.environment, + branch: flags.branch, + audience: audience.content as { + name: string; + type: "static" | "dynamic"; + }, + }); + + return; + } catch (error) { + return new SourceError( + (error as Error).message, + Audience.audienceJsonPath(audience), + "ApiError", + ); + } + }); + + const errors = (await Promise.all(errorPromises)).filter( + (e): e is Exclude => Boolean(e), + ); + + return errors; + } +} diff --git a/src/commands/pull.ts b/src/commands/pull.ts index ac1fb23c..44272160 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -13,6 +13,7 @@ import { RESOURCE_SUBDIRS, } from "@/lib/resources"; +import AudiencePull from "./audience/pull"; import GuidePull from "./guide/pull"; import EmailLayoutPull from "./layout/pull"; import MessageTypePull from "./message-type/pull"; @@ -98,6 +99,9 @@ const runResourcePullCommand = async ( ); switch (resourceType) { + case "audience": + return AudiencePull.run([...args, "--audiences-dir", subdirPath]); + case "email_layout": return EmailLayoutPull.run([...args, "--layouts-dir", subdirPath]); diff --git a/src/commands/push.ts b/src/commands/push.ts index 3016e7a5..d8017f17 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -14,6 +14,7 @@ import { RESOURCE_SUBDIRS, } from "@/lib/resources"; +import AudiencePush from "./audience/push"; import GuidePush from "./guide/push"; import EmailLayoutPush from "./layout/push"; import MessageTypePush from "./message-type/push"; @@ -104,6 +105,9 @@ const runResourcePushCommand = async ( } switch (resourceType) { + case "audience": + return AudiencePush.run([...args, "--audiences-dir", subdirPath]); + case "email_layout": return EmailLayoutPush.run([...args, "--layouts-dir", subdirPath]); diff --git a/src/lib/helpers/project-config.ts b/src/lib/helpers/project-config.ts index 3b2a7bc2..41a7b36a 100644 --- a/src/lib/helpers/project-config.ts +++ b/src/lib/helpers/project-config.ts @@ -117,6 +117,7 @@ export const ResourceDirectoriesByType: Record< Exclude, string > = { + audience: "audiences", workflow: "workflows", guide: "guides", partial: "partials", diff --git a/src/lib/marshal/audience/generator.ts b/src/lib/marshal/audience/generator.ts new file mode 100644 index 00000000..af2ef73a --- /dev/null +++ b/src/lib/marshal/audience/generator.ts @@ -0,0 +1,50 @@ +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; +}; + +/* + * 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; + } + + 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/helpers.ts b/src/lib/marshal/audience/helpers.ts new file mode 100644 index 00000000..b0e2adce --- /dev/null +++ b/src/lib/marshal/audience/helpers.ts @@ -0,0 +1,146 @@ +import * as path from "node:path"; + +import { ux } from "@oclif/core"; +import * as fs from "fs-extra"; + +import { DirContext } from "@/lib/helpers/fs"; +import { + ProjectConfig, + resolveResourceDir, +} from "@/lib/helpers/project-config"; +import { checkSlugifiedFormat } from "@/lib/helpers/string"; +import { AudienceDirContext, RunContext } from "@/lib/run-context"; + +import { AUDIENCE_JSON } from "./processor.isomorphic"; + +export const audienceJsonPath = (audienceDirCtx: AudienceDirContext): string => + path.resolve(audienceDirCtx.abspath, AUDIENCE_JSON); + +/* + * Check for audience.json file and return the file path if present. + */ +export const lsAudienceJson = async ( + dirPath: string, +): Promise => { + const audienceJsonPath = path.resolve(dirPath, AUDIENCE_JSON); + + const exists = await fs.pathExists(audienceJsonPath); + return exists ? audienceJsonPath : undefined; +}; + +/* + * Evaluates whether the given directory path is an audience directory, by + * checking for the presence of audience.json file. + */ +export const isAudienceDir = async (dirPath: string): Promise => + Boolean(await lsAudienceJson(dirPath)); + +/* + * Validates a string input for an audience key, and returns an error reason + * if invalid. + */ +export const validateAudienceKey = (input: string): string | undefined => { + if (!checkSlugifiedFormat(input, { onlyLowerCase: true })) { + return "must include only lowercase alphanumeric, dash, or underscore characters"; + } + + return undefined; +}; + +/* + * Validate the provided args and flags with the current run context, to first + * ensure the invoked command makes sense, and return the target context. + */ +type CommandTargetProps = { + flags: { + all: boolean | undefined; + "audiences-dir": DirContext | undefined; + }; + args: { + audienceKey: string | undefined; + }; +}; +type AudienceDirTarget = { + type: "audienceDir"; + context: AudienceDirContext; +}; +type AudiencesIndexDirTarget = { + type: "audiencesIndexDir"; + context: DirContext; +}; +export type AudienceCommandTarget = AudienceDirTarget | AudiencesIndexDirTarget; + +export const ensureValidCommandTarget = async ( + props: CommandTargetProps, + runContext: RunContext, + projectConfig?: ProjectConfig, +): Promise => { + const { args, flags } = props; + const { commandId, resourceDir: resourceDirCtx, cwd: runCwd } = runContext; + + // If the target resource is a different type than the current resource dir + // type, error out. + if (resourceDirCtx && resourceDirCtx.type !== "audience") { + return ux.error( + `Cannot run ${commandId} inside a ${resourceDirCtx.type} directory`, + ); + } + + // Cannot accept both audience key arg and --all flag. + if (flags.all && args.audienceKey) { + return ux.error( + `audienceKey arg \`${args.audienceKey}\` cannot also be provided when using --all`, + ); + } + + // Default to knock project config first if present, otherwise cwd. + const audiencesIndexDirCtx = await resolveResourceDir( + projectConfig, + "audience", + runCwd, + ); + + // --all flag is given, which means no audience key arg. + if (flags.all) { + // If --all flag used inside an audience directory, then require an audiences + // dir path. + if (resourceDirCtx && !flags["audiences-dir"]) { + return ux.error("Missing required flag audiences-dir"); + } + + return { + type: "audiencesIndexDir", + context: flags["audiences-dir"] || audiencesIndexDirCtx, + }; + } + + // Audience key arg is given, which means no --all flag. + if (args.audienceKey) { + if (resourceDirCtx && resourceDirCtx.key !== args.audienceKey) { + return ux.error( + `Cannot run ${commandId} \`${args.audienceKey}\` inside another audience directory:\n${resourceDirCtx.key}`, + ); + } + + const targetDirPath = resourceDirCtx + ? resourceDirCtx.abspath + : path.resolve(audiencesIndexDirCtx.abspath, args.audienceKey); + + const audienceDirCtx: AudienceDirContext = { + type: "audience", + key: args.audienceKey, + abspath: targetDirPath, + exists: await isAudienceDir(targetDirPath), + }; + + return { type: "audienceDir", context: audienceDirCtx }; + } + + // From this point on, we have neither an audience key arg nor --all flag. + // If running inside an audience directory, then use that audience directory. + if (resourceDirCtx) { + return { type: "audienceDir", context: resourceDirCtx }; + } + + return ux.error("Missing 1 required arg:\naudienceKey"); +}; diff --git a/src/lib/marshal/audience/index.ts b/src/lib/marshal/audience/index.ts new file mode 100644 index 00000000..43ea86e0 --- /dev/null +++ b/src/lib/marshal/audience/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/audience/processor.isomorphic.ts b/src/lib/marshal/audience/processor.isomorphic.ts new file mode 100644 index 00000000..a48394d9 --- /dev/null +++ b/src/lib/marshal/audience/processor.isomorphic.ts @@ -0,0 +1,29 @@ +import { AnyObj } from "@/lib/helpers/object.isomorphic"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; + +import { prepareResourceJson } from "../shared/helpers.isomorphic"; +import { AudienceData } from "./types"; + +export const AUDIENCE_JSON = "audience.json"; + +export type AudienceDirBundle = { + [relpath: string]: string | Record; +}; + +/* + * For a given audience payload, this function builds an "audience + * directory bundle". This is an object which contains all the relative paths and + * its file content. + * + * Note: Unlike other resources, audiences don't have extractable fields, + * so this is a simpler implementation. + */ +export const buildAudienceDirBundle = ( + remoteAudience: AudienceData, + _localAudience?: AnyObj, + $schema?: string, +): AudienceDirBundle => { + return { + [AUDIENCE_JSON]: prepareResourceJson(remoteAudience, $schema), + }; +}; diff --git a/src/lib/marshal/audience/reader.ts b/src/lib/marshal/audience/reader.ts new file mode 100644 index 00000000..db21952f --- /dev/null +++ b/src/lib/marshal/audience/reader.ts @@ -0,0 +1,129 @@ +import path from "node:path"; + +import { ux } from "@oclif/core"; +import * as fs from "fs-extra"; + +import { formatErrors, SourceError } from "@/lib/helpers/error"; +import { ParseJsonResult, readJson } from "@/lib/helpers/json"; +import { AnyObj, omitDeep } from "@/lib/helpers/object.isomorphic"; +import { AudienceDirContext } from "@/lib/run-context"; + +import { + AudienceCommandTarget, + isAudienceDir, + lsAudienceJson, +} from "./helpers"; +import { AUDIENCE_JSON } from "./processor.isomorphic"; + +// Hydrated audience directory context with its content. +export type AudienceDirData = AudienceDirContext & { + content: AnyObj; +}; + +/* + * For the given list of audience directory contexts, read each audience dir and + * return audience directory data. + */ +const readAudienceDirs = async ( + audienceDirCtxs: AudienceDirContext[], +): Promise<[AudienceDirData[], SourceError[]]> => { + const audiences: AudienceDirData[] = []; + const errors: SourceError[] = []; + + for (const audienceDirCtx of audienceDirCtxs) { + // eslint-disable-next-line no-await-in-loop + const [audience, readErrors] = await readAudienceDir(audienceDirCtx); + + if (readErrors.length > 0) { + const audienceJsonPath = path.resolve( + audienceDirCtx.abspath, + AUDIENCE_JSON, + ); + + const e = new SourceError(formatErrors(readErrors), audienceJsonPath); + errors.push(e); + continue; + } + + audiences.push({ ...audienceDirCtx, content: audience! }); + } + + return [audiences, errors]; +}; + +/* + * The main read function that takes the audience directory context, then reads + * the audience json from the file system and returns the audience data obj. + */ +export const readAudienceDir = async ( + audienceDirCtx: AudienceDirContext, +): Promise => { + const { abspath } = audienceDirCtx; + + const dirExists = await fs.pathExists(abspath); + if (!dirExists) throw new Error(`${abspath} does not exist`); + + const audienceJsonPath = await lsAudienceJson(abspath); + if (!audienceJsonPath) + throw new Error(`${abspath} is not an audience directory`); + + const result = await readJson(audienceJsonPath); + if (!result[0]) return result; + + let [audienceJson] = result; + + audienceJson = omitDeep(audienceJson, ["__readonly"]); + + return [audienceJson, []]; +}; + +/* + * List and read all audience directories found for the given command target. + * + * Note, it assumes the valid command target. + */ +export const readAllForCommandTarget = async ( + target: AudienceCommandTarget, +): Promise<[AudienceDirData[], SourceError[]]> => { + const { type: targetType, context: targetCtx } = target; + + if (!targetCtx.exists) { + const subject = + targetType === "audienceDir" + ? "an audience directory at" + : "audience directories in"; + + return ux.error(`Cannot locate ${subject} \`${targetCtx.abspath}\``); + } + + switch (targetType) { + case "audienceDir": { + return readAudienceDirs([targetCtx]); + } + + case "audiencesIndexDir": { + const dirents = await fs.readdir(targetCtx.abspath, { + withFileTypes: true, + }); + + const promises = dirents.map(async (dirent) => { + const abspath = path.resolve(targetCtx.abspath, dirent.name); + const audienceDirCtx: AudienceDirContext = { + type: "audience", + key: dirent.name, + abspath, + exists: await isAudienceDir(abspath), + }; + return audienceDirCtx; + }); + + const audienceDirCtxs = (await Promise.all(promises)).filter( + (audienceDirCtx) => audienceDirCtx.exists, + ); + return readAudienceDirs(audienceDirCtxs); + } + + default: + throw new Error(`Invalid audience command target: ${target}`); + } +}; diff --git a/src/lib/marshal/audience/types.ts b/src/lib/marshal/audience/types.ts new file mode 100644 index 00000000..b7f2d1d2 --- /dev/null +++ b/src/lib/marshal/audience/types.ts @@ -0,0 +1,29 @@ +import { MaybeWithAnnotation } from "../shared/types"; + +export enum AudienceType { + Static = "static", + Dynamic = "dynamic", +} + +export type AudienceCondition = { + property: string; + operator: string; + argument?: string; +}; + +export type AudienceSegment = { + conditions: AudienceCondition[]; +}; + +// Audience payload data from the API. +export type AudienceData = A & { + key: string; + type: AudienceType; + name: string; + description?: string; + segments?: AudienceSegment[]; + environment: string; + created_at: string; + updated_at: string; + sha: string; +}; diff --git a/src/lib/marshal/audience/writer.ts b/src/lib/marshal/audience/writer.ts new file mode 100644 index 00000000..625cf572 --- /dev/null +++ b/src/lib/marshal/audience/writer.ts @@ -0,0 +1,189 @@ +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 { AudienceDirContext } from "@/lib/run-context"; + +import { isAudienceDir } from "./helpers"; +import { + AUDIENCE_JSON, + AudienceDirBundle, + buildAudienceDirBundle, +} from "./processor.isomorphic"; +import { readAudienceDir } from "./reader"; +import { AudienceData } from "./types"; + +type WriteOpts = { + withSchema?: boolean; +}; + +const AUDIENCE_SCHEMA = "https://schemas.knock.app/cli/audience.json"; + +/* + * The main write function that takes the fetched audience data from Knock API + * (remote audience), and reads the same audience from the local file system + * (local audience, if available), then writes the remote audience into an + * audience directory with the local audience as a reference. + */ +export const writeAudienceDirFromData = async ( + audienceDirCtx: AudienceDirContext, + remoteAudience: AudienceData, + options?: WriteOpts, +): Promise => { + const { withSchema = false } = options || {}; + + // If the audience directory exists on the file system (i.e. previously + // pulled before), then read the audience file to use as a reference. + const [localAudience] = audienceDirCtx.exists + ? await readAudienceDir(audienceDirCtx) + : []; + + const bundle = buildAudienceDirBundle( + remoteAudience, + localAudience, + withSchema ? AUDIENCE_SCHEMA : undefined, + ); + + return writeAudienceDirFromBundle(audienceDirCtx, bundle); +}; + +/* + * A lower level write function that takes a constructed audience dir bundle + * and writes it into an audience directory on a local file system. + * + * It does not make any assumptions about how the audience directory bundle was + * built; for example, it can be from parsing the audience data fetched from + * the Knock API, or built manually for scaffolding purposes. + */ +const writeAudienceDirFromBundle = async ( + audienceDirCtx: AudienceDirContext, + audienceDirBundle: AudienceDirBundle, +): Promise => { + const backupDirPath = path.resolve(sandboxDir, uniqueId("backup")); + + try { + if (audienceDirCtx.exists) { + await fs.copy(audienceDirCtx.abspath, backupDirPath); + await fs.emptyDir(audienceDirCtx.abspath); + } + + const promises = Object.entries(audienceDirBundle).map( + ([relpath, fileContent]) => { + const filePath = path.resolve(audienceDirCtx.abspath, relpath); + + return relpath === AUDIENCE_JSON + ? fs.outputJson(filePath, fileContent, { spaces: DOUBLE_SPACES }) + : fs.outputFile(filePath, (fileContent as string) ?? ""); + }, + ); + await Promise.all(promises); + } catch (error) { + // In case of any error, wipe the target directory that is likely in a bad + // state then restore the backup if one existed before. + if (audienceDirCtx.exists) { + await fs.emptyDir(audienceDirCtx.abspath); + await fs.copy(backupDirPath, audienceDirCtx.abspath); + } else { + await fs.remove(audienceDirCtx.abspath); + } + + throw error; + } finally { + // Always clean up the backup directory in the temp sandbox. + await fs.remove(backupDirPath); + } +}; + +/* + * Prunes the index directory by removing any files, or directories that aren't + * audience dirs found in fetched audiences. We want to preserve any audience + * dirs that are going to be updated with remote audiences, so extracted links + * can be respected. + */ +const pruneAudiencesIndexDir = async ( + indexDirCtx: DirContext, + remoteAudiences: AudienceData[], +): Promise => { + const audiencesByKey = Object.fromEntries( + remoteAudiences.map((a) => [a.key.toLowerCase(), a]), + ); + + const dirents = await fs.readdir(indexDirCtx.abspath, { + withFileTypes: true, + }); + + const promises = dirents.map(async (dirent) => { + const direntPath = path.resolve(indexDirCtx.abspath, dirent.name); + const direntKey = dirent.name.toLowerCase(); + + if ((await isAudienceDir(direntPath)) && audiencesByKey[direntKey]) { + return; + } + + await fs.remove(direntPath); + }); + + await Promise.all(promises); +}; + +/* + * The bulk write function that takes the fetched audiences data from Knock API + * (remote audiences), and writes them into an audiences "index" directory by + * referencing locally available audiences. + */ +export const writeAudiencesIndexDir = async ( + indexDirCtx: DirContext, + remoteAudiences: AudienceData[], + options?: WriteOpts, +): Promise => { + const backupDirPath = path.resolve(sandboxDir, uniqueId("backup")); + + try { + // If the index directory already exists, back it up in the temp sandbox + // before wiping it clean. + if (indexDirCtx.exists) { + await fs.copy(indexDirCtx.abspath, backupDirPath); + await pruneAudiencesIndexDir(indexDirCtx, remoteAudiences); + } + + // Write given remote audiences into the given audiences directory path. + const writeAudienceDirPromises = remoteAudiences.map(async (audience) => { + const audienceDirPath = path.resolve(indexDirCtx.abspath, audience.key); + + const audienceDirCtx: AudienceDirContext = { + type: "audience", + key: audience.key, + abspath: audienceDirPath, + exists: indexDirCtx.exists + ? await isAudienceDir(audienceDirPath) + : false, + }; + + return writeAudienceDirFromData(audienceDirCtx, audience, options); + }); + + await Promise.all(writeAudienceDirPromises); + } catch (error) { + // In case of any error, wipe the index directory that is likely in a bad + // state then restore the backup if one existed before. + if (indexDirCtx.exists) { + await fs.emptyDir(indexDirCtx.abspath); + await fs.copy(backupDirPath, indexDirCtx.abspath); + } else { + await fs.remove(indexDirCtx.abspath); + } + + throw error; + } finally { + // Always clean up the backup directory in the temp sandbox. + await fs.remove(backupDirPath); + } +}; + +// Exported for tests. +export { pruneAudiencesIndexDir, writeAudienceDirFromBundle }; diff --git a/src/lib/marshal/index.isomorphic.ts b/src/lib/marshal/index.isomorphic.ts index 76984cf7..ca4dd71a 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 { buildAudienceDirBundle } from "./audience/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..9103db07 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 { AudienceData } from "../audience"; 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 = + | AudienceData | EmailLayoutData | PartialData | WorkflowData diff --git a/src/lib/resources.ts b/src/lib/resources.ts index 56ba7b27..07acff87 100644 --- a/src/lib/resources.ts +++ b/src/lib/resources.ts @@ -11,6 +11,8 @@ export type NonHiddenResourceType = Exclude; * first or else the validation and upsert of the layout will fail. */ export const ALL_RESOURCE_TYPES: NonHiddenResourceType[] = [ + // Audiences can be referenced by workflows, so push them early + "audience", // Partials first, as email layouts and workflows may reference them "partial", // Email layouts next, as workflows with email channel steps may reference them @@ -27,6 +29,7 @@ export const ALL_RESOURCE_TYPES: NonHiddenResourceType[] = [ * all resources to/from an environment. */ export const RESOURCE_SUBDIRS: Record = { + audience: "audiences", email_layout: "layouts", partial: "partials", translation: "translations", diff --git a/src/lib/run-context/loader.ts b/src/lib/run-context/loader.ts index 8a06dc7f..061dfdb3 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 Audience from "@/lib/marshal/audience"; import * as EmailLayout from "@/lib/marshal/email-layout"; import * as Guide from "@/lib/marshal/guide"; import * as MessageType from "@/lib/marshal/message-type"; @@ -29,36 +30,22 @@ const evaluateRecursively = async ( ): Promise => { // Check if we are inside a resource directory and if so update the context. if (!ctx.resourceDir) { - const isWorkflowDir = await Workflow.isWorkflowDir(currDir); - if (isWorkflowDir) { + if (await Audience.isAudienceDir(currDir)) { + ctx.resourceDir = buildResourceDirContext("audience", currDir); + } else if (await Workflow.isWorkflowDir(currDir)) { ctx.resourceDir = buildResourceDirContext("workflow", currDir); - } - - const isGuideDir = await Guide.isGuideDir(currDir); - if (isGuideDir) { + } else if (await Guide.isGuideDir(currDir)) { ctx.resourceDir = buildResourceDirContext("guide", currDir); - } - - const isEmailLayoutDir = await EmailLayout.isEmailLayoutDir(currDir); - if (isEmailLayoutDir) { + } else if (await EmailLayout.isEmailLayoutDir(currDir)) { ctx.resourceDir = buildResourceDirContext("email_layout", currDir); - } - - const isPartialDir = await Partial.isPartialDir(currDir); - if (isPartialDir) { + } else if (await Partial.isPartialDir(currDir)) { ctx.resourceDir = buildResourceDirContext("partial", currDir); - } - - const isMessageTypeDir = await MessageType.isMessageTypeDir(currDir); - if (isMessageTypeDir) { + } else if (await MessageType.isMessageTypeDir(currDir)) { ctx.resourceDir = buildResourceDirContext("message_type", currDir); - } - - // NOTE: Must keep this check as last in the order of directory-type checks - // since the `isTranslationDir` only checks that the directory name is a - // valid locale name. - const isTranslationDir = Translation.isTranslationDir(currDir); - if (isTranslationDir) { + } else if (Translation.isTranslationDir(currDir)) { + // NOTE: Must keep this check as last in the order of directory-type checks + // since the `isTranslationDir` only checks that the directory name is a + // valid locale name. ctx.resourceDir = buildResourceDirContext("translation", currDir); } } diff --git a/src/lib/run-context/types.ts b/src/lib/run-context/types.ts index 9fa26959..63a878a9 100644 --- a/src/lib/run-context/types.ts +++ b/src/lib/run-context/types.ts @@ -17,6 +17,7 @@ export type T = RunContext; */ export type ResourceType = + | "audience" | "workflow" | "email_layout" | "translation" @@ -30,6 +31,10 @@ type ResourceDirContextBase = DirContext & { key: string; }; +export type AudienceDirContext = ResourceDirContextBase & { + type: "audience"; +}; + export type WorkflowDirContext = ResourceDirContextBase & { type: "workflow"; }; @@ -59,6 +64,7 @@ export type ReusableStepDirContext = ResourceDirContextBase & { }; export type ResourceDirContext = + | AudienceDirContext | WorkflowDirContext | EmailLayoutDirContext | TranslationDirContext diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 0842efb7..43c0897c 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 viewAudienceUrl = ( + dashboardUrl: string, + accountSlug: string, + envOrBranchSlug: string, + audienceKey: string, +): string => + `${dashboardUrl}/${accountSlug}/${envOrBranchSlug.toLowerCase()}/audiences/${audienceKey}`; diff --git a/test/commands/audience/archive.test.ts b/test/commands/audience/archive.test.ts new file mode 100644 index 00000000..a4febbc4 --- /dev/null +++ b/test/commands/audience/archive.test.ts @@ -0,0 +1,89 @@ +import KnockMgmt from "@knocklabs/mgmt"; +import { expect, test } from "@oclif/test"; +import enquirer from "enquirer"; +import * as sinon from "sinon"; + +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(KnockMgmt.Audiences.prototype, "archive", (stub) => + stub.resolves({ result: "success" }), + ) + .stdout() + .command([ + "audience archive", + "vip-users", + "--environment", + "development", + "--force", + ]) + .it("calls apiV1 archiveAudience with correct props", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.archive as sinon.SinonStub, + "vip-users", + sinon.match({ + environment: "development", + }), + ); + }); + }); + + describe("given confirmation prompt is declined", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockMgmt.Audiences.prototype, "archive", (stub) => + stub.resolves({ result: "success" }), + ) + .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( + KnockMgmt.Audiences.prototype.archive as sinon.SinonStub, + ); + }); + }); + + describe("given archive fails", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockMgmt.Audiences.prototype, "archive", (stub) => + stub.rejects(new Error("The resource you requested does not exist")), + ) + .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/get.test.ts b/test/commands/audience/get.test.ts new file mode 100644 index 00000000..a3405035 --- /dev/null +++ b/test/commands/audience/get.test.ts @@ -0,0 +1,111 @@ +import KnockMgmt from "@knocklabs/mgmt"; +import { test } from "@oclif/test"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; + +describe("commands/audience/get", () => { + const whoami = { + account_name: "Collab.io", + account_slug: "collab-io", + service_token_name: "My cool token", + }; + + describe("given no audience key arg", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command(["audience get"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given an audience key arg, and no flags", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(KnockMgmt.Audiences.prototype, "retrieve", (stub) => + stub.resolves(factory.audience()), + ) + .stdout() + .command(["audience get", "foo"]) + .it("calls apiV1 getAudience with correct props", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.retrieve as sinon.SinonStub, + "foo", + sinon.match({ + environment: "development", + }), + ); + }); + }); + + describe("given an audience key arg, and flags", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(KnockMgmt.Audiences.prototype, "retrieve", (stub) => + stub.resolves(factory.audience()), + ) + .stdout() + .command([ + "audience get", + "foo", + "--hide-uncommitted-changes", + "--environment", + "staging", + ]) + .it("calls apiV1 getAudience with correct props", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.retrieve as sinon.SinonStub, + "foo", + sinon.match({ + environment: "staging", + hide_uncommitted_changes: true, + }), + ); + }); + }); + + describe("given a branch flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(KnockMgmt.Audiences.prototype, "retrieve", (stub) => + stub.resolves(factory.audience()), + ) + .stdout() + .command(["audience get", "foo", "--branch", "my-feature-branch-123"]) + .it("calls apiV1 getAudience with expected params", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.retrieve as sinon.SinonStub, + "foo", + sinon.match({ + environment: "development", + branch: "my-feature-branch-123", + }), + ); + }); + }); + + describe("given an audience key that does not exist", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(KnockMgmt.Audiences.prototype, "retrieve", (stub) => + stub.rejects(new Error("The resource you requested does not exist")), + ) + .stdout() + .command(["audience get", "foo"]) + .catch("The resource you requested does not exist") + .it("throws an error for resource not found"); + }); +}); diff --git a/test/commands/audience/list.test.ts b/test/commands/audience/list.test.ts new file mode 100644 index 00000000..5301136e --- /dev/null +++ b/test/commands/audience/list.test.ts @@ -0,0 +1,177 @@ +import KnockMgmt from "@knocklabs/mgmt"; +import { expect, test } from "@oclif/test"; +import enquirer from "enquirer"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; + +describe("commands/audience/list", () => { + const emptyAudiencesCursor = { + entries: [], + page_info: { after: undefined }, + }; + + describe("given no flags", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => + stub.resolves(emptyAudiencesCursor), + ) + .stdout() + .command(["audience list"]) + .it("calls apiV1 listAudiences with correct props", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.list as sinon.SinonStub, + sinon.match({ + environment: "development", + }), + ); + }); + }); + + describe("given flags", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => + stub.resolves(emptyAudiencesCursor), + ) + .stdout() + .command([ + "audience list", + "--hide-uncommitted-changes", + "--environment", + "staging", + "--limit", + "5", + "--after", + "xyz", + ]) + .it("calls apiV1 listAudiences with correct props", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.list as sinon.SinonStub, + sinon.match({ + environment: "staging", + hide_uncommitted_changes: true, + limit: 5, + after: "xyz", + }), + ); + }); + }); + + describe("given a branch flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => + stub.resolves(emptyAudiencesCursor), + ) + .stdout() + .command(["audience list", "--branch", "my-feature-branch-123"]) + .it("calls apiV1 listAudiences with expected params", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.list as sinon.SinonStub, + sinon.match({ + environment: "development", + branch: "my-feature-branch-123", + }), + ); + }); + }); + + describe("given a list of audiences in response", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => + stub.resolves({ + entries: [ + factory.audience({ key: "audience-1" }), + factory.audience({ key: "audience-2" }), + factory.audience({ key: "audience-3" }), + ], + page_info: { after: undefined }, + }), + ) + .stdout() + .command(["audience list"]) + .it("displays the list of audiences", (ctx) => { + expect(ctx.stdout).to.contain("Showing 3 audiences in"); + expect(ctx.stdout).to.contain("audience-1"); + expect(ctx.stdout).to.contain("audience-2"); + expect(ctx.stdout).to.contain("audience-3"); + + expect(ctx.stdout).to.not.contain("audience-4"); + }); + }); + + describe("given the first page of paginated audiences in resp", () => { + const paginatedAudiencesCursor = { + entries: [ + factory.audience({ key: "audience-1" }), + factory.audience({ key: "audience-2" }), + factory.audience({ key: "audience-3" }), + ], + page_info: { after: "xyz" }, + }; + + describe("plus a next page action from the prompt input", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => + stub.resolves(paginatedAudiencesCursor), + ) + .stub(enquirer.prototype, "prompt", (stub) => + stub + .onFirstCall() + .resolves({ input: "n" }) + .onSecondCall() + .resolves({ input: "" }), + ) + .stdout() + .command(["audience list"]) + .it( + "calls apiV1 listAudiences for the second time with page params", + () => { + const listStub = KnockMgmt.Audiences.prototype + .list as sinon.SinonStub; + + sinon.assert.calledTwice(listStub); + + // First call without page params. + sinon.assert.calledWith( + listStub.firstCall, + sinon.match({ + environment: "development", + }), + ); + + // Second call with page params to fetch the next page. + sinon.assert.calledWith( + listStub.secondCall, + sinon.match({ + environment: "development", + after: "xyz", + }), + ); + }, + ); + }); + + describe("plus a previous page action input from the prompt", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => + stub.resolves(paginatedAudiencesCursor), + ) + .stub(enquirer.prototype, "prompt", (stub) => + stub.onFirstCall().resolves({ input: "p" }), + ) + .stdout() + .command(["audience list"]) + .it("calls apiV1 listAudiences once for the initial page only", () => { + sinon.assert.calledOnce( + KnockMgmt.Audiences.prototype.list as sinon.SinonStub, + ); + }); + }); + }); +}); diff --git a/test/commands/audience/new.test.ts b/test/commands/audience/new.test.ts new file mode 100644 index 00000000..9349c9e0 --- /dev/null +++ b/test/commands/audience/new.test.ts @@ -0,0 +1,151 @@ +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"); + }); + }); + + 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"); + }); +}); diff --git a/test/commands/audience/open.test.ts b/test/commands/audience/open.test.ts new file mode 100644 index 00000000..7c95767d --- /dev/null +++ b/test/commands/audience/open.test.ts @@ -0,0 +1,75 @@ +import { expect, 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/audience/open", () => { + const whoami = { + account_name: "Collab.io", + account_slug: "collab-io", + service_token_name: "My cool token", + }; + + describe("given no audience key arg", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command(["audience open"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given an audience key arg", () => { + 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(["audience open", "vip-users"]) + .it("opens the audience in the dashboard", (ctx) => { + expect(ctx.stdout).to.contain("Opening audience `vip-users`"); + sinon.assert.calledOnce(browser.openUrl as any); + }); + }); + + describe("given an audience key arg with 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(["audience open", "vip-users", "--environment", "staging"]) + .it("opens the audience in the staging environment", (ctx) => { + expect(ctx.stdout).to.contain("Opening audience `vip-users`"); + sinon.assert.calledOnce(browser.openUrl as any); + sinon.assert.calledWith( + browser.openUrl as any, + sinon.match(/staging\/audiences\/vip-users/), + ); + }); + }); + + describe("given an audience key arg with 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(["audience open", "vip-users", "--branch", "my-feature-branch"]) + .it("opens the audience with branch in URL", (ctx) => { + expect(ctx.stdout).to.contain("Opening audience `vip-users`"); + sinon.assert.calledOnce(browser.openUrl as any); + sinon.assert.calledWith( + browser.openUrl as any, + sinon.match(/my-feature-branch\/audiences\/vip-users/), + ); + }); + }); +}); diff --git a/test/commands/audience/pull.test.ts b/test/commands/audience/pull.test.ts new file mode 100644 index 00000000..ff38814a --- /dev/null +++ b/test/commands/audience/pull.test.ts @@ -0,0 +1,201 @@ +import * as path from "node:path"; + +import KnockMgmt from "@knocklabs/mgmt"; +import { expect, test } from "@oclif/test"; +import enquirer from "enquirer"; +import * as fs from "fs-extra"; +import * as sinon from "sinon"; + +import { sandboxDir } from "@/lib/helpers/const"; +import { + AUDIENCE_JSON, + AudienceData, + AudienceType, +} from "@/lib/marshal/audience"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; + +const mockAudienceData: AudienceData = { + key: "vip-users", + name: "VIP Users", + type: AudienceType.Dynamic, + description: "Premium subscription users", + segments: [ + { + conditions: [ + { + property: "recipient.plan", + operator: "equal_to", + argument: "premium", + }, + ], + }, + ], + environment: "development", + updated_at: "2023-09-29T19:08:04.129228Z", + created_at: "2023-09-18T18:32:18.398053Z", + sha: "", + __annotation: { + extractable_fields: {}, + readonly_fields: ["environment", "key", "created_at", "updated_at", "sha"], + }, +}; + +const setupWithStub = (audienceData = mockAudienceData) => + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(enquirer.prototype, "prompt", (stub) => + stub.resolves({ input: true }), + ) + .stub(KnockMgmt.Audiences.prototype, "retrieve", (stub) => + stub.resolves(audienceData), + ); + +const currCwd = process.cwd(); + +describe("commands/audience/pull (a single audience)", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given an audience key arg", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience pull", "vip-users", "--force"]) + .it("calls apiV1 getAudience with expected props", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.retrieve as sinon.SinonStub, + "vip-users", + sinon.match({ + environment: "development", + annotate: true, + }), + ); + }); + + setupWithStub() + .stdout() + .command(["audience pull", "vip-users", "--force"]) + .it("creates the audience directory with 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("dynamic"); + }); + + describe("given a branch flag", () => { + setupWithStub() + .stdout() + .command([ + "audience pull", + "vip-users", + "--force", + "--branch", + "my-feature-branch-123", + ]) + .it("calls apiV1 getAudience with expected params", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.retrieve as sinon.SinonStub, + "vip-users", + sinon.match({ + environment: "development", + branch: "my-feature-branch-123", + annotate: true, + }), + ); + }); + }); + }); + + describe("given no audience key arg and not in an audience directory", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience pull", "--force"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given both audience key arg and --all flag", () => { + setupWithStub() + .stdout() + .command(["audience pull", "vip-users", "--all"]) + .exit(2) + .it("exits with status 2"); + }); +}); + +// Helper to create async iterator from array +function createAsyncIterator(items: T[]): AsyncIterable { + return { + [Symbol.asyncIterator]: async function* () { + for (const item of items) { + yield item; + } + }, + }; +} + +describe("commands/audience/pull (all audiences)", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given --all flag with audiences-dir", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(enquirer.prototype, "prompt", (stub) => + stub.resolves({ input: true }), + ) + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => + stub.returns(createAsyncIterator([mockAudienceData])), + ) + .stdout() + .command([ + "audience pull", + "--all", + "--audiences-dir", + "audiences", + "--force", + ]) + .it("calls apiV1 listAudiences and creates audience directories", () => { + sinon.assert.calledOnce( + KnockMgmt.Audiences.prototype.list as sinon.SinonStub, + ); + + const audienceJsonPath = path.resolve( + sandboxDir, + "audiences", + "vip-users", + AUDIENCE_JSON, + ); + expect(fs.existsSync(audienceJsonPath)).to.be.true; + }); + }); +}); diff --git a/test/commands/audience/push.test.ts b/test/commands/audience/push.test.ts new file mode 100644 index 00000000..c0f17128 --- /dev/null +++ b/test/commands/audience/push.test.ts @@ -0,0 +1,267 @@ +import * as path from "node:path"; + +import KnockMgmt from "@knocklabs/mgmt"; +import { expect, test } from "@oclif/test"; +import * as fs from "fs-extra"; +import * as sinon from "sinon"; + +import AudienceValidate from "@/commands/audience/validate"; +import { sandboxDir } from "@/lib/helpers/const"; +import { + AUDIENCE_JSON, + AudienceData, + AudienceType, +} from "@/lib/marshal/audience"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; + +const audienceJsonFile = "default/audience.json"; + +const mockAudienceData: AudienceData = { + key: "default", + name: "Default", + type: AudienceType.Static, + description: "This is a default audience", + environment: "development", + updated_at: "2023-09-29T19:08:04.129228Z", + created_at: "2023-09-18T18:32:18.398053Z", + sha: "", + __annotation: { + extractable_fields: {}, + readonly_fields: ["environment", "key", "created_at", "updated_at", "sha"], + }, +}; + +const setupWithStub = (audienceData = mockAudienceData) => + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(AudienceValidate, "validateAll", (stub) => stub.resolves([])) + .stub(KnockMgmt.Audiences.prototype, "upsert", (stub) => + stub.resolves({ audience: audienceData }), + ); + +const currCwd = process.cwd(); + +describe("commands/audience/push", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given an audience directory exists, for the audience key", () => { + beforeEach(() => { + const abspath = path.resolve(sandboxDir, audienceJsonFile); + fs.outputJsonSync(abspath, { name: "Default", type: "static" }); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience push", "default"]) + .it("calls apiV1 upsertAudience with expected props", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.upsert as sinon.SinonStub, + "default", + sinon.match({ + environment: "development", + annotate: true, + audience: sinon.match({ + name: "Default", + type: "static", + }), + }), + ); + }); + + setupWithStub() + .stdout() + .command([ + "audience push", + "default", + "--commit", + "-m", + "this is a commit comment!", + ]) + .it("calls apiV1 upsertAudience with commit flags, if provided", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.upsert as sinon.SinonStub, + "default", + sinon.match({ + environment: "development", + commit: true, + commit_message: "this is a commit comment!", + annotate: true, + audience: sinon.match({ + name: "Default", + type: "static", + }), + }), + ); + }); + + describe("given a branch flag", () => { + setupWithStub() + .stdout() + .command([ + "audience push", + "default", + "--branch", + "my-feature-branch-123", + ]) + .it("calls apiV1 upsertAudience with expected params", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.upsert as sinon.SinonStub, + "default", + sinon.match({ + environment: "development", + branch: "my-feature-branch-123", + annotate: true, + audience: sinon.match({ + name: "Default", + type: "static", + }), + }), + ); + }); + }); + + setupWithStub() + .stdout() + .command(["audience push", "default"]) + .it("writes the upserted audience data into audience.json", () => { + const abspath = path.resolve(sandboxDir, audienceJsonFile); + const audienceJson = fs.readJsonSync(abspath); + + expect(audienceJson).to.eql({ + $schema: "https://schemas.knock.app/cli/audience.json", + name: "Default", + type: "static", + description: "This is a default audience", + __readonly: { + key: "default", + environment: "development", + created_at: "2023-09-18T18:32:18.398053Z", + }, + }); + }); + }); + + describe("given an audience.json file, with syntax errors", () => { + beforeEach(() => { + const abspath = path.resolve(sandboxDir, audienceJsonFile); + fs.outputFileSync(abspath, '{"name":"default",}'); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience push", "default"]) + .catch((error) => expect(error.message).to.match(/JsonSyntaxError/)) + .it("throws an error"); + }); + + describe("given a nonexistent audience directory, for the audience key", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience push", "does-not-exist"]) + .catch((error) => + expect(error.message).to.match(/^Cannot locate an audience directory/), + ) + .it("throws an error"); + }); + + describe("given no audience key arg or --all flag", () => { + setupWithStub() + .stdout() + .command(["audience push"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given both audience key arg and --all flag", () => { + setupWithStub() + .stdout() + .command(["audience push", "default", "--all"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given --all and a nonexistent audiences index directory", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience push", "--all", "--audiences-dir", "audiences"]) + .catch((error) => + expect(error.message).to.match(/Cannot locate audience directories in/), + ) + .it("throws an error"); + }); + + describe("given --all and an audiences index directory, without any audiences", () => { + beforeEach(() => { + const indexDirPath = path.resolve(sandboxDir, "audiences"); + fs.ensureDirSync(indexDirPath); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience push", "--all", "--audiences-dir", "audiences"]) + .catch((error) => + expect(error.message).to.match(/No audience directories found in/), + ) + .it("throws an error"); + }); + + describe("given --all and an audiences index directory with 2 audiences", () => { + const indexDirPath = path.resolve(sandboxDir, "audiences"); + + beforeEach(() => { + const vipAudienceJson = path.resolve( + indexDirPath, + "vip-users", + AUDIENCE_JSON, + ); + fs.outputJsonSync(vipAudienceJson, { name: "VIP Users", type: "static" }); + + const betaAudienceJson = path.resolve( + indexDirPath, + "beta-testers", + AUDIENCE_JSON, + ); + fs.outputJsonSync(betaAudienceJson, { + name: "Beta Testers", + type: "dynamic", + }); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience push", "--all", "--audiences-dir", "audiences"]) + .it("calls apiV1 upsertAudience with expected props twice", () => { + // Validate all first + sinon.assert.calledOnce( + AudienceValidate.validateAll as sinon.SinonStub, + ); + + sinon.assert.calledTwice( + KnockMgmt.Audiences.prototype.upsert as sinon.SinonStub, + ); + }); + }); +}); diff --git a/test/commands/audience/validate.test.ts b/test/commands/audience/validate.test.ts new file mode 100644 index 00000000..dfec8b96 --- /dev/null +++ b/test/commands/audience/validate.test.ts @@ -0,0 +1,217 @@ +import * as path from "node:path"; + +import KnockMgmt from "@knocklabs/mgmt"; +import { expect, test } from "@oclif/test"; +import * as fs from "fs-extra"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import { sandboxDir } from "@/lib/helpers/const"; +import { AUDIENCE_JSON } from "@/lib/marshal/audience"; + +const audienceJsonFile = "default/audience.json"; + +const setupWithStub = () => + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockMgmt.Audiences.prototype, "validate", (stub) => + stub.resolves({ audience: factory.audience() }), + ); + +const currCwd = process.cwd(); + +describe("commands/audience/validate (a single audience)", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given an audience directory exists, for the audience key", () => { + beforeEach(() => { + const abspath = path.resolve(sandboxDir, audienceJsonFile); + fs.outputJsonSync(abspath, { name: "Default", type: "static" }); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience validate", "default"]) + .it("calls apiV1 validateAudience with expected props", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.validate as sinon.SinonStub, + "default", + sinon.match({ + environment: "development", + audience: sinon.match({ + name: "Default", + type: "static", + }), + }), + ); + }); + + describe("given a branch flag", () => { + setupWithStub() + .stdout() + .command([ + "audience validate", + "default", + "--branch", + "my-feature-branch-123", + ]) + .it("calls apiV1 validateAudience with expected params", () => { + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.validate as sinon.SinonStub, + "default", + sinon.match({ + environment: "development", + branch: "my-feature-branch-123", + audience: sinon.match({ + name: "Default", + type: "static", + }), + }), + ); + }); + }); + }); + + describe("given an audience.json file, with syntax errors", () => { + beforeEach(() => { + const abspath = path.resolve(sandboxDir, audienceJsonFile); + fs.outputFileSync(abspath, '{"name":"Default",}'); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience validate", "default"]) + .catch((error) => expect(error.message).to.match(/JsonSyntaxError/)) + .it("throws an error"); + }); + + describe("given an audience.json file, with data errors", () => { + beforeEach(() => { + const abspath = path.resolve(sandboxDir, audienceJsonFile); + fs.outputJsonSync(abspath, { name: 123 }); + + process.chdir(sandboxDir); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockMgmt.Audiences.prototype, "validate", (stub) => + stub.rejects(new Error('"name" must be a string')), + ) + .stdout() + .command(["audience validate", "default"]) + .catch((error) => + expect(error.message).to.match(/"name" must be a string/), + ) + .it("throws an error"); + }); + + describe("given a nonexistent audience directory, for the audience key", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience validate", "does-not-exist"]) + .catch((error) => + expect(error.message).to.match(/^Cannot locate an audience directory/), + ) + .it("throws an error"); + }); + + describe("given no audience key arg nor --all flag", () => { + setupWithStub() + .stdout() + .command(["audience validate"]) + .exit(2) + .it("exits with status 2"); + }); +}); + +describe("commands/audience/validate (all audiences)", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given a nonexistent audiences index directory", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience validate", "--all", "--audiences-dir", "audiences"]) + .catch((error) => + expect(error.message).to.match(/Cannot locate audience directories in/), + ) + .it("throws an error"); + }); + + describe("given an audience index directory, without any audiences", () => { + beforeEach(() => { + const indexDirPath = path.resolve(sandboxDir, "audiences"); + fs.ensureDirSync(indexDirPath); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience validate", "--all", "--audiences-dir", "audiences"]) + .catch((error) => + expect(error.message).to.match(/No audience directories found in/), + ) + .it("throws an error"); + }); + + describe("given an audiences index directory with 2 valid audiences", () => { + const indexDirPath = path.resolve(sandboxDir, "audiences"); + + beforeEach(() => { + const vipAudienceJson = path.resolve( + indexDirPath, + "vip-users", + AUDIENCE_JSON, + ); + fs.outputJsonSync(vipAudienceJson, { name: "VIP Users", type: "static" }); + + const betaAudienceJson = path.resolve( + indexDirPath, + "beta-testers", + AUDIENCE_JSON, + ); + fs.outputJsonSync(betaAudienceJson, { + name: "Beta Testers", + type: "dynamic", + }); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience validate", "--all", "--audiences-dir", "audiences"]) + .it("calls apiV1 validateAudience with expected props twice", () => { + sinon.assert.calledTwice( + KnockMgmt.Audiences.prototype.validate as sinon.SinonStub, + ); + }); + }); +}); diff --git a/test/commands/pull.test.ts b/test/commands/pull.test.ts index 094a3524..fa1d16e5 100644 --- a/test/commands/pull.test.ts +++ b/test/commands/pull.test.ts @@ -1,5 +1,6 @@ import * as path from "node:path"; +import KnockMgmt from "@knocklabs/mgmt"; import { expect, test } from "@oclif/test"; import enquirer from "enquirer"; import * as fs from "fs-extra"; @@ -9,6 +10,7 @@ import * as sinon from "sinon"; import { factory } from "@/../test/support"; import KnockApiV1 from "@/lib/api-v1"; import { sandboxDir } from "@/lib/helpers/const"; +import { AudienceData } from "@/lib/marshal/audience"; import { EmailLayoutData } from "@/lib/marshal/email-layout"; import { GuideData } from "@/lib/marshal/guide"; import { MessageTypeData } from "@/lib/marshal/message-type"; @@ -18,6 +20,17 @@ import { WorkflowData } from "@/lib/marshal/workflow"; const currCwd = process.cwd(); +// Helper to create async iterator from array +function createAsyncIterator(items: T[]): AsyncIterable { + return { + [Symbol.asyncIterator]: async function* () { + for (const item of items) { + yield item; + } + }, + }; +} + const setupWithListStubs = ( manyLayoutsAttrs: Partial[], manyPartialsAttrs: Partial[], @@ -25,6 +38,7 @@ const setupWithListStubs = ( manyWorkflowAttrs: Partial[], manyMessageTypeAttrs: Partial[], manyGuideAttrs: Partial[], + manyAudienceAttrs: Partial[] = [], // eslint-disable-next-line max-params ) => test @@ -32,6 +46,13 @@ const setupWithListStubs = ( .stub(KnockApiV1.prototype, "whoami", (stub) => stub.resolves(factory.resp({ data: factory.whoami() })), ) + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => + stub.returns( + createAsyncIterator( + manyAudienceAttrs.map((attrs) => factory.audience(attrs)), + ), + ), + ) .stub(KnockApiV1.prototype, "listEmailLayouts", (stub) => stub.resolves( factory.resp({ diff --git a/test/commands/push.test.ts b/test/commands/push.test.ts index cdfe9a54..d342c4d3 100644 --- a/test/commands/push.test.ts +++ b/test/commands/push.test.ts @@ -6,6 +6,7 @@ import * as fs from "fs-extra"; import { isEqual } from "lodash"; import * as sinon from "sinon"; +import AudienceValidate from "@/commands/audience/validate"; import GuideValidate from "@/commands/guide/validate"; import EmailLayoutValidate from "@/commands/layout/validate"; import MessageTypeValidate from "@/commands/message-type/validate"; @@ -790,6 +791,8 @@ describe("commands/push", () => { let upsertWorkflowStub: sinon.SinonStub; beforeEach(() => { + sinon.stub(AudienceValidate, "validateAll").resolves([]); + layoutValidateAllStub = sinon .stub(EmailLayoutValidate, "validateAll") .resolves([]); @@ -925,6 +928,8 @@ describe("commands/push", () => { .stub(KnockApiV1.prototype, "whoami") .resolves(factory.resp({ data: factory.whoami() })); + sinon.stub(AudienceValidate, "validateAll").resolves([]); + layoutValidateAllStub = sinon .stub(EmailLayoutValidate, "validateAll") .resolves([]); @@ -1029,6 +1034,7 @@ describe("commands/push", () => { let upsertGuideStub: sinon.SinonStub; beforeEach(() => { + sinon.stub(AudienceValidate, "validateAll").resolves([]); sinon.stub(EmailLayoutValidate, "validateAll").resolves([]); sinon.stub(PartialValidate, "validateAll").resolves([]); sinon.stub(TranslationValidate, "validateAll").resolves([]); diff --git a/test/lib/marshal/email-layout/reader.test.ts b/test/lib/marshal/email-layout/reader.test.ts index 6fb8c3f7..276b2420 100644 --- a/test/lib/marshal/email-layout/reader.test.ts +++ b/test/lib/marshal/email-layout/reader.test.ts @@ -79,7 +79,7 @@ describe("lib/marshal/layout/reader", () => { beforeEach(() => { fs.removeSync(sandboxDir); - fs.ensureDir(emailLayoutDirCtx.abspath); + fs.ensureDirSync(emailLayoutDirCtx.abspath); }); after(() => fs.removeSync(sandboxDir)); diff --git a/test/support/factory.ts b/test/support/factory.ts index 45912b22..4ce491d3 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 { AudienceData, AudienceType } from "@/lib/marshal/audience"; import { EmailLayoutData } from "@/lib/marshal/email-layout"; import { GuideData } from "@/lib/marshal/guide"; import { MessageTypeData } from "@/lib/marshal/message-type"; @@ -226,6 +227,31 @@ export const partial = (attrs: Partial = {}): PartialData => { }; }; +export const audience = (attrs: Partial = {}): AudienceData => { + return { + key: "vip-users", + name: "VIP Users", + type: AudienceType.Dynamic, + description: "Premium subscription users", + segments: [ + { + conditions: [ + { + property: "recipient.plan", + operator: "equal_to", + argument: "premium", + }, + ], + }, + ], + environment: "development", + created_at: "2022-12-31T12:00:00.000000Z", + updated_at: "2022-12-31T12:00:00.000000Z", + sha: "", + ...attrs, + }; +}; + export const messageType = ( attrs: Partial = {}, ): MessageTypeData => { diff --git a/yarn.lock b/yarn.lock index f09508fb..f25dbd7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1194,10 +1194,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@knocklabs/mgmt@^0.16.1": - version "0.16.1" - resolved "https://registry.yarnpkg.com/@knocklabs/mgmt/-/mgmt-0.16.1.tgz#101e7efb4726590058d5dfcd972354b0026dad00" - integrity sha512-7Ua1dX4GTcD3zm3mhww8DIRQc96/blrc1bAVQNbee0v1gNr4IJpyvNSqaCFvBA/rbap9DIl8HvchVoV1IlHHig== +"@knocklabs/mgmt@0.21.0": + version "0.21.0" + resolved "https://registry.npmjs.org/@knocklabs/mgmt/-/mgmt-0.21.0.tgz#4d1a6c95f6c62566c42960aa12273814e8a4fcc9" + integrity sha512-XHW7h6ZEL09+Q+K/CNBHn1ptAVEupBuqT2y84YT87H6GQ6mNEdldvtlPZbWVB7uzHRXuW1UfxNRb9zv/1EFD3A== "@napi-rs/nice-android-arm-eabi@1.0.1": version "1.0.1"