From 0f79572d9f4ebd9d5bcd5c88e4a0f1c4842e9746 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Tue, 9 Jun 2026 06:48:37 -0400 Subject: [PATCH 1/4] feat: type local pin binding writes --- .agents/diary/better-result-error-handling.md | 1 + .../better-result-error-handling.plan.md | 6 +- docs/product/error-conventions.md | 2 + packages/cli/src/controllers/app.ts | 9 +- packages/cli/src/controllers/project.ts | 26 ++- packages/cli/src/lib/project/local-pin.ts | 221 +++++++++++++++--- packages/cli/src/lib/project/setup.ts | 87 +++++-- packages/cli/tests/app-controller.test.ts | 47 ++++ packages/cli/tests/project-controller.test.ts | 59 ++++- packages/cli/tests/project.test.ts | 26 +++ 10 files changed, 434 insertions(+), 50 deletions(-) create mode 100644 .agents/diary/better-result-error-handling.md diff --git a/.agents/diary/better-result-error-handling.md b/.agents/diary/better-result-error-handling.md new file mode 100644 index 0000000..85f95d1 --- /dev/null +++ b/.agents/diary/better-result-error-handling.md @@ -0,0 +1 @@ +Unrelated bug or code smell: Full package typecheck is blocked by existing errors outside the phase two local-pin write scope, including `branch.ts`, `branch-database.ts`, test helper typings, project real-mode mock call signatures, and missing declarations for script imports. I fixed the phase-two type issue and left the unrelated failures untouched. diff --git a/.agents/projects/better-result-error-handling.plan.md b/.agents/projects/better-result-error-handling.plan.md index edc4d3f..e4a5a73 100644 --- a/.agents/projects/better-result-error-handling.plan.md +++ b/.agents/projects/better-result-error-handling.plan.md @@ -16,7 +16,7 @@ None. ### Phase 1: Foundation And Local Pin Read -**Status:** ◐ Implemented; targeted tests pass, repo typecheck blocked by unrelated existing errors +**Status:** ✓ Complete **Goal:** Add the dependency and prove typed expected failures on the smallest read-only project context slice. @@ -43,7 +43,7 @@ None. ### Phase 2: Local Pin Write And Directory Binding -**Status:** ☐ Not started +**Status:** ✓ Complete; full package typecheck blocked by unrelated existing errors **Goal:** Complete the local-pin call stack by typing write and gitignore update failures used by project binding. @@ -510,3 +510,5 @@ None. - `pnpm build:cli` passes if command runner, package metadata, or build-facing code changed in the phase. ## Revision Log + +- 2026-06-09: Phase 2 added `LOCAL_STATE_WRITE_FAILED` to the product error conventions because local Project binding write failures need a stable structured error code before controller-facing conversion. diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 19a3f5d..69adb66 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -167,6 +167,7 @@ These codes are the minimum stable set for the MVP: - `PROJECT_AMBIGUOUS` - `APP_AMBIGUOUS` - `LOCAL_PROJECT_WORKSPACE_MISMATCH` +- `LOCAL_STATE_WRITE_FAILED` - `LOCAL_STATE_STALE` - `BRANCH_NOT_DEPLOYABLE` - `APP_CONFIG_INVALID` @@ -219,6 +220,7 @@ Recommended meanings: - `PROJECT_AMBIGUOUS`: multiple safe project candidates matched - `APP_AMBIGUOUS`: multiple apps matched the inferred or explicit app target - `LOCAL_PROJECT_WORKSPACE_MISMATCH`: local Project pin points at a different workspace than the active authenticated workspace; callers should sign in to the linked workspace or relink the directory +- `LOCAL_STATE_WRITE_FAILED`: the CLI could not save local Project binding state such as `.prisma/local.json` or the matching `.gitignore` entry; callers should fix directory permissions or filesystem state before retrying - `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous - `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context - `APP_CONFIG_INVALID`: `prisma.app.json` is missing required build settings, has invalid JSON, or points outside the app root diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 7940609..cfe1de8 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -63,6 +63,7 @@ import { bindProjectToDirectory, formatCommandArgument, projectCreateFailedError, + projectDirectoryBindingErrorToCliError, projectSetupNameRequiredError, resolveProjectForSetup, toProjectSummary, @@ -275,8 +276,12 @@ export async function runAppDeploy( target.project, target.localPinAction, ); - localPinResult = setupResult.localPin; - maybeRenderProjectLinked(context, setupResult.directory, setupResult.project.name, setupResult.localPin.path); + if (setupResult.isErr()) { + throw projectDirectoryBindingErrorToCliError(setupResult.error); + } + const projectSetup = setupResult.value; + localPinResult = projectSetup.localPin; + maybeRenderProjectLinked(context, projectSetup.directory, projectSetup.project.name, projectSetup.localPin.path); } let framework = await resolveDeployFramework(context, { diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 48e3631..1dd6fb7 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -24,6 +24,7 @@ import { formatCommandArgument, isValidProjectSetupName, projectCreateFailedError, + projectDirectoryBindingErrorToCliError, projectSetupNameRequiredError, resolveProjectForSetup, toProjectSummary, @@ -40,6 +41,7 @@ import type { ProjectRepositoryConnectionResult, ProjectSetupResult, ProjectShowResult, + ProjectSummary, } from "../types/project"; import { createCliUseCaseGateways } from "../use-cases/create-cli-gateways"; import { createProjectUseCases } from "../use-cases/project"; @@ -218,10 +220,14 @@ export async function runProjectCreate( fallbackFix: "Retry the command, or choose an existing Project with prisma-cli project link .", }); }); - const result = await bindProjectToDirectory(context, workspace, { + const bindResult = await bindProjectToDirectory(context, workspace, { id: created.id, name: created.name, }, "created"); + if (bindResult.isErr()) { + throw projectDirectoryBindingErrorToCliError(bindResult.error); + } + const result = bindResult.value; return { command: "project.create", @@ -257,7 +263,7 @@ export async function runProjectLink( let result: ProjectSetupResult; if (projectRef?.trim()) { const project = resolveProjectForSetup(projectRef.trim(), projects, workspace); - result = await bindProjectToDirectory(context, workspace, toProjectSummary(project), "linked"); + result = await requireProjectDirectoryBinding(context, workspace, toProjectSummary(project), "linked"); } else if (canPrompt(context) && !context.flags.yes) { result = await resolveInteractiveProjectLinkSetup( context, @@ -305,7 +311,21 @@ async function resolveInteractiveProjectLinkSetup( }, }); - return bindProjectToDirectory(context, workspace, setup.project, setup.action); + return requireProjectDirectoryBinding(context, workspace, setup.project, setup.action); +} + +async function requireProjectDirectoryBinding( + context: CommandContext, + workspace: AuthWorkspace, + project: ProjectSummary, + action: ProjectSetupResult["action"], +): Promise { + const bindResult = await bindProjectToDirectory(context, workspace, project, action); + if (bindResult.isErr()) { + throw projectDirectoryBindingErrorToCliError(bindResult.error); + } + + return bindResult.value; } async function createProjectForLinkSetup( diff --git a/packages/cli/src/lib/project/local-pin.ts b/packages/cli/src/lib/project/local-pin.ts index 2982907..4126d83 100644 --- a/packages/cli/src/lib/project/local-pin.ts +++ b/packages/cli/src/lib/project/local-pin.ts @@ -66,6 +66,99 @@ export type LocalResolutionPinReadError = | LocalResolutionPinReadAbortedError | UnhandledException; +export class LocalResolutionPinSerializationError extends TaggedError( + "LocalResolutionPinSerializationError", +)<{ + message: string; + cause: unknown; + pinPath: string; +}>() { + constructor(cause: unknown) { + super({ + message: `Could not serialize ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH}.`, + cause, + pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + }); + } +} + +export class LocalResolutionPinWriteAbortedError extends TaggedError( + "LocalResolutionPinWriteAbortedError", +)<{ + message: string; + cause: unknown; + pinPath: string; +}>() { + constructor(cause: unknown) { + super({ + message: `Writing ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} was aborted.`, + cause, + pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + }); + } +} + +export class LocalResolutionPinWriteFailedError extends TaggedError( + "LocalResolutionPinWriteFailedError", +)<{ + message: string; + cause: unknown; + operation: "create-directory" | "write-temp-file" | "rename-temp-file"; + pinPath: string; +}>() { + constructor(operation: "create-directory" | "write-temp-file" | "rename-temp-file", cause: unknown) { + super({ + message: `Could not write ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH}.`, + cause, + operation, + pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + }); + } +} + +export type LocalResolutionPinWriteError = + | LocalResolutionPinSerializationError + | LocalResolutionPinWriteAbortedError + | LocalResolutionPinWriteFailedError; + +export class LocalResolutionPinGitignoreUpdateAbortedError extends TaggedError( + "LocalResolutionPinGitignoreUpdateAbortedError", +)<{ + message: string; + cause: unknown; + gitignorePath: string; +}>() { + constructor(cause: unknown) { + super({ + message: "Updating .gitignore for the local Project binding was aborted.", + cause, + gitignorePath: ".gitignore", + }); + } +} + +export class LocalResolutionPinGitignoreUpdateFailedError extends TaggedError( + "LocalResolutionPinGitignoreUpdateFailedError", +)<{ + message: string; + cause: unknown; + operation: "read" | "write"; + gitignorePath: string; +}>() { + constructor(operation: "read" | "write", cause: unknown) { + super({ + message: "Could not update .gitignore for the local Project binding.", + cause, + operation, + gitignorePath: ".gitignore", + }); + } +} + +export type LocalResolutionPinGitignoreUpdateError = + | LocalResolutionPinGitignoreUpdateAbortedError + | LocalResolutionPinGitignoreUpdateFailedError; + export async function readLocalResolutionPin( cwd: string, signal?: AbortSignal, @@ -138,44 +231,69 @@ export async function writeLocalResolutionPin( cwd: string, pin: LocalResolutionPin, signal?: AbortSignal, -): Promise { - const prismaDir = path.join(cwd, ".prisma"); - signal?.throwIfAborted(); - // mkdir does not accept AbortSignal; check before the filesystem boundary. - await mkdir(prismaDir, { recursive: true }); - const pinPath = path.join(cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH); - const tmpPath = path.join( - prismaDir, - `local.${process.pid}.${Date.now()}.tmp`, - ); - await writeFile(tmpPath, `${JSON.stringify(pin, null, 2)}\n`, { - encoding: "utf8", - signal, +): Promise> { + return Result.gen(async function* () { + const prismaDir = path.join(cwd, ".prisma"); + yield* ensureLocalResolutionPinWriteNotAborted(signal); + // mkdir does not accept AbortSignal; check before the filesystem boundary. + yield* Result.await(writeLocalResolutionPinBoundary( + () => mkdir(prismaDir, { recursive: true }), + "create-directory", + signal, + )); + const pinPath = path.join(cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH); + const tmpPath = path.join( + prismaDir, + `local.${process.pid}.${Date.now()}.tmp`, + ); + const serialized = yield* serializeLocalResolutionPin(pin); + yield* Result.await(writeLocalResolutionPinBoundary( + () => writeFile(tmpPath, serialized, { encoding: "utf8", signal }), + "write-temp-file", + signal, + )); + yield* ensureLocalResolutionPinWriteNotAborted(signal); + // rename does not accept AbortSignal; check before the filesystem boundary. + yield* Result.await(writeLocalResolutionPinBoundary( + () => rename(tmpPath, pinPath), + "rename-temp-file", + signal, + )); + + return Result.ok(undefined); }); - signal?.throwIfAborted(); - // rename does not accept AbortSignal; check before the filesystem boundary. - await rename(tmpPath, pinPath); } export async function ensureLocalResolutionPinGitignore( cwd: string, signal?: AbortSignal, -): Promise { +): Promise> { const gitignorePath = path.join(cwd, ".gitignore"); let existing: string | null = null; - signal?.throwIfAborted(); - try { - existing = await readFile(gitignorePath, { encoding: "utf8", signal }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; + const notAborted = ensureLocalResolutionPinGitignoreUpdateNotAborted(signal); + if (notAborted.isErr()) { + return Result.err(notAborted.error); + } + + const existingResult = await Result.tryPromise({ + try: () => readFile(gitignorePath, { encoding: "utf8", signal }), + catch: (cause) => signal?.aborted + ? new LocalResolutionPinGitignoreUpdateAbortedError(cause) + : new LocalResolutionPinGitignoreUpdateFailedError("read", cause), + }); + if (existingResult.isErr()) { + if (existingResult.error instanceof LocalResolutionPinGitignoreUpdateFailedError && (existingResult.error.cause as NodeJS.ErrnoException).code === "ENOENT") { + existing = null; + } else { + return Result.err(existingResult.error); } + } else { + existing = existingResult.value; } if (existing === null) { - await writeFile(gitignorePath, ".prisma/\n", { encoding: "utf8", signal }); - return; + return writeLocalResolutionPinGitignore(gitignorePath, ".prisma/\n", signal); } const hasPrismaIgnore = existing @@ -183,13 +301,62 @@ export async function ensureLocalResolutionPinGitignore( .map((line) => line.trim()) .some((line) => line === ".prisma/" || line === ".prisma/local.json"); if (hasPrismaIgnore) { - return; + return Result.ok(undefined); } const next = existing.endsWith("\n") ? `${existing}.prisma/\n` : `${existing}\n.prisma/\n`; - await writeFile(gitignorePath, next, { encoding: "utf8", signal }); + return writeLocalResolutionPinGitignore(gitignorePath, next, signal); +} + +function ensureLocalResolutionPinWriteNotAborted(signal: AbortSignal | undefined): Result { + return Result.try({ + try: () => signal?.throwIfAborted(), + catch: (cause) => new LocalResolutionPinWriteAbortedError(cause), + }); +} + +function serializeLocalResolutionPin(pin: LocalResolutionPin): Result { + return Result.try({ + try: () => `${JSON.stringify(pin, null, 2)}\n`, + catch: (cause) => new LocalResolutionPinSerializationError(cause), + }); +} + +function writeLocalResolutionPinBoundary( + run: () => Promise, + operation: "create-directory" | "write-temp-file" | "rename-temp-file", + signal: AbortSignal | undefined, +): Promise> { + return Result.tryPromise({ + try: async () => { + await run(); + }, + catch: (cause) => signal?.aborted + ? new LocalResolutionPinWriteAbortedError(cause) + : new LocalResolutionPinWriteFailedError(operation, cause), + }); +} + +function ensureLocalResolutionPinGitignoreUpdateNotAborted(signal: AbortSignal | undefined): Result { + return Result.try({ + try: () => signal?.throwIfAborted(), + catch: (cause) => new LocalResolutionPinGitignoreUpdateAbortedError(cause), + }); +} + +function writeLocalResolutionPinGitignore( + gitignorePath: string, + contents: string, + signal: AbortSignal | undefined, +): Promise> { + return Result.tryPromise({ + try: () => writeFile(gitignorePath, contents, { encoding: "utf8", signal }), + catch: (cause) => signal?.aborted + ? new LocalResolutionPinGitignoreUpdateAbortedError(cause) + : new LocalResolutionPinGitignoreUpdateFailedError("write", cause), + }); } function isLocalResolutionPin(value: unknown): value is LocalResolutionPin { diff --git a/packages/cli/src/lib/project/setup.ts b/packages/cli/src/lib/project/setup.ts index a1c54b7..b93f4e6 100644 --- a/packages/cli/src/lib/project/setup.ts +++ b/packages/cli/src/lib/project/setup.ts @@ -1,10 +1,13 @@ import type { AuthWorkspace } from "../../types/auth"; import type { ProjectSetupResult, ProjectSummary } from "../../types/project"; +import { Result, matchError } from "better-result"; import { CliError, usageError } from "../../shell/errors"; import type { CommandContext } from "../../shell/runtime"; import { ensureLocalResolutionPinGitignore, LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + type LocalResolutionPinGitignoreUpdateError, + type LocalResolutionPinWriteError, writeLocalResolutionPin, } from "./local-pin"; import { @@ -14,6 +17,10 @@ import { } from "./resolution"; export { formatCommandArgument } from "../../shell/command-arguments"; +export type ProjectDirectoryBindingError = + | LocalResolutionPinWriteError + | LocalResolutionPinGitignoreUpdateError; + export function isValidProjectSetupName(projectName: string): boolean { return projectName.trim().length > 0; } @@ -46,23 +53,73 @@ export async function bindProjectToDirectory( workspace: AuthWorkspace, project: ProjectSummary, action: ProjectSetupResult["action"], -): Promise { - await writeLocalResolutionPin(context.runtime.cwd, { - workspaceId: workspace.id, - projectId: project.id, - }, context.runtime.signal); - await ensureLocalResolutionPinGitignore(context.runtime.cwd, context.runtime.signal); +): Promise> { + return Result.gen(async function* () { + yield* Result.await(writeLocalResolutionPin(context.runtime.cwd, { + workspaceId: workspace.id, + projectId: project.id, + }, context.runtime.signal)); + yield* Result.await(ensureLocalResolutionPinGitignore(context.runtime.cwd, context.runtime.signal)); + + return Result.ok({ + workspace, + project, + directory: formatSetupDirectory(context.runtime.cwd), + localPin: { + path: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + written: true, + }, + action, + } satisfies ProjectSetupResult); + }); +} - return { - workspace, - project, - directory: formatSetupDirectory(context.runtime.cwd), - localPin: { - path: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, - written: true, +export function projectDirectoryBindingErrorToCliError(error: ProjectDirectoryBindingError): CliError { + return matchError(error, { + LocalResolutionPinSerializationError: (error) => localStateWriteFailedError(error, { + why: `The CLI could not serialize the Project binding before writing ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH}.`, + meta: { + pinPath: error.pinPath, + }, + }), + LocalResolutionPinWriteAbortedError: (error) => { + throw error; }, - action, - }; + LocalResolutionPinWriteFailedError: (error) => localStateWriteFailedError(error, { + why: `The CLI could not write ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH}.`, + meta: { + pinPath: error.pinPath, + operation: error.operation, + }, + }), + LocalResolutionPinGitignoreUpdateAbortedError: (error) => { + throw error; + }, + LocalResolutionPinGitignoreUpdateFailedError: (error) => localStateWriteFailedError(error, { + why: "The CLI could not update .gitignore to keep local Project binding state out of git.", + meta: { + gitignorePath: error.gitignorePath, + operation: error.operation, + }, + }), + }); +} + +function localStateWriteFailedError( + error: ProjectDirectoryBindingError, + options: { why: string; meta: Record }, +): CliError { + return new CliError({ + code: "LOCAL_STATE_WRITE_FAILED", + domain: "project", + summary: "Could not save local Project binding", + why: options.why, + fix: "Check that this directory is writable and that .prisma/local.json and .gitignore are not blocked by directories or permissions, then retry.", + debug: formatDebugDetails(error.cause), + meta: options.meta, + exitCode: 1, + nextSteps: ["prisma-cli project link ", "prisma-cli app deploy --project "], + }); } export function toProjectSummary(project: Pick): ProjectSummary { diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 0bc9fd4..ea8eb6b 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -1210,6 +1210,53 @@ describe("app controller", () => { await expect(readFile(path.join(cwd, ".gitignore"), "utf8")).resolves.toBe(".prisma/\n"); }); + it("returns LOCAL_STATE_WRITE_FAILED when deploy cannot store the local binding", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient("proj_my_app")); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => withBranchDatabaseProviderDefaults({ + resolveBranch: createResolveBranch(), + listApps: vi.fn(), + deployApp: vi.fn(), + listDeployments: vi.fn(), + showDeployment: vi.fn(), + })), + })); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + await mkdir(path.join(cwd, ".gitignore"), { recursive: true }); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + isTTY: false, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect(runAppDeploy(context, undefined, { + projectRef: "proj_my_app", + })).rejects.toMatchObject({ + code: "LOCAL_STATE_WRITE_FAILED", + domain: "project", + meta: { + gitignorePath: ".gitignore", + operation: "read", + }, + }); + await expect(readLocalPin(cwd)).resolves.toEqual({ + workspaceId: "ws_123", + projectId: "proj_my_app", + }); + }); + it("uses existing prisma.app.json deploy settings", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ diff --git a/packages/cli/tests/project-controller.test.ts b/packages/cli/tests/project-controller.test.ts index 156a252..e2a2117 100644 --- a/packages/cli/tests/project-controller.test.ts +++ b/packages/cli/tests/project-controller.test.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -90,6 +90,63 @@ describe("project controller", () => { await expect(readFile(path.join(cwd, ".gitignore"), "utf8")).resolves.toBe(".prisma/\n"); }); + it("returns LOCAL_STATE_WRITE_FAILED when the local pin cannot be written", async () => { + const cwd = await createTempCwd(); + await mkdir(path.join(cwd, ".prisma", "local.json"), { recursive: true }); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + fixturePath, + isTTY: false, + }); + + await context.stateStore.setAuthSession({ + provider: "github", + userId: "usr_456", + workspaceId: "ws_123", + }); + + const { runProjectLink } = await import("../src/controllers/project"); + await expect(runProjectLink(context, "proj_123")).rejects.toMatchObject({ + code: "LOCAL_STATE_WRITE_FAILED", + domain: "project", + meta: { + pinPath: ".prisma/local.json", + operation: "rename-temp-file", + }, + }); + }); + + it("returns LOCAL_STATE_WRITE_FAILED when gitignore cannot be updated", async () => { + const cwd = await createTempCwd(); + await mkdir(path.join(cwd, ".gitignore"), { recursive: true }); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + fixturePath, + isTTY: false, + }); + + await context.stateStore.setAuthSession({ + provider: "github", + userId: "usr_456", + workspaceId: "ws_123", + }); + + const { runProjectLink } = await import("../src/controllers/project"); + await expect(runProjectLink(context, "proj_123")).rejects.toMatchObject({ + code: "LOCAL_STATE_WRITE_FAILED", + domain: "project", + meta: { + gitignorePath: ".gitignore", + operation: "read", + }, + }); + await expect(readFile(path.join(cwd, ".prisma/local.json"), "utf8")).resolves.toContain('"projectId": "proj_123"'); + }); + it("creates a project and writes the local pin", async () => { const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); const createProject = vi.fn().mockResolvedValue({ diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index f50dcbb..bf24652 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -195,6 +195,32 @@ describe("project commands", () => { await expect(readFile(path.join(cwd, ".prisma/local.json"), "utf8")).resolves.toContain('"projectId": "proj_123"'); }); + it("returns LOCAL_STATE_WRITE_FAILED when project link cannot save the local pin", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await mkdir(path.join(cwd, ".prisma", "local.json"), { recursive: true }); + await login(cwd, stateDir); + + const result = await executeCli({ + argv: ["project", "link", "proj_123", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(""); + expect(payload.error).toMatchObject({ + code: "LOCAL_STATE_WRITE_FAILED", + domain: "project", + meta: { + pinPath: ".prisma/local.json", + operation: "rename-temp-file", + }, + }); + }); + it("disambiguates duplicate Project names in the bare project link picker", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); From 5bf650b3a3543a6058c07547edcbcd2bf3d88acf Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Tue, 9 Jun 2026 06:53:54 -0400 Subject: [PATCH 2/4] chore: drop phase diary entry --- .agents/diary/better-result-error-handling.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .agents/diary/better-result-error-handling.md diff --git a/.agents/diary/better-result-error-handling.md b/.agents/diary/better-result-error-handling.md deleted file mode 100644 index 85f95d1..0000000 --- a/.agents/diary/better-result-error-handling.md +++ /dev/null @@ -1 +0,0 @@ -Unrelated bug or code smell: Full package typecheck is blocked by existing errors outside the phase two local-pin write scope, including `branch.ts`, `branch-database.ts`, test helper typings, project real-mode mock call signatures, and missing declarations for script imports. I fixed the phase-two type issue and left the unrelated failures untouched. From 7f232f66a90ceff93e60de4c97488946dfbfeb99 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Tue, 9 Jun 2026 06:55:02 -0400 Subject: [PATCH 3/4] chore: mark binding error bridge temporary --- packages/cli/src/lib/project/setup.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/lib/project/setup.ts b/packages/cli/src/lib/project/setup.ts index b93f4e6..0e5446a 100644 --- a/packages/cli/src/lib/project/setup.ts +++ b/packages/cli/src/lib/project/setup.ts @@ -75,6 +75,7 @@ export async function bindProjectToDirectory( } export function projectDirectoryBindingErrorToCliError(error: ProjectDirectoryBindingError): CliError { + // Temporary during the migration to better-result: remove when command boundaries convert Result errors directly. return matchError(error, { LocalResolutionPinSerializationError: (error) => localStateWriteFailedError(error, { why: `The CLI could not serialize the Project binding before writing ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH}.`, From 39c6029062d293bc481d734772ce719f6822a934 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Tue, 9 Jun 2026 07:25:21 -0400 Subject: [PATCH 4/4] fix: rethrow local pin serialization errors --- packages/cli/src/lib/project/setup.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/lib/project/setup.ts b/packages/cli/src/lib/project/setup.ts index 0e5446a..dc7e73a 100644 --- a/packages/cli/src/lib/project/setup.ts +++ b/packages/cli/src/lib/project/setup.ts @@ -77,12 +77,9 @@ export async function bindProjectToDirectory( export function projectDirectoryBindingErrorToCliError(error: ProjectDirectoryBindingError): CliError { // Temporary during the migration to better-result: remove when command boundaries convert Result errors directly. return matchError(error, { - LocalResolutionPinSerializationError: (error) => localStateWriteFailedError(error, { - why: `The CLI could not serialize the Project binding before writing ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH}.`, - meta: { - pinPath: error.pinPath, - }, - }), + LocalResolutionPinSerializationError: (error) => { + throw error; + }, LocalResolutionPinWriteAbortedError: (error) => { throw error; },