@prisma/cli 3.0.0-dev.61.1 → 3.0.0-dev.63.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/README.md CHANGED
@@ -57,6 +57,7 @@ Useful next commands:
57
57
  npx prisma-cli app logs
58
58
  npx prisma-cli app open
59
59
  npx prisma-cli project env add DATABASE_URL=postgresql://example --role preview
60
+ npx prisma-cli project env add --file .env --role preview
60
61
  npx prisma-cli project env list
61
62
  npx prisma-cli project env list --role preview
62
63
  ```
@@ -17,16 +17,18 @@ function createEnvCommand(runtime) {
17
17
  }
18
18
  function createEnvAddCommand(runtime) {
19
19
  const command = attachCommandDescriptor(configureRuntimeCommand(new Command("add"), runtime), "project.env.add");
20
- command.argument("<assignment>", "Variable assignment as KEY=VALUE or KEY from the current environment").addOption(new Option("--role <role>", "Project template scope (production or preview)").choices(["production", "preview"])).addOption(new Option("--branch <git-name>", "Preview branch override scope")).addOption(new Option("--project <id-or-name>", "Project id or name"));
20
+ command.argument("[assignment]", "Variable assignment as KEY=VALUE or KEY from the current environment").addOption(new Option("--file <path>", "Read KEY=VALUE assignments from a dotenv file")).addOption(new Option("--role <role>", "Project template scope (production or preview)").choices(["production", "preview"])).addOption(new Option("--branch <git-name>", "Preview branch override scope")).addOption(new Option("--project <id-or-name>", "Project id or name"));
21
21
  addGlobalFlags(command);
22
22
  command.action(async (assignment, options) => {
23
23
  const roleName = options.role;
24
24
  const branchName = options.branch;
25
25
  const projectRef = options.project;
26
+ const filePath = options.file;
26
27
  await runCommand(runtime, "project.env.add", options, (context) => runEnvAdd(context, assignment, {
27
28
  roleName,
28
29
  branchName,
29
- projectRef
30
+ projectRef,
31
+ filePath
30
32
  }), {
31
33
  renderHuman: (context, descriptor, result) => renderEnvAdd(context, descriptor, result),
32
34
  renderJson: (result) => serializeEnvAdd(result)
@@ -36,16 +38,18 @@ function createEnvAddCommand(runtime) {
36
38
  }
37
39
  function createEnvUpdateCommand(runtime) {
38
40
  const command = attachCommandDescriptor(configureRuntimeCommand(new Command("update"), runtime), "project.env.update");
39
- command.argument("<assignment>", "Variable assignment as KEY=VALUE or KEY from the current environment").addOption(new Option("--role <role>", "Project template scope (production or preview)").choices(["production", "preview"])).addOption(new Option("--branch <git-name>", "Preview branch override scope")).addOption(new Option("--project <id-or-name>", "Project id or name"));
41
+ command.argument("[assignment]", "Variable assignment as KEY=VALUE or KEY from the current environment").addOption(new Option("--file <path>", "Read KEY=VALUE assignments from a dotenv file")).addOption(new Option("--role <role>", "Project template scope (production or preview)").choices(["production", "preview"])).addOption(new Option("--branch <git-name>", "Preview branch override scope")).addOption(new Option("--project <id-or-name>", "Project id or name"));
40
42
  addGlobalFlags(command);
41
43
  command.action(async (assignment, options) => {
42
44
  const roleName = options.role;
43
45
  const branchName = options.branch;
44
46
  const projectRef = options.project;
47
+ const filePath = options.file;
45
48
  await runCommand(runtime, "project.env.update", options, (context) => runEnvUpdate(context, assignment, {
46
49
  roleName,
47
50
  branchName,
48
- projectRef
51
+ projectRef,
52
+ filePath
49
53
  }), {
50
54
  renderHuman: (context, descriptor, result) => renderEnvUpdate(context, descriptor, result),
51
55
  renderJson: (result) => serializeEnvUpdate(result)
@@ -0,0 +1,54 @@
1
+ import { CliError, authRequiredError } from "../shell/errors.js";
2
+ //#region src/controllers/app-env-api.ts
3
+ async function findVariableByNaturalKey(client, projectId, key, resolved, signal) {
4
+ const { data, error, response } = await client.GET("/v1/environment-variables", {
5
+ params: { query: {
6
+ projectId,
7
+ class: resolved.apiTarget.class,
8
+ key
9
+ } },
10
+ signal
11
+ });
12
+ if (error || !data) throw apiCallError(`Failed to look up ${key}`, response, error);
13
+ return data.data.filter((row) => rowMatchesExactScope(row, resolved))[0] ?? null;
14
+ }
15
+ function toMetadata(row, requestedScope) {
16
+ const rowScope = row.branchId === null ? {
17
+ kind: "role",
18
+ role: row.class
19
+ } : requestedScope;
20
+ return {
21
+ id: row.id,
22
+ key: row.key,
23
+ scope: rowScope,
24
+ source: formatDescriptorLabel(rowScope),
25
+ isManagedBySystem: row.isManagedBySystem,
26
+ updatedAt: row.updatedAt
27
+ };
28
+ }
29
+ function rowMatchesExactScope(row, resolved) {
30
+ return row.class === resolved.apiTarget.class && row.branchId === resolved.apiTarget.branchId;
31
+ }
32
+ function apiCallError(summary, response, error) {
33
+ const status = response?.status ?? 0;
34
+ const apiCode = error?.error?.code;
35
+ const apiMessage = error?.error?.message;
36
+ const apiHint = error?.error?.hint;
37
+ if (status === 401 || status === 403) return authRequiredError(["prisma auth login"]);
38
+ return new CliError({
39
+ code: apiCode ?? "ENV_API_ERROR",
40
+ domain: "app",
41
+ summary,
42
+ why: apiMessage ?? `The Management API returned status ${status || "unknown"}.`,
43
+ fix: apiHint ?? "Re-run with --trace for the underlying API response details.",
44
+ exitCode: 1,
45
+ nextSteps: []
46
+ });
47
+ }
48
+ function formatDescriptorLabel(scope) {
49
+ if (scope.kind === "role") return scope.role ?? "unknown";
50
+ if (scope.kind === "overview") return "overview";
51
+ return `branch:${scope.branchName ?? scope.branchId ?? "unknown"}`;
52
+ }
53
+ //#endregion
54
+ export { apiCallError, findVariableByNaturalKey, toMetadata };
@@ -0,0 +1,179 @@
1
+ import { CliError } from "../shell/errors.js";
2
+ import { formatScopeLabel } from "../lib/app/env-config.js";
3
+ import { apiCallError, findVariableByNaturalKey, toMetadata } from "./app-env-api.js";
4
+ //#region src/controllers/app-env-file.ts
5
+ async function runEnvAddFile(context, client, projectId, resolved, filePath, assignments) {
6
+ const existing = await findVariablesByNaturalKey(client, projectId, assignments.map((assignment) => assignment.key), resolved, context.runtime.signal);
7
+ const existingKeys = assignments.map((assignment) => assignment.key).filter((key) => existing.has(key));
8
+ if (existingKeys.length > 0) throw new CliError({
9
+ code: "ENV_VARIABLE_ALREADY_EXISTS",
10
+ domain: "app",
11
+ summary: `${existingKeys.length} environment variable(s) already exist in ${formatScopeLabel(resolved.scope)}`,
12
+ why: `Existing keys: ${formatKeyList(existingKeys)}.`,
13
+ fix: "Split the input file by key state: update existing keys and add new keys separately.",
14
+ exitCode: 1,
15
+ nextSteps: splitFileNextSteps(filePath, resolved.scope, {
16
+ existingKeys,
17
+ first: "update-existing"
18
+ }),
19
+ meta: { keys: existingKeys }
20
+ });
21
+ const warnings = await missingPreviewDefaultWarnings(client, projectId, resolved.scope, assignments.map((assignment) => assignment.key), context.runtime.signal);
22
+ const variables = [];
23
+ for (const assignment of assignments) try {
24
+ const { data, error, response } = await client.POST("/v1/environment-variables", {
25
+ body: {
26
+ projectId,
27
+ class: resolved.apiTarget.class,
28
+ ...resolved.apiTarget.branchId !== null ? { branchId: resolved.apiTarget.branchId } : {},
29
+ key: assignment.key,
30
+ value: assignment.value
31
+ },
32
+ signal: context.runtime.signal
33
+ });
34
+ if (error || !data) throw apiCallError(`Failed to add ${assignment.key}`, response, error);
35
+ variables.push(toMetadata(data.data, resolved.descriptor));
36
+ } catch (error) {
37
+ throw envFileApplyFailedError("add", filePath, resolved.scope, assignment.key, variables, error);
38
+ }
39
+ return {
40
+ command: "project.env.add",
41
+ result: {
42
+ projectId,
43
+ scope: resolved.descriptor,
44
+ variables,
45
+ file: {
46
+ path: filePath,
47
+ count: variables.length
48
+ }
49
+ },
50
+ warnings,
51
+ nextSteps: []
52
+ };
53
+ }
54
+ async function runEnvUpdateFile(context, client, projectId, resolved, filePath, assignments) {
55
+ const existing = await findVariablesByNaturalKey(client, projectId, assignments.map((assignment) => assignment.key), resolved, context.runtime.signal);
56
+ const missingKeys = assignments.map((assignment) => assignment.key).filter((key) => !existing.has(key));
57
+ if (missingKeys.length > 0) throw new CliError({
58
+ code: "ENV_VARIABLE_NOT_FOUND",
59
+ domain: "app",
60
+ summary: `${missingKeys.length} environment variable(s) not found in ${formatScopeLabel(resolved.scope)}`,
61
+ why: `Missing keys: ${formatKeyList(missingKeys)}.`,
62
+ fix: "Split the input file by key state: add missing keys and update existing keys separately.",
63
+ exitCode: 1,
64
+ nextSteps: splitFileNextSteps(filePath, resolved.scope, {
65
+ missingKeys,
66
+ first: "add-missing"
67
+ }),
68
+ meta: { keys: missingKeys }
69
+ });
70
+ const variables = [];
71
+ for (const assignment of assignments) {
72
+ const existingVariable = existing.get(assignment.key);
73
+ if (!existingVariable) continue;
74
+ try {
75
+ const { data, error, response } = await client.PATCH("/v1/environment-variables/{envVarId}", {
76
+ params: { path: { envVarId: existingVariable.id } },
77
+ body: { value: assignment.value },
78
+ signal: context.runtime.signal
79
+ });
80
+ if (error || !data) throw apiCallError(`Failed to update value for ${assignment.key}`, response, error);
81
+ variables.push(toMetadata(data.data, resolved.descriptor));
82
+ } catch (error) {
83
+ throw envFileApplyFailedError("update", filePath, resolved.scope, assignment.key, variables, error);
84
+ }
85
+ }
86
+ return {
87
+ command: "project.env.update",
88
+ result: {
89
+ projectId,
90
+ scope: resolved.descriptor,
91
+ variables,
92
+ file: {
93
+ path: filePath,
94
+ count: variables.length
95
+ }
96
+ },
97
+ warnings: [],
98
+ nextSteps: []
99
+ };
100
+ }
101
+ async function findVariablesByNaturalKey(client, projectId, keys, resolved, signal) {
102
+ const found = /* @__PURE__ */ new Map();
103
+ for (const key of keys) {
104
+ const row = await findVariableByNaturalKey(client, projectId, key, resolved, signal);
105
+ if (row) found.set(key, row);
106
+ }
107
+ return found;
108
+ }
109
+ async function missingPreviewDefaultWarnings(client, projectId, scope, keys, signal) {
110
+ if (scope.kind !== "branch") return [];
111
+ const previewScope = {
112
+ scope: {
113
+ kind: "role",
114
+ role: "preview"
115
+ },
116
+ descriptor: {
117
+ kind: "role",
118
+ role: "preview"
119
+ },
120
+ apiTarget: {
121
+ class: "preview",
122
+ branchId: null
123
+ }
124
+ };
125
+ const missing = [];
126
+ for (const key of keys) if (!await findVariableByNaturalKey(client, projectId, key, previewScope, signal)) missing.push(key);
127
+ if (missing.length === 0) return [];
128
+ if (missing.length === 1) return [`Variable "${missing[0]}" does not exist in preview. It will only exist on ${formatScopeLabel(scope)}.`];
129
+ return [`Variables ${formatKeyList(missing)} do not exist in preview. They will only exist on ${formatScopeLabel(scope)}.`];
130
+ }
131
+ function envFileApplyFailedError(command, filePath, scope, failedKey, writtenVariables, error) {
132
+ const writtenKeys = writtenVariables.map((variable) => variable.key);
133
+ const cause = error instanceof CliError ? error.summary : error instanceof Error ? error.message : "Unknown error.";
134
+ return new CliError({
135
+ code: "ENV_FILE_APPLY_FAILED",
136
+ domain: "app",
137
+ summary: `Failed to ${command} "${failedKey}" from "${filePath}"`,
138
+ why: writtenKeys.length === 0 ? `No variables were written before ${failedKey} failed. Cause: ${cause}` : `Written keys before failure: ${formatKeyList(writtenKeys)}. Cause: ${cause}`,
139
+ fix: "Inspect the target scope, then retry the remaining keys once the API issue is resolved.",
140
+ exitCode: 1,
141
+ nextSteps: [`prisma-cli project env list ${formatScopeFlag(scope)}`, retryStepForApplyFailure(command, filePath, scope, writtenKeys)],
142
+ meta: {
143
+ file: filePath,
144
+ failedKey,
145
+ writtenKeys
146
+ }
147
+ });
148
+ }
149
+ function retryStepForApplyFailure(command, filePath, scope, writtenKeys) {
150
+ if (command === "update") return `prisma-cli project env update --file ${filePath} ${formatScopeFlag(scope)}`;
151
+ if (writtenKeys.length === 0) return `prisma-cli project env add --file ${filePath} ${formatScopeFlag(scope)}`;
152
+ return `prisma-cli project env add --file <remaining.env> ${formatScopeFlag(scope)}`;
153
+ }
154
+ function splitFileNextSteps(filePath, scope, options) {
155
+ const scopeFlag = formatScopeFlag(scope);
156
+ const existingFile = `${filePath}.existing`;
157
+ const newFile = `${filePath}.new`;
158
+ if (options.first === "update-existing") return [
159
+ `# existing keys: ${formatKeyList(options.existingKeys)}`,
160
+ `prisma-cli project env update --file ${existingFile} ${scopeFlag}`,
161
+ "# new keys only",
162
+ `prisma-cli project env add --file ${newFile} ${scopeFlag}`
163
+ ];
164
+ return [
165
+ `# missing keys: ${formatKeyList(options.missingKeys)}`,
166
+ `prisma-cli project env add --file ${newFile} ${scopeFlag}`,
167
+ "# existing keys only",
168
+ `prisma-cli project env update --file ${existingFile} ${scopeFlag}`
169
+ ];
170
+ }
171
+ function formatKeyList(keys) {
172
+ return keys.map((key) => `"${key}"`).join(", ");
173
+ }
174
+ function formatScopeFlag(scope) {
175
+ if (scope.kind === "role") return `--role ${scope.role}`;
176
+ return `--branch ${scope.branchName}`;
177
+ }
178
+ //#endregion
179
+ export { runEnvAddFile, runEnvUpdateFile };
@@ -5,29 +5,34 @@ import { readLocalGitBranch } from "../lib/git/local-branch.js";
5
5
  import { requireAuthenticatedAuthState } from "./auth.js";
