@the-meridian/cli 1.0.3 → 1.1.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @the-meridian/cli
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ef6a53f: Add `meridian db credential list` and `meridian db credential revoke <credential>` for the credentials a backend running outside Meridian uses to reach a hosted environment's MySQL database through the Meridian database gateway. `list` shows each credential's label, MySQL user, access mode, status, last connection and certificate expiry; `revoke` drops a credential after asking (or with `--yes`), so a leaked one can be cut off from a terminal. Both take `--env <name>`. Creating and rotating credentials stay in the dashboard.
8
+
9
+ ### Patch Changes
10
+
11
+ - be18bb6: The per-developer MySQL database is now called a **developer database** in every message and command description, matching the **Developer databases** page in the dashboard. Nothing about how it works changes.
12
+
3
13
  ## 1.0.3
4
14
 
5
15
  ### Patch Changes
package/README.md CHANGED
@@ -98,6 +98,17 @@ Runs `npx prisma db seed` with the proxy up and `DATABASE_URL` set. Extra args:
98
98
 
99
99
  Opens a SQL shell against the dev database: the real `mysql` client when it's on `PATH` (the password is passed via `MYSQL_PWD`, so it never appears in `ps`), otherwise a small built-in REPL over `mysql2`. `--repl` forces the built-in one, and `-e "SELECT 1"` runs a single statement and exits (`--json` formats the rows as JSON).
100
100
 
101
+ ### `meridian db credential`
102
+
103
+ Lists and revokes credentials for reaching a hosted environment's MySQL database from a backend running outside Meridian, through the Meridian database gateway (mutual TLS plus a password). This acts on a hosted environment's database, not the developer database.
104
+
105
+ ```bash
106
+ meridian db credential list --env production
107
+ meridian db credential revoke aws-backend --env production --yes
108
+ ```
109
+
110
+ Creating and rotating credentials happen in the dashboard (Hosting > Database > External access), behind a browser session: a CLI token cannot issue database access. Revoking needs the Credentials: Manage permission on the app.
111
+
101
112
  ### Global flags
102
113
 
103
114
  Accepted before or after the command name, so both `meridian --json whoami` and `meridian whoami --json` work.
package/dist/index.js CHANGED
@@ -117,6 +117,51 @@ var migrationRunSchema = z.object({
117
117
  output_excerpt: z.string().nullish(),
118
118
  created_at: z.string().nullish()
119
119
  }).loose();
120
+ function envelopeSchema(data) {
121
+ return z.object({ data }).loose();
122
+ }
123
+ var hostingAddonSchema = z.object({
124
+ uuid: z.string(),
125
+ type: z.string().nullish(),
126
+ name: z.string().nullish(),
127
+ status: z.string().nullish(),
128
+ database: z.object({
129
+ type: z.string().nullish(),
130
+ database: z.string().nullish()
131
+ }).loose().nullish()
132
+ }).loose();
133
+ var hostingEnvironmentSchema = z.object({
134
+ uuid: z.string(),
135
+ type: z.string().nullish(),
136
+ name: z.string().nullish(),
137
+ addons: z.array(hostingAddonSchema).nullish()
138
+ }).loose();
139
+ var externalCredentialSchema = z.object({
140
+ id: z.string(),
141
+ label: z.string(),
142
+ username: z.string(),
143
+ access_mode: z.string(),
144
+ max_connections: z.number().nullish(),
145
+ allowed_cidrs: z.array(z.string()).nullish(),
146
+ certificate_serial: z.string().nullish(),
147
+ certificate_expires_at: z.string().nullish(),
148
+ last_connected_at: z.string().nullish(),
149
+ last_connected_ip: z.string().nullish(),
150
+ locked_until: z.string().nullish(),
151
+ status: z.string(),
152
+ revoked_at: z.string().nullish(),
153
+ created_at: z.string().nullish()
154
+ }).loose();
155
+ var externalCredentialListMetaSchema = z.object({
156
+ available: z.boolean().nullish(),
157
+ host: z.string().nullish(),
158
+ port: z.coerce.number().nullish(),
159
+ max_credentials: z.number().nullish()
160
+ }).loose();
161
+ var externalCredentialListSchema = z.object({
162
+ data: z.array(externalCredentialSchema),
163
+ meta: externalCredentialListMetaSchema.nullish()
164
+ }).loose();
120
165
 
121
166
  // src/lib/errors.ts
122
167
  var CliError = class extends Error {
@@ -313,6 +358,28 @@ var ApiClient = class _ApiClient {
313
358
  `/app/cli/apps/${encodePathSegment(appId)}/dev-database/migrations`
314
359
  );
315
360
  }
