Skip to content
Closed
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ photon
├── projects
│ ├── ls list projects
│ ├── show [id] project detail
│ ├── create [--name <n> --location <loc> --spectrum] new project
│ ├── create [--name <n> --location <loc> --platforms <list>]
│ │ new project; no platform when omitted
│ ├── update [id] [...] rename / toggle flags
│ ├── delete [id] [-y] permanent delete
│ ├── regenerate-secret [id] [-y] rotate Spectrum secret
Expand Down
116 changes: 84 additions & 32 deletions src/commands/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,74 @@ 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>;

const OWNER_STATUS_WARNING_CODES = {
skipped_no_phone: "owner_phone_missing",
skipped_pool_exhausted: "shared_line_unavailable",
failed: "owner_enrollment_failed",
} as const satisfies Record<string, ProjectCreateWarningCode>;

type OwnerWarningStatus = keyof typeof OWNER_STATUS_WARNING_CODES;

function isOwnerWarningStatus(value: unknown): value is OwnerWarningStatus {
return typeof value === "string" && value in OWNER_STATUS_WARNING_CODES;
}
Comment on lines +60 to +62

function isProjectCreateWarningCode(
value: unknown
): value is ProjectCreateWarningCode {
return (
typeof value === "string" && value in PROJECT_CREATE_WARNINGS
);
Comment on lines +60 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use own-property checks for warning codes.

in accepts inherited keys such as "constructor" and "__proto__". A malformed API warning can pass isProjectCreateWarningCode, and readProjectCreateWarning can return a prototype member instead of a ProjectCreateWarning. Use an own-property check in both predicates.

Proposed fix
 function isOwnerWarningStatus(value: unknown): value is OwnerWarningStatus {
-  return typeof value === "string" && value in OWNER_STATUS_WARNING_CODES;
+  return (
+    typeof value === "string" &&
+    Object.prototype.hasOwnProperty.call(OWNER_STATUS_WARNING_CODES, value)
+  );
 }

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function isOwnerWarningStatus(value: unknown): value is OwnerWarningStatus {
return typeof value === "string" && value in OWNER_STATUS_WARNING_CODES;
}
function isProjectCreateWarningCode(
value: unknown
): value is ProjectCreateWarningCode {
return (
typeof value === "string" && value in PROJECT_CREATE_WARNINGS
);
function isOwnerWarningStatus(value: unknown): value is OwnerWarningStatus {
return (
typeof value === "string" &&
Object.prototype.hasOwnProperty.call(OWNER_STATUS_WARNING_CODES, value)
);
}
function isProjectCreateWarningCode(
value: unknown
): value is ProjectCreateWarningCode {
return (
typeof value === "string" &&
Object.prototype.hasOwnProperty.call(PROJECT_CREATE_WARNINGS, value)
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/projects.ts` around lines 60 - 69, Update isOwnerWarningStatus
and isProjectCreateWarningCode to validate warning-code membership using an
own-property check on OWNER_STATUS_WARNING_CODES and PROJECT_CREATE_WARNINGS
rather than the in operator, so inherited keys such as constructor and __proto__
are rejected.

}

function readProjectCreateWarning(result: {
ownerStatus?: unknown;
warning?: unknown;
}): ProjectCreateWarning | undefined {
if (result.warning && typeof result.warning === "object") {
const warning = result.warning as { code?: unknown; message?: unknown };
if (
isProjectCreateWarningCode(warning.code) &&
typeof warning.message === "string"
) {
return PROJECT_CREATE_WARNINGS[warning.code];
}
}
if (!isOwnerWarningStatus(result.ownerStatus)) return undefined;
return PROJECT_CREATE_WARNINGS[
OWNER_STATUS_WARNING_CODES[result.ownerStatus]
];
}

export function registerProjectsCommand(program: Command): void {
const projects = program
.command("projects")
Expand Down Expand Up @@ -216,7 +278,10 @@ function registerCreateCommand(projects: Command): void {
.description("create a new project")
.option("-n, --name <name>", "project name")
.option("-l, --location <location>", 'location (default: "United States")')
.option("--platforms <list>", `comma-separated platforms (${PLATFORMS.join(", ")})`)
.option(
"--platforms <list>",
`comma-separated platforms (${PLATFORMS.join(", ")}); omit to enable none`
)
.option("--template", "use as template")
.option("--observability", "enable observability")
.option("--api-host <url>", "API host URL (defaults to PHOTON_API_HOST or built-in production)")
Expand All @@ -241,16 +306,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 +344,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 Expand Up @@ -304,6 +381,9 @@ function parsePlatforms(value: string): Platform[] {
}

async function fillCreateOpts(opts: CreateOpts): Promise<FilledCreate> {
const platforms =
opts.platforms !== undefined ? parsePlatforms(opts.platforms) : [];

// Non-interactive path: name is required; defaults fill the rest.
if (!isInteractive()) {
if (!opts.name?.trim()) {
Expand All @@ -314,7 +394,7 @@ async function fillCreateOpts(opts: CreateOpts): Promise<FilledCreate> {
return {
name: opts.name.trim(),
location: opts.location ?? "United States",
platforms: opts.platforms !== undefined ? parsePlatforms(opts.platforms) : [],
platforms,
template: opts.template ?? false,
observability: opts.observability ?? false,
};
Expand Down Expand Up @@ -348,16 +428,6 @@ async function fillCreateOpts(opts: CreateOpts): Promise<FilledCreate> {
location = answer || "United States";
}

const platforms =
opts.platforms !== undefined
? parsePlatforms(opts.platforms)
: parsePlatforms(
await promptText(
`Platforms (comma-separated: ${PLATFORMS.join(", ")})`,
undefined,
true
)
);
const template = opts.template ?? (await promptBool("Use as template?", false));
const observability =
opts.observability ?? (await promptBool("Enable observability?", false));
Expand All @@ -366,24 +436,6 @@ async function fillCreateOpts(opts: CreateOpts): Promise<FilledCreate> {
return { name, location, platforms, template, observability };
}

/**
* Free-text prompt. When `optional`, an empty answer is allowed and
* returns "". Aborts on cancel.
*/
async function promptText(
message: string,
preset?: string,
optional = false
): Promise<string> {
if (preset !== undefined) return preset;
const answer = await text({
message,
placeholder: optional ? "(skip)" : undefined,
});
if (isCancel(answer)) die("Aborted.");
return answer ?? "";
}

async function promptBool(message: string, initial: boolean): Promise<boolean> {
const answer = await clackConfirm({ message, initialValue: initial });
if (isCancel(answer)) die("Aborted.");
Expand Down
41 changes: 35 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,7 @@ 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;
};
const result = data as PlatformToggleResult;
if (result.error) {
die(result.error, {
hint:
Expand All @@ -105,6 +121,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));
}
}
81 changes: 78 additions & 3 deletions src/commands/spectrum/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ 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 {
SpectrumUserAddFailure,
SpectrumUserAddFailureCode,
} from "~/lib/types.ts";
import { isInteractive } from "~/lib/tty.ts";

export function registerSpectrumUsers(spectrum: Command): void {
Expand Down Expand Up @@ -92,9 +96,27 @@ 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);
if (!data) {
failSpectrumUserAdd(
"Server did not return a Spectrum user result.",
opts.json ?? false,
);
}
const result = data as {
success?: true;
user?: SpectrumUser;
error?: string;
};
if (result.error) {
failSpectrumUserAdd(
{
code: "shared_user_create_failed",
message: result.error,
},
opts.json ?? false,
);
}

if (opts.json) return printJson(result.user ?? {});
const u = result.user;
Expand Down Expand Up @@ -151,6 +173,59 @@ interface SpectrumUser {
phoneNumber?: string | null;
}

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

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 {
firstName: string;
lastName: string;
Expand Down
Loading
Loading