From 16b9f444f45c1e9c9174105284c3482a83a1f62b Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:45:25 -0700 Subject: [PATCH 1/3] fix(projects): omit platforms when unspecified so server default applies CLI `projects create` serialized `platforms: []` when --platforms was omitted, overriding the server's omitted-field default of ['imessage'] and creating a project with no platform enabled (SDK then 403s on connect). Make FilledCreate.platforms optional and send undefined (omitted) when the user specifies nothing, in both the non-interactive and interactive paths; the create body type already marks platforms optional. Adds unit coverage. DX-REPORT #25. --- src/commands/projects.ts | 17 ++++++++++++----- tests/unit/create-platforms.test.ts | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 tests/unit/create-platforms.test.ts diff --git a/src/commands/projects.ts b/src/commands/projects.ts index bef32b8..d150cc4 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -268,7 +268,10 @@ function registerCreateCommand(projects: Command): void { interface FilledCreate { name: string; location: string; - platforms: Platform[]; + // `undefined` when the user specified no platforms — the field is then omitted + // from the create body so the server applies its default (iMessage). Sending an + // empty `[]` instead would create a project with NO platform enabled. + platforms: Platform[] | undefined; template: boolean; observability: boolean; } @@ -292,7 +295,7 @@ function parsePlatforms(value: string): Platform[] { return [...new Set(parsed)] as Platform[]; } -async function fillCreateOpts(opts: CreateOpts): Promise { +export async function fillCreateOpts(opts: CreateOpts): Promise { // Non-interactive path: name is required; defaults fill the rest. if (!isInteractive()) { if (!opts.name?.trim()) { @@ -303,7 +306,9 @@ async function fillCreateOpts(opts: CreateOpts): Promise { return { name: opts.name.trim(), location: opts.location ?? "United States", - platforms: opts.platforms !== undefined ? parsePlatforms(opts.platforms) : [], + // No --platforms => omit (undefined), letting the server default to iMessage. + platforms: + opts.platforms !== undefined ? parsePlatforms(opts.platforms) : undefined, template: opts.template ?? false, observability: opts.observability ?? false, }; @@ -337,16 +342,18 @@ async function fillCreateOpts(opts: CreateOpts): Promise { location = answer || "United States"; } - const platforms = + const parsedPlatforms = opts.platforms !== undefined ? parsePlatforms(opts.platforms) : parsePlatforms( await promptText( - `Platforms (comma-separated: ${PLATFORMS.join(", ")})`, + `Platforms (comma-separated, blank = iMessage default: ${PLATFORMS.join(", ")})`, undefined, true ) ); + // Blank => omit so the server applies its default; sending [] disables all platforms. + const platforms = parsedPlatforms.length > 0 ? parsedPlatforms : undefined; const template = opts.template ?? (await promptBool("Use as template?", false)); const observability = opts.observability ?? (await promptBool("Enable observability?", false)); diff --git a/tests/unit/create-platforms.test.ts b/tests/unit/create-platforms.test.ts new file mode 100644 index 0000000..bda31a4 --- /dev/null +++ b/tests/unit/create-platforms.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { fillCreateOpts } from "~/commands/projects.ts"; + +// Regression for the `projects create` platform-default footgun: when no +// --platforms flag is given, the CLI must OMIT the field (undefined) so the +// server applies its default (iMessage). Previously it sent `[]`, which the +// server reads as "no platforms enabled" — creating an unusable project. +describe("fillCreateOpts platform defaulting", () => { + test("omits platforms (undefined) when --platforms is not given", async () => { + const filled = await fillCreateOpts({ name: "dx-test" }); + expect(filled.platforms).toBeUndefined(); + }); + + test("parses an explicit --platforms value", async () => { + const filled = await fillCreateOpts({ + name: "dx-test", + platforms: "imessage", + }); + expect(filled.platforms).toEqual(["imessage"]); + }); +}); From ef1b6f6e755ef29ad46bf85525b30ba91d5ff321 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:03:47 -0700 Subject: [PATCH 2/3] fix(projects): treat blank --platforms as omitted, harden create test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-interactive create path only guarded the *absent* --platforms flag; a blank or whitespace-only value (e.g. `--platforms ""` from a script whose $PLATFORMS var is unset) still parsed to `[]`, which the server reads as "disable every platform" — the exact footgun the platform-default fix exists to avoid. Fold that normalization into a shared resolvePlatformsFlag() used by both the interactive and non-interactive paths, so empty input always omits the field. Also make tests/unit/create-platforms.test.ts hermetic: it calls fillCreateOpts() in-process, and `bun test` from a real terminal attaches a TTY to stdin/stdout, sending the test into the interactive path where it blocked on a clack prompt. Pin the non-interactive path and add blank/whitespace edge-case coverage. --- src/commands/projects.ts | 48 ++++++++++++++++++++--------- tests/unit/create-platforms.test.ts | 48 +++++++++++++++++++++++------ 2 files changed, 72 insertions(+), 24 deletions(-) diff --git a/src/commands/projects.ts b/src/commands/projects.ts index d150cc4..964b273 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -295,6 +295,23 @@ function parsePlatforms(value: string): Platform[] { return [...new Set(parsed)] as Platform[]; } +/** + * Resolve the `--platforms` flag (or its interactive answer) to the create + * body's `platforms` field. Returns `undefined` — which omits the field so the + * server applies its default (iMessage) — whenever the user gave nothing + * usable: an absent flag, an empty string, or an all-whitespace / comma-only + * list. Only a non-empty parse yields an explicit array (which may + * intentionally narrow the enabled platforms). Empty input must never reach the + * request body as `[]`, since the server reads that as "disable every platform". + */ +function resolvePlatformsFlag(value: string | undefined): Platform[] | undefined { + if (value === undefined) { + return undefined; + } + const parsed = parsePlatforms(value); + return parsed.length > 0 ? parsed : undefined; +} + export async function fillCreateOpts(opts: CreateOpts): Promise { // Non-interactive path: name is required; defaults fill the rest. if (!isInteractive()) { @@ -306,9 +323,11 @@ export async function fillCreateOpts(opts: CreateOpts): Promise { return { name: opts.name.trim(), location: opts.location ?? "United States", - // No --platforms => omit (undefined), letting the server default to iMessage. - platforms: - opts.platforms !== undefined ? parsePlatforms(opts.platforms) : undefined, + // No (or blank) --platforms => omit (undefined) so the server defaults to + // iMessage. resolvePlatformsFlag also folds an empty/whitespace value + // (e.g. `--platforms ""` from a script with an unset var) to undefined, so + // it never sends the `[]` that would disable every platform. + platforms: resolvePlatformsFlag(opts.platforms), template: opts.template ?? false, observability: opts.observability ?? false, }; @@ -342,18 +361,17 @@ export async function fillCreateOpts(opts: CreateOpts): Promise { location = answer || "United States"; } - const parsedPlatforms = - opts.platforms !== undefined - ? parsePlatforms(opts.platforms) - : parsePlatforms( - await promptText( - `Platforms (comma-separated, blank = iMessage default: ${PLATFORMS.join(", ")})`, - undefined, - true - ) - ); - // Blank => omit so the server applies its default; sending [] disables all platforms. - const platforms = parsedPlatforms.length > 0 ? parsedPlatforms : undefined; + // A missing flag falls back to the prompt; a blank flag value, a blank prompt + // answer, or an all-whitespace list all normalize to undefined so the server + // applies its default — sending [] would instead disable every platform. + const platforms = resolvePlatformsFlag( + opts.platforms ?? + (await promptText( + `Platforms (comma-separated, blank = iMessage default: ${PLATFORMS.join(", ")})`, + undefined, + true + )) + ); const template = opts.template ?? (await promptBool("Use as template?", false)); const observability = opts.observability ?? (await promptBool("Enable observability?", false)); diff --git a/tests/unit/create-platforms.test.ts b/tests/unit/create-platforms.test.ts index bda31a4..e2991f6 100644 --- a/tests/unit/create-platforms.test.ts +++ b/tests/unit/create-platforms.test.ts @@ -1,21 +1,51 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { fillCreateOpts } from "~/commands/projects.ts"; // Regression for the `projects create` platform-default footgun: when no -// --platforms flag is given, the CLI must OMIT the field (undefined) so the -// server applies its default (iMessage). Previously it sent `[]`, which the -// server reads as "no platforms enabled" — creating an unusable project. -describe("fillCreateOpts platform defaulting", () => { - test("omits platforms (undefined) when --platforms is not given", async () => { +// usable --platforms value is given, the CLI must OMIT the field (undefined) so +// the server applies its default (iMessage). Previously it sent `[]`, which the +// server reads as "no platforms enabled" — creating an unusable project. Eden +// drops undefined-valued keys from the JSON body, so undefined == omitted. +// +// `fillCreateOpts` branches on `isInteractive()` (stdout AND stdin a TTY). Under +// `bun test` from a real terminal BOTH are TTYs, which would send this into the +// interactive path and block on a clack prompt. Pin the non-interactive path so +// the suite is hermetic wherever it runs (piped CI or an attached terminal). +const stdin = process.stdin as { isTTY?: boolean }; +const originalStdinTTY = stdin.isTTY; + +beforeAll(() => { + stdin.isTTY = false; +}); + +afterAll(() => { + stdin.isTTY = originalStdinTTY; +}); + +describe("fillCreateOpts platform defaulting (non-interactive)", () => { + test("omits platforms (undefined) when --platforms is absent", async () => { const filled = await fillCreateOpts({ name: "dx-test" }); expect(filled.platforms).toBeUndefined(); }); + test("treats a blank/whitespace/comma-only --platforms as omitted", async () => { + // These are the scripting footguns — e.g. `--platforms "$VAR"` with $VAR + // unset — that previously parsed to `[]` and disabled every platform. + for (const value of ["", " ", ",", ",,", " , "]) { + const filled = await fillCreateOpts({ name: "dx-test", platforms: value }); + expect(filled.platforms).toBeUndefined(); + } + }); + test("parses an explicit --platforms value", async () => { - const filled = await fillCreateOpts({ + const one = await fillCreateOpts({ name: "dx-test", platforms: "imessage" }); + expect(one.platforms).toEqual(["imessage"]); + + // Trailing/interior blanks are tolerated; order and dedupe preserved. + const many = await fillCreateOpts({ name: "dx-test", - platforms: "imessage", + platforms: " imessage , voice ,", }); - expect(filled.platforms).toEqual(["imessage"]); + expect(many.platforms).toEqual(["imessage", "voice"]); }); }); From 87763c762d54089710784b7b01a087f06c8dccf6 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:43:18 +0800 Subject: [PATCH 3/3] test(projects): verify platform omission at wire boundary --- src/commands/projects.ts | 30 ++---- tests/contract/projects.contract.test.ts | 112 ++++++++++++++++++++++- tests/helpers/mock-server.ts | 10 +- tests/unit/create-platforms.test.ts | 51 ----------- 4 files changed, 126 insertions(+), 77 deletions(-) delete mode 100644 tests/unit/create-platforms.test.ts diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 964b273..d7bfe59 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -268,9 +268,6 @@ function registerCreateCommand(projects: Command): void { interface FilledCreate { name: string; location: string; - // `undefined` when the user specified no platforms — the field is then omitted - // from the create body so the server applies its default (iMessage). Sending an - // empty `[]` instead would create a project with NO platform enabled. platforms: Platform[] | undefined; template: boolean; observability: boolean; @@ -295,16 +292,10 @@ function parsePlatforms(value: string): Platform[] { return [...new Set(parsed)] as Platform[]; } -/** - * Resolve the `--platforms` flag (or its interactive answer) to the create - * body's `platforms` field. Returns `undefined` — which omits the field so the - * server applies its default (iMessage) — whenever the user gave nothing - * usable: an absent flag, an empty string, or an all-whitespace / comma-only - * list. Only a non-empty parse yields an explicit array (which may - * intentionally narrow the enabled platforms). Empty input must never reach the - * request body as `[]`, since the server reads that as "disable every platform". - */ -function resolvePlatformsFlag(value: string | undefined): Platform[] | undefined { +/** Empty platform selections stay omitted so the API can apply its default. */ +function normalizeOptionalPlatforms( + value: string | undefined +): Platform[] | undefined { if (value === undefined) { return undefined; } @@ -312,7 +303,7 @@ function resolvePlatformsFlag(value: string | undefined): Platform[] | undefined return parsed.length > 0 ? parsed : undefined; } -export async function fillCreateOpts(opts: CreateOpts): Promise { +async function fillCreateOpts(opts: CreateOpts): Promise { // Non-interactive path: name is required; defaults fill the rest. if (!isInteractive()) { if (!opts.name?.trim()) { @@ -323,11 +314,7 @@ export async function fillCreateOpts(opts: CreateOpts): Promise { return { name: opts.name.trim(), location: opts.location ?? "United States", - // No (or blank) --platforms => omit (undefined) so the server defaults to - // iMessage. resolvePlatformsFlag also folds an empty/whitespace value - // (e.g. `--platforms ""` from a script with an unset var) to undefined, so - // it never sends the `[]` that would disable every platform. - platforms: resolvePlatformsFlag(opts.platforms), + platforms: normalizeOptionalPlatforms(opts.platforms), template: opts.template ?? false, observability: opts.observability ?? false, }; @@ -361,10 +348,7 @@ export async function fillCreateOpts(opts: CreateOpts): Promise { location = answer || "United States"; } - // A missing flag falls back to the prompt; a blank flag value, a blank prompt - // answer, or an all-whitespace list all normalize to undefined so the server - // applies its default — sending [] would instead disable every platform. - const platforms = resolvePlatformsFlag( + const platforms = normalizeOptionalPlatforms( opts.platforms ?? (await promptText( `Platforms (comma-separated, blank = iMessage default: ${PLATFORMS.join(", ")})`, diff --git a/tests/contract/projects.contract.test.ts b/tests/contract/projects.contract.test.ts index 6163f44..b1918e1 100644 --- a/tests/contract/projects.contract.test.ts +++ b/tests/contract/projects.contract.test.ts @@ -1,5 +1,17 @@ -import { describe, test, expect, beforeAll, afterAll } from "bun:test"; -import { startMockServer, stopMockServer } from "../helpers/mock-server.ts"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { + getMockProjectCreateRequests, + resetMockState, + startMockServer, + stopMockServer, +} from "../helpers/mock-server.ts"; import { runCommand } from "../helpers/cli-runner.ts"; let baseUrl: string; @@ -12,6 +24,102 @@ afterAll(async () => { await stopMockServer(); }); +beforeEach(() => { + resetMockState(); +}); + +describe("photon projects create", () => { + test("omits platforms from the request body when --platforms is absent", async () => { + const { exitCode } = await runCommand( + ["projects", "create", "--name", "DX 25", "--json"], + { + env: { + CI: "1", + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + + expect(exitCode).toBe(0); + expect(getMockProjectCreateRequests()).toEqual([ + { + name: "DX 25", + location: "United States", + template: false, + observability: false, + }, + ]); + }); + + test("omits platforms from the request body when --platforms is empty", async () => { + const emptyValues = ["", " ", ",", ",,", " , "]; + + for (const value of emptyValues) { + const { exitCode } = await runCommand( + [ + "projects", + "create", + "--name", + "DX 25", + "--platforms", + value, + "--json", + ], + { + env: { + CI: "1", + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + expect(exitCode).toBe(0); + } + + expect(getMockProjectCreateRequests()).toEqual( + emptyValues.map(() => ({ + name: "DX 25", + location: "United States", + template: false, + observability: false, + })), + ); + }); + + test("preserves explicit platforms in the request body", async () => { + const { exitCode } = await runCommand( + [ + "projects", + "create", + "--name", + "DX 25", + "--platforms", + " imessage , voice , imessage ,", + "--json", + ], + { + env: { + CI: "1", + PHOTON_TOKEN: "test-token", + PHOTON_API_HOST: baseUrl, + }, + }, + ); + + expect(exitCode).toBe(0); + expect(getMockProjectCreateRequests()).toEqual([ + { + name: "DX 25", + location: "United States", + platforms: ["imessage", "voice"], + template: false, + observability: false, + }, + ]); + }); +}); + describe("photon projects list", () => { test("lists project names from fixtures", async () => { const { stdout, exitCode } = await runCommand(["projects", "list"], { diff --git a/tests/helpers/mock-server.ts b/tests/helpers/mock-server.ts index 0455822..2b0af3d 100644 --- a/tests/helpers/mock-server.ts +++ b/tests/helpers/mock-server.ts @@ -39,6 +39,7 @@ interface MockState { lineAvatarResponseFault: "missing-avatar-url" | "missing-upload-key" | null; lineProfileRequests: MockLineProfileRequest[]; profileSyncRequests: MockProfileSyncRequest[]; + projectCreateRequests: Record[]; } export interface MockLineProfileRequest { @@ -70,6 +71,7 @@ const state: MockState = { lineAvatarResponseFault: null, lineProfileRequests: [], profileSyncRequests: [], + projectCreateRequests: [], }; export function setMockSubscription(sub: "free" | "active"): void { @@ -102,6 +104,10 @@ export function getMockLineProfileRequests(): MockLineProfileRequest[] { return state.lineProfileRequests.map((request) => ({ ...request })); } +export function getMockProjectCreateRequests(): Record[] { + return state.projectCreateRequests.map((request) => ({ ...request })); +} + export function resetMockState(): void { state.subscription = subscriptionFree; state.forceUnauthorized = false; @@ -110,6 +116,7 @@ export function resetMockState(): void { state.lineAvatarResponseFault = null; state.lineProfileRequests = []; state.profileSyncRequests = []; + state.projectCreateRequests = []; } function requireAuth(headers: Record) { @@ -292,7 +299,8 @@ const app = new Elysia() if (found) return found; return projectFixture; }) - .post("/api/projects", ({ headers }) => { + .post("/api/projects", ({ body, headers }) => { + state.projectCreateRequests.push({ ...(body as Record) }); const denied = requireAuth(headers as Record); if (denied) return denied; return { success: true, id: projectFixture.id }; diff --git a/tests/unit/create-platforms.test.ts b/tests/unit/create-platforms.test.ts deleted file mode 100644 index e2991f6..0000000 --- a/tests/unit/create-platforms.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { fillCreateOpts } from "~/commands/projects.ts"; - -// Regression for the `projects create` platform-default footgun: when no -// usable --platforms value is given, the CLI must OMIT the field (undefined) so -// the server applies its default (iMessage). Previously it sent `[]`, which the -// server reads as "no platforms enabled" — creating an unusable project. Eden -// drops undefined-valued keys from the JSON body, so undefined == omitted. -// -// `fillCreateOpts` branches on `isInteractive()` (stdout AND stdin a TTY). Under -// `bun test` from a real terminal BOTH are TTYs, which would send this into the -// interactive path and block on a clack prompt. Pin the non-interactive path so -// the suite is hermetic wherever it runs (piped CI or an attached terminal). -const stdin = process.stdin as { isTTY?: boolean }; -const originalStdinTTY = stdin.isTTY; - -beforeAll(() => { - stdin.isTTY = false; -}); - -afterAll(() => { - stdin.isTTY = originalStdinTTY; -}); - -describe("fillCreateOpts platform defaulting (non-interactive)", () => { - test("omits platforms (undefined) when --platforms is absent", async () => { - const filled = await fillCreateOpts({ name: "dx-test" }); - expect(filled.platforms).toBeUndefined(); - }); - - test("treats a blank/whitespace/comma-only --platforms as omitted", async () => { - // These are the scripting footguns — e.g. `--platforms "$VAR"` with $VAR - // unset — that previously parsed to `[]` and disabled every platform. - for (const value of ["", " ", ",", ",,", " , "]) { - const filled = await fillCreateOpts({ name: "dx-test", platforms: value }); - expect(filled.platforms).toBeUndefined(); - } - }); - - test("parses an explicit --platforms value", async () => { - const one = await fillCreateOpts({ name: "dx-test", platforms: "imessage" }); - expect(one.platforms).toEqual(["imessage"]); - - // Trailing/interior blanks are tolerated; order and dedupe preserved. - const many = await fillCreateOpts({ - name: "dx-test", - platforms: " imessage , voice ,", - }); - expect(many.platforms).toEqual(["imessage", "voice"]); - }); -});