@the-meridian/cli 1.0.3 → 1.2.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 +20 -0
- package/README.md +11 -0
- package/dist/index.js +1233 -100
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -67,6 +67,7 @@ var deviceInfoResponseSchema = z.object({
|
|
|
67
67
|
created_at: z.string().optional()
|
|
68
68
|
}).loose();
|
|
69
69
|
var devDatabaseStatusSchema = z.union([z.enum(["provisioning", "ready", "error"]), z.string()]);
|
|
70
|
+
var devDatabaseAccessSchema = z.union([z.enum(["gateway", "proxy"]), z.string()]);
|
|
70
71
|
var devDatabaseSchema = z.object({
|
|
71
72
|
id: idSchema,
|
|
72
73
|
status: devDatabaseStatusSchema,
|
|
@@ -78,7 +79,21 @@ var devDatabaseSchema = z.object({
|
|
|
78
79
|
region: z.string().nullish(),
|
|
79
80
|
created_at: z.string().nullish(),
|
|
80
81
|
/** Set by the API only while `status` is `error`; surfaced verbatim to the developer. */
|
|
81
|
-
error_message: z.string().nullish()
|
|
82
|
+
error_message: z.string().nullish(),
|
|
83
|
+
/**
|
|
84
|
+
* How the CLI reaches the database: `gateway` (the Meridian database
|
|
85
|
+
* gateway with a per-developer certificate) or `proxy` (cloud-sql-proxy).
|
|
86
|
+
* Absent from an API older than the gateway, which means `proxy`.
|
|
87
|
+
*/
|
|
88
|
+
access: devDatabaseAccessSchema.nullish()
|
|
89
|
+
}).loose();
|
|
90
|
+
var devDatabaseCertificateSchema = z.object({
|
|
91
|
+
certificate_pem: z.string(),
|
|
92
|
+
ca_pem: z.string(),
|
|
93
|
+
host: z.string(),
|
|
94
|
+
port: z.coerce.number(),
|
|
95
|
+
expires_at: z.string(),
|
|
96
|
+
fingerprint: z.string()
|
|
82
97
|
}).loose();
|
|
83
98
|
var devCredentialsSchema = z.object({
|
|
84
99
|
host: z.string(),
|
|
@@ -117,6 +132,51 @@ var migrationRunSchema = z.object({
|
|
|
117
132
|
output_excerpt: z.string().nullish(),
|
|
118
133
|
created_at: z.string().nullish()
|
|
119
134
|
}).loose();
|
|
135
|
+
function envelopeSchema(data) {
|
|
136
|
+
return z.object({ data }).loose();
|
|
137
|
+
}
|
|
138
|
+
var hostingAddonSchema = z.object({
|
|
139
|
+
uuid: z.string(),
|
|
140
|
+
type: z.string().nullish(),
|
|
141
|
+
name: z.string().nullish(),
|
|
142
|
+
status: z.string().nullish(),
|
|
143
|
+
database: z.object({
|
|
144
|
+
type: z.string().nullish(),
|
|
145
|
+
database: z.string().nullish()
|
|
146
|
+
}).loose().nullish()
|
|
147
|
+
}).loose();
|
|
148
|
+
var hostingEnvironmentSchema = z.object({
|
|
149
|
+
uuid: z.string(),
|
|
150
|
+
type: z.string().nullish(),
|
|
151
|
+
name: z.string().nullish(),
|
|
152
|
+
addons: z.array(hostingAddonSchema).nullish()
|
|
153
|
+
}).loose();
|
|
154
|
+
var externalCredentialSchema = z.object({
|
|
155
|
+
id: z.string(),
|
|
156
|
+
label: z.string(),
|
|
157
|
+
username: z.string(),
|
|
158
|
+
access_mode: z.string(),
|
|
159
|
+
max_connections: z.number().nullish(),
|
|
160
|
+
allowed_cidrs: z.array(z.string()).nullish(),
|
|
161
|
+
certificate_serial: z.string().nullish(),
|
|
162
|
+
certificate_expires_at: z.string().nullish(),
|
|
163
|
+
last_connected_at: z.string().nullish(),
|
|
164
|
+
last_connected_ip: z.string().nullish(),
|
|
165
|
+
locked_until: z.string().nullish(),
|
|
166
|
+
status: z.string(),
|
|
167
|
+
revoked_at: z.string().nullish(),
|
|
168
|
+
created_at: z.string().nullish()
|
|
169
|
+
}).loose();
|
|
170
|
+
var externalCredentialListMetaSchema = z.object({
|
|
171
|
+
available: z.boolean().nullish(),
|
|
172
|
+
host: z.string().nullish(),
|
|
173
|
+
port: z.coerce.number().nullish(),
|
|
174
|
+
max_credentials: z.number().nullish()
|
|
175
|
+
}).loose();
|
|
176
|
+
var externalCredentialListSchema = z.object({
|
|
177
|
+
data: z.array(externalCredentialSchema),
|
|
178
|
+
meta: externalCredentialListMetaSchema.nullish()
|
|
179
|
+
}).loose();
|
|
120
180
|
|
|
121
181
|
// src/lib/errors.ts
|
|
122
182
|
var CliError = class extends Error {
|
|
@@ -297,6 +357,18 @@ var ApiClient = class _ApiClient {
|
|
|
297
357
|
`/app/cli/apps/${encodePathSegment(appId)}/dev-database/proxy-token`
|
|
298
358
|
);
|
|
299
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* Signs `publicKeyPem` (SPKI) as this developer's client certificate for the
|
|
362
|
+
* database gateway. Each call replaces the previous certificate server-side.
|
|
363
|
+
*/
|
|
364
|
+
async issueDevDatabaseCertificate(appId, publicKeyPem) {
|
|
365
|
+
return this.request(
|
|
366
|
+
devDatabaseCertificateSchema,
|
|
367
|
+
"POST",
|
|
368
|
+
`/app/cli/apps/${encodePathSegment(appId)}/dev-database/certificate`,
|
|
369
|
+
{ body: { public_key: publicKeyPem } }
|
|
370
|
+
);
|
|
371
|
+
}
|
|
300
372
|
async resetDevDatabase(appId) {
|
|
301
373
|
return this.request(devDatabaseSchema, "POST", `/app/cli/apps/${encodePathSegment(appId)}/dev-database/reset`);
|
|
302
374
|
}
|
|
@@ -313,6 +385,28 @@ var ApiClient = class _ApiClient {
|
|
|
313
385
|
`/app/cli/apps/${encodePathSegment(appId)}/dev-database/migrations`
|
|
314
386
|
);
|
|
315
387
|
}
|
|
388
|
+
/** `GET /app/apps/{app}/environments`, each with its add-ons. Used to find an environment's database. */
|
|
389
|
+
async listEnvironments(appId) {
|
|
390
|
+
const body = await this.request(
|
|
391
|
+
envelopeSchema(z2.array(hostingEnvironmentSchema)),
|
|
392
|
+
"GET",
|
|
393
|
+
`/app/apps/${encodePathSegment(appId)}/environments`
|
|
394
|
+
);
|
|
395
|
+
return body.data;
|
|
396
|
+
}
|
|
397
|
+
/** `GET /app/addons/{addon}/database/external-credentials`: the credentials, plus whether external access is on. */
|
|
398
|
+
async listExternalCredentials(addonId) {
|
|
399
|
+
const body = await this.request(externalCredentialListSchema, "GET", externalCredentialsPath(addonId));
|
|
400
|
+
return { credentials: body.data, meta: body.meta ?? {} };
|
|
401
|
+
}
|
|
402
|
+
async revokeExternalCredential(addonId, credentialId) {
|
|
403
|
+
const body = await this.request(
|
|
404
|
+
envelopeSchema(externalCredentialSchema),
|
|
405
|
+
"DELETE",
|
|
406
|
+
`${externalCredentialsPath(addonId)}/${encodePathSegment(credentialId)}`
|
|
407
|
+
);
|
|
408
|
+
return body.data;
|
|
409
|
+
}
|
|
316
410
|
#buildUrl(path, query) {
|
|
317
411
|
const url = new URL(`${this.baseUrl}${path}`);
|
|
318
412
|
if (query !== void 0) {
|
|
@@ -370,6 +464,9 @@ var ApiClient = class _ApiClient {
|
|
|
370
464
|
function encodePathSegment(id) {
|
|
371
465
|
return encodeURIComponent(String(id));
|
|
372
466
|
}
|
|
467
|
+
function externalCredentialsPath(addonId) {
|
|
468
|
+
return `/app/addons/${encodePathSegment(addonId)}/database/external-credentials`;
|
|
469
|
+
}
|
|
373
470
|
function parseJson(text, method, path) {
|
|
374
471
|
try {
|
|
375
472
|
return JSON.parse(text);
|
|
@@ -452,9 +549,12 @@ function extractErrorCode(body) {
|
|
|
452
549
|
return record !== void 0 && typeof record.error === "string" ? record.error : void 0;
|
|
453
550
|
}
|
|
454
551
|
var HINTS_BY_ERROR_CODE = {
|
|
455
|
-
dev_database_limit_reached: "Your plan does not include a
|
|
552
|
+
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.",
|
|
553
|
+
dev_databases_unavailable: "This Meridian environment does not offer developer databases. Run `meridian dev` against production (the default API URL) instead.",
|
|
456
554
|
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
|
-
|
|
555
|
+
cli_upgrade_required: "Upgrade the Meridian CLI: npm i -g @the-meridian/cli",
|
|
556
|
+
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.",
|
|
557
|
+
FEATURE_NOT_ENTITLED: "Your organization's plan does not include managed developer databases; upgrade it in the Meridian dashboard."
|
|
458
558
|
};
|
|
459
559
|
function hintForError(status, errorCode) {
|
|
460
560
|
if (errorCode !== void 0) {
|
|
@@ -465,7 +565,7 @@ function hintForError(status, errorCode) {
|
|
|
465
565
|
if (status === 403) return "You may not have access to this organization or app; check your Meridian membership.";
|
|
466
566
|
if (status === 404) return "Run `meridian link` to select a valid organization and app.";
|
|
467
567
|
if (status === 409) {
|
|
468
|
-
return "The
|
|
568
|
+
return "The developer database is not ready yet. Wait for provisioning to finish, then try again. `meridian dev` polls for you.";
|
|
469
569
|
}
|
|
470
570
|
if (status === 422) return "Check the request for validation errors and try again.";
|
|
471
571
|
if (status === 429) return "You're being rate limited by the Meridian API; wait a moment and try again.";
|
|
@@ -1671,14 +1771,770 @@ function redactDatabaseUrl(url) {
|
|
|
1671
1771
|
return url.replace(/^(mysql:\/\/[^:@/]+):([^@]*)@/, "$1:****@");
|
|
1672
1772
|
}
|
|
1673
1773
|
|
|
1774
|
+
// src/lib/gateway/certificate.ts
|
|
1775
|
+
import { generateKeyPair } from "crypto";
|
|
1776
|
+
import { createSecureContext } from "tls";
|
|
1777
|
+
import { promisify } from "util";
|
|
1778
|
+
var generateKeyPairAsync = promisify(generateKeyPair);
|
|
1779
|
+
function gatewayCertificateFromPem(material) {
|
|
1780
|
+
return {
|
|
1781
|
+
host: material.host,
|
|
1782
|
+
port: material.port,
|
|
1783
|
+
expiresAt: material.expiresAt,
|
|
1784
|
+
fingerprint: material.fingerprint,
|
|
1785
|
+
secureContext: createSecureContext({
|
|
1786
|
+
key: material.keyPem,
|
|
1787
|
+
cert: material.certificatePem,
|
|
1788
|
+
ca: material.caPem,
|
|
1789
|
+
minVersion: "TLSv1.2"
|
|
1790
|
+
})
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
async function generateGatewayKeyPair() {
|
|
1794
|
+
const { publicKey, privateKey } = await generateKeyPairAsync("ec", { namedCurve: "prime256v1" });
|
|
1795
|
+
return {
|
|
1796
|
+
publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
1797
|
+
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString()
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
async function issueGatewayCertificate(client, appId) {
|
|
1801
|
+
const { publicKeyPem, privateKeyPem } = await generateGatewayKeyPair();
|
|
1802
|
+
const issued = await client.issueDevDatabaseCertificate(appId, publicKeyPem);
|
|
1803
|
+
return gatewayCertificateFromPem({
|
|
1804
|
+
keyPem: privateKeyPem,
|
|
1805
|
+
certificatePem: issued.certificate_pem,
|
|
1806
|
+
caPem: issued.ca_pem,
|
|
1807
|
+
host: issued.host,
|
|
1808
|
+
port: issued.port,
|
|
1809
|
+
expiresAt: issued.expires_at,
|
|
1810
|
+
fingerprint: issued.fingerprint
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
var ASSUMED_LIFETIME_MS = 24 * 60 * 6e4;
|
|
1814
|
+
var DEFAULT_REFRESH_BEFORE_MS = 4 * 60 * 6e4;
|
|
1815
|
+
var MIN_REFRESH_DELAY_MS = 6e4;
|
|
1816
|
+
function certificateRefreshDelay(expiresAt, now, refreshBeforeMs = DEFAULT_REFRESH_BEFORE_MS) {
|
|
1817
|
+
const parsed = Date.parse(expiresAt);
|
|
1818
|
+
const expiry = Number.isNaN(parsed) ? now + ASSUMED_LIFETIME_MS : parsed;
|
|
1819
|
+
return Math.max(MIN_REFRESH_DELAY_MS, expiry - refreshBeforeMs - now);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// src/lib/gateway/forwarder.ts
|
|
1823
|
+
import { connect as netConnect, createServer, isIP } from "net";
|
|
1824
|
+
import { connect as tlsConnect } from "tls";
|
|
1825
|
+
|
|
1826
|
+
// src/lib/gateway/packets.ts
|
|
1827
|
+
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
1828
|
+
var CLIENT = {
|
|
1829
|
+
LONG_PASSWORD: 1,
|
|
1830
|
+
FOUND_ROWS: 2,
|
|
1831
|
+
LONG_FLAG: 4,
|
|
1832
|
+
CONNECT_WITH_DB: 8,
|
|
1833
|
+
NO_SCHEMA: 16,
|
|
1834
|
+
COMPRESS: 32,
|
|
1835
|
+
ODBC: 64,
|
|
1836
|
+
LOCAL_FILES: 128,
|
|
1837
|
+
IGNORE_SPACE: 256,
|
|
1838
|
+
PROTOCOL_41: 512,
|
|
1839
|
+
INTERACTIVE: 1024,
|
|
1840
|
+
SSL: 2048,
|
|
1841
|
+
IGNORE_SIGPIPE: 4096,
|
|
1842
|
+
TRANSACTIONS: 8192,
|
|
1843
|
+
RESERVED: 16384,
|
|
1844
|
+
SECURE_CONNECTION: 32768,
|
|
1845
|
+
MULTI_STATEMENTS: 65536,
|
|
1846
|
+
MULTI_RESULTS: 131072,
|
|
1847
|
+
PS_MULTI_RESULTS: 262144,
|
|
1848
|
+
PLUGIN_AUTH: 524288,
|
|
1849
|
+
CONNECT_ATTRS: 1048576,
|
|
1850
|
+
PLUGIN_AUTH_LENENC_CLIENT_DATA: 2097152,
|
|
1851
|
+
CAN_HANDLE_EXPIRED_PASSWORDS: 4194304,
|
|
1852
|
+
SESSION_TRACK: 8388608,
|
|
1853
|
+
DEPRECATE_EOF: 16777216,
|
|
1854
|
+
OPTIONAL_RESULTSET_METADATA: 33554432,
|
|
1855
|
+
ZSTD_COMPRESSION: 67108864,
|
|
1856
|
+
QUERY_ATTRIBUTES: 134217728
|
|
1857
|
+
};
|
|
1858
|
+
var GATEWAY_CAPABILITIES = CLIENT.LONG_PASSWORD | CLIENT.FOUND_ROWS | CLIENT.LONG_FLAG | CLIENT.CONNECT_WITH_DB | CLIENT.IGNORE_SPACE | CLIENT.PROTOCOL_41 | CLIENT.INTERACTIVE | CLIENT.SSL | CLIENT.IGNORE_SIGPIPE | CLIENT.TRANSACTIONS | CLIENT.RESERVED | CLIENT.SECURE_CONNECTION | CLIENT.MULTI_STATEMENTS | CLIENT.MULTI_RESULTS | CLIENT.PS_MULTI_RESULTS | CLIENT.PLUGIN_AUTH | CLIENT.CONNECT_ATTRS | CLIENT.PLUGIN_AUTH_LENENC_CLIENT_DATA | CLIENT.CAN_HANDLE_EXPIRED_PASSWORDS | CLIENT.SESSION_TRACK | CLIENT.DEPRECATE_EOF;
|
|
1859
|
+
var LOCAL_CAPABILITIES = GATEWAY_CAPABILITIES & ~CLIENT.SSL;
|
|
1860
|
+
var PLUGIN_NATIVE = "mysql_native_password";
|
|
1861
|
+
var PLUGIN_CACHING_SHA2 = "caching_sha2_password";
|
|
1862
|
+
var SCRAMBLE_LENGTH = 20;
|
|
1863
|
+
var DEFAULT_CHARSET = 255;
|
|
1864
|
+
var MAX_PAYLOAD = 16777215;
|
|
1865
|
+
var MAX_HANDSHAKE_PACKET = 64 * 1024;
|
|
1866
|
+
var AUTH_MORE_DATA = 1;
|
|
1867
|
+
var FAST_AUTH_SUCCESS = 3;
|
|
1868
|
+
var PERFORM_FULL_AUTH = 4;
|
|
1869
|
+
var SERVER_STATUS_AUTOCOMMIT = 2;
|
|
1870
|
+
function encodePacket(seq, payload) {
|
|
1871
|
+
if (payload.length >= MAX_PAYLOAD) throw new Error(`refusing to write a ${payload.length} byte handshake packet`);
|
|
1872
|
+
const header = Buffer.alloc(4);
|
|
1873
|
+
header.writeUIntLE(payload.length, 0, 3);
|
|
1874
|
+
header[3] = seq & 255;
|
|
1875
|
+
return Buffer.concat([header, payload]);
|
|
1876
|
+
}
|
|
1877
|
+
var PacketReader = class {
|
|
1878
|
+
#socket;
|
|
1879
|
+
#maxPacket;
|
|
1880
|
+
#buffer = Buffer.alloc(0);
|
|
1881
|
+
#failure;
|
|
1882
|
+
#waiter;
|
|
1883
|
+
#onData = (chunk) => {
|
|
1884
|
+
this.#buffer = this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk]);
|
|
1885
|
+
this.#settle();
|
|
1886
|
+
};
|
|
1887
|
+
#onClose = () => {
|
|
1888
|
+
this.#fail(new Error("the connection closed during the MySQL handshake"));
|
|
1889
|
+
};
|
|
1890
|
+
#onError = (error2) => {
|
|
1891
|
+
this.#fail(error2);
|
|
1892
|
+
};
|
|
1893
|
+
constructor(socket, maxPacket = MAX_HANDSHAKE_PACKET) {
|
|
1894
|
+
this.#socket = socket;
|
|
1895
|
+
this.#maxPacket = maxPacket;
|
|
1896
|
+
socket.on("data", this.#onData);
|
|
1897
|
+
socket.on("end", this.#onClose);
|
|
1898
|
+
socket.on("close", this.#onClose);
|
|
1899
|
+
socket.on("error", this.#onError);
|
|
1900
|
+
}
|
|
1901
|
+
read() {
|
|
1902
|
+
return new Promise((resolve2, reject) => {
|
|
1903
|
+
this.#waiter = { resolve: resolve2, reject };
|
|
1904
|
+
this.#settle();
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
/** Stops reading. `pause` keeps later bytes in the socket for a pipe() to pick up. */
|
|
1908
|
+
release(options) {
|
|
1909
|
+
if (options.pause) this.#socket.pause();
|
|
1910
|
+
this.#socket.off("data", this.#onData);
|
|
1911
|
+
this.#socket.off("end", this.#onClose);
|
|
1912
|
+
this.#socket.off("close", this.#onClose);
|
|
1913
|
+
this.#socket.off("error", this.#onError);
|
|
1914
|
+
const rest = this.#buffer;
|
|
1915
|
+
this.#buffer = Buffer.alloc(0);
|
|
1916
|
+
return rest;
|
|
1917
|
+
}
|
|
1918
|
+
#fail(error2) {
|
|
1919
|
+
if (this.#failure === void 0) this.#failure = error2;
|
|
1920
|
+
this.#settle();
|
|
1921
|
+
}
|
|
1922
|
+
#settle() {
|
|
1923
|
+
const waiter = this.#waiter;
|
|
1924
|
+
if (waiter === void 0) return;
|
|
1925
|
+
if (this.#buffer.length >= 4) {
|
|
1926
|
+
const length = this.#buffer.readUIntLE(0, 3);
|
|
1927
|
+
if (length > this.#maxPacket) {
|
|
1928
|
+
this.#waiter = void 0;
|
|
1929
|
+
waiter.reject(new Error(`a ${length} byte packet during the MySQL handshake is too large`));
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
if (this.#buffer.length >= 4 + length) {
|
|
1933
|
+
const seq = this.#buffer[3] ?? 0;
|
|
1934
|
+
const payload = Buffer.from(this.#buffer.subarray(4, 4 + length));
|
|
1935
|
+
this.#buffer = this.#buffer.subarray(4 + length);
|
|
1936
|
+
this.#waiter = void 0;
|
|
1937
|
+
waiter.resolve({ seq, payload });
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
if (this.#failure !== void 0) {
|
|
1942
|
+
this.#waiter = void 0;
|
|
1943
|
+
waiter.reject(this.#failure);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
};
|
|
1947
|
+
var Cursor = class {
|
|
1948
|
+
constructor(buf) {
|
|
1949
|
+
this.buf = buf;
|
|
1950
|
+
}
|
|
1951
|
+
buf;
|
|
1952
|
+
pos = 0;
|
|
1953
|
+
get remaining() {
|
|
1954
|
+
return this.buf.length - this.pos;
|
|
1955
|
+
}
|
|
1956
|
+
bytes(n, what) {
|
|
1957
|
+
if (n < 0 || this.remaining < n) throw new Error(`truncated packet reading ${what}`);
|
|
1958
|
+
const out = this.buf.subarray(this.pos, this.pos + n);
|
|
1959
|
+
this.pos += n;
|
|
1960
|
+
return out;
|
|
1961
|
+
}
|
|
1962
|
+
u8(what) {
|
|
1963
|
+
return this.bytes(1, what)[0] ?? 0;
|
|
1964
|
+
}
|
|
1965
|
+
u16(what) {
|
|
1966
|
+
return this.bytes(2, what).readUInt16LE(0);
|
|
1967
|
+
}
|
|
1968
|
+
u32(what) {
|
|
1969
|
+
return this.bytes(4, what).readUInt32LE(0);
|
|
1970
|
+
}
|
|
1971
|
+
nulString(what) {
|
|
1972
|
+
const end = this.buf.indexOf(0, this.pos);
|
|
1973
|
+
if (end < 0) throw new Error(`truncated packet reading ${what}`);
|
|
1974
|
+
const out = this.buf.subarray(this.pos, end);
|
|
1975
|
+
this.pos = end + 1;
|
|
1976
|
+
return out;
|
|
1977
|
+
}
|
|
1978
|
+
/** Everything left, without a trailing NUL. */
|
|
1979
|
+
restNul() {
|
|
1980
|
+
let out = this.buf.subarray(this.pos);
|
|
1981
|
+
this.pos = this.buf.length;
|
|
1982
|
+
if (out.length > 0 && out[out.length - 1] === 0) out = out.subarray(0, out.length - 1);
|
|
1983
|
+
return out;
|
|
1984
|
+
}
|
|
1985
|
+
lenencInt(what) {
|
|
1986
|
+
const first = this.u8(what);
|
|
1987
|
+
if (first < 251) return first;
|
|
1988
|
+
if (first === 252) return this.u16(what);
|
|
1989
|
+
if (first === 253) return this.bytes(3, what).readUIntLE(0, 3);
|
|
1990
|
+
if (first === 254) {
|
|
1991
|
+
const value = this.bytes(8, what).readBigUInt64LE(0);
|
|
1992
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`${what} is too large`);
|
|
1993
|
+
return Number(value);
|
|
1994
|
+
}
|
|
1995
|
+
throw new Error(`invalid length-encoded integer reading ${what}`);
|
|
1996
|
+
}
|
|
1997
|
+
lenencBytes(what) {
|
|
1998
|
+
return this.bytes(this.lenencInt(what), what);
|
|
1999
|
+
}
|
|
2000
|
+
};
|
|
2001
|
+
function lenencInt(n) {
|
|
2002
|
+
if (n < 251) return Buffer.from([n]);
|
|
2003
|
+
if (n <= 65535) {
|
|
2004
|
+
const b2 = Buffer.alloc(3);
|
|
2005
|
+
b2[0] = 252;
|
|
2006
|
+
b2.writeUInt16LE(n, 1);
|
|
2007
|
+
return b2;
|
|
2008
|
+
}
|
|
2009
|
+
if (n <= 16777215) {
|
|
2010
|
+
const b2 = Buffer.alloc(4);
|
|
2011
|
+
b2[0] = 253;
|
|
2012
|
+
b2.writeUIntLE(n, 1, 3);
|
|
2013
|
+
return b2;
|
|
2014
|
+
}
|
|
2015
|
+
const b = Buffer.alloc(9);
|
|
2016
|
+
b[0] = 254;
|
|
2017
|
+
b.writeBigUInt64LE(BigInt(n), 1);
|
|
2018
|
+
return b;
|
|
2019
|
+
}
|
|
2020
|
+
function lenencBytes(value) {
|
|
2021
|
+
return Buffer.concat([lenencInt(value.length), value]);
|
|
2022
|
+
}
|
|
2023
|
+
function u16(n) {
|
|
2024
|
+
const b = Buffer.alloc(2);
|
|
2025
|
+
b.writeUInt16LE(n & 65535, 0);
|
|
2026
|
+
return b;
|
|
2027
|
+
}
|
|
2028
|
+
function u32(n) {
|
|
2029
|
+
const b = Buffer.alloc(4);
|
|
2030
|
+
b.writeUInt32LE(n >>> 0, 0);
|
|
2031
|
+
return b;
|
|
2032
|
+
}
|
|
2033
|
+
var NUL = Buffer.from([0]);
|
|
2034
|
+
function encodeGreeting(g) {
|
|
2035
|
+
if (g.scramble.length !== SCRAMBLE_LENGTH) throw new Error("the greeting scramble must be 20 bytes");
|
|
2036
|
+
return Buffer.concat([
|
|
2037
|
+
Buffer.from([10]),
|
|
2038
|
+
Buffer.from(g.serverVersion, "utf8"),
|
|
2039
|
+
NUL,
|
|
2040
|
+
u32(g.connectionId),
|
|
2041
|
+
g.scramble.subarray(0, 8),
|
|
2042
|
+
NUL,
|
|
2043
|
+
u16(g.capabilities),
|
|
2044
|
+
Buffer.from([g.charset]),
|
|
2045
|
+
u16(g.status),
|
|
2046
|
+
u16(g.capabilities >>> 16),
|
|
2047
|
+
Buffer.from([SCRAMBLE_LENGTH + 1]),
|
|
2048
|
+
Buffer.alloc(10),
|
|
2049
|
+
g.scramble.subarray(8),
|
|
2050
|
+
NUL,
|
|
2051
|
+
Buffer.from(g.authPlugin, "utf8"),
|
|
2052
|
+
NUL
|
|
2053
|
+
]);
|
|
2054
|
+
}
|
|
2055
|
+
function localGreeting(connectionId, scramble, serverVersion) {
|
|
2056
|
+
return {
|
|
2057
|
+
serverVersion,
|
|
2058
|
+
connectionId,
|
|
2059
|
+
scramble,
|
|
2060
|
+
capabilities: LOCAL_CAPABILITIES,
|
|
2061
|
+
charset: DEFAULT_CHARSET,
|
|
2062
|
+
status: SERVER_STATUS_AUTOCOMMIT,
|
|
2063
|
+
authPlugin: PLUGIN_NATIVE
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
function parseGreeting(payload) {
|
|
2067
|
+
if (payload[0] === 255) throw serverErrorFromPacket(payload);
|
|
2068
|
+
const r = new Cursor(payload);
|
|
2069
|
+
const version = r.u8("protocol version");
|
|
2070
|
+
if (version !== 10) throw new Error(`unsupported handshake version ${version}`);
|
|
2071
|
+
const serverVersion = r.nulString("server version").toString("utf8");
|
|
2072
|
+
const connectionId = r.u32("connection id");
|
|
2073
|
+
const part1 = Buffer.from(r.bytes(8, "scramble part 1"));
|
|
2074
|
+
r.u8("filler");
|
|
2075
|
+
let capabilities = r.u16("capabilities");
|
|
2076
|
+
let charset = DEFAULT_CHARSET;
|
|
2077
|
+
let status = 0;
|
|
2078
|
+
let authPlugin = "";
|
|
2079
|
+
let scramble = part1;
|
|
2080
|
+
if (r.remaining > 0) {
|
|
2081
|
+
charset = r.u8("charset");
|
|
2082
|
+
status = r.u16("status");
|
|
2083
|
+
capabilities = (capabilities | r.u16("capabilities upper") << 16) >>> 0;
|
|
2084
|
+
const authLength = r.u8("auth data length");
|
|
2085
|
+
r.bytes(10, "reserved");
|
|
2086
|
+
if ((capabilities & CLIENT.SECURE_CONNECTION) !== 0) {
|
|
2087
|
+
let part2 = r.bytes(Math.max(13, authLength - 8), "scramble part 2");
|
|
2088
|
+
if (part2.length > 0 && part2[part2.length - 1] === 0) part2 = part2.subarray(0, part2.length - 1);
|
|
2089
|
+
scramble = Buffer.concat([part1, part2]);
|
|
2090
|
+
}
|
|
2091
|
+
if ((capabilities & CLIENT.PLUGIN_AUTH) !== 0) authPlugin = r.restNul().toString("utf8");
|
|
2092
|
+
}
|
|
2093
|
+
if (scramble.length < SCRAMBLE_LENGTH) throw new Error(`the server scramble is ${scramble.length} bytes`);
|
|
2094
|
+
return {
|
|
2095
|
+
serverVersion,
|
|
2096
|
+
connectionId,
|
|
2097
|
+
scramble: scramble.subarray(0, SCRAMBLE_LENGTH),
|
|
2098
|
+
capabilities,
|
|
2099
|
+
charset,
|
|
2100
|
+
status,
|
|
2101
|
+
authPlugin: authPlugin === "" ? PLUGIN_NATIVE : authPlugin
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
function encodeSslRequest(capabilities, maxPacketSize, charset) {
|
|
2105
|
+
return Buffer.concat([u32(capabilities | CLIENT.SSL), u32(maxPacketSize), Buffer.from([charset]), Buffer.alloc(23)]);
|
|
2106
|
+
}
|
|
2107
|
+
function parseHandshakeResponse(payload) {
|
|
2108
|
+
const r = new Cursor(payload);
|
|
2109
|
+
const capabilities = r.u32("capabilities");
|
|
2110
|
+
if ((capabilities & CLIENT.PROTOCOL_41) === 0) throw new Error("the client does not speak protocol 4.1");
|
|
2111
|
+
const maxPacketSize = r.u32("max packet size");
|
|
2112
|
+
const charset = r.u8("charset");
|
|
2113
|
+
r.bytes(23, "filler");
|
|
2114
|
+
if (r.remaining === 0) {
|
|
2115
|
+
return { capabilities, maxPacketSize, charset, username: "", authResponse: Buffer.alloc(0), database: "", authPlugin: "", attributes: [] };
|
|
2116
|
+
}
|
|
2117
|
+
const username = r.nulString("username").toString("utf8");
|
|
2118
|
+
let authResponse;
|
|
2119
|
+
if ((capabilities & CLIENT.PLUGIN_AUTH_LENENC_CLIENT_DATA) !== 0) authResponse = r.lenencBytes("auth response");
|
|
2120
|
+
else if ((capabilities & CLIENT.SECURE_CONNECTION) !== 0) authResponse = r.bytes(r.u8("auth response length"), "auth response");
|
|
2121
|
+
else authResponse = r.nulString("auth response");
|
|
2122
|
+
let database = "";
|
|
2123
|
+
if ((capabilities & CLIENT.CONNECT_WITH_DB) !== 0 && r.remaining > 0) database = r.nulString("database").toString("utf8");
|
|
2124
|
+
let authPlugin = "";
|
|
2125
|
+
if ((capabilities & CLIENT.PLUGIN_AUTH) !== 0 && r.remaining > 0) authPlugin = r.nulString("auth plugin").toString("utf8");
|
|
2126
|
+
const attributes = [];
|
|
2127
|
+
if ((capabilities & CLIENT.CONNECT_ATTRS) !== 0 && r.remaining > 0) {
|
|
2128
|
+
const attrs = new Cursor(r.lenencBytes("attributes"));
|
|
2129
|
+
while (attrs.remaining > 0) {
|
|
2130
|
+
const key = attrs.lenencBytes("attribute key").toString("utf8");
|
|
2131
|
+
const value = attrs.lenencBytes("attribute value").toString("utf8");
|
|
2132
|
+
attributes.push([key, value]);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
return {
|
|
2136
|
+
capabilities,
|
|
2137
|
+
maxPacketSize,
|
|
2138
|
+
charset,
|
|
2139
|
+
username,
|
|
2140
|
+
authResponse: Buffer.from(authResponse),
|
|
2141
|
+
database,
|
|
2142
|
+
authPlugin,
|
|
2143
|
+
attributes
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
function encodeHandshakeResponse(h) {
|
|
2147
|
+
const parts = [u32(h.capabilities), u32(h.maxPacketSize), Buffer.from([h.charset]), Buffer.alloc(23)];
|
|
2148
|
+
parts.push(Buffer.from(h.username, "utf8"), NUL);
|
|
2149
|
+
if ((h.capabilities & CLIENT.PLUGIN_AUTH_LENENC_CLIENT_DATA) !== 0) parts.push(lenencBytes(h.authResponse));
|
|
2150
|
+
else if ((h.capabilities & CLIENT.SECURE_CONNECTION) !== 0) parts.push(Buffer.from([h.authResponse.length]), h.authResponse);
|
|
2151
|
+
else parts.push(h.authResponse, NUL);
|
|
2152
|
+
if ((h.capabilities & CLIENT.CONNECT_WITH_DB) !== 0) parts.push(Buffer.from(h.database, "utf8"), NUL);
|
|
2153
|
+
if ((h.capabilities & CLIENT.PLUGIN_AUTH) !== 0) parts.push(Buffer.from(h.authPlugin, "utf8"), NUL);
|
|
2154
|
+
if ((h.capabilities & CLIENT.CONNECT_ATTRS) !== 0) {
|
|
2155
|
+
const attrs = h.attributes.map(
|
|
2156
|
+
([key, value]) => Buffer.concat([lenencBytes(Buffer.from(key, "utf8")), lenencBytes(Buffer.from(value, "utf8"))])
|
|
2157
|
+
);
|
|
2158
|
+
parts.push(lenencBytes(Buffer.concat(attrs)));
|
|
2159
|
+
}
|
|
2160
|
+
return Buffer.concat(parts);
|
|
2161
|
+
}
|
|
2162
|
+
function encodeAuthSwitchRequest(plugin, scramble) {
|
|
2163
|
+
return Buffer.concat([Buffer.from([254]), Buffer.from(plugin, "utf8"), NUL, scramble, NUL]);
|
|
2164
|
+
}
|
|
2165
|
+
function parseAuthSwitchRequest(payload) {
|
|
2166
|
+
if (payload[0] !== 254) throw new Error("not an auth switch request");
|
|
2167
|
+
const r = new Cursor(payload);
|
|
2168
|
+
r.pos = 1;
|
|
2169
|
+
const plugin = r.nulString("auth plugin").toString("utf8");
|
|
2170
|
+
let scramble = Buffer.from(r.restNul());
|
|
2171
|
+
if (scramble.length > SCRAMBLE_LENGTH) scramble = scramble.subarray(0, SCRAMBLE_LENGTH);
|
|
2172
|
+
return { plugin, scramble };
|
|
2173
|
+
}
|
|
2174
|
+
var isOk = (payload) => payload[0] === 0;
|
|
2175
|
+
var isErr = (payload) => payload[0] === 255;
|
|
2176
|
+
var isAuthSwitch = (payload) => payload[0] === 254;
|
|
2177
|
+
var isAuthMoreData = (payload) => payload[0] === AUTH_MORE_DATA;
|
|
2178
|
+
function encodeErr(code, sqlState, message) {
|
|
2179
|
+
const state2 = sqlState.length === 5 ? sqlState : "HY000";
|
|
2180
|
+
return Buffer.concat([Buffer.from([255]), u16(code), Buffer.from(`#${state2}`, "latin1"), Buffer.from(message, "utf8")]);
|
|
2181
|
+
}
|
|
2182
|
+
var MysqlServerError = class extends Error {
|
|
2183
|
+
constructor(code, sqlState, message) {
|
|
2184
|
+
super(message);
|
|
2185
|
+
this.code = code;
|
|
2186
|
+
this.sqlState = sqlState;
|
|
2187
|
+
this.name = "MysqlServerError";
|
|
2188
|
+
}
|
|
2189
|
+
code;
|
|
2190
|
+
sqlState;
|
|
2191
|
+
};
|
|
2192
|
+
function serverErrorFromPacket(payload) {
|
|
2193
|
+
if (payload.length < 3 || payload[0] !== 255) return new MysqlServerError(0, "HY000", "unknown error");
|
|
2194
|
+
const code = payload.readUInt16LE(1);
|
|
2195
|
+
let rest = payload.subarray(3);
|
|
2196
|
+
let sqlState = "HY000";
|
|
2197
|
+
if (rest.length >= 6 && rest[0] === 35) {
|
|
2198
|
+
sqlState = rest.subarray(1, 6).toString("latin1");
|
|
2199
|
+
rest = rest.subarray(6);
|
|
2200
|
+
}
|
|
2201
|
+
return new MysqlServerError(code, sqlState, rest.toString("utf8"));
|
|
2202
|
+
}
|
|
2203
|
+
function newScramble() {
|
|
2204
|
+
const b = randomBytes(SCRAMBLE_LENGTH);
|
|
2205
|
+
for (let i = 0; i < b.length; i++) {
|
|
2206
|
+
let v = (b[i] ?? 0) & 127 | 1;
|
|
2207
|
+
if (v === 36) v = 37;
|
|
2208
|
+
b[i] = v;
|
|
2209
|
+
}
|
|
2210
|
+
return b;
|
|
2211
|
+
}
|
|
2212
|
+
function xor(a, b) {
|
|
2213
|
+
const out = Buffer.alloc(a.length);
|
|
2214
|
+
for (let i = 0; i < a.length; i++) out[i] = (a[i] ?? 0) ^ (b[i] ?? 0);
|
|
2215
|
+
return out;
|
|
2216
|
+
}
|
|
2217
|
+
function scrambleNative(password, nonce) {
|
|
2218
|
+
if (password === "") return Buffer.alloc(0);
|
|
2219
|
+
const h1 = createHash("sha1").update(password, "utf8").digest();
|
|
2220
|
+
const h2 = createHash("sha1").update(h1).digest();
|
|
2221
|
+
const h3 = createHash("sha1").update(nonce).update(h2).digest();
|
|
2222
|
+
return xor(h1, h3);
|
|
2223
|
+
}
|
|
2224
|
+
function scrambleCachingSha2(password, nonce) {
|
|
2225
|
+
if (password === "") return Buffer.alloc(0);
|
|
2226
|
+
const h1 = createHash("sha256").update(password, "utf8").digest();
|
|
2227
|
+
const h2 = createHash("sha256").update(h1).digest();
|
|
2228
|
+
const h3 = createHash("sha256").update(h2).update(nonce).digest();
|
|
2229
|
+
return xor(h1, h3);
|
|
2230
|
+
}
|
|
2231
|
+
function scrambleFor(plugin, password, nonce) {
|
|
2232
|
+
switch (plugin) {
|
|
2233
|
+
case PLUGIN_NATIVE:
|
|
2234
|
+
return scrambleNative(password, nonce);
|
|
2235
|
+
case PLUGIN_CACHING_SHA2:
|
|
2236
|
+
return scrambleCachingSha2(password, nonce);
|
|
2237
|
+
default:
|
|
2238
|
+
throw new Error(`unsupported auth plugin "${plugin}"`);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
function scrambleMatches(received, expected) {
|
|
2242
|
+
return received.length === expected.length && timingSafeEqual(received, expected);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// src/lib/gateway/forwarder.ts
|
|
2246
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
2247
|
+
var DEFAULT_HANDSHAKE_TIMEOUT_MS = 3e4;
|
|
2248
|
+
var REFRESH_RETRY_BACKOFFS_MS = [3e4, 6e4, 12e4, 3e5];
|
|
2249
|
+
var SERVER_VERSION = "8.0.40-meridian-forwarder";
|
|
2250
|
+
var CLOSE_GRACE_MS = 2e3;
|
|
2251
|
+
var ER_ACCESS_DENIED = 1045;
|
|
2252
|
+
var ER_HANDSHAKE = 1043;
|
|
2253
|
+
var CR_CONN_HOST_ERROR = 2003;
|
|
2254
|
+
var LocalRefusal = class extends Error {
|
|
2255
|
+
constructor(code, sqlState, message) {
|
|
2256
|
+
super(message);
|
|
2257
|
+
this.code = code;
|
|
2258
|
+
this.sqlState = sqlState;
|
|
2259
|
+
}
|
|
2260
|
+
code;
|
|
2261
|
+
sqlState;
|
|
2262
|
+
};
|
|
2263
|
+
function accessDenied(username) {
|
|
2264
|
+
return new LocalRefusal(ER_ACCESS_DENIED, "28000", `Access denied for user '${username}'@'localhost' (using password: YES)`);
|
|
2265
|
+
}
|
|
2266
|
+
function upstreamCapabilities(clientCapabilities, database) {
|
|
2267
|
+
let caps = clientCapabilities & LOCAL_CAPABILITIES | CLIENT.SSL | CLIENT.PROTOCOL_41 | CLIENT.SECURE_CONNECTION | CLIENT.PLUGIN_AUTH | CLIENT.PLUGIN_AUTH_LENENC_CLIENT_DATA;
|
|
2268
|
+
if (database !== "") caps |= CLIENT.CONNECT_WITH_DB;
|
|
2269
|
+
return caps >>> 0;
|
|
2270
|
+
}
|
|
2271
|
+
function waitFor(socket, event) {
|
|
2272
|
+
return new Promise((resolve2, reject) => {
|
|
2273
|
+
const onError = (error2) => {
|
|
2274
|
+
socket.off(event, onDone);
|
|
2275
|
+
reject(error2);
|
|
2276
|
+
};
|
|
2277
|
+
const onDone = () => {
|
|
2278
|
+
socket.off("error", onError);
|
|
2279
|
+
resolve2();
|
|
2280
|
+
};
|
|
2281
|
+
socket.once(event, onDone);
|
|
2282
|
+
socket.once("error", onError);
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
function splice(a, b) {
|
|
2286
|
+
const closeOther = (other) => () => {
|
|
2287
|
+
if (other.destroyed) return;
|
|
2288
|
+
other.end();
|
|
2289
|
+
setTimeout(() => other.destroy(), CLOSE_GRACE_MS).unref();
|
|
2290
|
+
};
|
|
2291
|
+
a.on("close", closeOther(b));
|
|
2292
|
+
b.on("close", closeOther(a));
|
|
2293
|
+
a.pipe(b);
|
|
2294
|
+
b.pipe(a);
|
|
2295
|
+
}
|
|
2296
|
+
async function startGatewayForwarder(options) {
|
|
2297
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
2298
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
|
|
2299
|
+
const refreshBeforeMs = options.refreshBeforeMs ?? DEFAULT_REFRESH_BEFORE_MS;
|
|
2300
|
+
const now = options.now ?? Date.now;
|
|
2301
|
+
const emit = (event) => {
|
|
2302
|
+
try {
|
|
2303
|
+
options.onEvent?.(event);
|
|
2304
|
+
} catch {
|
|
2305
|
+
}
|
|
2306
|
+
};
|
|
2307
|
+
let certificate = options.certificate;
|
|
2308
|
+
let running = false;
|
|
2309
|
+
let stopped = false;
|
|
2310
|
+
let connectionId = 0;
|
|
2311
|
+
let refreshTimer;
|
|
2312
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
2313
|
+
const track = (socket) => {
|
|
2314
|
+
sockets.add(socket);
|
|
2315
|
+
socket.once("close", () => sockets.delete(socket));
|
|
2316
|
+
socket.on("error", () => socket.destroy());
|
|
2317
|
+
};
|
|
2318
|
+
async function openUpstream(cert, client) {
|
|
2319
|
+
const raw = netConnect({ host: cert.host, port: cert.port });
|
|
2320
|
+
track(raw);
|
|
2321
|
+
raw.setNoDelay(true);
|
|
2322
|
+
await waitFor(raw, "connect");
|
|
2323
|
+
const rawReader = new PacketReader(raw);
|
|
2324
|
+
const greetingPacket = await rawReader.read();
|
|
2325
|
+
const greeting = parseGreeting(greetingPacket.payload);
|
|
2326
|
+
if ((greeting.capabilities & CLIENT.SSL) === 0) throw new Error("the gateway did not offer TLS");
|
|
2327
|
+
const capabilities = upstreamCapabilities(client.capabilities, client.database);
|
|
2328
|
+
let seq = greetingPacket.seq + 1;
|
|
2329
|
+
raw.write(encodePacket(seq, encodeSslRequest(capabilities, client.maxPacketSize, client.charset)));
|
|
2330
|
+
rawReader.release({ pause: false });
|
|
2331
|
+
const socket = tlsConnect({
|
|
2332
|
+
socket: raw,
|
|
2333
|
+
secureContext: cert.secureContext,
|
|
2334
|
+
host: cert.host,
|
|
2335
|
+
// SNI carries host names only; an IP is still verified against the certificate.
|
|
2336
|
+
...isIP(cert.host) === 0 ? { servername: cert.host } : {},
|
|
2337
|
+
rejectUnauthorized: true
|
|
2338
|
+
});
|
|
2339
|
+
track(socket);
|
|
2340
|
+
socket.setNoDelay(true);
|
|
2341
|
+
await waitFor(socket, "secureConnect");
|
|
2342
|
+
const reader = new PacketReader(socket);
|
|
2343
|
+
let plugin = PLUGIN_CACHING_SHA2;
|
|
2344
|
+
seq++;
|
|
2345
|
+
socket.write(
|
|
2346
|
+
encodePacket(
|
|
2347
|
+
seq,
|
|
2348
|
+
encodeHandshakeResponse({
|
|
2349
|
+
capabilities,
|
|
2350
|
+
maxPacketSize: client.maxPacketSize,
|
|
2351
|
+
charset: client.charset,
|
|
2352
|
+
username: options.username,
|
|
2353
|
+
authResponse: scrambleCachingSha2(options.password, greeting.scramble),
|
|
2354
|
+
database: client.database,
|
|
2355
|
+
authPlugin: plugin,
|
|
2356
|
+
attributes: client.attributes
|
|
2357
|
+
})
|
|
2358
|
+
)
|
|
2359
|
+
);
|
|
2360
|
+
for (let round = 0; round < 6; round++) {
|
|
2361
|
+
const packet = await reader.read();
|
|
2362
|
+
seq = packet.seq;
|
|
2363
|
+
const payload = packet.payload;
|
|
2364
|
+
if (isOk(payload)) return { socket, ok: payload, leftover: reader.release({ pause: true }) };
|
|
2365
|
+
if (isErr(payload)) throw serverErrorFromPacket(payload);
|
|
2366
|
+
if (isAuthSwitch(payload)) {
|
|
2367
|
+
const request = parseAuthSwitchRequest(payload);
|
|
2368
|
+
plugin = request.plugin;
|
|
2369
|
+
seq++;
|
|
2370
|
+
socket.write(encodePacket(seq, scrambleFor(plugin, options.password, request.scramble)));
|
|
2371
|
+
continue;
|
|
2372
|
+
}
|
|
2373
|
+
if (isAuthMoreData(payload) && plugin === PLUGIN_CACHING_SHA2 && payload.length >= 2) {
|
|
2374
|
+
if (payload[1] === FAST_AUTH_SUCCESS) continue;
|
|
2375
|
+
if (payload[1] === PERFORM_FULL_AUTH) {
|
|
2376
|
+
seq++;
|
|
2377
|
+
socket.write(encodePacket(seq, Buffer.concat([Buffer.from(options.password, "utf8"), Buffer.from([0])])));
|
|
2378
|
+
continue;
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
throw new Error(`unexpected packet 0x${(payload[0] ?? 0).toString(16)} from the gateway during authentication`);
|
|
2382
|
+
}
|
|
2383
|
+
throw new Error("the gateway's authentication did not finish");
|
|
2384
|
+
}
|
|
2385
|
+
async function authenticateLocal(local, reader) {
|
|
2386
|
+
const scramble = newScramble();
|
|
2387
|
+
connectionId = connectionId + 1 >>> 0;
|
|
2388
|
+
local.write(encodePacket(0, encodeGreeting(localGreeting(connectionId, scramble, SERVER_VERSION))));
|
|
2389
|
+
const first = await reader.read();
|
|
2390
|
+
let seq = first.seq;
|
|
2391
|
+
const client = parseHandshakeResponse(first.payload);
|
|
2392
|
+
if (first.payload.length === 32) {
|
|
2393
|
+
throw new LocalRefusal(ER_HANDSHAKE, "08S01", "The Meridian forwarder on this port does not use TLS; connect without it (the connection never leaves this machine).");
|
|
2394
|
+
}
|
|
2395
|
+
if ((client.capabilities & (CLIENT.COMPRESS | CLIENT.ZSTD_COMPRESSION)) !== 0) {
|
|
2396
|
+
throw new LocalRefusal(ER_HANDSHAKE, "08S01", "Compression is not supported by the Meridian database gateway; turn it off in your client.");
|
|
2397
|
+
}
|
|
2398
|
+
if (client.username !== options.username) throw accessDenied(client.username);
|
|
2399
|
+
let authResponse = client.authResponse;
|
|
2400
|
+
let plugin = client.authPlugin;
|
|
2401
|
+
if (plugin === "" && (client.capabilities & CLIENT.SECURE_CONNECTION) !== 0) plugin = PLUGIN_NATIVE;
|
|
2402
|
+
if (plugin !== PLUGIN_NATIVE && plugin !== PLUGIN_CACHING_SHA2) {
|
|
2403
|
+
seq++;
|
|
2404
|
+
local.write(encodePacket(seq, encodeAuthSwitchRequest(PLUGIN_NATIVE, scramble)));
|
|
2405
|
+
const answer = await reader.read();
|
|
2406
|
+
seq = answer.seq;
|
|
2407
|
+
authResponse = answer.payload;
|
|
2408
|
+
plugin = PLUGIN_NATIVE;
|
|
2409
|
+
}
|
|
2410
|
+
const expected = plugin === PLUGIN_CACHING_SHA2 ? scrambleCachingSha2(options.password, scramble) : scrambleNative(options.password, scramble);
|
|
2411
|
+
if (!scrambleMatches(authResponse, expected)) throw accessDenied(client.username);
|
|
2412
|
+
if (plugin === PLUGIN_CACHING_SHA2) {
|
|
2413
|
+
seq++;
|
|
2414
|
+
local.write(encodePacket(seq, Buffer.from([AUTH_MORE_DATA, FAST_AUTH_SUCCESS])));
|
|
2415
|
+
}
|
|
2416
|
+
return { client, seq };
|
|
2417
|
+
}
|
|
2418
|
+
async function handleConnection(local) {
|
|
2419
|
+
track(local);
|
|
2420
|
+
local.setNoDelay(true);
|
|
2421
|
+
const reader = new PacketReader(local);
|
|
2422
|
+
let upstream;
|
|
2423
|
+
const timer = setTimeout(() => {
|
|
2424
|
+
emit({ type: "connection-error", message: "A local MySQL handshake timed out." });
|
|
2425
|
+
local.destroy();
|
|
2426
|
+
upstream?.destroy();
|
|
2427
|
+
}, handshakeTimeoutMs);
|
|
2428
|
+
timer.unref();
|
|
2429
|
+
let seq = 0;
|
|
2430
|
+
try {
|
|
2431
|
+
const authenticated = await authenticateLocal(local, reader);
|
|
2432
|
+
seq = authenticated.seq;
|
|
2433
|
+
const cert = certificate;
|
|
2434
|
+
let session;
|
|
2435
|
+
try {
|
|
2436
|
+
session = await openUpstream(cert, authenticated.client);
|
|
2437
|
+
} catch (error2) {
|
|
2438
|
+
if (error2 instanceof MysqlServerError) {
|
|
2439
|
+
throw new LocalRefusal(error2.code, error2.sqlState, error2.message);
|
|
2440
|
+
}
|
|
2441
|
+
throw new LocalRefusal(
|
|
2442
|
+
CR_CONN_HOST_ERROR,
|
|
2443
|
+
"HY000",
|
|
2444
|
+
`Cannot reach the Meridian database gateway at ${cert.host}:${cert.port}: ${describeError(error2)}`
|
|
2445
|
+
);
|
|
2446
|
+
}
|
|
2447
|
+
upstream = session.socket;
|
|
2448
|
+
clearTimeout(timer);
|
|
2449
|
+
local.write(encodePacket(seq + 1, session.ok));
|
|
2450
|
+
const pipelined = reader.release({ pause: true });
|
|
2451
|
+
if (pipelined.length > 0) upstream.write(pipelined);
|
|
2452
|
+
if (session.leftover.length > 0) local.write(session.leftover);
|
|
2453
|
+
splice(local, upstream);
|
|
2454
|
+
} catch (error2) {
|
|
2455
|
+
clearTimeout(timer);
|
|
2456
|
+
upstream?.destroy();
|
|
2457
|
+
if (error2 instanceof LocalRefusal) {
|
|
2458
|
+
if (error2.code !== ER_ACCESS_DENIED) emit({ type: "connection-error", message: error2.message });
|
|
2459
|
+
if (!local.destroyed) local.end(encodePacket(seq + 1, encodeErr(error2.code, error2.sqlState, error2.message)));
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
emit({ type: "connection-error", message: describeError(error2) });
|
|
2463
|
+
local.destroy();
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
async function refresh() {
|
|
2467
|
+
if (options.refreshCertificate === void 0) return;
|
|
2468
|
+
const next = await options.refreshCertificate();
|
|
2469
|
+
certificate = next;
|
|
2470
|
+
emit({ type: "certificate-refreshed", expiresAt: next.expiresAt });
|
|
2471
|
+
}
|
|
2472
|
+
function scheduleRefresh(delayMs, attempt) {
|
|
2473
|
+
if (options.refreshCertificate === void 0 || stopped) return;
|
|
2474
|
+
refreshTimer = setTimeout(() => {
|
|
2475
|
+
refresh().then(
|
|
2476
|
+
() => scheduleRefresh(certificateRefreshDelay(certificate.expiresAt, now(), refreshBeforeMs), 0),
|
|
2477
|
+
(error2) => {
|
|
2478
|
+
const backoff = REFRESH_RETRY_BACKOFFS_MS[Math.min(attempt, REFRESH_RETRY_BACKOFFS_MS.length - 1)] ?? 3e5;
|
|
2479
|
+
emit({
|
|
2480
|
+
type: "error",
|
|
2481
|
+
message: `Could not renew the database gateway certificate (${describeError(error2)}); retrying in ${Math.round(backoff / 1e3)}s.`
|
|
2482
|
+
});
|
|
2483
|
+
scheduleRefresh(backoff, attempt + 1);
|
|
2484
|
+
}
|
|
2485
|
+
);
|
|
2486
|
+
}, delayMs);
|
|
2487
|
+
refreshTimer.unref();
|
|
2488
|
+
}
|
|
2489
|
+
const server = createServer((local) => {
|
|
2490
|
+
void handleConnection(local);
|
|
2491
|
+
});
|
|
2492
|
+
await new Promise((resolve2, reject) => {
|
|
2493
|
+
server.once("error", (error2) => {
|
|
2494
|
+
reject(
|
|
2495
|
+
error2.code === "EADDRINUSE" ? new CliError(`Port ${options.port} is already in use.`, {
|
|
2496
|
+
hint: "Pick a different port with --port, or free up that port and try again."
|
|
2497
|
+
}) : error2
|
|
2498
|
+
);
|
|
2499
|
+
});
|
|
2500
|
+
server.listen({ port: options.port, host, exclusive: true }, () => resolve2());
|
|
2501
|
+
});
|
|
2502
|
+
server.on("error", (error2) => emit({ type: "error", message: describeError(error2) }));
|
|
2503
|
+
const address = server.address();
|
|
2504
|
+
const port = typeof address === "object" && address !== null ? address.port : options.port;
|
|
2505
|
+
running = true;
|
|
2506
|
+
emit({ type: "ready", port });
|
|
2507
|
+
scheduleRefresh(certificateRefreshDelay(certificate.expiresAt, now(), refreshBeforeMs), 0);
|
|
2508
|
+
return {
|
|
2509
|
+
host,
|
|
2510
|
+
port,
|
|
2511
|
+
get running() {
|
|
2512
|
+
return running;
|
|
2513
|
+
},
|
|
2514
|
+
get certificate() {
|
|
2515
|
+
return certificate;
|
|
2516
|
+
},
|
|
2517
|
+
refresh,
|
|
2518
|
+
async stop() {
|
|
2519
|
+
if (stopped) return;
|
|
2520
|
+
stopped = true;
|
|
2521
|
+
running = false;
|
|
2522
|
+
if (refreshTimer !== void 0) clearTimeout(refreshTimer);
|
|
2523
|
+
const closed = new Promise((resolve2) => server.close(() => resolve2()));
|
|
2524
|
+
for (const socket of sockets) socket.destroy();
|
|
2525
|
+
await closed;
|
|
2526
|
+
}
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
|
|
1674
2530
|
// src/lib/port.ts
|
|
1675
|
-
import { createServer } from "net";
|
|
2531
|
+
import { createServer as createServer2 } from "net";
|
|
1676
2532
|
var DEFAULT_START_PORT = 3307;
|
|
1677
2533
|
var DEFAULT_ATTEMPTS = 20;
|
|
1678
2534
|
var MAX_PORT = 65535;
|
|
1679
2535
|
function isPortFree(port, host = "127.0.0.1") {
|
|
1680
2536
|
return new Promise((resolve2) => {
|
|
1681
|
-
const server =
|
|
2537
|
+
const server = createServer2();
|
|
1682
2538
|
server.once("error", () => {
|
|
1683
2539
|
resolve2(false);
|
|
1684
2540
|
});
|
|
@@ -1690,7 +2546,7 @@ function isPortFree(port, host = "127.0.0.1") {
|
|
|
1690
2546
|
}
|
|
1691
2547
|
function bindEphemeralPort(host) {
|
|
1692
2548
|
return new Promise((resolve2) => {
|
|
1693
|
-
const server =
|
|
2549
|
+
const server = createServer2();
|
|
1694
2550
|
server.once("error", () => resolve2(null));
|
|
1695
2551
|
server.listen({ port: 0, host, exclusive: true }, () => {
|
|
1696
2552
|
const address = server.address();
|
|
@@ -1852,7 +2708,7 @@ function addShadowDatabaseUrl(root) {
|
|
|
1852
2708
|
function wrongUrlError(datasource, url) {
|
|
1853
2709
|
const problem = url.kind === "literal" ? `hardcodes \`url = "${url.value}"\`` : `reads \`env("${url.name}")\`, but Meridian sets \`${REQUIRED_URL_ENV}\``;
|
|
1854
2710
|
return new CliError(
|
|
1855
|
-
`\`${datasource.schemaPath}\` ${problem}, so this command would not touch the managed
|
|
2711
|
+
`\`${datasource.schemaPath}\` ${problem}, so this command would not touch the managed developer database.`,
|
|
1856
2712
|
{
|
|
1857
2713
|
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
2714
|
}
|
|
@@ -1860,7 +2716,7 @@ function wrongUrlError(datasource, url) {
|
|
|
1860
2716
|
}
|
|
1861
2717
|
function wrongProviderError(datasource, provider) {
|
|
1862
2718
|
return new CliError(
|
|
1863
|
-
`\`${datasource.schemaPath}\` sets \`provider = "${provider}"\`, but the managed
|
|
2719
|
+
`\`${datasource.schemaPath}\` sets \`provider = "${provider}"\`, but the managed developer database is MySQL.`,
|
|
1864
2720
|
{
|
|
1865
2721
|
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
2722
|
}
|
|
@@ -1927,7 +2783,7 @@ function diagnoseMigrationFailure(root) {
|
|
|
1927
2783
|
}
|
|
1928
2784
|
|
|
1929
2785
|
// src/lib/proxy/binary.ts
|
|
1930
|
-
import { createHash } from "crypto";
|
|
2786
|
+
import { createHash as createHash2 } from "crypto";
|
|
1931
2787
|
import { chmodSync as chmodSync3, existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync as renameSync3, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
|
|
1932
2788
|
import { join as join4 } from "path";
|
|
1933
2789
|
var PROXY_VERSION = "2.24.1";
|
|
@@ -1976,7 +2832,7 @@ function resolveProxyAsset(options) {
|
|
|
1976
2832
|
};
|
|
1977
2833
|
}
|
|
1978
2834
|
function sha256(data) {
|
|
1979
|
-
return
|
|
2835
|
+
return createHash2("sha256").update(data).digest("hex");
|
|
1980
2836
|
}
|
|
1981
2837
|
var CHECKSUM_ROW_PATTERN = /\[([^\]]+)]\([^)]*\)\s*\|\s*([0-9a-fA-F]{64})/g;
|
|
1982
2838
|
function parseChecksumTable(releaseBody) {
|
|
@@ -2117,8 +2973,8 @@ var PROBE_INTERVAL_MS = 150;
|
|
|
2117
2973
|
var KILL_GRACE_MS = 2e3;
|
|
2118
2974
|
var CRASH_BACKOFFS_MS = [200, 1e3, 3e3];
|
|
2119
2975
|
var MAX_CRASH_RESTARTS = CRASH_BACKOFFS_MS.length;
|
|
2120
|
-
var
|
|
2121
|
-
var
|
|
2976
|
+
var REFRESH_RETRY_BACKOFFS_MS2 = [3e4, 6e4, 12e4];
|
|
2977
|
+
var MIN_REFRESH_DELAY_MS2 = 6e4;
|
|
2122
2978
|
var LOG_RING_SIZE = 20;
|
|
2123
2979
|
var STDERR_TAIL_LINES = 10;
|
|
2124
2980
|
function buildProxyArgs(input2) {
|
|
@@ -2147,7 +3003,7 @@ function computeRefreshDelayMs(token, refreshSkewMs, assumedLifetimeMs) {
|
|
|
2147
3003
|
if (!Number.isNaN(parsed)) expiresAtMs = parsed;
|
|
2148
3004
|
}
|
|
2149
3005
|
const effectiveExpiryMs = expiresAtMs ?? Date.now() + assumedLifetimeMs;
|
|
2150
|
-
return Math.max(effectiveExpiryMs - Date.now() - refreshSkewMs,
|
|
3006
|
+
return Math.max(effectiveExpiryMs - Date.now() - refreshSkewMs, MIN_REFRESH_DELAY_MS2);
|
|
2151
3007
|
}
|
|
2152
3008
|
function buildStartupFailureError(code, signal, lastLines) {
|
|
2153
3009
|
const tail = lastLines.slice(-STDERR_TAIL_LINES).join("\n");
|
|
@@ -2325,7 +3181,7 @@ async function startProxy(options) {
|
|
|
2325
3181
|
}
|
|
2326
3182
|
function scheduleRefreshRetry() {
|
|
2327
3183
|
if (stopped) return;
|
|
2328
|
-
const delayMs =
|
|
3184
|
+
const delayMs = REFRESH_RETRY_BACKOFFS_MS2[Math.min(refreshBackoffIndex, REFRESH_RETRY_BACKOFFS_MS2.length - 1)] ?? 12e4;
|
|
2329
3185
|
refreshBackoffIndex += 1;
|
|
2330
3186
|
if (refreshTimer !== void 0) clearTimeout(refreshTimer);
|
|
2331
3187
|
refreshTimer = setTimeoutImpl(() => {
|
|
@@ -2525,6 +3381,8 @@ function resolveDeps(overrides) {
|
|
|
2525
3381
|
return {
|
|
2526
3382
|
ensureProxyBinary,
|
|
2527
3383
|
startProxy,
|
|
3384
|
+
issueGatewayCertificate,
|
|
3385
|
+
startGatewayForwarder,
|
|
2528
3386
|
findFreePort,
|
|
2529
3387
|
patchEnvFile,
|
|
2530
3388
|
waitForDatabase,
|
|
@@ -2541,17 +3399,17 @@ var DEV_DATABASE_ID_ENV = "MERIDIAN_DEV_DATABASE_ID";
|
|
|
2541
3399
|
function provisioningFailedError(devDatabase) {
|
|
2542
3400
|
const reason = devDatabase.error_message;
|
|
2543
3401
|
const detail2 = typeof reason === "string" && reason.trim() !== "" ? ` ${reason.trim()}` : "";
|
|
2544
|
-
return new CliError(`Provisioning the
|
|
3402
|
+
return new CliError(`Provisioning the developer database failed.${detail2}`, {
|
|
2545
3403
|
hint: "Check the Meridian dashboard for details, or contact support if this keeps happening."
|
|
2546
3404
|
});
|
|
2547
3405
|
}
|
|
2548
3406
|
function provisioningTimeoutError() {
|
|
2549
|
-
return new CliError("Timed out waiting for the
|
|
3407
|
+
return new CliError("Timed out waiting for the developer database to be provisioned.", {
|
|
2550
3408
|
hint: "Try `meridian dev` again in a few minutes, and check the Meridian dashboard for its status."
|
|
2551
3409
|
});
|
|
2552
3410
|
}
|
|
2553
3411
|
function provisioningNotFoundError() {
|
|
2554
|
-
return new CliError("The Meridian API never finished creating the
|
|
3412
|
+
return new CliError("The Meridian API never finished creating the developer database.", {
|
|
2555
3413
|
hint: "Try `meridian dev` again; if this keeps happening, contact Meridian support."
|
|
2556
3414
|
});
|
|
2557
3415
|
}
|
|
@@ -2563,7 +3421,7 @@ async function pollDevDatabase(client, appId, options, spinner2) {
|
|
|
2563
3421
|
const start = now();
|
|
2564
3422
|
const report = (status) => {
|
|
2565
3423
|
const elapsedMs = now() - start;
|
|
2566
|
-
spinner2.update(`Provisioning the
|
|
3424
|
+
spinner2.update(`Provisioning the developer database... (${status}, ${Math.round(elapsedMs / 1e3)}s)`);
|
|
2567
3425
|
options.onStatus?.(status, elapsedMs);
|
|
2568
3426
|
};
|
|
2569
3427
|
let current2 = await client.ensureDevDatabase(appId);
|
|
@@ -2590,9 +3448,9 @@ async function pollDevDatabase(client, appId, options, spinner2) {
|
|
|
2590
3448
|
}
|
|
2591
3449
|
async function ensureDevDatabaseReady(client, appId, options = {}) {
|
|
2592
3450
|
return withSpinner(
|
|
2593
|
-
"Provisioning the
|
|
3451
|
+
"Provisioning the developer database...",
|
|
2594
3452
|
(spinner2) => pollDevDatabase(client, appId, options, spinner2),
|
|
2595
|
-
{ success: (db) => `
|
|
3453
|
+
{ success: (db) => `Developer database "${db.database}" is ready.` }
|
|
2596
3454
|
);
|
|
2597
3455
|
}
|
|
2598
3456
|
function toProxyToken(token) {
|
|
@@ -2651,6 +3509,97 @@ function handleProxyEvent(event) {
|
|
|
2651
3509
|
return;
|
|
2652
3510
|
}
|
|
2653
3511
|
}
|
|
3512
|
+
async function pickLocalPort(options, deps) {
|
|
3513
|
+
const findPortOptions = { start: DEFAULT_PORT_START };
|
|
3514
|
+
if (options.port !== void 0) findPortOptions.preferred = options.port;
|
|
3515
|
+
const port = await deps.findFreePort(findPortOptions);
|
|
3516
|
+
if (options.port !== void 0 && port !== options.port) {
|
|
3517
|
+
throw new CliError(`Port ${options.port} is already in use.`, {
|
|
3518
|
+
hint: "Pick a different port with --port, or free up that port and try again."
|
|
3519
|
+
});
|
|
3520
|
+
}
|
|
3521
|
+
return port;
|
|
3522
|
+
}
|
|
3523
|
+
async function startProxyEndpoint(client, appId, devDatabase, credentials, options, deps) {
|
|
3524
|
+
const proxyToken = await withSpinner(
|
|
3525
|
+
"Requesting a Cloud SQL Auth Proxy token...",
|
|
3526
|
+
() => client.issueProxyToken(appId),
|
|
3527
|
+
{ success: "Issued a Cloud SQL Auth Proxy token." }
|
|
3528
|
+
);
|
|
3529
|
+
const binaryPath = await withSpinner(
|
|
3530
|
+
"Preparing the Cloud SQL Auth Proxy...",
|
|
3531
|
+
(spinner2) => {
|
|
3532
|
+
const ensureOptions = {
|
|
3533
|
+
onProgress: (received, total) => {
|
|
3534
|
+
spinner2.update(
|
|
3535
|
+
total !== void 0 ? `Downloading the Cloud SQL Auth Proxy... (${formatMb(received)}/${formatMb(total)} MB)` : `Downloading the Cloud SQL Auth Proxy... (${formatMb(received)} MB)`
|
|
3536
|
+
);
|
|
3537
|
+
}
|
|
3538
|
+
};
|
|
3539
|
+
return deps.ensureProxyBinary(ensureOptions);
|
|
3540
|
+
},
|
|
3541
|
+
{ success: "Cloud SQL Auth Proxy is ready." }
|
|
3542
|
+
);
|
|
3543
|
+
const port = await pickLocalPort(options, deps);
|
|
3544
|
+
const instanceConnectionName = firstNonEmpty(
|
|
3545
|
+
credentials.instance_connection_name,
|
|
3546
|
+
proxyToken.instance_connection_name,
|
|
3547
|
+
devDatabase.instance_connection_name
|
|
3548
|
+
);
|
|
3549
|
+
if (instanceConnectionName === void 0) {
|
|
3550
|
+
throw new CliError("The Meridian API did not return a Cloud SQL instance connection name.", {
|
|
3551
|
+
hint: "Try again, or contact Meridian support if this persists."
|
|
3552
|
+
});
|
|
3553
|
+
}
|
|
3554
|
+
return withSpinner(
|
|
3555
|
+
`Starting the Cloud SQL Auth Proxy on 127.0.0.1:${port}...`,
|
|
3556
|
+
() => deps.startProxy({
|
|
3557
|
+
binaryPath,
|
|
3558
|
+
instanceConnectionName,
|
|
3559
|
+
token: toProxyToken(proxyToken),
|
|
3560
|
+
port,
|
|
3561
|
+
...options.refreshToken !== false ? { refreshToken: async () => toProxyToken(await client.issueProxyToken(appId)) } : {},
|
|
3562
|
+
onEvent: handleProxyEvent
|
|
3563
|
+
}),
|
|
3564
|
+
{ success: `Cloud SQL Auth Proxy listening on 127.0.0.1:${port}.` }
|
|
3565
|
+
);
|
|
3566
|
+
}
|
|
3567
|
+
function handleForwarderEvent(event) {
|
|
3568
|
+
switch (event.type) {
|
|
3569
|
+
case "ready":
|
|
3570
|
+
debug(`Database gateway forwarder listening on port ${event.port}.`);
|
|
3571
|
+
return;
|
|
3572
|
+
case "connection-error":
|
|
3573
|
+
debug(`Database gateway connection failed: ${event.message}`);
|
|
3574
|
+
return;
|
|
3575
|
+
case "certificate-refreshed":
|
|
3576
|
+
step(`Renewed the database gateway certificate (expires ${event.expiresAt}).`);
|
|
3577
|
+
return;
|
|
3578
|
+
case "error":
|
|
3579
|
+
warn(`Database gateway: ${event.message} Restart \`meridian dev\` if the database becomes unreachable.`);
|
|
3580
|
+
return;
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
async function startGatewayEndpoint(client, appId, credentials, options, deps) {
|
|
3584
|
+
const certificate = await withSpinner(
|
|
3585
|
+
"Requesting a database gateway certificate...",
|
|
3586
|
+
() => deps.issueGatewayCertificate(client, appId),
|
|
3587
|
+
{ success: (cert) => `Issued a database gateway certificate (expires ${cert.expiresAt}).` }
|
|
3588
|
+
);
|
|
3589
|
+
const port = await pickLocalPort(options, deps);
|
|
3590
|
+
return withSpinner(
|
|
3591
|
+
`Starting the database gateway forwarder on 127.0.0.1:${port}...`,
|
|
3592
|
+
() => deps.startGatewayForwarder({
|
|
3593
|
+
port,
|
|
3594
|
+
username: credentials.username,
|
|
3595
|
+
password: credentials.password,
|
|
3596
|
+
certificate,
|
|
3597
|
+
...options.refreshToken !== false ? { refreshCertificate: () => deps.issueGatewayCertificate(client, appId) } : {},
|
|
3598
|
+
onEvent: handleForwarderEvent
|
|
3599
|
+
}),
|
|
3600
|
+
{ success: `Database gateway forwarder listening on 127.0.0.1:${port} (${certificate.host}).` }
|
|
3601
|
+
);
|
|
3602
|
+
}
|
|
2654
3603
|
async function startDevSession(options = {}) {
|
|
2655
3604
|
const deps = resolveDeps(options.deps);
|
|
2656
3605
|
let proxy;
|
|
@@ -2667,55 +3616,8 @@ async function startDevSession(options = {}) {
|
|
|
2667
3616
|
success: (creds) => `Fetched credentials for database "${creds.database}"${typeof creds.expires_at === "string" ? ` (expire ${creds.expires_at})` : ""}.`
|
|
2668
3617
|
}
|
|
2669
3618
|
);
|
|
2670
|
-
const
|
|
2671
|
-
|
|
2672
|
-
() => client.issueProxyToken(appId),
|
|
2673
|
-
{ success: "Issued a Cloud SQL Auth Proxy token." }
|
|
2674
|
-
);
|
|
2675
|
-
const binaryPath = await withSpinner(
|
|
2676
|
-
"Preparing the Cloud SQL Auth Proxy...",
|
|
2677
|
-
(spinner2) => {
|
|
2678
|
-
const ensureOptions = {
|
|
2679
|
-
onProgress: (received, total) => {
|
|
2680
|
-
spinner2.update(
|
|
2681
|
-
total !== void 0 ? `Downloading the Cloud SQL Auth Proxy... (${formatMb(received)}/${formatMb(total)} MB)` : `Downloading the Cloud SQL Auth Proxy... (${formatMb(received)} MB)`
|
|
2682
|
-
);
|
|
2683
|
-
}
|
|
2684
|
-
};
|
|
2685
|
-
return deps.ensureProxyBinary(ensureOptions);
|
|
2686
|
-
},
|
|
2687
|
-
{ success: "Cloud SQL Auth Proxy is ready." }
|
|
2688
|
-
);
|
|
2689
|
-
const findPortOptions = { start: DEFAULT_PORT_START };
|
|
2690
|
-
if (options.port !== void 0) findPortOptions.preferred = options.port;
|
|
2691
|
-
const port = await deps.findFreePort(findPortOptions);
|
|
2692
|
-
if (options.port !== void 0 && port !== options.port) {
|
|
2693
|
-
throw new CliError(`Port ${options.port} is already in use.`, {
|
|
2694
|
-
hint: "Pick a different port with --port, or free up that port and try again."
|
|
2695
|
-
});
|
|
2696
|
-
}
|
|
2697
|
-
const instanceConnectionName = firstNonEmpty(
|
|
2698
|
-
credentials.instance_connection_name,
|
|
2699
|
-
proxyToken.instance_connection_name,
|
|
2700
|
-
devDatabase.instance_connection_name
|
|
2701
|
-
);
|
|
2702
|
-
if (instanceConnectionName === void 0) {
|
|
2703
|
-
throw new CliError("The Meridian API did not return a Cloud SQL instance connection name.", {
|
|
2704
|
-
hint: "Try again, or contact Meridian support if this persists."
|
|
2705
|
-
});
|
|
2706
|
-
}
|
|
2707
|
-
proxy = await withSpinner(
|
|
2708
|
-
`Starting the Cloud SQL Auth Proxy on 127.0.0.1:${port}...`,
|
|
2709
|
-
() => deps.startProxy({
|
|
2710
|
-
binaryPath,
|
|
2711
|
-
instanceConnectionName,
|
|
2712
|
-
token: toProxyToken(proxyToken),
|
|
2713
|
-
port,
|
|
2714
|
-
...options.refreshToken !== false ? { refreshToken: async () => toProxyToken(await client.issueProxyToken(appId)) } : {},
|
|
2715
|
-
onEvent: handleProxyEvent
|
|
2716
|
-
}),
|
|
2717
|
-
{ success: `Cloud SQL Auth Proxy listening on 127.0.0.1:${port}.` }
|
|
2718
|
-
);
|
|
3619
|
+
const access = devDatabase.access === "gateway" ? "gateway" : "proxy";
|
|
3620
|
+
proxy = access === "gateway" ? await startGatewayEndpoint(client, appId, credentials, options, deps) : await startProxyEndpoint(client, appId, devDatabase, credentials, options, deps);
|
|
2719
3621
|
const connection = connectionInfoFromCredentials(credentials, { host: proxy.host, port: proxy.port });
|
|
2720
3622
|
if (options.waitForDb !== false) {
|
|
2721
3623
|
await withSpinner(
|
|
@@ -2767,6 +3669,7 @@ async function startDevSession(options = {}) {
|
|
|
2767
3669
|
connection,
|
|
2768
3670
|
databaseUrl,
|
|
2769
3671
|
...shadowDatabaseUrl !== void 0 ? { shadowDatabaseUrl } : {},
|
|
3672
|
+
access,
|
|
2770
3673
|
proxy,
|
|
2771
3674
|
env: {
|
|
2772
3675
|
...process.env,
|
|
@@ -2787,6 +3690,232 @@ async function startDevSession(options = {}) {
|
|
|
2787
3690
|
}
|
|
2788
3691
|
}
|
|
2789
3692
|
|
|
3693
|
+
// src/lib/db/external-credential.ts
|
|
3694
|
+
function describeAccessMode(mode) {
|
|
3695
|
+
if (mode === "read_only") return "read-only";
|
|
3696
|
+
if (mode === "read_write") return "read-write";
|
|
3697
|
+
return mode;
|
|
3698
|
+
}
|
|
3699
|
+
function resolveCredentialRef(credentials, ref) {
|
|
3700
|
+
const wanted = ref.trim();
|
|
3701
|
+
const byId = credentials.find((credential) => credential.id === wanted);
|
|
3702
|
+
if (byId !== void 0) return byId;
|
|
3703
|
+
const matchers = [
|
|
3704
|
+
(credential) => credential.label === wanted,
|
|
3705
|
+
(credential) => credential.label.toLowerCase() === wanted.toLowerCase()
|
|
3706
|
+
];
|
|
3707
|
+
for (const matches of matchers) {
|
|
3708
|
+
const found = credentials.filter(matches);
|
|
3709
|
+
const live = found.filter((credential) => credential.status !== "revoked");
|
|
3710
|
+
const pool = live.length > 0 ? live : found;
|
|
3711
|
+
if (pool.length === 1 && pool[0] !== void 0) return pool[0];
|
|
3712
|
+
if (pool.length > 1) {
|
|
3713
|
+
throw new CliError(`More than one credential is labelled "${wanted}".`, {
|
|
3714
|
+
hint: `Name it by id instead: ${pool.map((credential) => credential.id).join(", ")}.`
|
|
3715
|
+
});
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
throw new CliError(`No credential matches "${wanted}".`, {
|
|
3719
|
+
hint: "Run `meridian db credential list` to see each credential's label and id."
|
|
3720
|
+
});
|
|
3721
|
+
}
|
|
3722
|
+
function isMysqlDatabase(addon) {
|
|
3723
|
+
if (addon.type !== "database") return false;
|
|
3724
|
+
const type = addon.database?.type;
|
|
3725
|
+
return type === void 0 || type === null || type === "mysql";
|
|
3726
|
+
}
|
|
3727
|
+
function databaseTargets(environments) {
|
|
3728
|
+
const targets = [];
|
|
3729
|
+
for (const environment of environments) {
|
|
3730
|
+
const addon = (environment.addons ?? []).find(isMysqlDatabase);
|
|
3731
|
+
if (addon !== void 0) targets.push({ environment, addon });
|
|
3732
|
+
}
|
|
3733
|
+
return targets;
|
|
3734
|
+
}
|
|
3735
|
+
function environmentLabel(environment) {
|
|
3736
|
+
return environment.name ?? environment.type ?? environment.uuid;
|
|
3737
|
+
}
|
|
3738
|
+
function matchEnvironment(targets, wanted) {
|
|
3739
|
+
const target = wanted.trim().toLowerCase();
|
|
3740
|
+
return targets.find(
|
|
3741
|
+
({ environment }) => [environment.name, environment.type, environment.uuid].some(
|
|
3742
|
+
(value) => typeof value === "string" && value.toLowerCase() === target
|
|
3743
|
+
)
|
|
3744
|
+
);
|
|
3745
|
+
}
|
|
3746
|
+
function formatTimestamp(value, dateOnly = false) {
|
|
3747
|
+
if (value === null || value === void 0 || value === "") return "-";
|
|
3748
|
+
const date = new Date(value);
|
|
3749
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
3750
|
+
const iso = date.toISOString();
|
|
3751
|
+
return dateOnly ? iso.slice(0, 10) : `${iso.slice(0, 16).replace("T", " ")} UTC`;
|
|
3752
|
+
}
|
|
3753
|
+
function formatCredentialTable(credentials) {
|
|
3754
|
+
const rows = [
|
|
3755
|
+
["LABEL", "USERNAME", "MODE", "STATUS", "LAST CONNECTED", "CERT EXPIRES", "ID"],
|
|
3756
|
+
...credentials.map((credential) => [
|
|
3757
|
+
credential.label,
|
|
3758
|
+
credential.username,
|
|
3759
|
+
describeAccessMode(credential.access_mode),
|
|
3760
|
+
credential.status,
|
|
3761
|
+
credential.last_connected_at === null || credential.last_connected_at === void 0 ? "never" : formatTimestamp(credential.last_connected_at),
|
|
3762
|
+
formatTimestamp(credential.certificate_expires_at, true),
|
|
3763
|
+
credential.id
|
|
3764
|
+
])
|
|
3765
|
+
];
|
|
3766
|
+
const widths = rows[0]?.map((_, column) => Math.max(...rows.map((row) => (row[column] ?? "").length))) ?? [];
|
|
3767
|
+
return rows.map(
|
|
3768
|
+
(row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd()
|
|
3769
|
+
);
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3772
|
+
// src/commands/db-credential.ts
|
|
3773
|
+
function defaultDeps() {
|
|
3774
|
+
return { createApiClient, requireProject };
|
|
3775
|
+
}
|
|
3776
|
+
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.";
|
|
3777
|
+
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.";
|
|
3778
|
+
var DASHBOARD_HINT = "Create and rotate credentials in the dashboard: Hosting > Database > External access. The key is generated in your browser there.";
|
|
3779
|
+
async function withAccessHints(fn, needsManage) {
|
|
3780
|
+
try {
|
|
3781
|
+
return await fn();
|
|
3782
|
+
} catch (err) {
|
|
3783
|
+
if (isApiError(err) && err.status === 403) {
|
|
3784
|
+
const scope = err.message.includes("/app/cli/");
|
|
3785
|
+
if (scope || needsManage) {
|
|
3786
|
+
throw new ApiError(err.message, {
|
|
3787
|
+
status: err.status,
|
|
3788
|
+
body: err.body,
|
|
3789
|
+
...err.errorCode !== void 0 ? { errorCode: err.errorCode } : {},
|
|
3790
|
+
hint: scope ? CLI_SCOPE_HINT : MANAGE_HINT,
|
|
3791
|
+
cause: err
|
|
3792
|
+
});
|
|
3793
|
+
}
|
|
3794
|
+
}
|
|
3795
|
+
throw err;
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
async function resolveTarget(client, appId, env, deps) {
|
|
3799
|
+
const environments = await withAccessHints(() => client.listEnvironments(appId), false);
|
|
3800
|
+
const targets = databaseTargets(environments);
|
|
3801
|
+
if (targets.length === 0) {
|
|
3802
|
+
throw new CliError("None of this app's environments has a hosted MySQL database.", {
|
|
3803
|
+
hint: "Add a database to an environment under Hosting in the Meridian dashboard first."
|
|
3804
|
+
});
|
|
3805
|
+
}
|
|
3806
|
+
if (env !== void 0) {
|
|
3807
|
+
const match = matchEnvironment(targets, env);
|
|
3808
|
+
if (match === void 0) {
|
|
3809
|
+
throw new CliError(`No environment named "${env}" has a hosted MySQL database.`, {
|
|
3810
|
+
hint: `Environments with one: ${targets.map(({ environment }) => environmentLabel(environment)).join(", ")}.`
|
|
3811
|
+
});
|
|
3812
|
+
}
|
|
3813
|
+
return match;
|
|
3814
|
+
}
|
|
3815
|
+
const index = await selectOne({
|
|
3816
|
+
message: "Which environment's database?",
|
|
3817
|
+
choices: targets.map((target, position) => ({
|
|
3818
|
+
value: String(position),
|
|
3819
|
+
name: environmentLabel(target.environment),
|
|
3820
|
+
...target.addon.database?.database ? { description: target.addon.database.database } : {}
|
|
3821
|
+
})),
|
|
3822
|
+
flag: "--env",
|
|
3823
|
+
...deps.selectImpl !== void 0 ? { promptImpl: deps.selectImpl } : {}
|
|
3824
|
+
});
|
|
3825
|
+
const chosen = targets[Number(index)];
|
|
3826
|
+
if (chosen === void 0) throw new CliError("Nothing to choose from.");
|
|
3827
|
+
return chosen;
|
|
3828
|
+
}
|
|
3829
|
+
async function openContext(env, deps) {
|
|
3830
|
+
const project = deps.requireProject(workingDirectory());
|
|
3831
|
+
const client = deps.createApiClient({});
|
|
3832
|
+
const target = await resolveTarget(client, project.config.app_id, env, deps);
|
|
3833
|
+
return { client, target };
|
|
3834
|
+
}
|
|
3835
|
+
async function runDbCredentialList(options = {}) {
|
|
3836
|
+
const deps = { ...defaultDeps(), ...options.deps };
|
|
3837
|
+
const { client, target } = await openContext(options.env, deps);
|
|
3838
|
+
const { credentials, meta } = await withAccessHints(() => client.listExternalCredentials(target.addon.uuid), false);
|
|
3839
|
+
const envName = environmentLabel(target.environment);
|
|
3840
|
+
if (isJsonOutput()) {
|
|
3841
|
+
printJson({
|
|
3842
|
+
environment: envName,
|
|
3843
|
+
addon_id: target.addon.uuid,
|
|
3844
|
+
available: meta.available ?? null,
|
|
3845
|
+
host: meta.host ?? null,
|
|
3846
|
+
port: meta.port ?? null,
|
|
3847
|
+
credentials
|
|
3848
|
+
});
|
|
3849
|
+
return 0;
|
|
3850
|
+
}
|
|
3851
|
+
if (meta.available === false) {
|
|
3852
|
+
warn(`External access is not available for the ${envName} database yet.`);
|
|
3853
|
+
}
|
|
3854
|
+
if (credentials.length === 0) {
|
|
3855
|
+
log(`No external credentials for the ${envName} database.`);
|
|
3856
|
+
hint(DASHBOARD_HINT);
|
|
3857
|
+
return 0;
|
|
3858
|
+
}
|
|
3859
|
+
log(`External credentials for the ${style.bold(envName)} database${meta.host ? ` (${meta.host}:${meta.port ?? 3306})` : ""}:`);
|
|
3860
|
+
log();
|
|
3861
|
+
for (const line of formatCredentialTable(credentials)) log(` ${line}`);
|
|
3862
|
+
return 0;
|
|
3863
|
+
}
|
|
3864
|
+
async function runDbCredentialRevoke(options) {
|
|
3865
|
+
const deps = { ...defaultDeps(), ...options.deps };
|
|
3866
|
+
const { client, target } = await openContext(options.env, deps);
|
|
3867
|
+
const { credentials } = await withAccessHints(() => client.listExternalCredentials(target.addon.uuid), false);
|
|
3868
|
+
const credential = resolveCredentialRef(credentials, options.credential);
|
|
3869
|
+
const envName = environmentLabel(target.environment);
|
|
3870
|
+
if (credential.status === "revoked") {
|
|
3871
|
+
log(`Credential "${credential.label}" is already revoked.`);
|
|
3872
|
+
if (isJsonOutput()) printJson({ revoked: true, already_revoked: true, credential });
|
|
3873
|
+
return 0;
|
|
3874
|
+
}
|
|
3875
|
+
const confirmed = await confirmAction({
|
|
3876
|
+
message: `Revoke "${credential.label}" (${credential.username}) on the ${envName} database? Anything using it loses access within 30 seconds.`,
|
|
3877
|
+
default: false,
|
|
3878
|
+
assumeYes: isAssumeYes(),
|
|
3879
|
+
...deps.confirmImpl !== void 0 ? { promptImpl: deps.confirmImpl } : {}
|
|
3880
|
+
});
|
|
3881
|
+
if (!confirmed) {
|
|
3882
|
+
log("Aborted.");
|
|
3883
|
+
if (isJsonOutput()) printJson({ revoked: false, aborted: true, credential });
|
|
3884
|
+
return 0;
|
|
3885
|
+
}
|
|
3886
|
+
const revoked = await withAccessHints(
|
|
3887
|
+
() => withSpinner(
|
|
3888
|
+
`Revoking credential "${credential.label}"...`,
|
|
3889
|
+
() => client.revokeExternalCredential(target.addon.uuid, credential.id)
|
|
3890
|
+
),
|
|
3891
|
+
true
|
|
3892
|
+
);
|
|
3893
|
+
if (isJsonOutput()) printJson({ revoked: true, already_revoked: false, credential: revoked });
|
|
3894
|
+
else success(`Revoked "${credential.label}". Its MySQL user is gone and open connections close within 30 seconds.`);
|
|
3895
|
+
return 0;
|
|
3896
|
+
}
|
|
3897
|
+
async function runAndSetExitCode(fn) {
|
|
3898
|
+
const code = await fn();
|
|
3899
|
+
if (code !== 0) process.exitCode = code;
|
|
3900
|
+
}
|
|
3901
|
+
function registerDbCredentialCommand(db) {
|
|
3902
|
+
const credential = db.command("credential").description(
|
|
3903
|
+
"List and revoke credentials for reaching a hosted environment's database from outside Meridian. Create and rotate them in the dashboard."
|
|
3904
|
+
);
|
|
3905
|
+
credential.action(() => {
|
|
3906
|
+
credential.outputHelp();
|
|
3907
|
+
});
|
|
3908
|
+
const envOption = ["--env <name>", "the environment whose database to use (name or type, e.g. production)"];
|
|
3909
|
+
credential.command("list").description("List the database's external credentials.").option(...envOption).action(async (opts) => {
|
|
3910
|
+
await runAndSetExitCode(() => runDbCredentialList({ ...opts.env !== void 0 ? { env: opts.env } : {} }));
|
|
3911
|
+
});
|
|
3912
|
+
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) => {
|
|
3913
|
+
await runAndSetExitCode(
|
|
3914
|
+
() => runDbCredentialRevoke({ credential: ref, ...opts.env !== void 0 ? { env: opts.env } : {} })
|
|
3915
|
+
);
|
|
3916
|
+
});
|
|
3917
|
+
}
|
|
3918
|
+
|
|
2790
3919
|
// src/commands/db-shell-repl.ts
|
|
2791
3920
|
import { accessSync, constants as fsConstants } from "fs";
|
|
2792
3921
|
import { delimiter as delimiter2, join as join5 } from "path";
|
|
@@ -3059,7 +4188,7 @@ async function runDbSeed(options = {}) {
|
|
|
3059
4188
|
inherit: true
|
|
3060
4189
|
});
|
|
3061
4190
|
if (result.code === 0) {
|
|
3062
|
-
success("Seeded the
|
|
4191
|
+
success("Seeded the developer database.");
|
|
3063
4192
|
} else {
|
|
3064
4193
|
error(`\`npx prisma db seed\` exited with code ${result.code}.`);
|
|
3065
4194
|
hint("Make sure `prisma.seed` (or your Prisma 6 seed config) is set in package.json.");
|
|
@@ -3107,17 +4236,19 @@ async function runDbShell(options = {}) {
|
|
|
3107
4236
|
if (session !== void 0) await session.stop();
|
|
3108
4237
|
}
|
|
3109
4238
|
}
|
|
3110
|
-
async function
|
|
4239
|
+
async function runAndSetExitCode2(fn) {
|
|
3111
4240
|
const code = await fn();
|
|
3112
4241
|
if (code !== 0) process.exitCode = code;
|
|
3113
4242
|
}
|
|
3114
4243
|
function registerDbCommand(program) {
|
|
3115
|
-
const db = program.command("db").description(
|
|
4244
|
+
const db = program.command("db").description(
|
|
4245
|
+
"Manage the Meridian-managed MySQL developer database (reset, seed, or open a SQL shell), and external credentials for hosted databases."
|
|
4246
|
+
);
|
|
3116
4247
|
db.action(() => {
|
|
3117
4248
|
db.outputHelp();
|
|
3118
4249
|
});
|
|
3119
|
-
db.command("reset").description("Drop and recreate every table in the
|
|
3120
|
-
await
|
|
4250
|
+
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) => {
|
|
4251
|
+
await runAndSetExitCode2(
|
|
3121
4252
|
() => runDbReset({
|
|
3122
4253
|
...opts.migrate === true ? { migrate: true } : {},
|
|
3123
4254
|
...opts.seed === true ? { seed: true } : {},
|
|
@@ -3127,7 +4258,7 @@ function registerDbCommand(program) {
|
|
|
3127
4258
|
);
|
|
3128
4259
|
});
|
|
3129
4260
|
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
|
|
4261
|
+
await runAndSetExitCode2(
|
|
3131
4262
|
() => runDbSeed({
|
|
3132
4263
|
...opts.port !== void 0 ? { port: opts.port } : {},
|
|
3133
4264
|
...opts.envFile !== void 0 ? { envFile: opts.envFile } : {},
|
|
@@ -3135,8 +4266,8 @@ function registerDbCommand(program) {
|
|
|
3135
4266
|
})
|
|
3136
4267
|
);
|
|
3137
4268
|
});
|
|
3138
|
-
db.command("shell").description("Open a SQL shell against the
|
|
3139
|
-
await
|
|
4269
|
+
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) => {
|
|
4270
|
+
await runAndSetExitCode2(
|
|
3140
4271
|
() => runDbShell({
|
|
3141
4272
|
...opts.port !== void 0 ? { port: opts.port } : {},
|
|
3142
4273
|
...opts.envFile !== void 0 ? { envFile: opts.envFile } : {},
|
|
@@ -3146,10 +4277,11 @@ function registerDbCommand(program) {
|
|
|
3146
4277
|
})
|
|
3147
4278
|
);
|
|
3148
4279
|
});
|
|
4280
|
+
registerDbCredentialCommand(db);
|
|
3149
4281
|
}
|
|
3150
4282
|
|
|
3151
4283
|
// src/commands/dev.ts
|
|
3152
|
-
function
|
|
4284
|
+
function defaultDeps2() {
|
|
3153
4285
|
return { startDevSession, startProcess, readPackageScript, requireProject };
|
|
3154
4286
|
}
|
|
3155
4287
|
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.";
|
|
@@ -3206,7 +4338,7 @@ function printDevSummary(session, noEnv) {
|
|
|
3206
4338
|
const entries = [
|
|
3207
4339
|
["App", appLabel(session.project)],
|
|
3208
4340
|
["Database", session.devDatabase.database],
|
|
3209
|
-
["Proxy", `127.0.0.1:${session.proxy.port}`],
|
|
4341
|
+
[session.access === "gateway" ? "Gateway" : "Proxy", `127.0.0.1:${session.proxy.port}`],
|
|
3210
4342
|
["Env file", envEntry],
|
|
3211
4343
|
["DATABASE_URL", redactDatabaseUrl(session.databaseUrl)]
|
|
3212
4344
|
];
|
|
@@ -3221,6 +4353,7 @@ function buildDevJsonPayload(session) {
|
|
|
3221
4353
|
return {
|
|
3222
4354
|
app: appLabel(session.project),
|
|
3223
4355
|
database: session.devDatabase.database,
|
|
4356
|
+
access: session.access,
|
|
3224
4357
|
proxy: { host: session.proxy.host, port: session.proxy.port },
|
|
3225
4358
|
database_url: redactDatabaseUrl(session.databaseUrl),
|
|
3226
4359
|
env_file: session.envPatch?.path ?? null,
|
|
@@ -3228,7 +4361,7 @@ function buildDevJsonPayload(session) {
|
|
|
3228
4361
|
};
|
|
3229
4362
|
}
|
|
3230
4363
|
async function runDev(options = {}) {
|
|
3231
|
-
const deps = { ...
|
|
4364
|
+
const deps = { ...defaultDeps2(), ...options.deps };
|
|
3232
4365
|
if (isInsideDevSession()) throw nestedSessionError();
|
|
3233
4366
|
const project = deps.requireProject(workingDirectory());
|
|
3234
4367
|
const resolved = resolveDevCommand(deps, project, options.command);
|
|
@@ -3272,7 +4405,7 @@ async function runDev(options = {}) {
|
|
|
3272
4405
|
}
|
|
3273
4406
|
}
|
|
3274
4407
|
function registerDevCommand(program) {
|
|
3275
|
-
program.command("dev").description("Run the managed
|
|
4408
|
+
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
4409
|
const port = parsePortOption(opts.port);
|
|
3277
4410
|
const code = await runDev({
|
|
3278
4411
|
...command.length > 0 ? { command } : {},
|
|
@@ -3287,7 +4420,7 @@ function registerDevCommand(program) {
|
|
|
3287
4420
|
// src/commands/link.ts
|
|
3288
4421
|
import { existsSync as existsSync5 } from "fs";
|
|
3289
4422
|
import { join as join7 } from "path";
|
|
3290
|
-
function
|
|
4423
|
+
function defaultDeps3() {
|
|
3291
4424
|
return { createClient: createApiClient };
|
|
3292
4425
|
}
|
|
3293
4426
|
function formatOrg(org) {
|
|
@@ -3302,7 +4435,7 @@ function resolveChoiceAnswer(choices, raw, keysOf) {
|
|
|
3302
4435
|
return match !== void 0 ? match.name : raw;
|
|
3303
4436
|
}
|
|
3304
4437
|
async function runLink(options = {}) {
|
|
3305
|
-
const deps = { ...
|
|
4438
|
+
const deps = { ...defaultDeps3(), ...options.deps };
|
|
3306
4439
|
const json = isJsonOutput();
|
|
3307
4440
|
const root = resolveProjectRoot(workingDirectory()) ?? workingDirectory();
|
|
3308
4441
|
if (!existsSync5(join7(root, "package.json"))) {
|
|
@@ -3498,7 +4631,7 @@ async function defaultOpenUrl(url) {
|
|
|
3498
4631
|
const { default: open } = await import("open");
|
|
3499
4632
|
await open(url);
|
|
3500
4633
|
}
|
|
3501
|
-
function
|
|
4634
|
+
function defaultDeps4() {
|
|
3502
4635
|
return { createClient: createApiClient, runDeviceFlow, openUrl: defaultOpenUrl };
|
|
3503
4636
|
}
|
|
3504
4637
|
function shouldOpenBrowser(noBrowser) {
|
|
@@ -3519,7 +4652,7 @@ function hintUnlessLinked(json) {
|
|
|
3519
4652
|
hint("Run `meridian link` to connect this project to a Meridian app.");
|
|
3520
4653
|
}
|
|
3521
4654
|
async function runLogin(options = {}) {
|
|
3522
|
-
const deps = { ...
|
|
4655
|
+
const deps = { ...defaultDeps4(), ...options.deps };
|
|
3523
4656
|
const json = isJsonOutput();
|
|
3524
4657
|
if (options.force !== true) {
|
|
3525
4658
|
const auth = getAuth();
|
|
@@ -3656,7 +4789,7 @@ async function createSnapshot(info, options) {
|
|
|
3656
4789
|
}
|
|
3657
4790
|
});
|
|
3658
4791
|
} catch (error2) {
|
|
3659
|
-
throw new CliError(`Could not snapshot the
|
|
4792
|
+
throw new CliError(`Could not snapshot the developer database: ${describeError(error2)}`, {
|
|
3660
4793
|
hint: SKIP_SNAPSHOT_HINT,
|
|
3661
4794
|
cause: error2
|
|
3662
4795
|
});
|
|
@@ -3665,13 +4798,13 @@ async function createSnapshot(info, options) {
|
|
|
3665
4798
|
try {
|
|
3666
4799
|
bytes = (await stat(path)).size;
|
|
3667
4800
|
} catch (error2) {
|
|
3668
|
-
throw new CliError("Could not snapshot the
|
|
4801
|
+
throw new CliError("Could not snapshot the developer database: the dump did not produce a file.", {
|
|
3669
4802
|
hint: SKIP_SNAPSHOT_HINT,
|
|
3670
4803
|
cause: error2
|
|
3671
4804
|
});
|
|
3672
4805
|
}
|
|
3673
4806
|
if (bytes === 0) {
|
|
3674
|
-
throw new CliError("Could not snapshot the
|
|
4807
|
+
throw new CliError("Could not snapshot the developer database: the dump produced an empty file.", {
|
|
3675
4808
|
hint: SKIP_SNAPSHOT_HINT
|
|
3676
4809
|
});
|
|
3677
4810
|
}
|
|
@@ -3862,7 +4995,7 @@ async function pruneSnapshots(dir, keep) {
|
|
|
3862
4995
|
// src/commands/migrate.ts
|
|
3863
4996
|
var DEFAULT_KEEP_SNAPSHOTS = 5;
|
|
3864
4997
|
var MAX_OUTPUT_EXCERPT = 4e3;
|
|
3865
|
-
function
|
|
4998
|
+
function defaultDeps5() {
|
|
3866
4999
|
return {
|
|
3867
5000
|
startDevSession,
|
|
3868
5001
|
createSnapshot,
|
|
@@ -3880,7 +5013,7 @@ function defaultDeps4() {
|
|
|
3880
5013
|
};
|
|
3881
5014
|
}
|
|
3882
5015
|
function resolveDeps2(overrides) {
|
|
3883
|
-
return { ...
|
|
5016
|
+
return { ...defaultDeps5(), ...overrides };
|
|
3884
5017
|
}
|
|
3885
5018
|
function formatSize(bytes) {
|
|
3886
5019
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -3907,10 +5040,10 @@ async function runMigration(session, deps, ctx) {
|
|
|
3907
5040
|
let snapshot;
|
|
3908
5041
|
if (options.skipSnapshot !== true) {
|
|
3909
5042
|
snapshot = await withSpinner(
|
|
3910
|
-
"Snapshotting the
|
|
5043
|
+
"Snapshotting the developer database...",
|
|
3911
5044
|
() => deps.createSnapshot(session.connection, { dir, now: deps.now }),
|
|
3912
5045
|
{
|
|
3913
|
-
success: (result) => result.path === null ? "
|
|
5046
|
+
success: (result) => result.path === null ? "Developer database is empty. Nothing to snapshot." : `Snapshot saved to ${style.path(result.path)} (${formatSize(result.bytes)}).`
|
|
3914
5047
|
}
|
|
3915
5048
|
);
|
|
3916
5049
|
}
|
|
@@ -3943,12 +5076,12 @@ async function runMigration(session, deps, ctx) {
|
|
|
3943
5076
|
} else {
|
|
3944
5077
|
const snapshotPath = snapshot.path;
|
|
3945
5078
|
warn(
|
|
3946
|
-
`\`${commandString}\` failed (exit code ${runResult.code}); ` + (snapshotPath === null ? "rolling the
|
|
5079
|
+
`\`${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
5080
|
);
|
|
3948
5081
|
try {
|
|
3949
5082
|
await client.resetDevDatabase(appId);
|
|
3950
5083
|
if (snapshotPath === null) {
|
|
3951
|
-
success("
|
|
5084
|
+
success("Developer database rolled back to its pre-migration (empty) state.");
|
|
3952
5085
|
} else {
|
|
3953
5086
|
await withSpinner(
|
|
3954
5087
|
"Restoring the pre-migration snapshot...",
|
|
@@ -3961,7 +5094,7 @@ async function runMigration(session, deps, ctx) {
|
|
|
3961
5094
|
status = "rolled_back";
|
|
3962
5095
|
rolledBack = true;
|
|
3963
5096
|
} catch {
|
|
3964
|
-
error("Automatic rollback failed. The
|
|
5097
|
+
error("Automatic rollback failed. The developer database may be in a partial state. Treat it as unsafe.");
|
|
3965
5098
|
if (snapshotPath === null) {
|
|
3966
5099
|
hint("Recover by hand with `meridian db reset`. The database was empty before this migration.");
|
|
3967
5100
|
} else {
|
|
@@ -4036,7 +5169,7 @@ async function runMigrate(options = {}) {
|
|
|
4036
5169
|
}
|
|
4037
5170
|
function registerMigrateCommand(program) {
|
|
4038
5171
|
program.command("migrate").description(
|
|
4039
|
-
"Snapshot the
|
|
5172
|
+
"Snapshot the developer database, run `prisma migrate dev`, and roll back to the snapshot automatically if the migration fails."
|
|
4040
5173
|
).option("--skip-snapshot", "run without a pre-migration snapshot (no rollback is possible)").option(
|
|
4041
5174
|
"--allow-foreign-migrations",
|
|
4042
5175
|
"run even when prisma/migrations was generated for SQLite or Postgres and cannot apply to MySQL"
|
|
@@ -4089,7 +5222,7 @@ function registerUnlinkCommand(program) {
|
|
|
4089
5222
|
|
|
4090
5223
|
// src/commands/whoami.ts
|
|
4091
5224
|
var SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
4092
|
-
function
|
|
5225
|
+
function defaultDeps6() {
|
|
4093
5226
|
return { createClient: createApiClient };
|
|
4094
5227
|
}
|
|
4095
5228
|
function toConfigUser2(user) {
|
|
@@ -4153,7 +5286,7 @@ function printJsonResult(result, project) {
|
|
|
4153
5286
|
});
|
|
4154
5287
|
}
|
|
4155
5288
|
async function runWhoami(options = {}) {
|
|
4156
|
-
const deps = { ...
|
|
5289
|
+
const deps = { ...defaultDeps6(), ...options.deps };
|
|
4157
5290
|
const json = isJsonOutput();
|
|
4158
5291
|
const auth = getAuth();
|
|
4159
5292
|
if (auth === null) throw notLoggedInError();
|
|
@@ -4260,7 +5393,7 @@ function resolveGlobalFlags(actionCommand) {
|
|
|
4260
5393
|
function buildProgram() {
|
|
4261
5394
|
const program = new Command();
|
|
4262
5395
|
program.name("meridian").description(
|
|
4263
|
-
"Managed remote
|
|
5396
|
+
"Managed remote developer databases for Meridian apps.\n\nRun `meridian login` once, `meridian link` per project, then `meridian dev`."
|
|
4264
5397
|
).version(VERSION, "-V, --version").enablePositionalOptions().showHelpAfterError("(run `meridian --help` for usage)");
|
|
4265
5398
|
addGlobalFlags(program);
|
|
4266
5399
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|