@prisma/cli 3.0.0-dev.112.1 → 3.0.0-dev.115.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/mock-api.js +96 -0
- package/dist/adapters/token-storage.js +23 -0
- package/dist/commands/database/index.js +92 -2
- package/dist/commands/project/index.js +51 -3
- package/dist/controllers/database.js +249 -6
- package/dist/controllers/project.js +326 -3
- package/dist/lib/auth/recipient.js +42 -0
- package/dist/lib/database/provider.js +148 -1
- package/dist/lib/project/provider.js +92 -0
- package/dist/presenters/database.js +175 -1
- package/dist/presenters/project.js +90 -1
- package/dist/shell/command-meta.js +82 -0
- package/dist/shell/command-runner.js +7 -1
- package/package.json +1 -1
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { resolvePrismaCliPackageCommandFormatterSync } from "../lib/agent/cli-command.js";
|
|
1
2
|
import { formatCommandArgument } from "../shell/command-arguments.js";
|
|
2
|
-
import { CliError, authRequiredError, featureUnavailableError, usageError, workspaceRequiredError } from "../shell/errors.js";
|
|
3
|
+
import { CliError, authRequiredError, featureUnavailableError, usageError, workspaceAmbiguousError, workspaceNotAuthenticatedError, workspaceRequiredError } from "../shell/errors.js";
|
|
3
4
|
import { renderSummaryLine } from "../shell/ui.js";
|
|
4
5
|
import { canPrompt } from "../shell/runtime.js";
|
|
6
|
+
import { SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client.js";
|
|
7
|
+
import { WorkspaceSelectionError } from "../adapters/token-storage.js";
|
|
5
8
|
import { createAppProvider } from "../lib/app/app-provider.js";
|
|
6
|
-
import { readLocalResolutionPin } from "../lib/project/local-pin.js";
|
|
9
|
+
import { LOCAL_RESOLUTION_PIN_RELATIVE_PATH, readLocalResolutionPin, writeLocalResolutionPin } from "../lib/project/local-pin.js";
|
|
7
10
|
import { buildProjectSetupNextActions, inferTargetName, inspectProjectBinding, projectResolutionErrorToCliError, resolveProjectTarget, sortProjects } from "../lib/project/resolution.js";
|
|
8
11
|
import { bindProjectToDirectory, isValidProjectSetupName, projectCreateFailedError, projectDirectoryBindingErrorToCliError, projectSetupNameRequiredError, resolveProjectForSetup, toProjectSummary } from "../lib/project/setup.js";
|
|
9
12
|
import { requireComputeAuth } from "../lib/auth/guard.js";
|
|
@@ -11,7 +14,11 @@ import { promptForProjectSetupChoice } from "../lib/project/interactive-setup.js
|
|
|
11
14
|
import { createCliUseCaseGateways } from "../use-cases/create-cli-gateways.js";
|
|
12
15
|
import { requireAuthenticatedAuthState } from "./auth.js";
|
|
13
16
|
import { parseGitHubRepositoryUrl, readGitOriginRemote } from "../adapters/git.js";
|
|
17
|
+
import { RecipientSessionInvalidError, resolveRecipientWorkspaceSession } from "../lib/auth/recipient.js";
|
|
18
|
+
import { createManagementProjectProvider, projectRemoveBlockedError, projectRenameFailedError, projectTransferRejectedError } from "../lib/project/provider.js";
|
|
14
19
|
import { createProjectUseCases } from "../use-cases/project.js";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { unlink } from "node:fs/promises";
|
|
15
22
|
import { matchError } from "better-result";
|
|
16
23
|
import open from "open";
|
|
17
24
|
//#region src/controllers/project.ts
|
|
@@ -217,6 +224,322 @@ async function projectLinkTargetRequiredError(context, projects) {
|
|
|
217
224
|
})
|
|
218
225
|
});
|
|
219
226
|
}
|
|
227
|
+
async function runProjectRename(context, newName, options) {
|
|
228
|
+
const workspace = (await requireAuthenticatedAuthState(context)).workspace;
|
|
229
|
+
if (!workspace) throw workspaceRequiredError();
|
|
230
|
+
const name = newName.trim();
|
|
231
|
+
if (!isValidProjectSetupName(name)) throw projectSetupNameRequiredError("project rename");
|
|
232
|
+
const { provider, target } = await requireProjectCommandContext(context, workspace, options.project, "project rename");
|
|
233
|
+
const previousName = target.project.name;
|
|
234
|
+
return {
|
|
235
|
+
command: "project.rename",
|
|
236
|
+
result: {
|
|
237
|
+
workspace,
|
|
238
|
+
project: await provider.renameProject({
|
|
239
|
+
projectId: target.project.id,
|
|
240
|
+
name,
|
|
241
|
+
signal: context.runtime.signal
|
|
242
|
+
}),
|
|
243
|
+
previousName
|
|
244
|
+
},
|
|
245
|
+
warnings: [],
|
|
246
|
+
nextSteps: []
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
async function runProjectRemove(context, projectRef, options) {
|
|
250
|
+
const formatCommand = resolvePrismaCliPackageCommandFormatterSync(context.runtime.cwd);
|
|
251
|
+
const workspace = (await requireAuthenticatedAuthState(context)).workspace;
|
|
252
|
+
if (!workspace) throw workspaceRequiredError();
|
|
253
|
+
const { provider, projects } = await requireProjectMutationContext(context, workspace);
|
|
254
|
+
const project = toProjectSummary(resolveProjectForSetup(projectRef.trim(), projects, workspace));
|
|
255
|
+
requireProjectExactConfirmation({
|
|
256
|
+
id: project.id,
|
|
257
|
+
confirm: options.confirm,
|
|
258
|
+
summary: "Confirm project removal",
|
|
259
|
+
why: "Removing a project is permanent, deletes its databases, and stops its apps, so it requires the exact project id.",
|
|
260
|
+
nextStep: formatCommand([
|
|
261
|
+
"project",
|
|
262
|
+
"remove",
|
|
263
|
+
project.id,
|
|
264
|
+
"--confirm",
|
|
265
|
+
project.id
|
|
266
|
+
])
|
|
267
|
+
});
|
|
268
|
+
await provider.removeProject({
|
|
269
|
+
projectId: project.id,
|
|
270
|
+
signal: context.runtime.signal
|
|
271
|
+
});
|
|
272
|
+
const warnings = [];
|
|
273
|
+
return {
|
|
274
|
+
command: "project.remove",
|
|
275
|
+
result: {
|
|
276
|
+
workspace,
|
|
277
|
+
project,
|
|
278
|
+
localPin: { cleared: await cleanupLocalPinForProject(context, project.id, { onError: (message) => warnings.push(message) }) }
|
|
279
|
+
},
|
|
280
|
+
warnings,
|
|
281
|
+
nextSteps: []
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
async function runProjectTransfer(context, projectRef, options) {
|
|
285
|
+
const formatCommand = resolvePrismaCliPackageCommandFormatterSync(context.runtime.cwd);
|
|
286
|
+
const workspace = (await requireAuthenticatedAuthState(context)).workspace;
|
|
287
|
+
if (!workspace) throw workspaceRequiredError();
|
|
288
|
+
if (options.toWorkspace && options.recipientToken) throw usageError("Choose one transfer recipient source", "--to-workspace and --recipient-token are mutually exclusive.", "Pass either --to-workspace <id-or-name> or --recipient-token <token>.", [formatCommand([
|
|
289
|
+
"project",
|
|
290
|
+
"transfer",
|
|
291
|
+
"<project>",
|
|
292
|
+
"--to-workspace",
|
|
293
|
+
"<id-or-name>",
|
|
294
|
+
"--confirm",
|
|
295
|
+
"<project-id>"
|
|
296
|
+
])], "project");
|
|
297
|
+
if (!options.toWorkspace?.trim() && !options.recipientToken?.trim()) throw transferRecipientRequiredError(formatCommand);
|
|
298
|
+
const { provider, projects } = await requireProjectMutationContext(context, workspace);
|
|
299
|
+
const project = toProjectSummary(resolveProjectForSetup(projectRef.trim(), projects, workspace));
|
|
300
|
+
requireProjectExactConfirmation({
|
|
301
|
+
id: project.id,
|
|
302
|
+
confirm: options.confirm,
|
|
303
|
+
summary: "Confirm project transfer",
|
|
304
|
+
why: "Transferring moves the project to another workspace and this workspace loses access, so it requires the exact project id.",
|
|
305
|
+
nextStep: `${formatCommand([
|
|
306
|
+
"project",
|
|
307
|
+
"transfer",
|
|
308
|
+
project.id
|
|
309
|
+
])} ${options.toWorkspace ? `--to-workspace ${formatCommandArgument(options.toWorkspace)}` : "--recipient-token <token>"} --confirm ${project.id}`
|
|
310
|
+
});
|
|
311
|
+
const recipient = await resolveTransferRecipient(context, options);
|
|
312
|
+
await provider.transferProject({
|
|
313
|
+
projectId: project.id,
|
|
314
|
+
recipientAccessToken: recipient.accessToken,
|
|
315
|
+
signal: context.runtime.signal
|
|
316
|
+
});
|
|
317
|
+
const warnings = [];
|
|
318
|
+
const pinAction = await rewriteOrClearLocalPinForProject(context, project.id, recipient.workspaceId, { onError: (message) => warnings.push(message) });
|
|
319
|
+
return {
|
|
320
|
+
command: "project.transfer",
|
|
321
|
+
result: {
|
|
322
|
+
workspace,
|
|
323
|
+
project,
|
|
324
|
+
recipient: {
|
|
325
|
+
workspaceId: recipient.workspaceId,
|
|
326
|
+
workspaceName: recipient.workspaceName,
|
|
327
|
+
source: recipient.source
|
|
328
|
+
},
|
|
329
|
+
localPin: { action: pinAction }
|
|
330
|
+
},
|
|
331
|
+
warnings,
|
|
332
|
+
nextSteps: options.toWorkspace ? [`${formatCommand([
|
|
333
|
+
"auth",
|
|
334
|
+
"workspace",
|
|
335
|
+
"use"
|
|
336
|
+
])} ${formatCommandArgument(options.toWorkspace)}`] : []
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
async function resolveTransferRecipient(context, options) {
|
|
340
|
+
const formatCommand = resolvePrismaCliPackageCommandFormatterSync(context.runtime.cwd);
|
|
341
|
+
const recipientToken = options.recipientToken?.trim();
|
|
342
|
+
if (recipientToken) return {
|
|
343
|
+
accessToken: recipientToken,
|
|
344
|
+
workspaceId: isRealMode(context) ? null : recipientToken,
|
|
345
|
+
workspaceName: null,
|
|
346
|
+
source: "recipient-token"
|
|
347
|
+
};
|
|
348
|
+
const workspaceRef = options.toWorkspace?.trim();
|
|
349
|
+
if (!workspaceRef) throw transferRecipientRequiredError(formatCommand);
|
|
350
|
+
if (!isRealMode(context)) {
|
|
351
|
+
const matches = context.api.listWorkspaces().filter((candidate) => candidate.id === workspaceRef || candidate.name.toLowerCase() === workspaceRef.toLowerCase());
|
|
352
|
+
if (matches.length === 0) throw workspaceNotAuthenticatedError(workspaceRef);
|
|
353
|
+
if (matches.length > 1) throw workspaceAmbiguousError(workspaceRef, matches.map((match) => ({
|
|
354
|
+
id: match.id,
|
|
355
|
+
name: match.name,
|
|
356
|
+
credentialWorkspaceId: match.id
|
|
357
|
+
})));
|
|
358
|
+
return {
|
|
359
|
+
accessToken: matches[0].id,
|
|
360
|
+
workspaceId: matches[0].id,
|
|
361
|
+
workspaceName: matches[0].name,
|
|
362
|
+
source: "workspace-session"
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (context.runtime.env["PRISMA_SERVICE_TOKEN"] !== void 0) throw transferRecipientUnavailableError(formatCommand);
|
|
366
|
+
try {
|
|
367
|
+
const session = await resolveRecipientWorkspaceSession(workspaceRef, context.runtime.env, context.runtime.signal);
|
|
368
|
+
return {
|
|
369
|
+
accessToken: session.accessToken,
|
|
370
|
+
workspaceId: session.workspace.id,
|
|
371
|
+
workspaceName: session.workspace.name,
|
|
372
|
+
source: "workspace-session"
|
|
373
|
+
};
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (error instanceof WorkspaceSelectionError) {
|
|
376
|
+
if (error.reason === "ambiguous") throw workspaceAmbiguousError(error.workspaceRef ?? workspaceRef, error.matches.map((match) => ({
|
|
377
|
+
id: match.id,
|
|
378
|
+
name: match.name,
|
|
379
|
+
credentialWorkspaceId: match.credentialWorkspaceId
|
|
380
|
+
})));
|
|
381
|
+
throw workspaceNotAuthenticatedError(error.workspaceRef ?? workspaceRef);
|
|
382
|
+
}
|
|
383
|
+
if (error instanceof RecipientSessionInvalidError) throw workspaceNotAuthenticatedError(error.workspaceRef);
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async function requireProjectMutationContext(context, workspace) {
|
|
388
|
+
if (isRealMode(context)) {
|
|
389
|
+
const client = await requireProjectClient(context);
|
|
390
|
+
return {
|
|
391
|
+
provider: createManagementProjectProvider(client),
|
|
392
|
+
projects: await listRealWorkspaceProjects(client, workspace, context.runtime.signal)
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
provider: createFixtureProjectProvider(context),
|
|
397
|
+
projects: listFixtureWorkspaceProjects(context, workspace)
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
async function requireProjectCommandContext(context, workspace, explicitProject, commandName) {
|
|
401
|
+
const client = isRealMode(context) ? await requireProjectClient(context) : null;
|
|
402
|
+
const listProjects = async () => client ? listRealWorkspaceProjects(client, workspace, context.runtime.signal) : listFixtureWorkspaceProjects(context, workspace);
|
|
403
|
+
const targetResult = await resolveProjectTarget({
|
|
404
|
+
context,
|
|
405
|
+
workspace,
|
|
406
|
+
explicitProject,
|
|
407
|
+
listProjects,
|
|
408
|
+
commandName
|
|
409
|
+
});
|
|
410
|
+
if (targetResult.isErr()) throw projectResolutionErrorToCliError(targetResult.error);
|
|
411
|
+
return {
|
|
412
|
+
provider: client ? createManagementProjectProvider(client) : createFixtureProjectProvider(context),
|
|
413
|
+
target: targetResult.value
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
async function requireProjectClient(context) {
|
|
417
|
+
const client = await requireComputeAuth(context.runtime.env, context.runtime.signal);
|
|
418
|
+
if (!client) throw authRequiredError();
|
|
419
|
+
return client;
|
|
420
|
+
}
|
|
421
|
+
function createFixtureProjectProvider(context) {
|
|
422
|
+
const fixtureFormatCommand = resolvePrismaCliPackageCommandFormatterSync(context.runtime.cwd);
|
|
423
|
+
return {
|
|
424
|
+
async renameProject(options) {
|
|
425
|
+
const renamed = context.api.renameProject(options.projectId, options.name);
|
|
426
|
+
if (!renamed) throw projectRenameFailedError(options.name, void 0);
|
|
427
|
+
return {
|
|
428
|
+
id: renamed.id,
|
|
429
|
+
name: renamed.name,
|
|
430
|
+
...renamed.url ? { url: renamed.url } : {}
|
|
431
|
+
};
|
|
432
|
+
},
|
|
433
|
+
async removeProject(options) {
|
|
434
|
+
const removed = context.api.removeProject(options.projectId);
|
|
435
|
+
if (removed.outcome === "blocked") throw projectRemoveBlockedError(options.projectId, void 0);
|
|
436
|
+
if (removed.outcome === "not-found") throw new CliError({
|
|
437
|
+
code: "PROJECT_NOT_FOUND",
|
|
438
|
+
domain: "project",
|
|
439
|
+
summary: "Project not found",
|
|
440
|
+
why: `No project matched "${options.projectId}".`,
|
|
441
|
+
fix: `Pass a project id or name from ${fixtureFormatCommand(["project", "list"])}.`,
|
|
442
|
+
exitCode: 1,
|
|
443
|
+
nextSteps: [fixtureFormatCommand(["project", "list"])]
|
|
444
|
+
});
|
|
445
|
+
},
|
|
446
|
+
async transferProject(options) {
|
|
447
|
+
if (context.api.transferProject(options.projectId, options.recipientAccessToken).outcome !== "transferred") throw projectTransferRejectedError(options.projectId, void 0);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function requireProjectExactConfirmation(options) {
|
|
452
|
+
if (options.confirm === options.id) return;
|
|
453
|
+
throw new CliError({
|
|
454
|
+
code: "CONFIRMATION_REQUIRED",
|
|
455
|
+
domain: "project",
|
|
456
|
+
summary: options.summary,
|
|
457
|
+
why: options.why,
|
|
458
|
+
fix: `Rerun with --confirm ${options.id}.`,
|
|
459
|
+
exitCode: 2,
|
|
460
|
+
nextSteps: [options.nextStep],
|
|
461
|
+
meta: {
|
|
462
|
+
expectedConfirm: options.id,
|
|
463
|
+
receivedConfirm: options.confirm ?? null
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
function transferRecipientRequiredError(formatCommand) {
|
|
468
|
+
return new CliError({
|
|
469
|
+
code: "TRANSFER_RECIPIENT_REQUIRED",
|
|
470
|
+
domain: "project",
|
|
471
|
+
summary: "Transfer recipient required",
|
|
472
|
+
why: "Project transfer needs the receiving workspace.",
|
|
473
|
+
fix: "Pass --to-workspace <id-or-name> for a locally authenticated workspace, or --recipient-token <token> for a cross-account transfer.",
|
|
474
|
+
exitCode: 2,
|
|
475
|
+
nextSteps: [formatCommand([
|
|
476
|
+
"auth",
|
|
477
|
+
"workspace",
|
|
478
|
+
"list"
|
|
479
|
+
]), formatCommand([
|
|
480
|
+
"project",
|
|
481
|
+
"transfer",
|
|
482
|
+
"<project>",
|
|
483
|
+
"--to-workspace",
|
|
484
|
+
"<id-or-name>",
|
|
485
|
+
"--confirm",
|
|
486
|
+
"<project-id>"
|
|
487
|
+
])]
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
function transferRecipientUnavailableError(formatCommand) {
|
|
491
|
+
return new CliError({
|
|
492
|
+
code: "TRANSFER_RECIPIENT_UNAVAILABLE",
|
|
493
|
+
domain: "project",
|
|
494
|
+
summary: "Local workspace sessions are unavailable",
|
|
495
|
+
why: `--to-workspace resolves locally stored OAuth sessions, but ${SERVICE_TOKEN_ENV_VAR} is set and service-token mode does not read them.`,
|
|
496
|
+
fix: "Pass --recipient-token <token> with an access token for the receiving workspace, or unset the service token.",
|
|
497
|
+
exitCode: 1,
|
|
498
|
+
nextSteps: [formatCommand([
|
|
499
|
+
"project",
|
|
500
|
+
"transfer",
|
|
501
|
+
"<project>",
|
|
502
|
+
"--recipient-token",
|
|
503
|
+
"<token>",
|
|
504
|
+
"--confirm",
|
|
505
|
+
"<project-id>"
|
|
506
|
+
])]
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
async function cleanupLocalPinForProject(context, projectId, hooks) {
|
|
510
|
+
const pinResult = await readLocalResolutionPin(context.runtime.cwd, context.runtime.signal);
|
|
511
|
+
if (pinResult.isErr()) return false;
|
|
512
|
+
const pin = pinResult.value;
|
|
513
|
+
if (pin.kind !== "present" || pin.pin.projectId !== projectId) return false;
|
|
514
|
+
try {
|
|
515
|
+
await unlink(path.join(context.runtime.cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH));
|
|
516
|
+
return true;
|
|
517
|
+
} catch {
|
|
518
|
+
hooks.onError(`The local pin ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} points at the removed project but could not be deleted.`);
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async function rewriteOrClearLocalPinForProject(context, projectId, recipientWorkspaceId, hooks) {
|
|
523
|
+
const pinResult = await readLocalResolutionPin(context.runtime.cwd, context.runtime.signal);
|
|
524
|
+
if (pinResult.isErr()) return "none";
|
|
525
|
+
const pin = pinResult.value;
|
|
526
|
+
if (pin.kind !== "present" || pin.pin.projectId !== projectId) return "none";
|
|
527
|
+
if (recipientWorkspaceId) {
|
|
528
|
+
if ((await writeLocalResolutionPin(context.runtime.cwd, {
|
|
529
|
+
workspaceId: recipientWorkspaceId,
|
|
530
|
+
projectId
|
|
531
|
+
}, context.runtime.signal)).isOk()) return "rewritten";
|
|
532
|
+
hooks.onError(`The local pin ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} points at the transferred project but could not be rewritten.`);
|
|
533
|
+
return "none";
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
await unlink(path.join(context.runtime.cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH));
|
|
537
|
+
return "cleared";
|
|
538
|
+
} catch {
|
|
539
|
+
hooks.onError(`The local pin ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} points at the transferred project but could not be cleared.`);
|
|
540
|
+
return "none";
|
|
541
|
+
}
|
|
542
|
+
}
|
|
220
543
|
async function runGitConnect(context, gitUrl, options = {}) {
|
|
221
544
|
const workspace = (await requireAuthenticatedAuthState(context)).workspace;
|
|
222
545
|
if (!workspace) throw workspaceRequiredError();
|
|
@@ -728,4 +1051,4 @@ function repoConnectionFixForStatus(status) {
|
|
|
728
1051
|
return "Re-run with --trace for the underlying API response details.";
|
|
729
1052
|
}
|
|
730
1053
|
//#endregion
|
|
731
|
-
export { listFixtureWorkspaceProjects, listRealWorkspaceProjects, runGitConnect, runGitDisconnect, runProjectCreate, runProjectLink, runProjectList, runProjectShow };
|
|
1054
|
+
export { listFixtureWorkspaceProjects, listRealWorkspaceProjects, runGitConnect, runGitDisconnect, runProjectCreate, runProjectLink, runProjectList, runProjectRemove, runProjectRename, runProjectShow, runProjectTransfer };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getApiBaseUrl } from "./client.js";
|
|
2
|
+
import { FileTokenStorage } from "../../adapters/token-storage.js";
|
|
3
|
+
import { createManagementApiSdk } from "@prisma/management-api-sdk";
|
|
4
|
+
//#region src/lib/auth/recipient.ts
|
|
5
|
+
var RecipientSessionInvalidError = class extends Error {
|
|
6
|
+
constructor(workspaceRef) {
|
|
7
|
+
super(`The stored session for workspace "${workspaceRef}" could not be validated.`);
|
|
8
|
+
this.workspaceRef = workspaceRef;
|
|
9
|
+
this.name = "RecipientSessionInvalidError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a locally stored OAuth workspace session and return a validated
|
|
14
|
+
* access token for it, refreshing through the SDK when the stored token has
|
|
15
|
+
* expired. The active workspace pointer is never touched.
|
|
16
|
+
*
|
|
17
|
+
* Throws WorkspaceSelectionError when the ref does not match exactly one
|
|
18
|
+
* stored session, and RecipientSessionInvalidError when the session cannot
|
|
19
|
+
* be validated or refreshed.
|
|
20
|
+
*/
|
|
21
|
+
async function resolveRecipientWorkspaceSession(workspaceRef, env = process.env, signal) {
|
|
22
|
+
const workspace = await new FileTokenStorage(env, signal).resolveWorkspace(workspaceRef);
|
|
23
|
+
const pinnedStorage = new FileTokenStorage(env, signal, {
|
|
24
|
+
activateOnSetTokens: false,
|
|
25
|
+
lockSetTokens: false,
|
|
26
|
+
pinnedWorkspaceId: workspace.credentialWorkspaceId
|
|
27
|
+
});
|
|
28
|
+
if ((await createManagementApiSdk({
|
|
29
|
+
clientId: "cmm3lndn701oo0uefvxzo0ivw",
|
|
30
|
+
redirectUri: "http://localhost:0/auth/callback",
|
|
31
|
+
tokenStorage: pinnedStorage,
|
|
32
|
+
apiBaseUrl: getApiBaseUrl(env)
|
|
33
|
+
}).client.GET("/v1/workspaces", { signal })).error) throw new RecipientSessionInvalidError(workspaceRef);
|
|
34
|
+
const tokens = await pinnedStorage.getTokens();
|
|
35
|
+
if (!tokens) throw new RecipientSessionInvalidError(workspaceRef);
|
|
36
|
+
return {
|
|
37
|
+
workspace,
|
|
38
|
+
accessToken: tokens.accessToken
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
export { RecipientSessionInvalidError, resolveRecipientWorkspaceSession };
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { formatPrismaCliCommand } from "../../shell/cli-command.js";
|
|
1
2
|
import { CliError } from "../../shell/errors.js";
|
|
2
3
|
//#region src/lib/database/provider.ts
|
|
3
|
-
function createManagementDatabaseProvider(client) {
|
|
4
|
+
function createManagementDatabaseProvider(client, options) {
|
|
5
|
+
const formatCommand = options?.formatCommand ?? ((args) => formatPrismaCliCommand(args));
|
|
4
6
|
return {
|
|
5
7
|
async listDatabases(options) {
|
|
6
8
|
const databases = [];
|
|
@@ -75,6 +77,55 @@ function createManagementDatabaseProvider(client) {
|
|
|
75
77
|
signal: options?.signal
|
|
76
78
|
});
|
|
77
79
|
if (result.error) throw databaseApiError("Failed to remove database connection", result.response, result.error);
|
|
80
|
+
},
|
|
81
|
+
async getUsage(databaseId, options) {
|
|
82
|
+
const result = await client.GET("/v1/databases/{databaseId}/usage", {
|
|
83
|
+
params: {
|
|
84
|
+
path: { databaseId },
|
|
85
|
+
query: {
|
|
86
|
+
...options?.from ? { startDate: options.from } : {},
|
|
87
|
+
...options?.to ? { endDate: options.to } : {}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
signal: options?.signal
|
|
91
|
+
});
|
|
92
|
+
if (result.error || !result.data) throw databaseApiError("Failed to fetch database usage", result.response, result.error);
|
|
93
|
+
return normalizeUsage(result.data);
|
|
94
|
+
},
|
|
95
|
+
async listBackups(databaseId, options) {
|
|
96
|
+
const result = await client.GET("/v1/databases/{databaseId}/backups", {
|
|
97
|
+
params: {
|
|
98
|
+
path: { databaseId },
|
|
99
|
+
query: { ...options?.limit !== void 0 ? { limit: options.limit } : {} }
|
|
100
|
+
},
|
|
101
|
+
signal: options?.signal
|
|
102
|
+
});
|
|
103
|
+
if (result.response?.status === 422) throw backupsUnsupportedError(databaseId, result.error);
|
|
104
|
+
if (result.error || !result.data) throw databaseApiError("Failed to list database backups", result.response, result.error);
|
|
105
|
+
return normalizeBackupList(result.data);
|
|
106
|
+
},
|
|
107
|
+
async restoreDatabase(options) {
|
|
108
|
+
const result = await client.POST("/v1/databases/{targetDatabaseId}/restore", {
|
|
109
|
+
params: { path: { targetDatabaseId: options.targetDatabaseId } },
|
|
110
|
+
body: { source: {
|
|
111
|
+
type: "backup",
|
|
112
|
+
databaseId: options.sourceDatabaseId,
|
|
113
|
+
backupId: options.backupId
|
|
114
|
+
} },
|
|
115
|
+
signal: options.signal
|
|
116
|
+
});
|
|
117
|
+
if (result.response?.status === 409) throw restoreConflictError(options.targetDatabaseId, result.error, formatCommand);
|
|
118
|
+
if (result.response?.status === 404) throw restoreBackupNotFoundError(options, result.error, formatCommand);
|
|
119
|
+
if (result.error || !result.data) throw databaseApiError("Failed to restore database", result.response, result.error);
|
|
120
|
+
return normalizeDatabase(result.data.data, options.projectId);
|
|
121
|
+
},
|
|
122
|
+
async rotateConnection(connectionId, options) {
|
|
123
|
+
const result = await client.POST("/v1/connections/{id}/rotate", {
|
|
124
|
+
params: { path: { id: connectionId } },
|
|
125
|
+
signal: options?.signal
|
|
126
|
+
});
|
|
127
|
+
if (result.error || !result.data) throw databaseApiError("Failed to rotate database connection", result.response, result.error);
|
|
128
|
+
return normalizeRotatedConnection(result.data.data);
|
|
78
129
|
}
|
|
79
130
|
};
|
|
80
131
|
}
|
|
@@ -151,6 +202,102 @@ function requireDatabaseProjectId(database, fallbackProjectId) {
|
|
|
151
202
|
function extractConnectionString(connection) {
|
|
152
203
|
return connection.endpoints?.pooled?.connectionString ?? connection.connectionString ?? connection.endpoints?.direct?.connectionString ?? connection.endpoints?.accelerate?.connectionString ?? null;
|
|
153
204
|
}
|
|
205
|
+
function normalizeUsage(usage) {
|
|
206
|
+
return {
|
|
207
|
+
period: {
|
|
208
|
+
start: usage.period?.start ?? "",
|
|
209
|
+
end: usage.period?.end ?? ""
|
|
210
|
+
},
|
|
211
|
+
metrics: {
|
|
212
|
+
operations: {
|
|
213
|
+
used: usage.metrics?.operations?.used ?? 0,
|
|
214
|
+
unit: usage.metrics?.operations?.unit ?? "ops"
|
|
215
|
+
},
|
|
216
|
+
storage: {
|
|
217
|
+
used: usage.metrics?.storage?.used ?? 0,
|
|
218
|
+
unit: usage.metrics?.storage?.unit ?? "GiB"
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
generatedAt: usage.generatedAt ?? ""
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function normalizeBackupList(body) {
|
|
225
|
+
return {
|
|
226
|
+
backups: (body.data ?? []).map((backup) => ({
|
|
227
|
+
id: backup.id,
|
|
228
|
+
backupType: backup.backupType ?? "unknown",
|
|
229
|
+
status: backup.status ?? "unknown",
|
|
230
|
+
size: backup.size ?? null,
|
|
231
|
+
createdAt: backup.createdAt ?? ""
|
|
232
|
+
})),
|
|
233
|
+
retentionDays: body.meta?.backupRetentionDays ?? null,
|
|
234
|
+
hasMore: body.pagination?.hasMore ?? false
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function normalizeRotatedConnection(connection) {
|
|
238
|
+
const connectionString = extractConnectionString(connection);
|
|
239
|
+
if (!connectionString) throw new CliError({
|
|
240
|
+
code: "DATABASE_CONNECTION_STRING_MISSING",
|
|
241
|
+
domain: "database",
|
|
242
|
+
summary: "Rotated connection did not return a connection string",
|
|
243
|
+
why: "Rotated connection strings are one-time-view secrets, but the Management API did not include one in this rotate response.",
|
|
244
|
+
fix: "Re-run the rotation, or create a replacement connection and store the returned URL immediately.",
|
|
245
|
+
exitCode: 1,
|
|
246
|
+
nextSteps: []
|
|
247
|
+
});
|
|
248
|
+
const database = connection.database?.id && connection.database?.name ? {
|
|
249
|
+
id: connection.database.id,
|
|
250
|
+
name: connection.database.name
|
|
251
|
+
} : null;
|
|
252
|
+
return {
|
|
253
|
+
connection: normalizeConnection(connection, connection.database?.id ?? connection.databaseId ?? ""),
|
|
254
|
+
database,
|
|
255
|
+
connectionString
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function backupsUnsupportedError(databaseId, error) {
|
|
259
|
+
return new CliError({
|
|
260
|
+
code: "DATABASE_BACKUPS_UNSUPPORTED",
|
|
261
|
+
domain: "database",
|
|
262
|
+
summary: "Backups are not available for this database",
|
|
263
|
+
why: error?.error?.message ?? `The platform does not manage backups for database "${databaseId}", for example because it is a remote/BYO database.`,
|
|
264
|
+
fix: "Use your own backup tooling for externally managed databases.",
|
|
265
|
+
exitCode: 1,
|
|
266
|
+
nextSteps: []
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
function restoreBackupNotFoundError(options, error, formatCommand) {
|
|
270
|
+
const listCommand = formatCommand([
|
|
271
|
+
"database",
|
|
272
|
+
"backup",
|
|
273
|
+
"list",
|
|
274
|
+
options.sourceDatabaseId
|
|
275
|
+
]);
|
|
276
|
+
return new CliError({
|
|
277
|
+
code: "DATABASE_BACKUP_NOT_FOUND",
|
|
278
|
+
domain: "database",
|
|
279
|
+
summary: "Database backup not found",
|
|
280
|
+
why: error?.error?.message ?? `No backup matched "${options.backupId}" for database "${options.sourceDatabaseId}".`,
|
|
281
|
+
fix: `Pass a backup id from ${listCommand}.`,
|
|
282
|
+
exitCode: 1,
|
|
283
|
+
nextSteps: [listCommand]
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function restoreConflictError(targetDatabaseId, error, formatCommand) {
|
|
287
|
+
return new CliError({
|
|
288
|
+
code: "DATABASE_RESTORE_CONFLICT",
|
|
289
|
+
domain: "database",
|
|
290
|
+
summary: "Database cannot be restored right now",
|
|
291
|
+
why: error?.error?.message ?? `Database "${targetDatabaseId}" is provisioning or already recovering.`,
|
|
292
|
+
fix: "Wait for the database to become ready, then retry the restore.",
|
|
293
|
+
exitCode: 1,
|
|
294
|
+
nextSteps: [formatCommand([
|
|
295
|
+
"database",
|
|
296
|
+
"show",
|
|
297
|
+
targetDatabaseId
|
|
298
|
+
])]
|
|
299
|
+
});
|
|
300
|
+
}
|
|
154
301
|
function databaseApiError(summary, response, error) {
|
|
155
302
|
const status = response?.status ?? 0;
|
|
156
303
|
return new CliError({
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { formatPrismaCliCommand } from "../../shell/cli-command.js";
|
|
2
|
+
import { CliError } from "../../shell/errors.js";
|
|
3
|
+
//#region src/lib/project/provider.ts
|
|
4
|
+
function createManagementProjectProvider(client) {
|
|
5
|
+
return {
|
|
6
|
+
async renameProject(options) {
|
|
7
|
+
const result = await client.PATCH("/v1/projects/{id}", {
|
|
8
|
+
params: { path: { id: options.projectId } },
|
|
9
|
+
body: { name: options.name },
|
|
10
|
+
signal: options.signal
|
|
11
|
+
});
|
|
12
|
+
const status = result.response?.status ?? 0;
|
|
13
|
+
if (status === 400 || status === 422) throw projectRenameFailedError(options.name, result.error);
|
|
14
|
+
if (result.error || !result.data) throw projectApiError("Failed to rename project", result.response, result.error);
|
|
15
|
+
const project = result.data.data;
|
|
16
|
+
return {
|
|
17
|
+
id: project.id,
|
|
18
|
+
name: project.name,
|
|
19
|
+
...project.url ? { url: project.url } : {}
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
async removeProject(options) {
|
|
23
|
+
const result = await client.DELETE("/v1/projects/{id}", {
|
|
24
|
+
params: { path: { id: options.projectId } },
|
|
25
|
+
signal: options.signal
|
|
26
|
+
});
|
|
27
|
+
if (result.response?.status === 400) throw projectRemoveBlockedError(options.projectId, result.error);
|
|
28
|
+
if (result.error) throw projectApiError("Failed to remove project", result.response, result.error);
|
|
29
|
+
},
|
|
30
|
+
async transferProject(options) {
|
|
31
|
+
const result = await client.POST("/v1/projects/{id}/transfer", {
|
|
32
|
+
params: { path: { id: options.projectId } },
|
|
33
|
+
body: { recipientAccessToken: options.recipientAccessToken },
|
|
34
|
+
signal: options.signal
|
|
35
|
+
});
|
|
36
|
+
if (result.response?.status === 400) throw projectTransferRejectedError(options.projectId, result.error);
|
|
37
|
+
if (result.error) throw projectApiError("Failed to transfer project", result.response, result.error);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function projectRenameFailedError(name, error) {
|
|
42
|
+
return new CliError({
|
|
43
|
+
code: "PROJECT_RENAME_FAILED",
|
|
44
|
+
domain: "project",
|
|
45
|
+
summary: "Project rename failed",
|
|
46
|
+
why: error?.error?.message ?? `The platform rejected the name "${name}".`,
|
|
47
|
+
fix: error?.error?.hint ?? "Pass a different project name and retry the rename.",
|
|
48
|
+
exitCode: 1,
|
|
49
|
+
nextSteps: []
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function projectRemoveBlockedError(projectId, error) {
|
|
53
|
+
return new CliError({
|
|
54
|
+
code: "PROJECT_REMOVE_BLOCKED",
|
|
55
|
+
domain: "project",
|
|
56
|
+
summary: "Project cannot be removed yet",
|
|
57
|
+
why: error?.error?.message ?? `Project "${projectId}" still has active deployments.`,
|
|
58
|
+
fix: "Remove the project's apps first, then retry the removal.",
|
|
59
|
+
exitCode: 1,
|
|
60
|
+
nextSteps: [formatPrismaCliCommand([
|
|
61
|
+
"app",
|
|
62
|
+
"remove",
|
|
63
|
+
"--app",
|
|
64
|
+
"<name>"
|
|
65
|
+
])]
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function projectTransferRejectedError(projectId, error) {
|
|
69
|
+
return new CliError({
|
|
70
|
+
code: "PROJECT_TRANSFER_REJECTED",
|
|
71
|
+
domain: "project",
|
|
72
|
+
summary: "Project transfer was rejected",
|
|
73
|
+
why: error?.error?.message ?? `The platform rejected the transfer of project "${projectId}", for example because the recipient token is invalid or expired.`,
|
|
74
|
+
fix: "Check the recipient workspace session or token and retry the transfer.",
|
|
75
|
+
exitCode: 1,
|
|
76
|
+
nextSteps: []
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function projectApiError(summary, response, error) {
|
|
80
|
+
const status = response?.status ?? 0;
|
|
81
|
+
return new CliError({
|
|
82
|
+
code: error?.error?.code ?? "PROJECT_API_ERROR",
|
|
83
|
+
domain: "project",
|
|
84
|
+
summary,
|
|
85
|
+
why: error?.error?.message ?? `The Management API returned status ${status || "unknown"}.`,
|
|
86
|
+
fix: error?.error?.hint ?? "Re-run with --trace for the underlying API response details.",
|
|
87
|
+
exitCode: 1,
|
|
88
|
+
nextSteps: []
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
export { createManagementProjectProvider, projectRemoveBlockedError, projectRenameFailedError, projectTransferRejectedError };
|