Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions src/commands/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,56 @@ import { SessionExpiredError } from "~/lib/errors.ts";
import { confirmDestructive } from "~/lib/interactive.ts";
import { c, die, formatApiError, printJson, printTable } from "~/lib/output.ts";
import { requireArray } from "~/lib/shape.ts";
import type {
ProjectCreateResult,
ProjectCreateWarning,
ProjectCreateWarningCode,
} from "~/lib/types.ts";
import { isInteractive } from "~/lib/tty.ts";

/** Platforms accepted by `projects create` (mirrors the API's create body). */
const PLATFORMS = ["imessage", "whatsapp_business", "voice"] as const;
type Platform = (typeof PLATFORMS)[number];

const PROJECT_CREATE_WARNINGS = {
owner_phone_missing: {
code: "owner_phone_missing",
message:
"Your project was created without a connected phone. Add a phone number to your Photon account or connect a dedicated line.",
},
shared_line_unavailable: {
code: "shared_line_unavailable",
message:
"We couldn't connect your phone to a shared iMessage line. You can add another phone or connect a dedicated line.",
},
owner_enrollment_failed: {
code: "owner_enrollment_failed",
message:
"We couldn't connect your phone to a shared iMessage line. Try again with another phone or connect a dedicated line.",
},
} as const satisfies Record<ProjectCreateWarningCode, ProjectCreateWarning>;

function isProjectCreateWarningCode(
value: unknown
): value is ProjectCreateWarningCode {
return (
typeof value === "string" &&
Object.prototype.hasOwnProperty.call(PROJECT_CREATE_WARNINGS, value)
);
}

function readProjectCreateWarning(result: {
warning?: unknown;
}): ProjectCreateWarning | undefined {
if (result.warning && typeof result.warning === "object") {
const warning = result.warning as { code?: unknown };
if (isProjectCreateWarningCode(warning.code)) {
return PROJECT_CREATE_WARNINGS[warning.code];
}
}
return undefined;
}
Comment thread
caezium marked this conversation as resolved.

export function registerProjectsCommand(program: Command): void {
const projects = program
.command("projects")
Expand Down Expand Up @@ -241,16 +285,25 @@ function registerCreateCommand(projects: Command): void {
if (error) {
die(`Failed to create project: ${formatApiError(error)}`);
}
const result = data as { success?: true; id?: string; error?: string };
if (!data) {
die("Server did not return a project result.");
}
const result = data as unknown as ProjectCreateResult;
if (result.error) {
die(result.error);
}
if (!result.id) {
die("Server did not return a project id.");
}

const warning = readProjectCreateWarning(result);
if (opts.json) {
printJson({ id: result.id, name: filled.name, env: env.name });
printJson({
id: result.id,
name: filled.name,
env: env.name,
...(warning ? { warning } : {}),
});
return;
}

Expand All @@ -270,6 +323,9 @@ function registerCreateCommand(projects: Command): void {
)
);
}
if (warning) {
console.error(c.warn(warning.message));
}
console.log(
c.dim(` To make this the active project: export PHOTON_PROJECT_ID='${result.id}'`)
);
Expand Down
44 changes: 38 additions & 6 deletions src/commands/spectrum/platforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ import { resolveProject } from "~/lib/api-context.ts";
import { SessionExpiredError } from "~/lib/errors.ts";
import { c, die, formatApiError, printJson, printTable } from "~/lib/output.ts";
import { requireBooleanRecord } from "~/lib/shape.ts";
import type {
PlatformToggleResult,
PlatformToggleWarning,
} from "~/lib/types.ts";

const IMESSAGE_CONNECTION_MISSING_WARNING: PlatformToggleWarning = {
code: "imessage_connection_missing",
message:
"iMessage was enabled without a connected phone. Add another phone or connect a dedicated line.",
};

function readPlatformToggleWarning(
value: unknown
): PlatformToggleWarning | undefined {
if (!(value && typeof value === "object")) return undefined;
const warning = value as { code?: unknown };
return warning.code === "imessage_connection_missing"
? IMESSAGE_CONNECTION_MISSING_WARNING
: undefined;
}

