1import type { NotifyApprovalPreview, NotifyDeviceInfo, NotifyRequest } from "@nzip/shared";2import { ApiClient, resolveCliTargetWithStatus } from "../lib/api.ts";3import type { Config } from "../lib/config.ts";4import { ago, bold, cyan, dim, emit, fail, green, table } from "../lib/fmt.ts";56export type NotifyInvocation =7 | { kind: "send"; body: string }8 | { kind: "test" }9 | { kind: "pair" }10 | { kind: "approve"; code: string }11 | { kind: "devices" }12 | { kind: "revoke"; deviceId: string };1314export function parseNotifyInvocation(rest: string[]): NotifyInvocation {15 const [first, second, ...extra] = rest;16 if (!first) {17 fail("usage: nzip notify <send|test|pair|approve|devices|revoke> ...");18 }19 if (first === "test") {20 if (second !== undefined) fail("usage: nzip notify test");21 return { kind: "test" };22 }23 if (first === "pair") {24 if (second !== undefined) fail("usage: nzip notify pair");25 return { kind: "pair" };26 }27 if (first === "approve") {28 if (!second || extra.length > 0) fail("usage: nzip notify approve <code> --name NAME");29 return { kind: "approve", code: second };30 }31 if (first === "devices" || first === "ls") {32 if (second !== undefined) fail("usage: nzip notify devices");33 return { kind: "devices" };34 }35 if (first === "revoke") {36 if (!second || extra.length > 0) fail("usage: nzip notify revoke <device-id> [--yes]");37 return { kind: "revoke", deviceId: second };38 }39 if (first !== "send") {40 fail(`unknown notify command: ${first}`);41 }42 if (!second || extra.length > 0) {43 fail("usage: nzip notify send <body> [--title TEXT] [--open TARGET] [--tag TEXT]");44 }45 return { kind: "send", body: second };46}4748function location(preview: NotifyApprovalPreview): string {49 return [preview.country, preview.region].filter(Boolean).join("/") || "unknown";50}5152export function filterNotificationDevices(53 devices: NotifyDeviceInfo[],54 includeAll: boolean,55): NotifyDeviceInfo[] {56 if (includeAll) return devices;57 return devices.filter((device) =>58 device.status === "pending" || device.status === "approved" || device.status === "active"59 );60}6162export async function cmdNotify(63 config: Config,64 rest: string[],65 options: {66 title?: string;67 open?: string;68 tag?: string;69 name?: string;70 yes: boolean;71 all: boolean;72 },73): Promise<void> {74 const invocation = parseNotifyInvocation(rest);75 const api = new ApiClient(config);7677 if (invocation.kind === "pair") {78 const pairing = await api.openNotificationPairing();79 emit(80 () => console.log(`${green("✓")} pairing enabled for 10 minutes`),81 { ok: true, pairing },82 );83 return;84 }8586 if (invocation.kind === "devices") {87 const devices = filterNotificationDevices(await api.notificationDevices(), options.all);88 emit(() => {89 if (devices.length === 0) return console.log(dim("no notification devices"));90 console.log(table(91 [92 "ID",93 "NAME",94 "STATUS",95 "CREATED",96 "APPROVED",97 "SEEN",98 "ATTACHED",99 "DELIVERED",100 "ERROR",101 ],102 devices.map((device) => [103 cyan(device.id),104 device.name ?? dim("—"),105 device.status,106 ago(device.createdAt),107 device.approvedAt ? ago(device.approvedAt) : dim("never"),108 device.lastSeenAt ? ago(device.lastSeenAt) : dim("never"),109 device.lastAttachedAt ? ago(device.lastAttachedAt) : dim("never"),110 device.lastSuccessAt ? ago(device.lastSuccessAt) : dim("never"),111 device.lastError ?? "",112 ]),113 ));114 }, { ok: true, devices });115 return;116 }117118 if (invocation.kind === "approve") {119 if (!options.name?.trim()) fail("--name is required when approving a notification device");120 const preview = await api.notificationApprovalPreview(invocation.code);121 if (!options.yes) {122 emit(() => {123 console.log(`approve notification device ${bold(options.name!.trim())}?`);124 console.log(125 ` created ${ago(preview.createdAt)} · ${preview.deviceClass ?? "unknown device"}`,126 );127 console.log(` ${preview.userAgentSummary ?? "unknown browser"}`);128 console.log(` ${location(preview)} · ASN ${preview.asn ?? "unknown"}`);129 });130 const answer = prompt("approve this device? [y/N]");131 if (answer?.trim().toLowerCase() !== "y") {132 emit(() => console.log(dim("aborted")), { ok: false, error: "aborted" });133 return;134 }135 }136 const device = await api.approveNotificationDevice({137 code: invocation.code,138 name: options.name.trim(),139 });140 emit(141 () => console.log(`${green("✓")} approved ${bold(device.name ?? device.id)}`),142 { ok: true, device },143 );144 return;145 }146147 if (invocation.kind === "revoke") {148 if (!options.yes) {149 const answer = prompt(`revoke notification device ${invocation.deviceId}? [y/N]`);150 if (answer?.trim().toLowerCase() !== "y") {151 emit(() => console.log(dim("aborted")), { ok: false, error: "aborted" });152 return;153 }154 }155 await api.revokeNotificationDevice(invocation.deviceId);156 emit(157 () => console.log(`${green("✓")} revoked ${bold(invocation.deviceId)}`),158 { ok: true, revoked: invocation.deviceId },159 );160 return;161 }162163 const event: NotifyRequest = invocation.kind === "test"164 ? { title: "nzip", body: "nzip notifications are working", tag: "nzip-test" }165 : { body: invocation.body };166 if (invocation.kind === "send") {167 if (options.title !== undefined) event.title = options.title;168 if (options.tag !== undefined) event.tag = options.tag;169 if (options.open !== undefined) {170 const target = await resolveCliTargetWithStatus(options.open, config, api);171 const site = await api.siteDetail(target);172 event.path = `/${site.address}`;173 }174 }175 const response = await api.notify(event);176 emit(177 () =>178 console.log(179 `notification queued for ${response.queuedDevices} devices (event ${response.eventId})`,180 ),181 { ok: true, ...response },182 );183}