361
+ /** `GET /app/apps/{app}/environments`, each with its add-ons. Used to find an environment's database. */
362
+ async listEnvironments(appId) {
363
+ const body = await this.request(
364
+ envelopeSchema(z2.array(hostingEnvironmentSchema)),
365
+ "GET",
366
+ `/app/apps/${encodePathSegment(appId)}/environments`
367
+ );
368
+ return body.data;
369
+ }
370
+ /** `GET /app/addons/{addon}/database/external-credentials`: the credentials, plus whether external access is on. */
371
+ async listExternalCredentials(addonId) {
372
+ const body = await this.request(externalCredentialListSchema, "GET", externalCredentialsPath(addonId));
373
+ return { credentials: body.data, meta: body.meta ?? {} };
374
+ }
375
+ async revokeExternalCredential(addonId, credentialId) {
376
+ const body = await this.request(
377
+ envelopeSchema(externalCredentialSchema),
378
+ "DELETE",
379
+ `${externalCredentialsPath(addonId)}/${encodePathSegment(credentialId)}`
380
+ );
381
+ return body.data;
382
+ }
316
383
  #buildUrl(path, query) {
317
384
  const url = new URL(`${this.baseUrl}${path}`);
318
385
  if (query !== void 0) {
@@ -370,6 +437,9 @@ var ApiClient = class _ApiClient {
370
437
  function encodePathSegment(id) {
371
438
  return encodeURIComponent(String(id));
372
439
  }
440
+ function externalCredentialsPath(addonId) {
441
+ return `/app/addons/${encodePathSegment(addonId)}/database/external-credentials`;
442
+ }
373
443
  function parseJson(text, method, path) {
374
444
  try {
375
445
  return JSON.parse(text);
@@ -452,9 +522,10 @@ function extractErrorCode(body) {
452
522
  return record !== void 0 && typeof record.error === "string" ? record.error : void 0;
453
523
  }
454
524
  var HINTS_BY_ERROR_CODE = {
455
- dev_database_limit_reached: "Your plan does not include a development database for you on this app. Every collaborator gets their own out of the plan's allowance, so upgrading the organization's plan in the Meridian dashboard is what unlocks it.",
525
+ dev_database_limit_reached: "Your plan does not include a developer database for you on this app. Every collaborator gets their own out of the plan's allowance, so upgrading the organization's plan in the Meridian dashboard is what unlocks it.",
456
526
  dev_database_over_storage_limit: "Run `meridian db reset` to drop and recreate the schema. Reset and delete stay available while you're over the limit.",
457
- FEATURE_NOT_ENTITLED: "Your organization's plan does not include managed development databases; upgrade it in the Meridian dashboard."
527
+ EXTERNAL_ACCESS_UNAVAILABLE: "External access is not switched on for this database yet, or the database is not ready. Check the database under Hosting in the Meridian dashboard.",
528
+ FEATURE_NOT_ENTITLED: "Your organization's plan does not include managed developer databases; upgrade it in the Meridian dashboard."
458
529
  };
459
530
  function hintForError(status, errorCode) {
460
531
  if (errorCode !== void 0) {
@@ -465,7 +536,7 @@ function hintForError(status, errorCode) {
465
536
  if (status === 403) return "You may not have access to this organization or app; check your Meridian membership.";
466
537
  if (status === 404) return "Run `meridian link` to select a valid organization and app.";
467
538
  if (status === 409) {
468
- return "The development database is not ready yet. Wait for provisioning to finish, then try again. `meridian dev` polls for you.";
539
+ return "The developer database is not ready yet. Wait for provisioning to finish, then try again. `meridian dev` polls for you.";
469
540
  }
470
541
  if (status === 422) return "Check the request for validation errors and try again.";
471
542
  if (status === 429) return "You're being rate limited by the Meridian API; wait a moment and try again.";
@@ -1852,7 +1923,7 @@ function addShadowDatabaseUrl(root) {
1852
1923
  function wrongUrlError(datasource, url) {
1853
1924
  const problem = url.kind === "literal" ? `hardcodes \`url = "${url.value}"\`` : `reads \`env("${url.name}")\`, but Meridian sets \`${REQUIRED_URL_ENV}\``;
1854
1925
  return new CliError(
1855
- `\`${datasource.schemaPath}\` ${problem}, so this command would not touch the managed development database.`,
1926
+ `\`${datasource.schemaPath}\` ${problem}, so this command would not touch the managed developer database.`,
1856
1927
  {
1857
1928
  hint: `Change the \`${datasource.name}\` datasource to \`url = env("${REQUIRED_URL_ENV}")\` with \`provider = "${REQUIRED_PROVIDER}"\`. Migrations generated against another database use dialect-specific SQL and have to be regenerated. ${BYPASS_HINT}`
1858
1929
  }
@@ -1860,7 +1931,7 @@ function wrongUrlError(datasource, url) {
1860
1931
  }
1861
1932
  function wrongProviderError(datasource, provider) {
1862
1933
  return new CliError(
1863
- `\`${datasource.schemaPath}\` sets \`provider = "${provider}"\`, but the managed development database is MySQL.`,
1934
+ `\`${datasource.schemaPath}\` sets \`provider = "${provider}"\`, but the managed developer database is MySQL.`,
1864
1935
  {
1865
1936
  hint: `Change the \`${datasource.name}\` datasource to \`provider = "${REQUIRED_PROVIDER}"\` and regenerate any migrations generated for ${provider}, which use dialect-specific SQL. ${BYPASS_HINT}`
1866
1937
  }
@@ -2541,17 +2612,17 @@ var DEV_DATABASE_ID_ENV = "MERIDIAN_DEV_DATABASE_ID";
2541
2612
  function provisioningFailedError(devDatabase) {
2542
2613
  const reason = devDatabase.error_message;
2543
2614
  const detail2 = typeof reason === "string" && reason.trim() !== "" ? ` ${reason.trim()}` : "";
2544
- return new CliError(`Provisioning the development database failed.${detail2}`, {
2615
+ return new CliError(`Provisioning the developer database failed.${detail2}`, {
2545
2616
  hint: "Check the Meridian dashboard for details, or contact support if this keeps happening."
2546
2617
  });
2547
2618
  }
2548
2619
  function provisioningTimeoutError() {
2549
- return new CliError("Timed out waiting for the development database to be provisioned.", {
2620
+ return new CliError("Timed out waiting for the developer database to be provisioned.", {
2550
2621
  hint: "Try `meridian dev` again in a few minutes, and check the Meridian dashboard for its status."
2551
2622
  });
2552
2623
  }
2553
2624
  function provisioningNotFoundError() {
2554
- return new CliError("The Meridian API never finished creating the development database.", {
2625
+ return new CliError("The Meridian API never finished creating the developer database.", {
2555
2626
  hint: "Try `meridian dev` again; if this keeps happening, contact Meridian support."
2556
2627
  });
2557
2628
  }
@@ -2563,7 +2634,7 @@ async function pollDevDatabase(client, appId, options, spinner2) {
2563
2634
  const start = now();
2564
2635
  const report = (status) => {
2565
2636
  const elapsedMs = now() - start;
2566
- spinner2.update(`Provisioning the development database... (${status}, ${Math.round(elapsedMs / 1e3)}s)`);
2637
+ spinner2.update(`Provisioning the developer database... (${status}, ${Math.round(elapsedMs / 1e3)}s)`);
2567
2638
  options.onStatus?.(status, elapsedMs);
2568
2639
  };
2569
2640
  let current2 = await client.ensureDevDatabase(appId);
@@ -2590,9 +2661,9 @@ async function pollDevDatabase(client, appId, options, spinner2) {
2590
2661
  }
2591
2662
  async function ensureDevDatabaseReady(client, appId, options = {}) {
2592
2663
  return withSpinner(
2593
- "Provisioning the development database...",
2664
+ "Provisioning the developer database...",
2594
2665
  (spinner2) => pollDevDatabase(client, appId, options, spinner2),
2595
- { success: (db) => `Development database "${db.database}" is ready.` }
2666
+ { success: (db) => `Developer database "${db.database}" is ready.` }
2596
2667
  );
2597
2668
  }
2598
2669
  function toProxyToken(token) {
@@ -2787,6 +2858,232 @@ async function startDevSession(options = {}) {
2787
2858
  }
2788
2859
  }
2789
2860
 
2861
+ // src/lib/db/external-credential.ts
2862
+ function describeAccessMode(mode) {
2863
+ if (mode === "read_only") return "read-only";
2864
+ if (mode === "read_write") return "read-write";
2865
+ return mode;
2866
+ }
2867
+ function resolveCredentialRef(credentials, ref) {
2868
+ const wanted = ref.trim();
2869
+ const byId = credentials.find((credential) => credential.id === wanted);
2870
+ if (byId !== void 0) return byId;
2871
+ const matchers = [
2872
+ (credential) => credential.label === wanted,
2873
+ (credential) => credential.label.toLowerCase() === wanted.toLowerCase()
2874
+ ];
2875
+ for (const matches of matchers) {
2876
+ const found = credentials.filter(matches);
2877
+ const live = found.filter((credential) => credential.status !== "revoked");
2878
+ const pool = live.length > 0 ? live : found;
2879
+ if (pool.length === 1 && pool[0] !== void 0) return pool[0];
2880
+ if (pool.length > 1) {
2881
+ throw new CliError(`More than one credential is labelled "${wanted}".`, {
2882
+ hint: `Name it by id instead: ${pool.map((credential) => credential.id).join(", ")}.`
2883
+ });
2884
+ }
2885
+ }
2886
+ throw new CliError(`No credential matches "${wanted}".`, {
2887
+ hint: "Run `meridian db credential list` to see each credential's label and id."
2888
+ });
2889
+ }
2890
+ function isMysqlDatabase(addon) {
2891
+ if (addon.type !== "database") return false;
2892
+ const type = addon.database?.type;
2893
+ return type === void 0 || type === null || type === "mysql";
2894
+ }
2895
+ function databaseTargets(environments) {
2896
+ const targets = [];
2897
+ for (const environment of environments) {
2898
+ const addon = (environment.addons ?? []).find(isMysqlDatabase);
2899
+ if (addon !== void 0) targets.push({ environment, addon });
2900
+ }
2901
+ return targets;
2902
+ }
2903
+ function environmentLabel(environment) {
2904
+ return environment.name ?? environment.type ?? environment.uuid;
2905
+ }
2906
+ function matchEnvironment(targets, wanted) {
2907
+ const target = wanted.trim().toLowerCase();
2908
+ return targets.find(
2909
+ ({ environment }) => [environment.name, environment.type, environment.uuid].some(
2910
+ (value) => typeof value === "string" && value.toLowerCase() === target
2911
+ )
2912
+ );
2913
+ }
2914
+ function formatTimestamp(value, dateOnly = false) {
2915
+ if (value === null || value === void 0 || value === "") return "-";
2916
+ const date = new Date(value);
2917
+ if (Number.isNaN(date.getTime())) return value;
2918
+ const iso = date.toISOString();
2919
+ return dateOnly ? iso.slice(0, 10) : `${iso.slice(0, 16).replace("T", " ")} UTC`;
2920
+ }
2921
+ function formatCredentialTable(credentials) {
2922
+ const rows = [
2923
+ ["LABEL", "USERNAME", "MODE", "STATUS", "LAST CONNECTED", "CERT EXPIRES", "ID"],
2924
+ ...credentials.map((credential) => [
2925
+ credential.label,
2926
+ credential.username,
2927
+ describeAccessMode(credential.access_mode),
2928
+ credential.status,
2929
+ credential.last_connected_at === null || credential.last_connected_at === void 0 ? "never" : formatTimestamp(credential.last_connected_at),
2930
+ formatTimestamp(credential.certificate_expires_at, true),
2931
+ credential.id
2932
+ ])
2933
+ ];
2934
+ const widths = rows[0]?.map((_, column) => Math.max(...rows.map((row) => (row[column] ?? "").length))) ?? [];
2935
+ return rows.map(
2936
+ (row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd()
2937
+ );
2938
+ }
2939
+
2940
+ // src/commands/db-credential.ts
2941
+ function defaultDeps() {
2942
+ return { createApiClient, requireProject };
2943
+ }
2944
+ var MANAGE_HINT = "Revoking an external credential needs the Credentials: Manage permission on this app. Ask an organization owner or admin to grant it, or to revoke it for you.";
2945
+ var CLI_SCOPE_HINT = "This Meridian API does not accept CLI tokens for external credentials yet. Manage them under Hosting > Database > External access in the dashboard.";
2946
+ var DASHBOARD_HINT = "Create and rotate credentials in the dashboard: Hosting > Database > External access. The key is generated in your browser there.";
2947
+ async function withAccessHints(fn, needsManage) {
2948
+ try {
2949
+ return await fn();
2950
+ } catch (err) {
2951
+ if (isApiError(err) && err.status === 403) {
2952
+ const scope = err.message.includes("/app/cli/");
2953
+ if (scope || needsManage) {
2954
+ throw new ApiError(err.message, {
2955
+ status: err.status,
2956
+ body: err.body,
2957
+ ...err.errorCode !== void 0 ? { errorCode: err.errorCode } : {},
2958
+ hint: scope ? CLI_SCOPE_HINT : MANAGE_HINT,
2959
+ cause: err
2960
+ });
2961
+ }
2962
+ }
2963
+ throw err;
2964
+ }
2965
+ }
2966
+ async function resolveTarget(client, appId, env, deps) {
2967
+ const environments = await withAccessHints(() => client.listEnvironments(appId), false);
2968
+ const targets = databaseTargets(environments);
2969
+ if (targets.length === 0) {
2970
+ throw new CliError("None of this app's environments has a hosted MySQL database.", {
2971
+ hint: "Add a database to an environment under Hosting in the Meridian dashboard first."
2972
+ });
2973
+ }
2974
+ if (env !== void 0) {
2975
+ const match = matchEnvironment(targets, env);
2976
+ if (match === void 0) {
2977
+ throw new CliError(`No environment named "${env}" has a hosted MySQL database.`, {
2978
+ hint: `Environments with one: ${targets.map(({ environment }) => environmentLabel(environment)).join(", ")}.`
2979
+ });
2980
+ }
2981
+ return match;
2982
+ }
2983
+ const index = await selectOne({
2984
+ message: "Which environment's database?",
2985
+ choices: targets.map((target, position) => ({
2986
+ value: String(position),
2987
+ name: environmentLabel(target.environment),
2988
+ ...target.addon.database?.database ? { description: target.addon.database.database } : {}
2989
+ })),
2990
+ flag: "--env",
2991
+ ...deps.selectImpl !== void 0 ? { promptImpl: deps.selectImpl } : {}
2992
+ });
2993
+ const chosen = targets[Number(index)];
2994
+ if (chosen === void 0) throw new CliError("Nothing to choose from.");
2995
+ return chosen;
2996
+ }
2997
+ async function openContext(env, deps) {
2998
+ const project = deps.requireProject(workingDirectory());
2999
+ const client = deps.createApiClient({});
3000
+ const target = await resolveTarget(client, project.config.app_id, env, deps);
3001
+ return { client, target };
3002
+ }
3003
+ async function runDbCredentialList(options = {}) {
3004
+ const deps = { ...defaultDeps(), ...options.deps };
3005
+ const { client, target } = await openContext(options.env, deps);
3006
+ const { credentials, meta } = await withAccessHints(() => client.listExternalCredentials(target.addon.uuid), false);
3007
+ const envName = environmentLabel(target.environment);
3008
+ if (isJsonOutput()) {
3009
+ printJson({
3010
+ environment: envName,
3011
+ addon_id: target.addon.uuid,
3012
+ available: meta.available ?? null,
3013
+ host: meta.host ?? null,
3014
+ port: meta.port ?? null,
3015
+ credentials
3016
+ });
3017
+ return 0;
3018
+ }
3019
+ if (meta.available === false) {
3020
+ warn(`External access is not available for the ${envName} database yet.`);
3021
+ }
3022
+ if (credentials.length === 0) {
3023
+ log(`No external credentials for the ${envName} database.`);
3024
+ hint(DASHBOARD_HINT);
3025
+ return 0;
3026
+ }
3027
+ log(`External credentials for the ${style.bold(envName)} database${meta.host ? ` (${meta.host}:${meta.port ?? 3306})` : ""}:`);
3028
+ log();
3029
+ for (const line of formatCredentialTable(credentials)) log(` ${line}`);
3030
+ return 0;
3031
+ }
3032
+ async function runDbCredentialRevoke(options) {
3033
+ const deps = { ...defaultDeps(), ...options.deps };
3034
+ const { client, target } = await openContext(options.env, deps);
3035
+ const { credentials } = await withAccessHints(() => client.listExternalCredentials(target.addon.uuid), false);
3036
+ const credential = resolveCredentialRef(credentials, options.credential);
3037
+ const envName = environmentLabel(target.environment);
3038
+ if (credential.status === "revoked") {
3039
+ log(`Credential "${credential.label}" is already revoked.`);
3040
+ if (isJsonOutput()) printJson({ revoked: true, already_revoked: true, credential });
3041
+ return 0;
3042
+ }
3043
+ const confirmed = await confirmAction({
3044
+ message: `Revoke "${credential.label}" (${credential.username}) on the ${envName} database? Anything using it loses access within 30 seconds.`,
3045
+ default: false,
3046
+ assumeYes: isAssumeYes(),
3047
+ ...deps.confirmImpl !== void 0 ? { promptImpl: deps.confirmImpl } : {}
3048
+ });
3049
+ if (!confirmed) {
3050
+ log("Aborted.");
3051
+ if (isJsonOutput()) printJson({ revoked: false, aborted: true, credential });
3052
+ return 0;
3053
+ }
3054
+ const revoked = await withAccessHints(
3055
+ () => withSpinner(
3056
+ `Revoking credential "${credential.label}"...`,
3057
+ () => client.revokeExternalCredential(target.addon.uuid, credential.id)
3058
+ ),
3059
+ true
3060
+ );
3061
+ if (isJsonOutput()) printJson({ revoked: true, already_revoked: false, credential: revoked });
3062
+ else success(`Revoked "${credential.label}". Its MySQL user is gone and open connections close within 30 seconds.`);
3063
+ return 0;
3064
+ }
3065
+ async function runAndSetExitCode(fn) {
3066
+ const code = await fn();
3067
+ if (code !== 0) process.exitCode = code;
3068
+ }
3069
+ function registerDbCredentialCommand(db) {
3070
+ const credential = db.command("credential").description(
3071
+ "List and revoke credentials for reaching a hosted environment's database from outside Meridian. Create and rotate them in the dashboard."
3072
+ );
3073
+ credential.action(() => {
3074
+ credential.outputHelp();
3075
+ });
3076
+ const envOption = ["--env <name>", "the environment whose database to use (name or type, e.g. production)"];
3077
+ credential.command("list").description("List the database's external credentials.").option(...envOption).action(async (opts) => {
3078
+ await runAndSetExitCode(() => runDbCredentialList({ ...opts.env !== void 0 ? { env: opts.env } : {} }));
3079
+ });
3080
+ credential.command("revoke").description("Revoke a credential: its MySQL user is dropped and open connections close within 30 seconds.").argument("<credential>", "the credential's id or label").option(...envOption).action(async (ref, opts) => {
3081
+ await runAndSetExitCode(
3082
+ () => runDbCredentialRevoke({ credential: ref, ...opts.env !== void 0 ? { env: opts.env } : {} })
3083
+ );
3084
+ });
3085
+ }
3086
+
2790
3087
  // src/commands/db-shell-repl.ts
2791
3088
  import { accessSync, constants as fsConstants } from "fs";
2792
3089
  import { delimiter as delimiter2, join as join5 } from "path";
@@ -3059,7 +3356,7 @@ async function runDbSeed(options = {}) {
3059
3356
  inherit: true
3060
3357
  });
3061
3358
  if (result.code === 0) {
3062
- success("Seeded the development database.");
3359
+ success("Seeded the developer database.");
3063
3360
  } else {
3064
3361
  error(`\`npx prisma db seed\` exited with code ${result.code}.`);
3065
3362
  hint("Make sure `prisma.seed` (or your Prisma 6 seed config) is set in package.json.");
@@ -3107,17 +3404,19 @@ async function runDbShell(options = {}) {
3107
3404
  if (session !== void 0) await session.stop();
3108
3405
  }
3109
3406
  }
3110
- async function runAndSetExitCode(fn) {
3407
+ async function runAndSetExitCode2(fn) {
3111
3408
  const code = await fn();
3112
3409
  if (code !== 0) process.exitCode = code;
3113
3410
  }
3114
3411
  function registerDbCommand(program) {
3115
- const db = program.command("db").description("Manage the Meridian-managed MySQL development database: reset, seed, or open a SQL shell.");
3412
+ const db = program.command("db").description(
3413
+ "Manage the Meridian-managed MySQL developer database (reset, seed, or open a SQL shell), and external credentials for hosted databases."
3414
+ );
3116
3415
  db.action(() => {
3117
3416
  db.outputHelp();
3118
3417
  });
3119
- db.command("reset").description("Drop and recreate every table in the development database (keeps the MySQL user).").option("--migrate", "run `npx prisma migrate deploy` afterwards, without asking").option("--seed", "run `npx prisma db seed` afterwards, without asking").option("--port <port>", "local port for the Cloud SQL Auth Proxy", (value) => parsePortOption(value)).option("--env-file <file>", "project .env file to update (defaults to .env)").action(async (opts) => {
3120
- await runAndSetExitCode(
3418
+ db.command("reset").description("Drop and recreate every table in the developer database (keeps the MySQL user).").option("--migrate", "run `npx prisma migrate deploy` afterwards, without asking").option("--seed", "run `npx prisma db seed` afterwards, without asking").option("--port <port>", "local port for the Cloud SQL Auth Proxy", (value) => parsePortOption(value)).option("--env-file <file>", "project .env file to update (defaults to .env)").action(async (opts) => {
3419
+ await runAndSetExitCode2(
3121
3420
  () => runDbReset({
3122
3421
  ...opts.migrate === true ? { migrate: true } : {},
3123
3422
  ...opts.seed === true ? { seed: true } : {},
@@ -3127,7 +3426,7 @@ function registerDbCommand(program) {
3127
3426
  );
3128
3427
  });
3129
3428
  db.command("seed").description("Run `npx prisma db seed` with the Cloud SQL Auth Proxy up and DATABASE_URL set.").option("--port <port>", "local port for the Cloud SQL Auth Proxy", (value) => parsePortOption(value)).option("--env-file <file>", "project .env file to update (defaults to .env)").passThroughOptions().argument("[args...]", "extra arguments forwarded to `npx prisma db seed` (put them after `--`)").action(async (args, opts) => {
3130
- await runAndSetExitCode(
3429
+ await runAndSetExitCode2(
3131
3430
  () => runDbSeed({
3132
3431
  ...opts.port !== void 0 ? { port: opts.port } : {},
3133
3432
  ...opts.envFile !== void 0 ? { envFile: opts.envFile } : {},
@@ -3135,8 +3434,8 @@ function registerDbCommand(program) {
3135
3434
  })
3136
3435
  );
3137
3436
  });
3138
- db.command("shell").description("Open a SQL shell against the development database (the `mysql` client if available, otherwise a built-in REPL).").option("--port <port>", "local port for the Cloud SQL Auth Proxy", (value) => parsePortOption(value)).option("--env-file <file>", "project .env file to read/patch (defaults to .env)").option("--repl", "force the built-in SQL REPL even when `mysql` is on PATH").option("-e, --execute <sql>", "run one SQL statement and exit").option("--no-env", "do not patch DATABASE_URL into the project's .env file").action(async (opts) => {
3139
- await runAndSetExitCode(
3437
+ db.command("shell").description("Open a SQL shell against the developer database (the `mysql` client if available, otherwise a built-in REPL).").option("--port <port>", "local port for the Cloud SQL Auth Proxy", (value) => parsePortOption(value)).option("--env-file <file>", "project .env file to read/patch (defaults to .env)").option("--repl", "force the built-in SQL REPL even when `mysql` is on PATH").option("-e, --execute <sql>", "run one SQL statement and exit").option("--no-env", "do not patch DATABASE_URL into the project's .env file").action(async (opts) => {
3438
+ await runAndSetExitCode2(
3140
3439
  () => runDbShell({
3141
3440
  ...opts.port !== void 0 ? { port: opts.port } : {},
3142
3441
  ...opts.envFile !== void 0 ? { envFile: opts.envFile } : {},
@@ -3146,10 +3445,11 @@ function registerDbCommand(program) {
3146
3445
  })
3147
3446
  );
3148
3447
  });
3448
+ registerDbCredentialCommand(db);
3149
3449
  }
3150
3450
 
3151
3451
  // src/commands/dev.ts
3152
- function defaultDeps() {
3452
+ function defaultDeps2() {
3153
3453
  return { startDevSession, startProcess, readPackageScript, requireProject };
3154
3454
  }
3155
3455
  var RECURSION_HINT = "Set `dev_command` in `.meridian/project.json` to the underlying command (e.g. `next dev`), or run `meridian dev -- next dev` to bypass the script entirely.";
@@ -3228,7 +3528,7 @@ function buildDevJsonPayload(session) {
3228
3528
  };
3229
3529
  }
3230
3530
  async function runDev(options = {}) {
3231
- const deps = { ...defaultDeps(), ...options.deps };
3531
+ const deps = { ...defaultDeps2(), ...options.deps };
3232
3532
  if (isInsideDevSession()) throw nestedSessionError();
3233
3533
  const project = deps.requireProject(workingDirectory());
3234
3534
  const resolved = resolveDevCommand(deps, project, options.command);
@@ -3272,7 +3572,7 @@ async function runDev(options = {}) {
3272
3572
  }
3273
3573
  }
3274
3574
  function registerDevCommand(program) {
3275
- program.command("dev").description("Run the managed dev database proxy and your app's dev server together.").option("--port <port>", "preferred local port for the Cloud SQL Auth Proxy").option("--env-file <file>", "the .env file to patch, relative to the project root (default .env)").option("--no-env", "don't touch any .env file; just print the DATABASE_URL for manual use").argument("[command...]", "run this instead of the app's dev script, e.g. `meridian dev -- next dev`").passThroughOptions().action(async (command, opts) => {
3575
+ program.command("dev").description("Run the managed developer database proxy and your app's dev server together.").option("--port <port>", "preferred local port for the Cloud SQL Auth Proxy").option("--env-file <file>", "the .env file to patch, relative to the project root (default .env)").option("--no-env", "don't touch any .env file; just print the DATABASE_URL for manual use").argument("[command...]", "run this instead of the app's dev script, e.g. `meridian dev -- next dev`").passThroughOptions().action(async (command, opts) => {
3276
3576
  const port = parsePortOption(opts.port);
3277
3577
  const code = await runDev({
3278
3578
  ...command.length > 0 ? { command } : {},
@@ -3287,7 +3587,7 @@ function registerDevCommand(program) {
3287
3587
  // src/commands/link.ts
3288
3588
  import { existsSync as existsSync5 } from "fs";
3289
3589
  import { join as join7 } from "path";
3290
- function defaultDeps2() {
3590
+ function defaultDeps3() {
3291
3591
  return { createClient: createApiClient };
3292
3592
  }
3293
3593
  function formatOrg(org) {
@@ -3302,7 +3602,7 @@ function resolveChoiceAnswer(choices, raw, keysOf) {
3302
3602
  return match !== void 0 ? match.name : raw;
3303
3603
  }
3304
3604
  async function runLink(options = {}) {
3305
- const deps = { ...defaultDeps2(), ...options.deps };
3605
+ const deps = { ...defaultDeps3(), ...options.deps };
3306
3606
  const json = isJsonOutput();
3307
3607
  const root = resolveProjectRoot(workingDirectory()) ?? workingDirectory();
3308
3608
  if (!existsSync5(join7(root, "package.json"))) {
@@ -3498,7 +3798,7 @@ async function defaultOpenUrl(url) {
3498
3798
  const { default: open } = await import("open");
3499
3799
  await open(url);
3500
3800
  }
3501
- function defaultDeps3() {
3801
+ function defaultDeps4() {
3502
3802
  return { createClient: createApiClient, runDeviceFlow, openUrl: defaultOpenUrl };
3503
3803
  }
3504
3804
  function shouldOpenBrowser(noBrowser) {
@@ -3519,7 +3819,7 @@ function hintUnlessLinked(json) {
3519
3819
  hint("Run `meridian link` to connect this project to a Meridian app.");
3520
3820
  }
3521
3821
  async function runLogin(options = {}) {
3522
- const deps = { ...defaultDeps3(), ...options.deps };
3822
+ const deps = { ...defaultDeps4(), ...options.deps };
3523
3823
  const json = isJsonOutput();
3524
3824
  if (options.force !== true) {
3525
3825
  const auth = getAuth();
@@ -3656,7 +3956,7 @@ async function createSnapshot(info, options) {
3656
3956
  }
3657
3957
  });
3658
3958
  } catch (error2) {
3659
- throw new CliError(`Could not snapshot the development database: ${describeError(error2)}`, {
3959
+ throw new CliError(`Could not snapshot the developer database: ${describeError(error2)}`, {
3660
3960
  hint: SKIP_SNAPSHOT_HINT,
3661
3961
  cause: error2
3662
3962
  });
@@ -3665,13 +3965,13 @@ async function createSnapshot(info, options) {
3665
3965
  try {
3666
3966
  bytes = (await stat(path)).size;
3667
3967
  } catch (error2) {
3668
- throw new CliError("Could not snapshot the development database: the dump did not produce a file.", {
3968
+ throw new CliError("Could not snapshot the developer database: the dump did not produce a file.", {
3669
3969
  hint: SKIP_SNAPSHOT_HINT,
3670
3970
  cause: error2
3671
3971
  });
3672
3972
  }
3673
3973
  if (bytes === 0) {
3674
- throw new CliError("Could not snapshot the development database: the dump produced an empty file.", {
3974
+ throw new CliError("Could not snapshot the developer database: the dump produced an empty file.", {
3675
3975
  hint: SKIP_SNAPSHOT_HINT
3676
3976
  });
3677
3977
  }
@@ -3862,7 +4162,7 @@ async function pruneSnapshots(dir, keep) {
3862
4162
  // src/commands/migrate.ts
3863
4163
  var DEFAULT_KEEP_SNAPSHOTS = 5;
3864
4164
  var MAX_OUTPUT_EXCERPT = 4e3;
3865
- function defaultDeps4() {
4165
+ function defaultDeps5() {
3866
4166
  return {
3867
4167
  startDevSession,
3868
4168
  createSnapshot,
@@ -3880,7 +4180,7 @@ function defaultDeps4() {
3880
4180
  };
3881
4181
  }
3882
4182
  function resolveDeps2(overrides) {
3883
- return { ...defaultDeps4(), ...overrides };
4183
+ return { ...defaultDeps5(), ...overrides };
3884
4184
  }
3885
4185
  function formatSize(bytes) {
3886
4186
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -3907,10 +4207,10 @@ async function runMigration(session, deps, ctx) {
3907
4207
  let snapshot;
3908
4208
  if (options.skipSnapshot !== true) {
3909
4209
  snapshot = await withSpinner(
3910
- "Snapshotting the development database...",
4210
+ "Snapshotting the developer database...",
3911
4211
  () => deps.createSnapshot(session.connection, { dir, now: deps.now }),
3912
4212
  {
3913
- success: (result) => result.path === null ? "Development database is empty. Nothing to snapshot." : `Snapshot saved to ${style.path(result.path)} (${formatSize(result.bytes)}).`
4213
+ success: (result) => result.path === null ? "Developer database is empty. Nothing to snapshot." : `Snapshot saved to ${style.path(result.path)} (${formatSize(result.bytes)}).`
3914
4214
  }
3915
4215
  );
3916
4216
  }
@@ -3943,12 +4243,12 @@ async function runMigration(session, deps, ctx) {
3943
4243
  } else {
3944
4244
  const snapshotPath = snapshot.path;
3945
4245
  warn(
3946
- `\`${commandString}\` failed (exit code ${runResult.code}); ` + (snapshotPath === null ? "rolling the development database back to its pre-migration (empty) state..." : "rolling back the development database to the pre-migration snapshot...")
4246
+ `\`${commandString}\` failed (exit code ${runResult.code}); ` + (snapshotPath === null ? "rolling the developer database back to its pre-migration (empty) state..." : "rolling back the developer database to the pre-migration snapshot...")
3947
4247
  );
3948
4248
  try {
3949
4249
  await client.resetDevDatabase(appId);
3950
4250
  if (snapshotPath === null) {
3951
- success("Development database rolled back to its pre-migration (empty) state.");
4251
+ success("Developer database rolled back to its pre-migration (empty) state.");
3952
4252
  } else {
3953
4253
  await withSpinner(
3954
4254
  "Restoring the pre-migration snapshot...",
@@ -3961,7 +4261,7 @@ async function runMigration(session, deps, ctx) {
3961
4261
  status = "rolled_back";
3962
4262
  rolledBack = true;
3963
4263
  } catch {
3964
- error("Automatic rollback failed. The development database may be in a partial state. Treat it as unsafe.");
4264
+ error("Automatic rollback failed. The developer database may be in a partial state. Treat it as unsafe.");
3965
4265
  if (snapshotPath === null) {
3966
4266
  hint("Recover by hand with `meridian db reset`. The database was empty before this migration.");
3967
4267
  } else {
@@ -4036,7 +4336,7 @@ async function runMigrate(options = {}) {
4036
4336
  }
4037
4337
  function registerMigrateCommand(program) {
4038
4338
  program.command("migrate").description(
4039
- "Snapshot the development database, run `prisma migrate dev`, and roll back to the snapshot automatically if the migration fails."
4339
+ "Snapshot the developer database, run `prisma migrate dev`, and roll back to the snapshot automatically if the migration fails."
4040
4340
  ).option("--skip-snapshot", "run without a pre-migration snapshot (no rollback is possible)").option(
4041
4341
  "--allow-foreign-migrations",
4042
4342
  "run even when prisma/migrations was generated for SQLite or Postgres and cannot apply to MySQL"
@@ -4089,7 +4389,7 @@ function registerUnlinkCommand(program) {
4089
4389
 
4090
4390
  // src/commands/whoami.ts
4091
4391
  var SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1e3;
4092
- function defaultDeps5() {
4392
+ function defaultDeps6() {
4093
4393
  return { createClient: createApiClient };
4094
4394
  }
4095
4395
  function toConfigUser2(user) {
@@ -4153,7 +4453,7 @@ function printJsonResult(result, project) {
4153
4453
  });
4154
4454
  }
4155
4455
  async function runWhoami(options = {}) {
4156
- const deps = { ...defaultDeps5(), ...options.deps };
4456
+ const deps = { ...defaultDeps6(), ...options.deps };
4157
4457
  const json = isJsonOutput();
4158
4458
  const auth = getAuth();
4159
4459
  if (auth === null) throw notLoggedInError();
@@ -4260,7 +4560,7 @@ function resolveGlobalFlags(actionCommand) {
4260
4560
  function buildProgram() {
4261
4561
  const program = new Command();
4262
4562
  program.name("meridian").description(
4263
- "Managed remote development databases for Meridian apps.\n\nRun `meridian login` once, `meridian link` per project, then `meridian dev`."
4563
+ "Managed remote developer databases for Meridian apps.\n\nRun `meridian login` once, `meridian link` per project, then `meridian dev`."
4264
4564
  ).version(VERSION, "-V, --version").enablePositionalOptions().showHelpAfterError("(run `meridian --help` for usage)");
4265
4565
  addGlobalFlags(program);
4266
4566
  program.hook("preAction", (_thisCommand, actionCommand) => {