export function registerSpectrumPlatforms(spectrum: Command): void {
const platforms = spectrum
Expand Down Expand Up @@ -91,11 +111,10 @@ async function togglePlatform(
.platforms.toggle.post({ platformId: name, enabled });
if (status === 401) throw new SessionExpiredError(resolved.name);
if (error) die(`Failed to ${enabled ? "enable" : "disable"} ${name}: ${formatApiError(error)}`);
const result = data as {
success?: true;
platforms?: Record<string, boolean>;
error?: string;
};
if (!data) {
die("Server did not return a platform result.");
}
const result = data as PlatformToggleResult;
if (result.error) {
die(result.error, {
hint:
Expand All @@ -105,6 +124,19 @@ async function togglePlatform(
});
}

if (opts.json) return printJson(result.platforms ?? {});
const warning =
name === "imessage" && enabled
? readPlatformToggleWarning(result.warning)
: undefined;
if (opts.json) {
return printJson(
warning
? { platforms: result.platforms ?? {}, warning }
: (result.platforms ?? {}),
);
}
console.log(c.success(`${enabled ? "Enabled" : "Disabled"} ${c.bold(name)}`));
if (warning) {
console.error(c.warn(warning.message));
}
}
103 changes: 92 additions & 11 deletions src/commands/spectrum/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { SessionExpiredError } from "~/lib/errors.ts";
import { confirmDestructive } from "~/lib/interactive.ts";
import { c, die, formatApiError, printJson, printTable } from "~/lib/output.ts";
import { requireArrayField } from "~/lib/shape.ts";
import type {
SpectrumUser,
SpectrumUserAddFailure,
SpectrumUserAddFailureCode,
SpectrumUserAddResult,
} from "~/lib/types.ts";
import { isInteractive } from "~/lib/tty.ts";

export function registerSpectrumUsers(spectrum: Command): void {
Expand Down Expand Up @@ -92,15 +98,29 @@ export function registerSpectrumUsers(spectrum: Command): void {
sendInvite: opts.invite ?? false,
});
if (status === 401) throw new SessionExpiredError(resolved.name);
if (error) die(`Failed to add user: ${formatApiError(error)}`);
const result = data as { success?: true; user?: SpectrumUser; error?: string };
if (result.error) die(result.error);
if (error) failSpectrumUserAdd(error, opts.json ?? false);
const result = parseSpectrumUserAddResult(data);
if (!result) {
failSpectrumUserAdd(
"Server did not return a valid Spectrum user result.",
opts.json ?? false,
);
}
if ("error" in result) {
failSpectrumUserAdd(
{
code: "shared_user_create_failed",
message: result.error,
},
opts.json ?? false,
);
}

if (opts.json) return printJson(result.user ?? {});
if (opts.json) return printJson(result.user);
const u = result.user;
console.log(
c.success(
`Added ${formatName(u ?? filled)} ${u?.id ? c.dim(`(${u.id})`) : ""}`
`Added ${formatName(u)} ${c.dim(`(${u.id})`)}`
)
);
if (opts.invite) console.log(c.dim(" Invite sent."));
Expand Down Expand Up @@ -143,12 +163,73 @@ export function registerSpectrumUsers(spectrum: Command): void {
});
}

interface SpectrumUser {
id: string;
firstName?: string | null;
lastName?: string | null;
email?: string | null;
phoneNumber?: string | null;
function parseSpectrumUserAddResult(
value: unknown
): SpectrumUserAddResult | null {
if (!(value && typeof value === "object") || Array.isArray(value)) return null;
const result = value as Record<string, unknown>;
if (typeof result.error === "string") return { error: result.error };
if (
result.success !== true ||
!(result.user && typeof result.user === "object") ||
Array.isArray(result.user) ||
typeof (result.user as Record<string, unknown>).id !== "string"
) {
return null;
}
return { success: true, user: result.user as SpectrumUser };
}

function isSpectrumUserAddFailureCode(
value: unknown
): value is SpectrumUserAddFailureCode {
return (
value === "imessage_not_enabled" ||
value === "shared_line_unavailable" ||
value === "shared_user_create_failed"
);
}

function findStructuredFailure(error: unknown): SpectrumUserAddFailure | null {
const queue: unknown[] = [error];
const seen = new Set<object>();
while (queue.length > 0) {
const value = queue.shift();
if (!(value && typeof value === "object") || seen.has(value)) continue;
seen.add(value);
const record = value as Record<string, unknown>;
if (
isSpectrumUserAddFailureCode(record.code) &&
typeof record.message === "string"
) {
return { code: record.code, message: record.message };
}
for (const key of ["value", "message", "error", "cause"]) {
if (record[key] && typeof record[key] === "object") {
queue.push(record[key]);
}
}
}
return null;
}

const SHARED_LINE_UNAVAILABLE_MESSAGE =
"This phone couldn't be connected to a shared iMessage line. Try another phone or connect a dedicated line.";

function failSpectrumUserAdd(error: unknown, json: boolean): never {
const parsedFailure = findStructuredFailure(error) ?? {
code: "shared_user_create_failed",
message: formatApiError(error),
};
const failure =
parsedFailure.code === "shared_line_unavailable"
? { ...parsedFailure, message: SHARED_LINE_UNAVAILABLE_MESSAGE }
: parsedFailure;
if (json) {
printJson({ error: failure });
process.exit(1);
}
die(`Failed to add user: ${failure.message}`);
}

interface FilledAdd {
Expand Down
50 changes: 49 additions & 1 deletion src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,52 @@
* in command logic.
*/

export {};
export type ProjectCreateWarningCode =
| "owner_enrollment_failed"
| "owner_phone_missing"
| "shared_line_unavailable";

export interface ProjectCreateWarning {
code: ProjectCreateWarningCode;
message: string;
}

export interface ProjectCreateResult {
error?: string;
id?: string;
warning?: unknown;
}

export interface PlatformToggleWarning {
code: "imessage_connection_missing";
message: string;
}

export interface PlatformToggleResult {
error?: string;
platforms?: Record<string, boolean>;
success?: true;
warning?: unknown;
}

export type SpectrumUserAddFailureCode =
| "imessage_not_enabled"
| "shared_line_unavailable"
| "shared_user_create_failed";

export interface SpectrumUserAddFailure {
code: SpectrumUserAddFailureCode;
message: string;
}

export interface SpectrumUser {
email?: string | null;
firstName?: string | null;
id: string;
lastName?: string | null;
phoneNumber?: string | null;
}

export type SpectrumUserAddResult =
| { error: string }
| { success: true; user: SpectrumUser };
Loading
Loading