@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 +10 -0
- package/README.md +11 -0
- package/dist/index.js +340 -40
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2664
|
+
"Provisioning the developer database...",
|
|
2594
2665
|
(spinner2) => pollDevDatabase(client, appId, options, spinner2),
|
|
2595
|
-
{ success: (db) => `
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
3120
|
-
await
|
|
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
|
|
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
|
|
3139
|
-
await
|
|
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
|
|
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 = { ...
|
|
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
|
|
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
|
|
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 = { ...
|
|
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
|
|
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 = { ...
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 { ...
|
|
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
|
|
4210
|
+
"Snapshotting the developer database...",
|
|
3911
4211
|
() => deps.createSnapshot(session.connection, { dir, now: deps.now }),
|
|
3912
4212
|
{
|
|
3913
|
-
success: (result) => result.path === null ? "
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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 = { ...
|
|
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
|
|
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) => {
|