6
6
  import { listRealWorkspaceProjects } from "./project.js";
7
7
  import { formatScopeLabel, parseKeyValuePositional, resolveEnvScope } from "../lib/app/env-config.js";
8
+ import { readEnvFileAssignments } from "../lib/app/env-file.js";
9
+ import { apiCallError, findVariableByNaturalKey, toMetadata } from "./app-env-api.js";
10
+ import { runEnvAddFile, runEnvUpdateFile } from "./app-env-file.js";
8
11
  //#region src/controllers/app-env.ts
9
12
  async function runEnvAdd(context, rawAssignment, flags) {
10
- const { key, value } = parseKeyValuePositional(rawAssignment, "add", context.runtime.env);
13
+ const source = resolveEnvWriteSource(rawAssignment, flags.filePath, "add");
11
14
  const scope = resolveEnvScope(flags, {
12
15
  requireExplicit: true,
13
16
  command: "add"
14
17
  });
15
- if (!scope) throw usageError(`prisma-cli project env add requires --role or --branch`, "Writing without an explicit scope is rejected.", "Pass --role production, --role preview, or --branch <git-name>.", [`prisma-cli project env add ${key}=${value} --role production`], "app");
18
+ if (!scope) throw usageError(`prisma-cli project env add requires --role or --branch`, "Writing without an explicit scope is rejected.", "Pass --role production, --role preview, or --branch <git-name>.", ["prisma-cli project env add KEY=value --role production"], "app");
19
+ const input = await resolveEnvWriteInput(context, source, "add");
16
20
  const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env add");
17
21
  const resolved = await resolveScopeToApi(client, projectId, scope, {
18
22
  createBranchIfMissing: true,
19
23
  signal: context.runtime.signal
20
24
  });
21
- if (await findVariableByNaturalKey(client, projectId, key, resolved, context.runtime.signal)) throw new CliError({
25
+ if (input.kind === "file") return runEnvAddFile(context, client, projectId, resolved, input.filePath, input.assignments);
26
+ if (await findVariableByNaturalKey(client, projectId, input.key, resolved, context.runtime.signal)) throw new CliError({
22
27
  code: "ENV_VARIABLE_ALREADY_EXISTS",
23
28
  domain: "app",
24
- summary: `Variable "${key}" already exists in ${formatScopeLabel(scope)}`,
29
+ summary: `Variable "${input.key}" already exists in ${formatScopeLabel(scope)}`,
25
30
  why: "A variable with this key already exists in the targeted scope.",
26
31
  fix: "Use `prisma-cli project env update` to change an existing variable's value.",
27
32
  exitCode: 1,
28
- nextSteps: [`prisma-cli project env update ${key}=<new-value> ${formatScopeFlag(scope)}`]
33
+ nextSteps: [`prisma-cli project env update ${input.key}=<new-value> ${formatScopeFlag(scope)}`]
29
34
  });
30
- const warnings = scope.kind === "branch" && !await findVariableByNaturalKey(client, projectId, key, {
35
+ const warnings = scope.kind === "branch" && !await findVariableByNaturalKey(client, projectId, input.key, {
31
36
  scope: {
32
37
  kind: "role",
33
38
  role: "preview"
@@ -40,18 +45,18 @@ async function runEnvAdd(context, rawAssignment, flags) {
40
45
  class: "preview",
41
46
  branchId: null
42
47
  }
43
- }, context.runtime.signal) ? [`Variable "${key}" does not exist in preview. It will only exist on ${formatScopeLabel(scope)}.`] : [];
48
+ }, context.runtime.signal) ? [`Variable "${input.key}" does not exist in preview. It will only exist on ${formatScopeLabel(scope)}.`] : [];
44
49
  const { data, error, response } = await client.POST("/v1/environment-variables", {
45
50
  body: {
46
51
  projectId,
47
52
  class: resolved.apiTarget.class,
48
53
  ...resolved.apiTarget.branchId !== null ? { branchId: resolved.apiTarget.branchId } : {},
49
- key,
50
- value
54
+ key: input.key,
55
+ value: input.value
51
56
  },
52
57
  signal: context.runtime.signal
53
58
  });
54
- if (error || !data) throw apiCallError(`Failed to add ${key}`, response, error);
59
+ if (error || !data) throw apiCallError(`Failed to add ${input.key}`, response, error);
55
60
  return {
56
61
  command: "project.env.add",
57
62
  result: {
@@ -64,33 +69,35 @@ async function runEnvAdd(context, rawAssignment, flags) {
64
69
  };
65
70
  }
66
71
  async function runEnvUpdate(context, rawAssignment, flags) {
67
- const { key, value } = parseKeyValuePositional(rawAssignment, "update", context.runtime.env);
72
+ const source = resolveEnvWriteSource(rawAssignment, flags.filePath, "update");
68
73
  const scope = resolveEnvScope(flags, {
69
74
  requireExplicit: true,
70
75
  command: "update"
71
76
  });
72
- if (!scope) throw usageError(`prisma-cli project env update requires --role or --branch`, "Writing without an explicit scope is rejected.", "Pass --role production, --role preview, or --branch <git-name>.", [`prisma-cli project env update ${key}=${value} --role production`], "app");
77
+ if (!scope) throw usageError(`prisma-cli project env update requires --role or --branch`, "Writing without an explicit scope is rejected.", "Pass --role production, --role preview, or --branch <git-name>.", ["prisma-cli project env update KEY=value --role production"], "app");
78
+ const input = await resolveEnvWriteInput(context, source, "update");
73
79
  const { client, projectId } = await requireClientAndProject(context, flags.projectRef, "project env update");
74
80
  const resolved = await resolveScopeToApi(client, projectId, scope, {
75
81
  createBranchIfMissing: false,
76
82
  signal: context.runtime.signal
77
83
  });
78
- const existing = await findVariableByNaturalKey(client, projectId, key, resolved, context.runtime.signal);
84
+ if (input.kind === "file") return runEnvUpdateFile(context, client, projectId, resolved, input.filePath, input.assignments);
85
+ const existing = await findVariableByNaturalKey(client, projectId, input.key, resolved, context.runtime.signal);
79
86
  if (!existing) throw new CliError({
80
87
  code: "ENV_VARIABLE_NOT_FOUND",
81
88
  domain: "app",
82
- summary: `Variable "${key}" not found in ${formatScopeLabel(scope)}`,
89
+ summary: `Variable "${input.key}" not found in ${formatScopeLabel(scope)}`,
83
90
  why: "No variable with this key exists in the targeted scope.",
84
91
  fix: "Use `prisma-cli project env add` to create a new variable.",
85
92
  exitCode: 1,
86
- nextSteps: [`prisma-cli project env add ${key}=<value> ${formatScopeFlag(scope)}`]
93
+ nextSteps: [`prisma-cli project env add ${input.key}=<value> ${formatScopeFlag(scope)}`]
87
94
  });
88
95
  const { data, error, response } = await client.PATCH("/v1/environment-variables/{envVarId}", {
89
96
  params: { path: { envVarId: existing.id } },
90
- body: { value },
97
+ body: { value: input.value },
91
98
  signal: context.runtime.signal
92
99
  });
93
- if (error || !data) throw apiCallError(`Failed to update value for ${key}`, response, error);
100
+ if (error || !data) throw apiCallError(`Failed to update value for ${input.key}`, response, error);
94
101
  return {
95
102
  command: "project.env.update",
96
103
  result: {
@@ -102,6 +109,32 @@ async function runEnvUpdate(context, rawAssignment, flags) {
102
109
  nextSteps: []
103
110
  };
104
111
  }
112
+ function resolveEnvWriteSource(rawAssignment, filePath, command) {
113
+ if (filePath !== void 0 && rawAssignment !== void 0) throw usageError(`prisma-cli project env ${command} accepts either KEY=VALUE or --file`, "The command received both a positional assignment and a dotenv file path.", "Pass one input source.", [`prisma-cli project env ${command} KEY=value --role preview`, `prisma-cli project env ${command} --file .env --role preview`], "app");
114
+ if (filePath !== void 0) {
115
+ if (filePath.length === 0) throw usageError(`prisma-cli project env ${command} --file requires a path`, "The --file flag was passed without a file path.", "Pass a readable dotenv file path.", [`prisma-cli project env ${command} --file .env --role preview`], "app");
116
+ return {
117
+ kind: "file",
118
+ filePath
119
+ };
120
+ }
121
+ if (rawAssignment === void 0) throw usageError(`prisma-cli project env ${command} requires KEY=VALUE or --file`, "No environment variable input was supplied.", "Pass a single KEY=VALUE assignment or a dotenv file path.", [`prisma-cli project env ${command} KEY=value --role preview`, `prisma-cli project env ${command} --file .env --role preview`], "app");
122
+ return {
123
+ kind: "single",
124
+ rawAssignment
125
+ };
126
+ }
127
+ async function resolveEnvWriteInput(context, source, command) {
128
+ if (source.kind === "file") return {
129
+ kind: "file",
130
+ filePath: source.filePath,
131
+ assignments: await readEnvFileAssignments(context.runtime.cwd, source.filePath, command)
132
+ };
133
+ return {
134
+ kind: "single",
135
+ ...parseKeyValuePositional(source.rawAssignment, command, context.runtime.env)
136
+ };
137
+ }
105
138
  async function runEnvList(context, flags) {
106
139
  const explicit = resolveEnvScope(flags, {
107
140
  requireExplicit: false,
@@ -410,18 +443,6 @@ async function projectHasDefaultBranch(client, projectId, signal) {
410
443
  cursor = result.data.pagination.nextCursor;
411
444
  }
412
445
  }
413
- async function findVariableByNaturalKey(client, projectId, key, resolved, signal) {
414
- const { data, error, response } = await client.GET("/v1/environment-variables", {
415
- params: { query: {
416
- projectId,
417
- class: resolved.apiTarget.class,
418
- key
419
- } },
420
- signal
421
- });
422
- if (error || !data) throw apiCallError(`Failed to look up ${key}`, response, error);
423
- return data.data.filter((row) => rowMatchesExactScope(row, resolved))[0] ?? null;
424
- }
425
446
  async function listVariables(client, projectId, resolved, signal) {
426
447
  return materializeEffectiveRows(await collectEnvironmentVariables(client, projectId, signal, {
427
448
  className: resolved.apiTarget.class,
@@ -461,9 +482,6 @@ function rowMatchesScope(row, resolved) {
461
482
  if (resolved.apiTarget.branchId === null) return row.branchId === null;
462
483
  return row.branchId === null || row.branchId === resolved.apiTarget.branchId;
463
484
  }
464
- function rowMatchesExactScope(row, resolved) {
465
- return row.class === resolved.apiTarget.class && row.branchId === resolved.apiTarget.branchId;
466
- }
467
485
  function materializeEffectiveRows(rows, resolved) {
468
486
  if (resolved.apiTarget.branchId === null) return rows;
469
487
  const byKey = /* @__PURE__ */ new Map();
@@ -471,40 +489,5 @@ function materializeEffectiveRows(rows, resolved) {
471
489
  for (const row of rows) if (row.branchId === resolved.apiTarget.branchId) byKey.set(row.key, row);
472
490
  return [...byKey.values()].sort((left, right) => left.key.localeCompare(right.key));
473
491
  }
474
- function toMetadata(row, requestedScope) {
475
- const rowScope = row.branchId === null ? {
476
- kind: "role",
477
- role: row.class
478
- } : requestedScope;
479
- return {
480
- id: row.id,
481
- key: row.key,
482
- scope: rowScope,
483
- source: formatDescriptorLabel(rowScope),
484
- isManagedBySystem: row.isManagedBySystem,
485
- updatedAt: row.updatedAt
486
- };
487
- }
488
- function formatDescriptorLabel(scope) {
489
- if (scope.kind === "role") return scope.role ?? "unknown";
490
- if (scope.kind === "overview") return "overview";
491
- return `branch:${scope.branchName ?? scope.branchId ?? "unknown"}`;
492
- }
493
- function apiCallError(summary, response, error) {
494
- const status = response?.status ?? 0;
495
- const apiCode = error?.error?.code;
496
- const apiMessage = error?.error?.message;
497
- const apiHint = error?.error?.hint;
498
- if (status === 401 || status === 403) return authRequiredError(["prisma auth login"]);
499
- return new CliError({
500
- code: apiCode ?? "ENV_API_ERROR",
501
- domain: "app",
502
- summary,
503
- why: apiMessage ?? `The Management API returned status ${status || "unknown"}.`,
504
- fix: apiHint ?? "Re-run with --trace for the underlying API response details.",
505
- exitCode: 1,
506
- nextSteps: []
507
- });
508
- }
509
492
  //#endregion
510
493
  export { runEnvAdd, runEnvList, runEnvRemove, runEnvUpdate };
@@ -64,4 +64,4 @@ function formatScopeLabel(scope) {
64
64
  return `branch:${scope.branchName}`;
65
65
  }
66
66
  //#endregion
67
- export { formatScopeLabel, parseKeyValuePositional, resolveEnvScope };
67
+ export { formatScopeLabel, parseKeyValuePositional, resolveEnvScope, validateKey };
@@ -0,0 +1,82 @@
1
+ import { usageError } from "../../shell/errors.js";
2
+ import { validateKey } from "./env-config.js";
3
+ import { readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { parse } from "dotenv";
6
+ //#region src/lib/app/env-file.ts
7
+ const ASSIGNMENT_KEY_PATTERN = /^\s*(?:export\s+)?([^#=\s]+)\s*=/;
8
+ async function readEnvFileAssignments(cwd, filePath, command) {
9
+ const resolvedPath = path.resolve(cwd, filePath);
10
+ let contents;
11
+ try {
12
+ contents = await readFile(resolvedPath, "utf8");
13
+ } catch (error) {
14
+ throw usageError(`Failed to read env file "${filePath}"`, error instanceof Error ? error.message : "The file could not be read.", "Pass a readable dotenv file path.", [`prisma-cli project env ${command} --file .env --role preview`], "app");
15
+ }
16
+ return parseEnvFileContents(contents, filePath, command);
17
+ }
18
+ function parseEnvFileContents(contents, filePath, command) {
19
+ const parsedKeys = extractParsedKeys(contents);
20
+ if (parsedKeys.length === 0) throw usageError(`No environment variables found in "${filePath}"`, "The file does not contain any KEY=VALUE assignments.", "Pass a dotenv file with at least one non-empty variable.", [], "app");
21
+ const seen = /* @__PURE__ */ new Map();
22
+ for (const entry of parsedKeys) {
23
+ validateEnvFileKey(entry.key, entry.line, filePath, command);
24
+ const firstLine = seen.get(entry.key);
25
+ if (firstLine !== void 0) throw usageError(`Duplicate environment variable "${entry.key}" in "${filePath}"`, `Lines ${firstLine} and ${entry.line} both define ${entry.key}.`, "Keep one assignment for each key before importing the file.", [], "app");
26
+ seen.set(entry.key, entry.line);
27
+ }
28
+ const parsedValues = parse(contents);
29
+ return parsedKeys.map(({ key }) => {
30
+ const value = parsedValues[key];
31
+ if (typeof value !== "string" || value.length === 0) {
32
+ const line = seen.get(key);
33
+ throw usageError(`Environment variable "${key}" in "${filePath}" has an empty value`, line === void 0 ? `${key} has an empty value.` : `Line ${line} defines ${key} with an empty value.`, "Pass a non-empty value, or omit the key from the file.", [], "app");
34
+ }
35
+ return {
36
+ key,
37
+ value
38
+ };
39
+ });
40
+ }
41
+ function extractParsedKeys(contents) {
42
+ const keys = [];
43
+ let multilineQuote = null;
44
+ const lines = contents.split(/\n/);
45
+ for (const [index, line] of lines.entries()) {
46
+ const lineNumber = index + 1;
47
+ if (multilineQuote !== null) {
48
+ if (hasClosingQuote(line, multilineQuote, 0)) multilineQuote = null;
49
+ continue;
50
+ }
51
+ const match = ASSIGNMENT_KEY_PATTERN.exec(line);
52
+ if (!match) continue;
53
+ const key = match[1];
54
+ keys.push({
55
+ key,
56
+ line: lineNumber
57
+ });
58
+ const valueStart = line.slice(match[0].length).trimStart();
59
+ const openingQuote = valueStart[0];
60
+ if ((openingQuote === "\"" || openingQuote === "'" || openingQuote === "`") && !hasClosingQuote(valueStart, openingQuote, 1)) multilineQuote = openingQuote;
61
+ }
62
+ return keys;
63
+ }
64
+ function validateEnvFileKey(key, line, filePath, command) {
65
+ try {
66
+ validateKey(key, command);
67
+ } catch (error) {
68
+ const reason = error instanceof Error && error.message.length > 0 ? error.message : "Invalid environment variable key.";
69
+ throw usageError(`Invalid environment variable "${key}" in "${filePath}"`, `Line ${line}: ${reason}`, "Use a valid env-var key and retry the import.", [], "app");
70
+ }
71
+ }
72
+ function hasClosingQuote(value, quote, startIndex) {
73
+ for (let index = startIndex; index < value.length; index += 1) if (value[index] === quote && !isEscaped(value, index)) return true;
74
+ return false;
75
+ }
76
+ function isEscaped(value, index) {
77
+ let backslashes = 0;
78
+ for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) backslashes += 1;
79
+ return backslashes % 2 === 1;
80
+ }
81
+ //#endregion
82
+ export { readEnvFileAssignments };
@@ -5,8 +5,12 @@ import { readFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  //#region src/lib/project/resolution.ts
7
7
  async function resolveProjectTarget(options) {
8
+ const localPin = await readImplicitLocalPin(options, { allowEnvProjectId: true });
8
9
  const projects = await options.listProjects();
9
- const target = await resolveBoundProjectTarget(options, projects, { allowEnvProjectId: true });
10
+ const target = await resolveBoundProjectTarget(options, projects, {
11
+ allowEnvProjectId: true,
12
+ localPin
13
+ });
10
14
  if (target) return target;
11
15
  throw await projectSetupRequiredError({
12
16
  cwd: options.context.runtime.cwd,
@@ -16,8 +20,12 @@ async function resolveProjectTarget(options) {
16
20
  });
17
21
  }
18
22
  async function inspectProjectBinding(options) {
23
+ const localPin = await readImplicitLocalPin(options, { allowEnvProjectId: false });
19
24
  const projects = await options.listProjects();
20
- const target = await resolveBoundProjectTarget(options, projects, { allowEnvProjectId: false });
25
+ const target = await resolveBoundProjectTarget(options, projects, {
26
+ allowEnvProjectId: false,
27
+ localPin
28
+ });
21
29
  if (target) return target;
22
30
  return {
23
31
  workspace: options.workspace,
@@ -73,6 +81,28 @@ function localStateStaleError() {
73
81
  nextSteps: ["prisma-cli project list", "prisma-cli project link <id-or-name>"]
74
82
  });
75
83
  }
84
+ function localProjectWorkspaceMismatchError(options) {
85
+ return new CliError({
86
+ code: "LOCAL_PROJECT_WORKSPACE_MISMATCH",
87
+ domain: "project",
88
+ summary: "Project link uses another workspace",
89
+ why: `${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} links this directory to project ${options.pinnedProjectId} in workspace ${options.pinnedWorkspaceId}, but your current CLI session is workspace "${options.activeWorkspace.name}" (${options.activeWorkspace.id}).`,
90
+ fix: "Sign in to the linked workspace, or relink this directory to a project in the current workspace.",
91
+ meta: {
92
+ pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH,
93
+ pinnedWorkspaceId: options.pinnedWorkspaceId,
94
+ pinnedProjectId: options.pinnedProjectId,
95
+ activeWorkspaceId: options.activeWorkspace.id,
96
+ activeWorkspaceName: options.activeWorkspace.name
97
+ },
98
+ exitCode: 1,
99
+ nextSteps: [
100
+ "prisma-cli auth login",
101
+ "prisma-cli project list",
102
+ "prisma-cli project link <id-or-name>"
103
+ ]
104
+ });
105
+ }
76
106
  async function buildProjectSetupSuggestion(options) {
77
107
  const suggestedName = await inferTargetName(options.cwd, options.signal);
78
108
  const candidates = sortProjects(options.projects.filter((project) => projectMatchesSuggestedName(project, suggestedName.name))).map(toProjectSummary);
@@ -192,10 +222,15 @@ async function resolveBoundProjectTarget(options, projects, settings) {
192
222
  targetNameSource: "env"
193
223
  });
194
224
  }
195
- const localPin = await readLocalResolutionPin(options.context.runtime.cwd, options.context.runtime.signal);
225
+ const localPin = settings.localPin;
226
+ if (!localPin) return null;
196
227
  if (localPin.kind === "invalid") throw localStateStaleError();
197
228
  if (localPin.kind === "present") {
198
- if (localPin.pin.workspaceId !== options.workspace.id) throw localStateStaleError();
229
+ if (localPin.pin.workspaceId !== options.workspace.id) throw localProjectWorkspaceMismatchError({
230
+ pinnedWorkspaceId: localPin.pin.workspaceId,
231
+ pinnedProjectId: localPin.pin.projectId,
232
+ activeWorkspace: options.workspace
233
+ });
199
234
  const project = projects.find((candidate) => candidate.id === localPin.pin.projectId);
200
235
  if (!project) throw localStateStaleError();
201
236
  return resolvedTarget(options.workspace, project, "local-pin", {
@@ -210,6 +245,16 @@ async function resolveBoundProjectTarget(options, projects, settings) {
210
245
  });
211
246
  return null;
212
247
  }
248
+ async function readImplicitLocalPin(options, settings) {
249
+ if (options.explicitProject || settings.allowEnvProjectId && options.envProjectId) return null;
250
+ const localPin = await readLocalResolutionPin(options.context.runtime.cwd, options.context.runtime.signal);
251
+ if (localPin.kind === "present" && localPin.pin.workspaceId !== options.workspace.id) throw localProjectWorkspaceMismatchError({
252
+ pinnedWorkspaceId: localPin.pin.workspaceId,
253
+ pinnedProjectId: localPin.pin.projectId,
254
+ activeWorkspace: options.workspace
255
+ });
256
+ return localPin;
257
+ }
213
258
  function resolvedTarget(workspace, project, projectSource, resolutionDetails) {
214
259
  return {
215
260
  workspace,
@@ -15,6 +15,21 @@ function listTargetLabel(result) {
15
15
  return scopeLabel(result.scope);
16
16
  }
17
17
  function renderEnvAdd(context, descriptor, result) {
18
+ if (result.variables !== void 0) return renderList({
19
+ title: "Setting new environment variables from file.",
20
+ descriptor,
21
+ parentContext: {
22
+ key: "target",
23
+ value: `${scopeLabel(result.scope)} from ${result.file.path}`
24
+ },
25
+ items: result.variables.map((variable) => ({
26
+ noun: "variable",
27
+ label: `${variable.key} (${variable.source})`,
28
+ id: variable.id,
29
+ status: variable.isManagedBySystem ? "default" : null
30
+ })),
31
+ emptyMessage: "No environment variables imported."
32
+ }, context.ui);
18
33
  return renderShow({
19
34
  title: "Setting a new environment variable.",
20
35
  descriptor,
@@ -48,6 +63,21 @@ function serializeEnvAdd(result) {
48
63
  return result;
49
64
  }
50
65
  function renderEnvUpdate(context, descriptor, result) {
66
+ if (result.variables !== void 0) return renderList({
67
+ title: "Replacing environment variable values from file.",
68
+ descriptor,
69
+ parentContext: {
70
+ key: "target",
71
+ value: `${scopeLabel(result.scope)} from ${result.file.path}`
72
+ },
73
+ items: result.variables.map((variable) => ({
74
+ noun: "variable",
75
+ label: `${variable.key} (${variable.source})`,
76
+ id: variable.id,
77
+ status: variable.isManagedBySystem ? "default" : null
78
+ })),
79
+ emptyMessage: "No environment variables updated."
80
+ }, context.ui);
51
81
  return renderShow({
52
82
  title: "Replacing the environment variable's value.",
53
83
  descriptor,
@@ -1,25 +1,24 @@
1
1
  import { padDisplay, renderNextSteps, renderSummaryLine } from "../shell/ui.js";
2
2
  import { formatDescriptorLabel } from "../shell/command-meta.js";
3
3
  import { formatCommandArgument } from "../shell/command-arguments.js";
4
- import { renderList, renderMutate, renderShow, serializeList } from "../output/patterns.js";
4
+ import { renderMutate, renderShow, serializeList } from "../output/patterns.js";
5
5
  import path from "node:path";
6
+ import stringWidth from "string-width";
6
7
  //#region src/presenters/project.ts
7
8
  function renderProjectList(context, descriptor, result) {
8
- const lines = renderList({
9
- title: "Listing projects for the authenticated workspace.",
10
- descriptor,
11
- parentContext: {
12
- key: "workspace",
13
- value: result.workspace.name
14
- },
15
- items: result.projects.map((project) => ({
16
- noun: "project",
17
- label: project.name,
18
- id: project.id,
19
- status: null
20
- })),
21
- emptyMessage: "No projects found."
22
- }, context.ui);
9
+ const ui = context.ui;
10
+ const rail = ui.dim("│");
11
+ const lines = [`${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Listing projects for the authenticated workspace.")}`, ""];
12
+ lines.push(`${rail} ${ui.accent("workspace:")} ${result.workspace.name}`);
13
+ if (result.projects.length === 0) {
14
+ lines.push(`${rail} ${ui.dim("No projects found.")}`);
15
+ if (result.localBinding?.status === "not-linked" || result.localBinding?.status === "invalid") lines.push(...renderNextSteps(["Link an existing Project you choose: prisma-cli project link <id-or-name>", "Create a new Project: prisma-cli project create <name>"]));
16
+ return lines;
17
+ }
18
+ const nameWidth = Math.max(4, ...result.projects.map((project) => stringWidth(project.name)));
19
+ lines.push(rail);
20
+ lines.push(`${rail} ${ui.accent(padDisplay("name", nameWidth))} ${ui.accent("id")}`);
21
+ for (const project of result.projects) lines.push(`${rail} ${padDisplay(project.name, nameWidth)} ${project.id}`);
23
22
  if (result.localBinding?.status === "not-linked" || result.localBinding?.status === "invalid") lines.push(...renderNextSteps(["Link an existing Project you choose: prisma-cli project link <id-or-name>", "Create a new Project: prisma-cli project create <name>"]));
24
23
  return lines;
25
24
  }
@@ -363,6 +363,7 @@ const DESCRIPTORS = [
363
363
  examples: [
364
364
  "prisma-cli project env list",
365
365
  "prisma-cli project env add STRIPE_KEY=sk_test_xxx --role production",
366
+ "prisma-cli project env add --file .env --role preview",
366
367
  "prisma-cli project env add DATABASE_URL=postgresql://branch --branch feature/foo",
367
368
  "prisma-cli project env remove STRIPE_KEY --role preview"
368
369
  ]
@@ -379,7 +380,9 @@ const DESCRIPTORS = [
379
380
  examples: [
380
381
  "prisma-cli project env add STRIPE_KEY=sk_test_xxx --role production",
381
382
  "prisma-cli project env add STRIPE_KEY=sk_test_xxx --role preview",
383
+ "prisma-cli project env add --file .env --role preview",
382
384
  "prisma-cli project env add DATABASE_URL=postgresql://branch --branch feature/foo",
385
+ "prisma-cli project env add --file .env.local --branch feature/foo",
383
386
  "API_URL=https://api.example prisma-cli project env add API_URL --project proj_123 --role preview"
384
387
  ]
385
388
  },
@@ -395,6 +398,7 @@ const DESCRIPTORS = [
395
398
  examples: [
396
399
  "prisma-cli project env update STRIPE_KEY=sk_new_xxx --role production",
397
400
  "prisma-cli project env update STRIPE_KEY=sk_new_xxx --role preview",
401
+ "prisma-cli project env update --file .env --role production",
398
402
  "prisma-cli project env update DATABASE_URL=postgresql://branch --branch feature/foo"
399
403
  ]
400
404
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prisma/cli",
3
- "version": "3.0.0-dev.61.1",
3
+ "version": "3.0.0-dev.63.1",
4
4
  "description": "Command-line interface for the Prisma Developer Platform.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,6 +42,7 @@
42
42
  "c12": "4.0.0-beta.5",
43
43
  "colorette": "^2.0.20",
44
44
  "commander": "^14.0.3",
45
+ "dotenv": "^17.4.2",
45
46
  "magicast": "^0.5.3",
46
47
  "open": "^11.0.0",
47
48
  "string-width": "^8.2.1",