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
6 changes: 4 additions & 2 deletions .agents/projects/better-result-error-handling.plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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.
2 changes: 2 additions & 0 deletions docs/product/error-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/controllers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
bindProjectToDirectory,
formatCommandArgument,
projectCreateFailedError,
projectDirectoryBindingErrorToCliError,
projectSetupNameRequiredError,
resolveProjectForSetup,
toProjectSummary,
Expand Down Expand Up @@ -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, {
Expand Down
26 changes: 23 additions & 3 deletions packages/cli/src/controllers/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
formatCommandArgument,
isValidProjectSetupName,
projectCreateFailedError,
projectDirectoryBindingErrorToCliError,
projectSetupNameRequiredError,
resolveProjectForSetup,
toProjectSummary,
Expand All @@ -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";
Expand Down Expand Up @@ -218,10 +220,14 @@ export async function runProjectCreate(
fallbackFix: "Retry the command, or choose an existing Project with prisma-cli project link <id-or-name>.",
});
});
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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ProjectSetupResult> {
const bindResult = await bindProjectToDirectory(context, workspace, project, action);
if (bindResult.isErr()) {
throw projectDirectoryBindingErrorToCliError(bindResult.error);
}

return bindResult.value;
}

async function createProjectForLinkSetup(
Expand Down
221 changes: 194 additions & 27 deletions packages/cli/src/lib/project/local-pin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -138,58 +231,132 @@ export async function writeLocalResolutionPin(
cwd: string,
pin: LocalResolutionPin,
signal?: AbortSignal,
): Promise<void> {
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<Result<void, LocalResolutionPinWriteError>> {
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<void> {
): Promise<Result<void, LocalResolutionPinGitignoreUpdateError>> {
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
.split(/\r?\n/)
.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<void, LocalResolutionPinWriteAbortedError> {
return Result.try({
try: () => signal?.throwIfAborted(),
catch: (cause) => new LocalResolutionPinWriteAbortedError(cause),
});
}

function serializeLocalResolutionPin(pin: LocalResolutionPin): Result<string, LocalResolutionPinSerializationError | LocalResolutionPinWriteAbortedError> {
return Result.try({
try: () => `${JSON.stringify(pin, null, 2)}\n`,
catch: (cause) => new LocalResolutionPinSerializationError(cause),
});
}

function writeLocalResolutionPinBoundary(
run: () => Promise<unknown>,
operation: "create-directory" | "write-temp-file" | "rename-temp-file",
signal: AbortSignal | undefined,
): Promise<Result<void, LocalResolutionPinWriteAbortedError | LocalResolutionPinWriteFailedError>> {
return Result.tryPromise({
try: async () => {
await run();
},
catch: (cause) => signal?.aborted
? new LocalResolutionPinWriteAbortedError(cause)
: new LocalResolutionPinWriteFailedError(operation, cause),
});
}

function ensureLocalResolutionPinGitignoreUpdateNotAborted(signal: AbortSignal | undefined): Result<void, LocalResolutionPinGitignoreUpdateAbortedError> {
return Result.try({
try: () => signal?.throwIfAborted(),
catch: (cause) => new LocalResolutionPinGitignoreUpdateAbortedError(cause),
});
}

function writeLocalResolutionPinGitignore(
gitignorePath: string,
contents: string,
signal: AbortSignal | undefined,
): Promise<Result<void, LocalResolutionPinGitignoreUpdateAbortedError | LocalResolutionPinGitignoreUpdateFailedError>> {
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 {
Expand Down
Loading
Loading