diff --git a/README.md b/README.md index a3176e0..1505406 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ Before upgrading a CI pipeline to 1.2.0, note two changes: an unknown flag is no - [Rolling Back Updates](#rolling-back-updates) - [Auto-Rollback](#auto-rollback) - [Viewing Release History](#viewing-release-history) +- [Viewing Failed Updates](#viewing-failed-updates) - [Clearing Release History](#clearing-release-history) +- [Webhooks](#webhooks) ## Usage @@ -967,6 +969,26 @@ By default, the history doesn't display the author of each release, but if you a _NOTE: The history command can also be run using the "h" alias_ +## Viewing Failed Updates + +When a device installs a release and then rolls it back, most often because the app crashed before calling `notifyAppReady`, the SDK reports a failed update. You can list those reports for a deployment using the following command: + +``` +dpctl deployment errors +[--limit ] +[--format ] +``` + +The output starts with a summary: how many devices reported failures, how many reports there were, and which release affected the most devices. Below it, each report shows the release that failed, the last release that worked on that device, the app version, platform and location. + +`--limit` sets how many reports to show (50 by default), and `--format json` gives machine-readable output, for example to fail a CI job when a release you just shipped starts failing. + +```shell +dpctl deployment errors MyApp-iOS Production --limit 100 +``` + +_NOTE: This reads the same data as the Errors tab in the dashboard, and needs an access key that isn't limited to specific apps._ + ## Clearing Release History You can clear the release history associated with a deployment using the following command: @@ -977,6 +999,27 @@ dpctl deployment clear After running this command, client devices configured to receive updates using its associated deployment key will no longer receive the updates that have been cleared. This command is irreversible, and therefore should not be used in a production deployment. +## Webhooks + +A webhook posts to a URL of yours when something happens in your account, for example a release or a rollback. Webhooks belong to the account, not to a single app. + +``` +dpctl webhook list [--format ] +dpctl webhook add [--name ] [--events ] [--secret ] [--disabled] +dpctl webhook update [--url ] [--name ] [--events ] [--secret ] [--enabled|--disabled] +dpctl webhook remove +``` + +`--events` takes a comma-separated list, and a webhook with no list receives every event: + +```shell +dpctl webhook add https://example.com/hook --name "Deploy channel" --events Upload,Rollback +``` + +To go back to receiving every event, pass an empty list: `dpctl webhook update --events ""`. + +`--secret` is used to sign the payload, so your endpoint can verify a request really came from DeployPulse. `--disabled` adds a webhook without turning it on, or turns an existing one off; `--enabled` turns it back on. + ### License This project contains code originally developed by Microsoft Corporation and diff --git a/script/command-executor.ts b/script/command-executor.ts index bdf6c98..c7b40a5 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -39,6 +39,8 @@ import { Org, CollaboratorProperties, Deployment, + DeploymentError, + DeploymentErrorsResult, DeploymentMetrics, Headers, Package, @@ -741,6 +743,95 @@ function deploymentRename(command: cli.IDeploymentRenameCommand): Promise }); } +function deploymentErrors(command: cli.IDeploymentErrorsCommand): Promise { + throwForInvalidOutputFormat(command.format); + + return sdk + .getDeploymentErrors(command.appName, command.deploymentName) + .then((result: DeploymentErrorsResult): void => printDeploymentErrors(command, result)) + .catch((error: any): void => { + // Failure reports come from an account-level endpoint, so an app-scoped key gets a bare 403. + // Say why, rather than letting it read as a permissions bug. + let message = String((error && error.message) || ""); + try { + message = JSON.parse(message).message || message; + } catch { + /* not JSON; keep as-is */ + } + if (/limited to specific apps/i.test(message)) { + throw new Error(`${message} Failure reports are account-level, so use a key that is not limited to specific apps.`); + } + throw error; + }); +} + +export function summarizeDeploymentErrors(entries: DeploymentError[]) { + const devices = new Set(); + const devicesByLabel = new Map>(); + let totalReports = 0; + entries.forEach((entry: DeploymentError) => { + devices.add(entry.clientUniqueId); + totalReports += Number(entry.failureCount) || 0; + if (!devicesByLabel.has(entry.label)) devicesByLabel.set(entry.label, new Set()); + devicesByLabel.get(entry.label).add(entry.clientUniqueId); + }); + let topFailingRelease: string | null = null; + let topDevices = -1; + // Most distinct devices wins, ties broken by label so the output is stable. + Array.from(devicesByLabel.keys()) + .sort() + .forEach((label: string) => { + const count = devicesByLabel.get(label).size; + if (count > topDevices) { + topDevices = count; + topFailingRelease = label; + } + }); + return { affectedDevices: devices.size, failedReleases: devicesByLabel.size, totalReports, topFailingRelease }; +} + +function printDeploymentErrors(command: cli.IDeploymentErrorsCommand, result: DeploymentErrorsResult): void { + const limit = Number.isFinite(Number(command.limit)) ? Number(command.limit) : 50; + const summary = summarizeDeploymentErrors(result.entries); + + if (command.format === "json") { + printJson({ summary, truncated: result.truncated, entries: result.entries.slice(0, limit) }); + return; + } + + if (!result.entries.length) { + log(`No failed updates have been reported for the "${command.deploymentName}" deployment of "${command.appName}".`); + return; + } + + log( + `${summary.affectedDevices} device(s) reported ${summary.totalReports} failed update(s) across ` + + `${summary.failedReleases} release(s). Release affecting the most devices: ${summary.topFailingRelease}.` + ); + + printTable(["Last Seen", "Failed Release", "Last Good", "App Version", "Platform", "Device", "Retries", "Location"], (dataSource: any[]) => { + result.entries.slice(0, limit).forEach((entry: DeploymentError) => { + dataSource.push([ + formatDate(new Date(entry.lastSeen).getTime()), + entry.label, + entry.lastSuccessfulLabel || "", + entry.appVersion || "", + entry.platform || "", + entry.clientUniqueId ? entry.clientUniqueId.slice(0, 8) : "", + String(entry.failureCount), + [entry.city, entry.region, entry.country].filter(Boolean).join(", "), + ]); + }); + }); + + if (result.entries.length > limit) { + log(`Showing ${limit} of ${result.entries.length} reports. Pass --limit to see more, or --format json.`); + } + if (result.truncated) { + log(chalk.yellow("This app has more failure reports than the API returns, so only the most recent are included.")); + } +} + function deploymentHistory(command: cli.IDeploymentHistoryCommand): Promise { throwForInvalidOutputFormat(command.format); @@ -873,6 +964,9 @@ export function execute(command: cli.ICommand) { case cli.CommandType.deploymentHistoryClear: return deploymentHistoryClear(command); + case cli.CommandType.deploymentErrors: + return deploymentErrors(command); + case cli.CommandType.deploymentHistory: return deploymentHistory(command); @@ -930,6 +1024,18 @@ export function execute(command: cli.ICommand) { case cli.CommandType.sessionList: return sessionList(command); + case cli.CommandType.webhookList: + return webhookList(command); + + case cli.CommandType.webhookAdd: + return webhookAdd(command); + + case cli.CommandType.webhookUpdate: + return webhookUpdate(command); + + case cli.CommandType.webhookRemove: + return webhookRemove(command); + case cli.CommandType.sessionRemove: return sessionRemove(command); @@ -2181,6 +2287,72 @@ function sessionRemove(command: cli.ISessionRemoveCommand): Promise { } } +function webhookList(command: cli.IWebhookListCommand): Promise { + throwForInvalidOutputFormat(command.format); + return sdk.getWebhooks().then((webhooks: any[]): void => { + if (command.format === "json") { + printJson(webhooks); + return; + } + if (!webhooks || webhooks.length === 0) { + log('No webhooks found. Use "dpctl webhook add " to create one.'); + return; + } + printTable(["ID", "Name", "URL", "Events", "Enabled"], (dataSource: any[]): void => { + webhooks.forEach((webhook: any): void => { + dataSource.push([ + webhook.id, + webhook.name ?? "", + webhook.url, + webhook.events ? webhook.events.join(", ") : "(all)", + webhook.enabled ? chalk.green("Yes") : chalk.red("No"), + ]); + }); + }); + }); +} + +function webhookAdd(command: cli.IWebhookAddCommand): Promise { + const events: string[] | undefined = command.events ? splitEvents(command.events) : undefined; + return sdk + .addWebhook(command.url, command.name, events, command.secret, command.disabled ? false : undefined) + .then((webhook: any): void => { + log(`Successfully added webhook "${webhook.id}" for URL: ${webhook.url}`); + }); +} + +function webhookUpdate(command: cli.IWebhookUpdateCommand): Promise { + const updates: Record = {}; + const given = (value: any): boolean => value !== null && value !== undefined; + if (given(command.url)) updates.url = command.url; + if (given(command.name)) updates.name = command.name; + if (given(command.secret)) updates.secret = command.secret; + if (given(command.enabled)) updates.enabled = command.enabled; + // An empty --events is how you go back to receiving every event, so it is a value, not an omission. + if (given(command.events)) updates.events = command.events === "" ? null : splitEvents(command.events); + + if (Object.keys(updates).length === 0) { + log("No changes specified. Use --url, --name, --events, --secret, or --enabled/--no-enabled."); + return Q(null); + } + return sdk.updateWebhook(command.id, updates).then((): void => { + log(`Successfully updated webhook "${command.id}".`); + }); +} + +function webhookRemove(command: cli.IWebhookRemoveCommand): Promise { + return sdk.removeWebhook(command.id).then((): void => { + log(`Successfully removed webhook "${command.id}".`); + }); +} + +function splitEvents(events: string): string[] { + return events + .split(",") + .map((event: string) => event.trim()) + .filter(Boolean); +} + function releaseErrorHandler(error: CodePushError, command: cli.ICommand): void { if ((command).noDuplicateReleaseError && error.statusCode === AccountManager.ERROR_CONFLICT) { console.warn(chalk.yellow("[Warning] " + error.message)); diff --git a/script/command-parser.ts b/script/command-parser.ts index 38a473d..d358650 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -312,6 +312,60 @@ function deploymentRemove(commandName: string, yargs: yargs.Argv): void { addCommonConfiguration(yargs); } +function webhookList(commandName: string, yargs: yargs.Argv): void { + isValidCommand = true; + yargs.usage(USAGE_PREFIX + " webhook " + commandName).option("format", { + alias: "f", + default: "table", + demand: false, + description: 'Output format: "table" or "json"', + type: "string", + }); + addCommonConfiguration(yargs); +} + +function webhookRemove(commandName: string, yargs: yargs.Argv): void { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " webhook " + commandName + " ") + .demand(/*count*/ 1, /*max*/ 1) // The id; the category counts the words before it. + .example("webhook " + commandName + " abc-123", "Remove the webhook with id abc-123"); + addCommonConfiguration(yargs); +} + +function deploymentErrors(commandName: string, yargs: yargs.Argv): void { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " deployment " + commandName + " [options]") + .demand(/*count*/ 2, /*max*/ 2) // Require exactly two non-option arguments + .example("deployment " + commandName + " MyApp Production", 'Shows failed updates for the "Production" deployment of "MyApp"') + .example( + "deployment " + commandName + " MyApp Production --format json", + "Same, as JSON (for example to fail a CI job when a new release starts failing)" + ) + .option("format", { + default: "table", + demand: false, + description: 'Output format ("json" or "table")', + type: "string", + }) + .option("limit", { + default: 50, + demand: false, + description: "Maximum number of failure reports to show", + type: "number", + }) + .check((argv: any): any => { + // Rejected rather than quietly replaced by the default, which is what --limit 0 used to do. + if (!Number.isInteger(argv.limit) || argv.limit < 1) { + throw new Error("--limit must be a whole number of 1 or more."); + } + return true; + }); + + addCommonConfiguration(yargs); +} + function deploymentHistory(commandName: string, yargs: yargs.Argv): void { isValidCommand = true; yargs @@ -492,6 +546,7 @@ yargs }) .command("list", "List the deployments associated with an app", (yargs: yargs.Argv) => deploymentList("list", yargs)) .command("ls", "List the deployments associated with an app", (yargs: yargs.Argv) => deploymentList("ls", yargs)) + .command("errors", "Show failed updates reported by devices for a deployment", (yargs: yargs.Argv) => deploymentErrors("errors", yargs)) .command("history", "Display the release history for a deployment", (yargs: yargs.Argv) => deploymentHistory("history", yargs)) .command("h", "Display the release history for a deployment", (yargs: yargs.Argv) => deploymentHistory("h", yargs)) .command("auto-rollback", "Manage auto-rollback configuration for a deployment", (yargs: yargs.Argv) => { @@ -1192,6 +1247,112 @@ yargs addCommonConfiguration(yargs); }) + .command("webhook", "View and manage webhooks for your account", (yargs: yargs.Argv) => { + isValidCommandCategory = true; + yargs + .usage(USAGE_PREFIX + " webhook ") + .demand(/*count*/ 2, /*max*/ 2) + .command("list", "List all webhooks for your account", (yargs: yargs.Argv): void => webhookList("list", yargs)) + .command("ls", "List all webhooks for your account", (yargs: yargs.Argv): void => webhookList("ls", yargs)) + .command("add", "Add a new webhook to your account", (yargs: yargs.Argv): void => { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " webhook add [options]") + .demand(/*count*/ 1, /*max*/ 1) // The url; the category counts the words before it. + .example("webhook add https://example.com/hook --events Upload,Rollback", "Add a webhook that fires on uploads and rollbacks") + .option("name", { + alias: "n", + default: null, + demand: false, + description: "Friendly name for the webhook", + type: "string", + }) + .option("events", { + alias: "e", + default: null, + demand: false, + description: "Comma-separated list of events to subscribe to. If omitted, all events are sent.", + type: "string", + }) + .option("secret", { + alias: "s", + default: null, + demand: false, + description: "HMAC signing secret for payload verification", + type: "string", + }) + .option("disabled", { + alias: "x", + default: false, + demand: false, + description: "Create the webhook in a disabled state", + type: "boolean", + }); + addCommonConfiguration(yargs); + }) + .command("update", "Update an existing webhook", (yargs: yargs.Argv): void => { + isValidCommand = true; + yargs + .usage(USAGE_PREFIX + " webhook update [options]") + .demand(/*count*/ 1, /*max*/ 1) // The id; the category counts the words before it. + .example("webhook update abc-123 --disabled", "Disable an existing webhook") + .option("url", { + alias: "u", + default: null, + demand: false, + description: "New URL for the webhook", + type: "string", + }) + .option("name", { + alias: "n", + default: null, + demand: false, + description: "New friendly name", + type: "string", + }) + .option("events", { + alias: "e", + default: null, + demand: false, + description: "Comma-separated list of events, or empty string to receive all events", + type: "string", + }) + .option("secret", { + alias: "s", + default: null, + demand: false, + description: "New HMAC signing secret", + type: "string", + }) + .option("enabled", { + default: null, + demand: false, + description: "Enable or disable the webhook (--enabled / --no-enabled)", + type: "boolean", + }) + // `webhook add` takes --disabled, so update takes it too rather than making people discover + // that the same idea is spelled --no-enabled here. + .option("disabled", { + alias: "x", + default: null, + demand: false, + description: "Disable the webhook. The same as --no-enabled", + type: "boolean", + }) + .check((argv: any): any => { + if (argv.enabled !== null && argv.enabled !== undefined && argv.disabled !== null && argv.disabled !== undefined) { + throw new Error("Pass --enabled or --disabled, not both."); + } + return true; + }); + addCommonConfiguration(yargs); + }) + .command("remove", "Remove a webhook", (yargs: yargs.Argv): void => webhookRemove("remove", yargs)) + .command("rm", "Remove a webhook", (yargs: yargs.Argv): void => webhookRemove("rm", yargs)) + .check((argv: any): any => isValidCommand); + + addCommonConfiguration(yargs); + }) .command("session", "View and manage the current login sessions associated with your account", (yargs: yargs.Argv) => { isValidCommandCategory = true; yargs @@ -1475,6 +1636,19 @@ export function createCommand(): cli.ICommand { } break; + case "errors": + if (arg2 && arg3) { + cmd = { type: cli.CommandType.deploymentErrors }; + + const deploymentErrorsCommand = cmd; + + deploymentErrorsCommand.appName = arg2; + deploymentErrorsCommand.deploymentName = arg3; + deploymentErrorsCommand.format = argv["format"] as any; + deploymentErrorsCommand.limit = argv["limit"] as any; + } + break; + case "history": case "h": if (arg2 && arg3) { @@ -1702,6 +1876,55 @@ export function createCommand(): cli.ICommand { } break; + case "webhook": + switch (arg1) { + case "list": + case "ls": + cmd = { type: cli.CommandType.webhookList }; + (cmd).format = argv["format"] as any; + break; + + case "add": + if (arg2) { + cmd = { type: cli.CommandType.webhookAdd }; + + const webhookAddCommand = cmd; + + webhookAddCommand.url = arg2; + webhookAddCommand.name = argv["name"] as any; + webhookAddCommand.events = argv["events"] as any; + webhookAddCommand.secret = argv["secret"] as any; + webhookAddCommand.disabled = argv["disabled"] as any; + } + break; + + case "update": + if (arg2) { + cmd = { type: cli.CommandType.webhookUpdate }; + + const webhookUpdateCommand = cmd; + + webhookUpdateCommand.id = arg2; + webhookUpdateCommand.url = argv["url"] as any; + webhookUpdateCommand.name = argv["name"] as any; + webhookUpdateCommand.events = argv["events"] as any; + webhookUpdateCommand.secret = argv["secret"] as any; + // --disabled is the other way of saying --no-enabled; the check above rejects both at once. + const disabledFlag: any = argv["disabled"]; + webhookUpdateCommand.enabled = disabledFlag === null || disabledFlag === undefined ? (argv["enabled"] as any) : !disabledFlag; + } + break; + + case "remove": + case "rm": + if (arg2) { + cmd = { type: cli.CommandType.webhookRemove }; + (cmd).id = arg2; + } + break; + } + break; + case "session": switch (arg1) { case "list": diff --git a/script/management-sdk.ts b/script/management-sdk.ts index 63f0dfe..80e35af 100644 --- a/script/management-sdk.ts +++ b/script/management-sdk.ts @@ -20,6 +20,8 @@ import { CodePushError, CollaboratorMap, Deployment, + DeploymentError, + DeploymentErrorsResult, DeploymentMetrics, Headers, Org, @@ -311,6 +313,50 @@ class AccountManager { }); }); } + public getDeploymentErrors(appName: string, deploymentName: string): Promise { + const PAGE_SIZE = 200; + const MAX_PAGES = 50; + const entries: DeploymentError[] = []; + + const fetchPage = (page: number): Promise => + this.get(`/logs/errors?appId=${encodeURIComponent(appName)}&page=${page}&limit=${PAGE_SIZE}`).then( + (res: JsonResponse): DeploymentErrorsResult | Promise => { + const body = res.body || {}; + (body.entries || []).forEach((entry: DeploymentError) => { + if (entry.deploymentName === deploymentName) entries.push(entry); + }); + const pages = Number(body.pages) || 1; + if (page < pages && page < MAX_PAGES) return fetchPage(page + 1); + return { entries, truncated: page < pages }; + } + ); + + return fetchPage(1); + } + + public getWebhooks(): Promise { + return this.get(urlEncode(["/webhooks"])).then((res: JsonResponse) => res.body.webhooks); + } + + public addWebhook(url: string, name?: string, events?: string[], secret?: string, enabled?: boolean): Promise { + const body: Record = { url }; + if (name) body.name = name; + if (events) body.events = events; + if (secret) body.secret = secret; + if (enabled !== undefined) body.enabled = enabled; + return this.post(urlEncode(["/webhooks"]), JSON.stringify(body), /*expectResponseBody=*/ true).then( + (res: JsonResponse) => res.body.webhook + ); + } + + public updateWebhook(id: string, updates: Record): Promise { + return this.patch(urlEncode([`/webhooks/${id}`]), JSON.stringify(updates), /*expectResponseBody=*/ false).then(() => null); + } + + public removeWebhook(id: string): Promise { + return this.del(urlEncode([`/webhooks/${id}`])).then(() => null); + } + public getAccessKeys(): Promise { return this.get(urlEncode(["/accessKeys"])).then((res: JsonResponse) => { const accessKeys: AccessKey[] = []; diff --git a/script/types.ts b/script/types.ts index 2cdf152..9f80e4a 100644 --- a/script/types.ts +++ b/script/types.ts @@ -37,6 +37,28 @@ export interface AccessKey { appNames?: string[] | null; } +/** A device's failure report for one release, as returned by GET /logs/errors. */ +export interface DeploymentError { + clientUniqueId: string; + appName: string; + deploymentName: string; + label: string; + lastSuccessfulLabel?: string | null; + appVersion?: string | null; + platform?: string | null; + sdkVersion?: string | null; + country?: string | null; + region?: string | null; + city?: string | null; + failureCount: number; + lastSeen: string; +} + +export interface DeploymentErrorsResult { + entries: DeploymentError[]; + truncated: boolean; +} + export interface Session { loggedInTime: number; machineName: string; diff --git a/script/types/cli.ts b/script/types/cli.ts index e293264..ede1c28 100644 --- a/script/types/cli.ts +++ b/script/types/cli.ts @@ -23,6 +23,7 @@ export enum CommandType { deploymentAutoRollbackDisable, deploymentAutoRollbackEnable, deploymentAutoRollbackGet, + deploymentErrors, deploymentHistory, deploymentHistoryClear, deploymentList, @@ -42,6 +43,10 @@ export enum CommandType { rollback, sessionList, sessionRemove, + webhookAdd, + webhookList, + webhookRemove, + webhookUpdate, whoami, } @@ -57,6 +62,13 @@ export interface IAccessKeyAddCommand extends ICommand { appNames?: string[]; } +export interface IDeploymentErrorsCommand extends ICommand { + appName: string; + deploymentName: string; + format: string; + limit: number; +} + export interface IAccessKeyPatchCommand extends ICommand { newName?: string; oldName: string; @@ -281,6 +293,31 @@ export interface ISessionRemoveCommand extends ICommand { machineName: string; } +export interface IWebhookListCommand extends ICommand { + format: string; +} + +export interface IWebhookAddCommand extends ICommand { + url: string; + name?: string; + events?: string; + secret?: string; + disabled?: boolean; +} + +export interface IWebhookUpdateCommand extends ICommand { + id: string; + url?: string; + name?: string; + events?: string; + secret?: string; + enabled?: boolean; +} + +export interface IWebhookRemoveCommand extends ICommand { + id: string; +} + export type ReleaseHook = ( currentCommand: IReleaseCommand, originalCommand: IReleaseCommand, diff --git a/test/cli.ts b/test/cli.ts index 60ce939..642ef38 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -125,6 +125,18 @@ export class SdkStub { ]); } + public getDeploymentErrors(appName: string, deploymentName: string): Q.Promise { + const all = [ + { clientUniqueId: "device-one-aaaa", appName: "a", deploymentName: "Production", label: "v5", lastSuccessfulLabel: "v4", appVersion: "1.0.0", platform: "ios", sdkVersion: "10.4.3", country: "US", region: "TX", city: "Dallas", failureCount: 3, lastSeen: "2026-09-14T10:00:00Z" }, + { clientUniqueId: "device-two-bbbb", appName: "a", deploymentName: "Production", label: "v5", lastSuccessfulLabel: "v4", appVersion: "1.0.0", platform: "android", sdkVersion: "10.4.3", country: "DE", region: null, city: null, failureCount: 1, lastSeen: "2026-09-14T09:00:00Z" }, + { clientUniqueId: "device-one-aaaa", appName: "a", deploymentName: "Production", label: "v4", lastSuccessfulLabel: "v3", appVersion: "1.0.0", platform: "ios", sdkVersion: "10.4.3", country: "US", region: "TX", city: "Dallas", failureCount: 2, lastSeen: "2026-09-13T10:00:00Z" }, + { clientUniqueId: "device-three-cc", appName: "a", deploymentName: "Staging", label: "v9", lastSuccessfulLabel: null, appVersion: "1.0.0", platform: "ios", sdkVersion: "10.4.3", country: "US", region: null, city: null, failureCount: 7, lastSeen: "2026-09-14T11:00:00Z" }, + { clientUniqueId: "device-four-dd", appName: "a", deploymentName: "Staging", label: "v2", lastSuccessfulLabel: null, appVersion: "1.0.0", platform: "ios", sdkVersion: "10.4.3", country: "US", region: null, city: null, failureCount: 1, lastSeen: "2026-09-12T11:00:00Z" }, + { clientUniqueId: "device-five-ee", appName: "a", deploymentName: "Staging", label: "v2", lastSuccessfulLabel: null, appVersion: "1.0.0", platform: "ios", sdkVersion: "10.4.3", country: "US", region: null, city: null, failureCount: 1, lastSeen: "2026-09-12T12:00:00Z" }, + ]; + return Q({ entries: all.filter((e) => e.deploymentName === deploymentName), truncated: false }); + } + public setOrgId(orgId: string | null): void { this.orgId = orgId; } @@ -254,6 +266,26 @@ export class SdkStub { return Q([{ id: "release-id", platform: "ios", runtimeVersion: "1.0.0", rollout }]); } + public getWebhooks(): Q.Promise { + return Q([ + { id: "wh-1", name: "My Hook", url: "https://example.com/hook", events: ["Upload"], enabled: true }, + { id: "wh-2", name: null, url: "https://other.com/hook", events: null, enabled: false }, + ]); + } + + public addWebhook(url: string, name?: string, events?: string[], secret?: string, enabled?: boolean): Q.Promise { + return Q({ id: "wh-new", url, name: name ?? null, events: events ?? null, enabled: enabled !== false }); + } + + public updateWebhook(id: string, updates: Record): Q.Promise { + return Q(null); + } + + public removeWebhook(id: string): Q.Promise { + return Q(null); + } + + public patchRelease(): Q.Promise { return Q(null); } @@ -583,6 +615,96 @@ describe("CLI", () => { }); }); + it("deploymentErrors returns only the requested deployment, with a summary, as JSON", (done: Mocha.Done): void => { + var command: cli.IDeploymentErrorsCommand = { + type: cli.CommandType.deploymentErrors, + appName: "a", + deploymentName: "Production", + format: "json", + limit: 50, + }; + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(log); + var output = JSON.parse(log.args[0][0]); + assert.equal(output.entries.length, 3); + assert.ok(output.entries.every((e: any) => e.deploymentName === "Production")); + assert.deepEqual(output.summary, { affectedDevices: 2, failedReleases: 2, totalReports: 6, topFailingRelease: "v5" }); + assert.equal(output.truncated, false); + done(); + }); + }); + + it("deploymentErrors applies --limit to the JSON entries but not to the summary", (done: Mocha.Done): void => { + var command: cli.IDeploymentErrorsCommand = { + type: cli.CommandType.deploymentErrors, + appName: "a", + deploymentName: "Production", + format: "json", + limit: 1, + }; + + cmdexec.execute(command).done((): void => { + var output = JSON.parse(log.args[0][0]); + assert.equal(output.entries.length, 1); + // The summary counts everything that failed, not just what fits under --limit. + assert.deepEqual(output.summary, { affectedDevices: 2, failedReleases: 2, totalReports: 6, topFailingRelease: "v5" }); + done(); + }); + }); + + it("deploymentErrors prints a summary line and a table", (done: Mocha.Done): void => { + var command: cli.IDeploymentErrorsCommand = { + type: cli.CommandType.deploymentErrors, + appName: "a", + deploymentName: "Production", + format: "table", + limit: 2, + }; + + cmdexec.execute(command).done((): void => { + var lines = log.args.map((args: any[]) => String(args[0])); + assert.ok(lines[0].startsWith("2 device(s) reported 6 failed update(s) across 2 release(s).")); + assert.ok(lines.some((line: string) => line.includes("Failed Release") && line.includes("v5"))); + assert.ok(lines.some((line: string) => line.startsWith("Showing 2 of 3 reports."))); + // v3 is only ever the third entry's last good release, so it is gone iff the row was dropped. + assert.ok(!lines.some((line: string) => line.includes("v3")), "the third report should not be in the table"); + done(); + }); + }); + + it("deploymentErrors names the release on the most devices, not the highest label", (done: Mocha.Done): void => { + var command: cli.IDeploymentErrorsCommand = { + type: cli.CommandType.deploymentErrors, + appName: "a", + deploymentName: "Staging", + format: "json", + limit: 50, + }; + + cmdexec.execute(command).done((): void => { + var output = JSON.parse(log.args[0][0]); + // v9 has one device and 7 retries; v2 has two devices and 2. Devices win. + assert.deepEqual(output.summary, { affectedDevices: 3, failedReleases: 2, totalReports: 9, topFailingRelease: "v2" }); + done(); + }); + }); + + it("deploymentErrors says so when nothing has failed", (done: Mocha.Done): void => { + var command: cli.IDeploymentErrorsCommand = { + type: cli.CommandType.deploymentErrors, + appName: "a", + deploymentName: "Nope", + format: "table", + limit: 50, + }; + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(log); + assert.ok(String(log.args[0][0]).startsWith("No failed updates have been reported")); + done(); + }); + }); it("accessKeyRemove removes access key", (done: Mocha.Done): void => { var command: cli.IAccessKeyRemoveCommand = { type: cli.CommandType.accessKeyRemove, @@ -2668,6 +2790,199 @@ describe("CLI", () => { ); } // --------------------------------------------------------------------------- + // webhook tests + // --------------------------------------------------------------------------- + + it("webhookList prints webhooks as table", (done: Mocha.Done): void => { + var command: cli.IWebhookListCommand = { + type: cli.CommandType.webhookList, + format: "table", + }; + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(log); + done(); + }); + }); + + it("webhookList prints webhooks as JSON", (done: Mocha.Done): void => { + var command: cli.IWebhookListCommand = { + type: cli.CommandType.webhookList, + format: "json", + }; + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(log); + var actual: any[] = JSON.parse(log.args[0][0]); + assert.ok(Array.isArray(actual)); + assert.equal(actual[0].id, "wh-1"); + assert.equal(actual[1].id, "wh-2"); + done(); + }); + }); + + it("webhookList shows empty message when no webhooks exist", (done: Mocha.Done): void => { + sandbox.stub(cmdexec.sdk, "getWebhooks").returns(Q([])); + + var command: cli.IWebhookListCommand = { + type: cli.CommandType.webhookList, + format: "table", + }; + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(log); + assert.ok((log.args[0][0] as string).includes("No webhooks found")); + done(); + }); + }); + + it("webhookAdd creates webhook with URL only", (done: Mocha.Done): void => { + var command: cli.IWebhookAddCommand = { + type: cli.CommandType.webhookAdd, + url: "https://example.com/hook", + }; + + var addWebhook: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "addWebhook"); + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(addWebhook); + sinon.assert.calledWith(addWebhook, "https://example.com/hook", undefined, undefined, undefined, undefined); + sinon.assert.calledOnce(log); + assert.ok((log.args[0][0] as string).includes("Successfully added webhook")); + done(); + }); + }); + + it("webhookAdd creates webhook with name, events, and secret", (done: Mocha.Done): void => { + var command: cli.IWebhookAddCommand = { + type: cli.CommandType.webhookAdd, + url: "https://example.com/hook", + name: "My Hook", + events: "Upload,Rollback", + secret: "mysecret", + }; + + var addWebhook: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "addWebhook"); + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(addWebhook); + sinon.assert.calledWith(addWebhook, "https://example.com/hook", "My Hook", ["Upload", "Rollback"], "mysecret", undefined); + done(); + }); + }); + + it("webhookAdd creates webhook in disabled state when --disabled is set", (done: Mocha.Done): void => { + var command: cli.IWebhookAddCommand = { + type: cli.CommandType.webhookAdd, + url: "https://example.com/hook", + disabled: true, + }; + + var addWebhook: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "addWebhook"); + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(addWebhook); + sinon.assert.calledWith(addWebhook, "https://example.com/hook", undefined, undefined, undefined, false); + done(); + }); + }); + + it("webhookUpdate updates webhook URL", (done: Mocha.Done): void => { + var command: cli.IWebhookUpdateCommand = { + type: cli.CommandType.webhookUpdate, + id: "wh-1", + url: "https://new.example.com/hook", + }; + + var updateWebhook: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "updateWebhook"); + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(updateWebhook); + sinon.assert.calledWith(updateWebhook, "wh-1", { url: "https://new.example.com/hook" }); + sinon.assert.calledOnce(log); + assert.ok((log.args[0][0] as string).includes("Successfully updated webhook")); + done(); + }); + }); + + it("webhookUpdate enables webhook via --enabled flag", (done: Mocha.Done): void => { + var command: cli.IWebhookUpdateCommand = { + type: cli.CommandType.webhookUpdate, + id: "wh-1", + enabled: true, + }; + + var updateWebhook: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "updateWebhook"); + + cmdexec.execute(command).done((): void => { + sinon.assert.calledWith(updateWebhook, "wh-1", { enabled: true }); + done(); + }); + }); + + it("webhookUpdate parses events from comma-separated string", (done: Mocha.Done): void => { + var command: cli.IWebhookUpdateCommand = { + type: cli.CommandType.webhookUpdate, + id: "wh-1", + events: "Upload,Promote", + }; + + var updateWebhook: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "updateWebhook"); + + cmdexec.execute(command).done((): void => { + sinon.assert.calledWith(updateWebhook, "wh-1", { events: ["Upload", "Promote"] }); + done(); + }); + }); + + it("webhookUpdate clears event filter when events is empty string", (done: Mocha.Done): void => { + var command: cli.IWebhookUpdateCommand = { + type: cli.CommandType.webhookUpdate, + id: "wh-1", + events: "", + }; + + var updateWebhook: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "updateWebhook"); + + cmdexec.execute(command).done((): void => { + sinon.assert.calledWith(updateWebhook, "wh-1", { events: null }); + done(); + }); + }); + + it("webhookUpdate logs message and skips SDK call when no fields are provided", (done: Mocha.Done): void => { + var command: cli.IWebhookUpdateCommand = { + type: cli.CommandType.webhookUpdate, + id: "wh-1", + }; + + var updateWebhook: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "updateWebhook"); + + cmdexec.execute(command).done((): void => { + sinon.assert.notCalled(updateWebhook); + sinon.assert.calledOnce(log); + assert.ok((log.args[0][0] as string).includes("No changes specified")); + done(); + }); + }); + + it("webhookRemove removes webhook by ID", (done: Mocha.Done): void => { + var command: cli.IWebhookRemoveCommand = { + type: cli.CommandType.webhookRemove, + id: "wh-1", + }; + + var removeWebhook: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "removeWebhook"); + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(removeWebhook); + sinon.assert.calledWith(removeWebhook, "wh-1"); + sinon.assert.calledOnce(log); + assert.ok((log.args[0][0] as string).includes("Successfully removed webhook")); + done(); + }); + }); + // --------------------------------------------------------------------------- // release-expo and the wrong-command check // --------------------------------------------------------------------------- diff --git a/test/command-parser.ts b/test/command-parser.ts index 35069ee..6aefc0e 100644 --- a/test/command-parser.ts +++ b/test/command-parser.ts @@ -43,6 +43,20 @@ function run(...args: string[]): Run { return { status: result.status, stdout, stderr, output: stdout + stderr }; } +// Parses args and returns the command object, without running it. A fresh process per call, because +// yargs keeps module state between parses. +function parse(...args: string[]): any { + const script = 'const p = require(process.argv[1]); console.log("CMD:" + JSON.stringify(p.createCommand()));'; + const parserPath = path.join(__dirname, "..", "script", "command-parser.ts"); + const result = spawnSync(process.execPath, ["-r", "ts-node/register", "-e", script, parserPath, ...args], { + encoding: "utf8", + env: { ...process.env, HOME: sandboxHome, TS_NODE_TRANSPILE_ONLY: "1", FORCE_COLOR: "0" }, + }); + const line = (result.stdout || "").split("\n").find((l: string) => l.startsWith("CMD:")); + assert.ok(line, `no command parsed: ${(result.stdout || "") + (result.stderr || "")}`.slice(0, 400)); + return JSON.parse(line.slice("CMD:".length)); +} + describe("command line", function () { // Each run boots ts-node, so this suite is seconds rather than milliseconds. this.timeout(60000); @@ -151,6 +165,38 @@ describe("command line", function () { assert.ok(NOT_LOGGED_IN.test(result.output), result.output.slice(0, 300)); }); + // --------------------------------------------------------------------------- + // Two checks that reject at parse time, before anything is sent. + // --------------------------------------------------------------------------- + + it("deployment errors rejects a --limit below 1", () => { + const result = run("deployment", "errors", "myapp", "Production", "--limit", "0"); + assert.notStrictEqual(result.status, 0); + assert.ok(/--limit must be a whole number of 1 or more/.test(result.output), result.output.slice(0, 300)); + }); + + it("webhook update rejects --enabled and --disabled together", () => { + const result = run("webhook", "update", "abc-123", "--enabled", "--disabled"); + assert.notStrictEqual(result.status, 0); + assert.ok(/Pass --enabled or --disabled, not both/.test(result.output), result.output.slice(0, 300)); + }); + + it("webhook update takes either flag on its own", () => { + ["--enabled", "--disabled", "--no-enabled"].forEach((flag: string) => { + const result = run("webhook", "update", "abc-123", flag); + assert.ok(!UNKNOWN_ARGUMENT.test(result.output), `${flag}: ${result.output.slice(0, 300)}`); + assert.ok(NOT_LOGGED_IN.test(result.output), `${flag}: ${result.output.slice(0, 300)}`); + }); + }); + + it("webhook update maps --disabled to enabled: false", () => { + assert.strictEqual(parse("webhook", "update", "abc-123", "--disabled").enabled, false); + assert.strictEqual(parse("webhook", "update", "abc-123", "--enabled").enabled, true); + assert.strictEqual(parse("webhook", "update", "abc-123", "--no-enabled").enabled, false); + // Neither flag given is not the same as disabling it. + assert.strictEqual(parse("webhook", "update", "abc-123", "--name", "x").enabled, null); + }); + it("each command is registered once", () => { // A command registered twice silently wins with its last builder, so its newest options vanish // from the parse while still showing up in the source. diff --git a/test/management-sdk.ts b/test/management-sdk.ts index 4999ce4..53eafea 100644 --- a/test/management-sdk.ts +++ b/test/management-sdk.ts @@ -443,6 +443,34 @@ describe("Management SDK", () => { // The Expo routes are addressed by deployment key rather than app and deployment name, and they build // their requests directly instead of going through get/post, so what they send is worth pinning down. + // The failure-report route is account-wide and paged, so the SDK walks the pages and keeps only the + // deployment that was asked for. + + it("getDeploymentErrors follows the pages and keeps one deployment", (done: Mocha.Done) => { + const requested = mockPages([ + { pages: 2, entries: [{ deploymentName: "Production", label: "v1" }, { deploymentName: "Staging", label: "v9" }] }, + { pages: 2, entries: [{ deploymentName: "Production", label: "v2" }] }, + ]); + manager.getDeploymentErrors("MyApp", "Production").then((result) => { + assert.deepStrictEqual(result.entries.map((entry) => entry.label), ["v1", "v2"]); + assert.strictEqual(result.truncated, false); + assert.deepStrictEqual(requested.map((url) => /[?&]page=(\d+)/.exec(url)[1]), ["1", "2"]); + assert.ok(requested[0].indexOf("appId=MyApp") >= 0, requested[0]); + done(); + }, rejectHandler); + }); + + it("getDeploymentErrors says when there are more pages than it will read", (done: Mocha.Done) => { + const requested = mockPages( + Array.from({ length: 51 }, (unused, index) => ({ pages: 99, entries: [{ deploymentName: "Production", label: `v${index}` }] })) + ); + manager.getDeploymentErrors("MyApp", "Production").then((result) => { + assert.strictEqual(requested.length, 50, "should stop after 50 pages"); + assert.strictEqual(result.truncated, true); + done(); + }, rejectHandler); + }); + it("the Expo routes live under /expo/v1 and are addressed by deployment key", (done: Mocha.Done) => { mockReturn(JSON.stringify({ releases: [] }), 200); manager.patchExpoRollout("key with spaces", 25).then(() => { @@ -500,6 +528,25 @@ let lastRequestBody: any; let lastRequestHeaders: any; let lastRequestUrl: string; +function mockPages(pageBodies: any[]): string[] { + const requested: string[] = []; + lastRequestBody = undefined; + require("superagent-mock")(request, [ + { + pattern: "https://api.deploypulse.io/(.*)", + fixtures: function (match: any): any { + requested.push(match[0]); + const page = Number(/[?&]page=(\d+)/.exec(match[0])?.[1] || 1); + return { text: JSON.stringify(pageBodies[page - 1]), status: 200, ok: true, header: {}, headers: {} }; + }, + callback: function (match: any, data: any): any { + return data; + }, + }, + ]); + return requested; +} + function mockReturn(bodyText: string, statusCode: number, header = {}, throwOnError = true): void { lastRequestBody = undefined; lastRequestUrl = undefined;