@metabase/rde 0.1.5 → 0.1.7

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.
Files changed (3) hide show
  1. package/README.md +18 -18
  2. package/dist/cli.mjs +1249 -877
  3. package/package.json +2 -2
package/dist/cli.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { defineCommand, runMain, showUsage } from "citty";
2
+ import { defineCommand, renderUsage, runMain } from "citty";
3
3
  import { createWriteStream, promises } from "node:fs";
4
4
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { ZodError, z } from "zod";
6
+ import { createHash, randomBytes } from "node:crypto";
6
7
  import { homedir, tmpdir, userInfo } from "node:os";
7
8
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
8
9
  import { Readable } from "node:stream";
@@ -11,7 +12,6 @@ import { confirm, intro, isCancel, log, multiselect, note, outro, password, sele
11
12
  import { spawn } from "node:child_process";
12
13
  import { connect, createServer } from "node:net";
13
14
  import semver from "semver";
14
- import { randomBytes } from "node:crypto";
15
15
  //#region src/errors.ts
16
16
  var AbortError = class extends Error {
17
17
  constructor(message = "interrupted") {
@@ -25,6 +25,14 @@ var ConfigError = class extends Error {
25
25
  this.name = "ConfigError";
26
26
  }
27
27
  };
28
+ var StepFailedError = class extends Error {
29
+ step;
30
+ constructor(step, cause) {
31
+ super(`step ${step.id} failed`, { cause });
32
+ this.name = "StepFailedError";
33
+ this.step = step;
34
+ }
35
+ };
28
36
  var ExternalToolError = class extends Error {
29
37
  tool;
30
38
  argv;
@@ -91,11 +99,19 @@ const INTERRUPTED_MESSAGE = "Interrupted. Run `rde init` again to resume.";
91
99
  const FAILURE_EXIT_CODE = 1;
92
100
  const USAGE_EXIT_CODE = 2;
93
101
  function exitCodeFor(error) {
102
+ if (error instanceof StepFailedError) return exitCodeFor(error.cause);
94
103
  if (error instanceof AbortError) return 130;
95
104
  if (error instanceof ConfigError) return USAGE_EXIT_CODE;
96
105
  return FAILURE_EXIT_CODE;
97
106
  }
107
+ function stepFailureMessage(step, cause) {
108
+ const stopped = `rde init stopped at "${step.title}"`;
109
+ const resume = `Resume with rde init --from ${step.id}.`;
110
+ if (cause.includes("\n")) return `${cause}\n${stopped}. ${resume}`;
111
+ return `${stopped}: ${cause.replace(/\.$/, "")}. ${resume}`;
112
+ }
98
113
  function messageFor$1(error) {
114
+ if (error instanceof StepFailedError) return stepFailureMessage(error.step, messageFor$1(error.cause));
99
115
  if (error instanceof AbortError) return INTERRUPTED_MESSAGE;
100
116
  if (error instanceof Error) return error.message;
101
117
  return String(error);
@@ -130,6 +146,10 @@ function printLines(lines) {
130
146
  function printJson(value) {
131
147
  process.stdout.write(JSON.stringify(value, null, 2) + "\n");
132
148
  }
149
+ const ESCAPE_SEQUENCE = new RegExp(`${String.fromCodePoint(27)}(?:\\[[0-?]*[ -/]*[@-~]|[@-Z\\\\-_])`, "g");
150
+ function withoutEscapes(text) {
151
+ return text.replace(ESCAPE_SEQUENCE, "");
152
+ }
133
153
  const StepId = z.enum([
134
154
  "preflight",
135
155
  "install-mb",
@@ -378,16 +398,14 @@ function readEnv(name) {
378
398
  return process.env[name];
379
399
  }
380
400
  const ENV_PATH = "PATH";
401
+ const ENV_NO_COLOR = "NO_COLOR";
381
402
  const ENV_XDG_STATE_HOME = "XDG_STATE_HOME";
382
403
  const ENV_JAVA_HOME = "JAVA_HOME";
383
404
  const ENV_MB_PROFILE = "MB_PROFILE";
384
405
  const ENV_MB_URL = "MB_URL";
385
406
  const ENV_MB_API_KEY = "MB_API_KEY";
386
- function environmentWith(overrides) {
387
- return {
388
- ...process.env,
389
- ...overrides
390
- };
407
+ function hostEnvironment() {
408
+ return { ...process.env };
391
409
  }
392
410
  //#endregion
393
411
  //#region src/state/paths.ts
@@ -400,11 +418,16 @@ const SECRETS_FILE = "secrets.json";
400
418
  const GLOBAL_CONFIG_FILE = "config.json";
401
419
  const JARS_DIR = "jars";
402
420
  const JAR_FILE$1 = "metabase.jar";
421
+ const HOME_TAG_LENGTH = 8;
403
422
  function rdeHome() {
404
423
  const override = readEnv(ENV_RDE_HOME);
405
- if (override !== void 0) return override;
424
+ if (override !== void 0) return resolve(override);
406
425
  return join(homedir(), RDE_DIR_NAME);
407
426
  }
427
+ function homeTag(home) {
428
+ if (home === join(homedir(), RDE_DIR_NAME)) return null;
429
+ return createHash("sha256").update(home).digest("hex").slice(0, HOME_TAG_LENGTH);
430
+ }
408
431
  function instancesDir() {
409
432
  return join(rdeHome(), INSTANCES_DIR);
410
433
  }
@@ -544,509 +567,183 @@ async function writeGlobalConfig(config) {
544
567
  await writeJsonFile(globalConfigPath(), config);
545
568
  }
546
569
  //#endregion
547
- //#region src/steps/credentials.ts
548
- const LEGACY_NOTE = "This is the public default password; when the instance holds nothing you need, `rde uninstall` then `rde init` recreates it with a generated one";
549
- const PROMPTED_NOTE = "The password is the one entered during `rde init`; rde never stored it";
550
- function storedPassword(state, secrets) {
551
- if (secrets === null || secrets.admin === null) throw new ConfigError(`the secrets file of instance ${state.name} holds no admin password; \`rde doctor\` says what to do`);
552
- return secrets.admin.password;
570
+ //#region src/metabase/domain/health.ts
571
+ const Health = z.object({ status: z.literal("ok") }).loose();
572
+ const Initializing = z.object({
573
+ status: z.literal("initializing"),
574
+ progress: z.number()
575
+ }).loose();
576
+ //#endregion
577
+ //#region src/metabase/http.ts
578
+ const REQUEST_TIMEOUT_MS = 3e4;
579
+ const PARTIAL_SUFFIX = ".part";
580
+ const CONTENT_LENGTH_HEADER = "content-length";
581
+ const SESSION_HEADER = "X-Metabase-Session";
582
+ const API_KEY_HEADER = "X-Api-Key";
583
+ const JSON_CONTENT_TYPE = "application/json";
584
+ const SERVICE_UNAVAILABLE_STATUS = 503;
585
+ const NO_AUTH = { kind: "none" };
586
+ const ErrorMessage = z.object({ message: z.string() }).loose();
587
+ const ErrorFields = z.object({ errors: z.record(z.string(), z.string()) }).loose();
588
+ function authHeaders(auth) {
589
+ switch (auth.kind) {
590
+ case "none": return {};
591
+ case "session": return { [SESSION_HEADER]: auth.id };
592
+ case "api-key": return { [API_KEY_HEADER]: auth.key };
593
+ }
553
594
  }
554
- function credentialsReport(state, secrets) {
555
- const admin = state.admin;
556
- if (admin === null) throw new ConfigError(`instance ${state.name} has no admin user yet; run \`rde init\``);
557
- const login = {
558
- instance: state.name,
559
- url: state.url,
560
- email: admin.email
595
+ function parseBody(status, text) {
596
+ if (text.length === 0) return {
597
+ status,
598
+ text,
599
+ json: { parsed: false }
561
600
  };
562
- switch (admin.kind) {
563
- case "generated": return {
564
- ...login,
565
- password: storedPassword(state, secrets),
566
- note: null
567
- };
568
- case "legacy-default": return {
569
- ...login,
570
- password: storedPassword(state, secrets),
571
- note: LEGACY_NOTE
601
+ try {
602
+ return {
603
+ status,
604
+ text,
605
+ json: {
606
+ parsed: true,
607
+ value: JSON.parse(text)
608
+ }
572
609
  };
573
- case "prompted": return {
574
- ...login,
575
- password: null,
576
- note: PROMPTED_NOTE
610
+ } catch {
611
+ return {
612
+ status,
613
+ text,
614
+ json: { parsed: false }
577
615
  };
578
616
  }
579
617
  }
580
- function credentialsLines(report) {
581
- const url = report.url === null ? [] : [`Metabase: ${report.url}`];
582
- const password = report.password === null ? [] : [`Password: ${report.password}`];
583
- const note = report.note === null ? [] : [report.note];
584
- return [
585
- ...url,
586
- `Email: ${report.email}`,
587
- ...password,
588
- ...note
589
- ];
618
+ function jsonValue(body) {
619
+ return body.json.parsed ? body.json.value : void 0;
590
620
  }
591
- //#endregion
592
- //#region src/commands/define.ts
593
- function repeatedValues(rawArgs, flag) {
594
- const values = [];
595
- const inline = `${flag}=`;
596
- for (let index = 0; index < rawArgs.length; index += 1) {
597
- const arg = rawArgs[index];
598
- if (arg === void 0) continue;
599
- if (arg.startsWith(inline)) values.push(arg.slice(inline.length));
600
- else if (arg === flag) {
601
- const next = rawArgs[index + 1];
602
- if (next !== void 0) {
603
- values.push(next);
604
- index += 1;
605
- }
606
- }
621
+ function serverMessage(body) {
622
+ const parsed = ErrorMessage.safeParse(jsonValue(body));
623
+ if (parsed.success) return parsed.data.message;
624
+ const text = body.text.trim();
625
+ return text.length === 0 ? `HTTP ${body.status}` : text;
626
+ }
627
+ function serverErrors(body) {
628
+ const parsed = ErrorFields.safeParse(jsonValue(body));
629
+ return parsed.success ? parsed.data.errors : null;
630
+ }
631
+ function failureFor(path, body) {
632
+ if (body.status === SERVICE_UNAVAILABLE_STATUS) {
633
+ const initializing = Initializing.safeParse(jsonValue(body));
634
+ if (initializing.success) return new MetabaseInitializingError(initializing.data.progress);
607
635
  }
608
- return values;
636
+ return new MetabaseApiError({
637
+ status: body.status,
638
+ path,
639
+ message: serverMessage(body),
640
+ errors: serverErrors(body)
641
+ });
609
642
  }
610
- function defineRdeCommand(def) {
611
- return defineCommand({
612
- meta: def.meta,
613
- ...def.args !== void 0 && { args: def.args },
614
- async run({ args, rawArgs }) {
643
+ function shapeError(path, body, reason) {
644
+ return new MetabaseApiError({
645
+ status: body.status,
646
+ path,
647
+ message: reason,
648
+ errors: null
649
+ });
650
+ }
651
+ function isTimeout(error) {
652
+ return error instanceof Error && error.name === "TimeoutError";
653
+ }
654
+ function describeCause(error) {
655
+ if (error instanceof Error && error.cause instanceof Error) return error.cause.message;
656
+ return error instanceof Error ? error.message : String(error);
657
+ }
658
+ function createHttp(options) {
659
+ const fetchImpl = options.fetchImpl ?? fetch;
660
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
661
+ async function send(method, path, body) {
662
+ const url = `${baseUrl}${path}`;
663
+ const headers = {
664
+ accept: JSON_CONTENT_TYPE,
665
+ ...authHeaders(options.auth)
666
+ };
667
+ const init = {
668
+ method,
669
+ headers,
670
+ signal: AbortSignal.any([options.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
671
+ };
672
+ if (body !== void 0) {
673
+ headers["content-type"] = JSON_CONTENT_TYPE;
674
+ init.body = JSON.stringify(body);
675
+ }
676
+ try {
677
+ return await fetchImpl(url, init);
678
+ } catch (error) {
679
+ if (options.signal.aborted) throw new AbortError();
680
+ if (isTimeout(error)) throw new MetabaseUnreachableError(url, `no response within ${REQUEST_TIMEOUT_MS}ms`);
681
+ throw new MetabaseUnreachableError(url, describeCause(error));
682
+ }
683
+ }
684
+ async function requestBody(method, path, body) {
685
+ const response = await send(method, path, body);
686
+ const parsed = parseBody(response.status, await response.text());
687
+ if (!response.ok) throw failureFor(path, parsed);
688
+ return parsed;
689
+ }
690
+ return {
691
+ async requestJson(method, path, request) {
692
+ const body = await requestBody(method, path, request.body);
693
+ if (!body.json.parsed) throw shapeError(path, body, "expected a JSON body");
615
694
  try {
616
- await def.run(args, rawArgs);
695
+ return request.schema.parse(body.json.value);
617
696
  } catch (error) {
618
- reportError(error);
697
+ if (error instanceof ZodError) throw shapeError(path, body, `unexpected response shape:\n${z.prettifyError(error)}`);
698
+ throw error;
619
699
  }
700
+ },
701
+ async requestNoContent(method, path, request = {}) {
702
+ await requestBody(method, path, request.body);
620
703
  }
621
- });
704
+ };
622
705
  }
623
- //#endregion
624
- //#region src/commands/instance.ts
625
- async function defaultInstanceName() {
626
- const config = await readGlobalConfig();
627
- if (config === null) throw new ConfigError("no Metabase instance yet; run `rde init` first");
628
- return config.defaultInstance;
706
+ function contentLength(response) {
707
+ const header = response.headers.get(CONTENT_LENGTH_HEADER);
708
+ if (header === null) return null;
709
+ const total = Number.parseInt(header, 10);
710
+ return Number.isNaN(total) ? null : total;
629
711
  }
630
- async function instanceNameOrDefault() {
712
+ function progressCounter(total, onProgress) {
713
+ let received = 0;
714
+ return (chunk) => {
715
+ received += chunk.byteLength;
716
+ onProgress({
717
+ received,
718
+ total
719
+ });
720
+ return chunk;
721
+ };
722
+ }
723
+ async function downloadFile(url, destination, options) {
724
+ const fetchImpl = options.fetchImpl ?? fetch;
725
+ let response;
631
726
  try {
632
- return (await readGlobalConfig())?.defaultInstance ?? "default";
727
+ response = await fetchImpl(url, { signal: options.signal });
633
728
  } catch (error) {
634
- if (error instanceof ConfigError) return DEFAULT_INSTANCE_NAME;
635
- throw error;
729
+ if (options.signal.aborted) throw new AbortError();
730
+ throw new DownloadError(url, describeCause(error));
636
731
  }
637
- }
638
- //#endregion
639
- //#region src/commands/credentials.ts
640
- const credentials = defineRdeCommand({
641
- meta: {
642
- name: "credentials",
643
- description: "Print the admin login of the instance: URL, email, and password"
644
- },
645
- args: { json: {
646
- type: "boolean",
647
- description: "Print the login as JSON",
648
- default: false
649
- } },
650
- async run(args) {
651
- const name = await defaultInstanceName();
652
- const report = credentialsReport(await requireInstanceState(name), await readInstanceSecrets(name));
653
- if (args.json) {
654
- printJson(report);
655
- return;
656
- }
657
- printLines(credentialsLines(report));
732
+ if (!response.ok) throw new DownloadError(url, `HTTP ${response.status}`);
733
+ if (response.body === null) throw new DownloadError(url, "empty response");
734
+ const partial = `${destination}${PARTIAL_SUFFIX}`;
735
+ const count = progressCounter(contentLength(response), options.onProgress);
736
+ try {
737
+ await pipeline(Readable.fromWeb(response.body), async function* (chunks) {
738
+ for await (const chunk of chunks) yield count(chunk);
739
+ }, createWriteStream(partial), { signal: options.signal });
740
+ } catch (error) {
741
+ await promises.rm(partial, { force: true });
742
+ if (options.signal.aborted) throw new AbortError();
743
+ throw new DownloadError(url, describeCause(error));
658
744
  }
659
- });
660
- //#endregion
661
- //#region src/connections/parse-uri.ts
662
- var UriParseError = class extends Error {
663
- engine;
664
- reason;
665
- example;
666
- constructor(engine, reason) {
667
- const example = EXAMPLE_URIS[engine] ?? null;
668
- super(describeFailure(engine, reason, example));
669
- this.name = "UriParseError";
670
- this.engine = engine;
671
- this.reason = reason;
672
- this.example = example;
673
- }
674
- };
675
- function describeFailure(engine, reason, example) {
676
- if (reason === "unsupported-engine") return `Connection strings for ${engine} can't be parsed here; enter the fields one by one instead`;
677
- return `That doesn't look like a ${engine} connection string${example === null ? "" : `, for example ${example}`}`;
678
- }
679
- const EXAMPLE_URIS = {
680
- athena: "jdbc:athena://WorkGroup=primary;Region=us-east-1;",
681
- "bigquery-cloud-sdk": "jdbc:bigquery://https://www.googleapis.com/bigquery/v2:443;ProjectId=MyBigQueryProject;OAuthType=1;",
682
- clickhouse: "jdbc:clickhouse:https://localhost:8443?ssl=true",
683
- druid: "jdbc:avatica:remote:url=http://localhost:8888/druid/v2/sql/avatica/;transparent_reconnection=true",
684
- "druid-jdbc": "jdbc:avatica:remote:url=http://localhost:8888/druid/v2/sql/avatica/;transparent_reconnection=true",
685
- databricks: "jdbc:databricks://127.0.0.1:8123;httpPath=/sql/1.0/endpoints/abc;OAuthSecret=1234567890;OAuth2ClientId=xyz",
686
- mongo: "mongodb://user:pass@localhost:27017/dbname?authSource=admin",
687
- mysql: "jdbc:mysql://user:pass@host:3306/dbname?ssl=true",
688
- oracle: "jdbc:oracle:thin:@mydbhost:1521/mydbservice?ssl_server_cert_dn=ServerDN",
689
- postgres: "jdbc:postgresql://localhost:5432/mydb",
690
- "presto-jdbc": "jdbc:presto://host:1234/sample-catalog/sample-schema?SSL=true&SSLTrustStorePassword=1234",
691
- redshift: "jdbc:redshift://examplecluster.abc123xyz789.us-west-2.redshift.amazonaws.com:5439/dev",
692
- snowflake: "snowflake://example.snowflakecomputing.com/?db=maindb&warehouse=mainwarehouse",
693
- sparksql: "jdbc:sparksql:Server=127.0.0.1;",
694
- sqlite: "jdbc:sqlite:///C:/path/to/database.db",
695
- sqlserver: "jdbc:sqlserver://mydbhost:1433;databaseName=mydb",
696
- starburst: "jdbc:trino://starburst.example.com:43011/hive/sales?user=test&password=secret&SSL=true&roles=system:myrole",
697
- vertica: "jdbc:vertica://vertica.example.com:1234/databaseName?user=jane"
698
- };
699
- const jdbcPrefix = "(?<hasJdbcPrefix>jdbc:)?";
700
- const userPass = "(?:(?<username>[^:/?#]+)(?::(?<password>[^@/?#]*))?@)?";
701
- const hostAndPort = "(?<host>[^:/?#]+)?(?::(?<port>\\d+)?)?";
702
- const params = "(?:(?<params>.*))?";
703
- const semicolonParams = ";?(?<semicolonParams>.*)?;?";
704
- const druidRegex = new RegExp("^" + jdbcPrefix + "(?<protocol>avatica)" + ":remote:url=(https?://)?(?<host>[^:/]+):(?<port>\\d+)(?<path>/[^;]*)" + semicolonParams + "$", "i");
705
- const mongoRegex = new RegExp("^(?<protocol>mongodb(?:\\+srv)?)://" + userPass + "(?<host>[^/?#]+)?" + "(?:/(?<database>[^/?#]*))?" + "(?:\\?(?<params>.*))?" + "$", "i");
706
- const CONNECTION_STRING_REGEXES = {
707
- athena: [/* @__PURE__ */ new RegExp("^(?<hasJdbcPrefix>jdbc:)?(?<protocol>awsathena|athena)://((?<host>athena.*com)(?::(?<port>\\d+)?)?)?;?(?<semicolonParams>.*)?$", "i")],
708
- redshift: [new RegExp("^" + jdbcPrefix + "(?<protocol>redshift)://" + userPass + hostAndPort + "(?:/(?<database>[^/?#;]*))?" + ";?" + "(?<semicolonParams>.*)?" + "$", "i")],
709
- "bigquery-cloud-sdk": [new RegExp("^" + jdbcPrefix + "(?<protocol>bigquery)://" + "(https?://)?(?<host>[^:/]+)(?<path>/[^;]*):(?<port>\\d+)" + semicolonParams + "$", "i")],
710
- clickhouse: [new RegExp("^" + jdbcPrefix + "(?<protocol>clickhouse|ch):(?:https?:)?//" + userPass + hostAndPort + "(?:/(?<database>[^/?#]*))?" + params)],
711
- databricks: [new RegExp("^" + jdbcPrefix + "(?<protocol>databricks)://" + hostAndPort + "(?:/(?<schema>[^/?#]*))?" + semicolonParams + "$", "i")],
712
- druid: [druidRegex],
713
- "druid-jdbc": [druidRegex],
714
- mongo: [mongoRegex],
715
- mysql: [new RegExp("^" + jdbcPrefix + "(?<protocol>mysql)://" + userPass + hostAndPort + "(?:/(?<database>[^/?#]*))?" + params + "$", "i")],
716
- postgres: [new RegExp("^" + jdbcPrefix + "(?<protocol>postgres(?:ql)?)://" + userPass + hostAndPort + "(?:/(?<database>[^/?#]*))?" + params + "$", "i")],
717
- "presto-jdbc": [new RegExp("^" + jdbcPrefix + "(?<protocol>presto)://" + hostAndPort + "(?:/(?<catalog>[^/?#]*)/(?<schema>[^/?#]*))?" + params + "$", "i")],
718
- oracle: [new RegExp("^" + jdbcPrefix + "(?<protocol>oracle):(?<subprotocol>thin):" + "(?:(?<username>[^@/]+)/(?<password>[^@/]+))?" + "@" + "(?<host>[^:/?#]+)(?::(?<port>\\d+))?(?:/(?<database>[^/?#]+))?" + params + "$", "i")],
719
- snowflake: [new RegExp("^" + jdbcPrefix + "(?<protocol>snowflake)://" + userPass + hostAndPort + "(?:/(?<database>[^/?#]*)?)?" + params + "$", "i")],
720
- sparksql: [new RegExp("^" + jdbcPrefix + "(?<protocol>sparksql):" + semicolonParams + "$", "i"), new RegExp("^" + jdbcPrefix + "(?<protocol>hive2)://" + hostAndPort + "(?:/(?<database>[^/?#;]*))?" + semicolonParams + "$", "i")],
721
- sqlite: [new RegExp("^" + jdbcPrefix + "(?<protocol>sqlite):///(?<path>.+)$", "i")],
722
- sqlserver: [new RegExp("^" + jdbcPrefix + "(?<protocol>sqlserver)://" + hostAndPort + semicolonParams + "$", "i")],
723
- starburst: [new RegExp("^" + jdbcPrefix + "(?<protocol>trino)://" + hostAndPort + "(?:/(?<catalog>[^/?#]*)/(?<schema>[^/?#]*))?" + params + "$", "i")],
724
- vertica: [new RegExp("^" + jdbcPrefix + "(?<protocol>vertica)://" + hostAndPort + "(?:/(?<database>[^/?#]*))?" + params + "$", "i")]
725
- };
726
- function parseUri(uri, engine) {
727
- const regexes = CONNECTION_STRING_REGEXES[engine];
728
- if (regexes === void 0) return new UriParseError(engine, "unsupported-engine");
729
- const trimmed = uri.trim();
730
- for (const regex of regexes) {
731
- const match = trimmed.match(regex);
732
- if (match !== null) return fromMatch(engine, trimmed, match);
733
- }
734
- return new UriParseError(engine, "no-match");
735
- }
736
- function detectEngine(uri) {
737
- const trimmed = uri.trim();
738
- return Object.entries(CONNECTION_STRING_REGEXES).filter(([, regexes]) => regexes.some((regex) => regex.test(trimmed))).map(([engine]) => engine);
739
- }
740
- function fromMatch(engine, uri, match) {
741
- const groups = match.groups;
742
- if (groups === void 0) throw new Error(`the ${engine} connection string pattern has no named groups`);
743
- const group = (name) => groups[name] ?? null;
744
- const protocol = group("protocol");
745
- if (protocol === null) throw new Error(`the ${engine} connection string pattern has no protocol group`);
746
- return {
747
- engine,
748
- uri,
749
- protocol,
750
- hasJdbcPrefix: group("hasJdbcPrefix") !== null,
751
- username: safeDecode(group("username")),
752
- password: safeDecode(group("password")),
753
- host: group("host"),
754
- port: group("port"),
755
- database: group("database"),
756
- catalog: group("catalog"),
757
- schema: group("schema"),
758
- path: group("path"),
759
- ...parseParams(group("params"), group("semicolonParams"))
760
- };
761
- }
762
- function parseParams(query, semicolon) {
763
- if (semicolon !== null) return {
764
- params: parseSemicolonParams(semicolon),
765
- paramSeparator: ";"
766
- };
767
- return {
768
- params: query === null ? {} : Object.fromEntries(new URLSearchParams(query)),
769
- paramSeparator: "&"
770
- };
771
- }
772
- function parseSemicolonParams(text) {
773
- const result = {};
774
- for (const param of text.split(";")) {
775
- const separator = param.indexOf("=");
776
- const key = separator === -1 ? param : param.slice(0, separator);
777
- if (key.length === 0) continue;
778
- result[key] = decodeComponent(separator === -1 ? "" : param.slice(separator + 1));
779
- }
780
- return result;
781
- }
782
- function safeDecode(text) {
783
- return text === null ? null : decodeComponent(text);
784
- }
785
- function decodeComponent(text) {
786
- try {
787
- return decodeURIComponent(text);
788
- } catch {
789
- return text;
790
- }
791
- }
792
- //#endregion
793
- //#region src/connections/engines.ts
794
- const ELEVATED_ENGINES = [
795
- "postgres",
796
- "mysql",
797
- "clickhouse",
798
- "snowflake",
799
- "bigquery-cloud-sdk",
800
- "redshift",
801
- "databricks",
802
- "sqlserver",
803
- "athena",
804
- "starburst",
805
- "presto-jdbc",
806
- "mongo",
807
- "sqlite",
808
- "druid-jdbc"
809
- ];
810
- const HIDDEN_ENGINES = /* @__PURE__ */ new Set(["h2"]);
811
- const PROMPT_KINDS = {
812
- string: "text",
813
- password: "password",
814
- integer: "integer",
815
- boolean: "boolean",
816
- select: "select",
817
- text: "multiline",
818
- textFile: "file",
819
- section: "none",
820
- info: "none",
821
- hidden: "none"
822
- };
823
- function promptKindFor(field) {
824
- if (field.type === void 0) return "text";
825
- return PROMPT_KINDS[field.type] ?? "text";
826
- }
827
- function fieldLabel(field) {
828
- return field["display-name"] ?? field.name;
829
- }
830
- function flattenFields(fields) {
831
- return fields.flatMap((field) => isFieldGroup(field) ? field.fields : [field]);
832
- }
833
- function isFieldGroup(field) {
834
- return "fields" in field && field.type === "group";
835
- }
836
- function toEntry(key, engine) {
837
- return {
838
- key,
839
- displayName: engine["driver-name"],
840
- creatable: engine["creatable?"] !== false,
841
- supersededBy: engine["superseded-by"],
842
- exampleUri: EXAMPLE_URIS[key] ?? null
843
- };
844
- }
845
- function rank(key) {
846
- const position = ELEVATED_ENGINES.indexOf(key);
847
- return position === -1 ? ELEVATED_ENGINES.length : position;
848
- }
849
- function compareEntries(a, b) {
850
- const byRank = rank(a.key) - rank(b.key);
851
- return byRank !== 0 ? byRank : a.displayName.localeCompare(b.displayName);
852
- }
853
- function engineCatalog(engines) {
854
- const entries = /* @__PURE__ */ new Map();
855
- const fieldsByEngine = /* @__PURE__ */ new Map();
856
- for (const [key, engine] of Object.entries(engines)) {
857
- if (HIDDEN_ENGINES.has(key)) continue;
858
- entries.set(key, toEntry(key, engine));
859
- fieldsByEngine.set(key, flattenFields(engine["details-fields"] ?? []));
860
- }
861
- const ordered = [...entries.values()].toSorted(compareEntries);
862
- return {
863
- list: () => [...ordered],
864
- entry: (engine) => entries.get(engine) ?? null,
865
- fields: (engine) => {
866
- const fields = fieldsByEngine.get(engine);
867
- if (fields === void 0) throw new Error(`unknown engine ${engine}`);
868
- return [...fields];
869
- }
870
- };
871
- }
872
- //#endregion
873
- //#region src/metabase/domain/health.ts
874
- const Health = z.object({ status: z.literal("ok") }).loose();
875
- const Initializing = z.object({
876
- status: z.literal("initializing"),
877
- progress: z.number()
878
- }).loose();
879
- //#endregion
880
- //#region src/metabase/http.ts
881
- const REQUEST_TIMEOUT_MS = 3e4;
882
- const PARTIAL_SUFFIX = ".part";
883
- const CONTENT_LENGTH_HEADER = "content-length";
884
- const SESSION_HEADER = "X-Metabase-Session";
885
- const API_KEY_HEADER = "X-Api-Key";
886
- const JSON_CONTENT_TYPE = "application/json";
887
- const SERVICE_UNAVAILABLE_STATUS = 503;
888
- const NO_AUTH = { kind: "none" };
889
- const ErrorMessage = z.object({ message: z.string() }).loose();
890
- const ErrorFields = z.object({ errors: z.record(z.string(), z.string()) }).loose();
891
- function authHeaders(auth) {
892
- switch (auth.kind) {
893
- case "none": return {};
894
- case "session": return { [SESSION_HEADER]: auth.id };
895
- case "api-key": return { [API_KEY_HEADER]: auth.key };
896
- }
897
- }
898
- function parseBody(status, text) {
899
- if (text.length === 0) return {
900
- status,
901
- text,
902
- json: { parsed: false }
903
- };
904
- try {
905
- return {
906
- status,
907
- text,
908
- json: {
909
- parsed: true,
910
- value: JSON.parse(text)
911
- }
912
- };
913
- } catch {
914
- return {
915
- status,
916
- text,
917
- json: { parsed: false }
918
- };
919
- }
920
- }
921
- function jsonValue(body) {
922
- return body.json.parsed ? body.json.value : void 0;
923
- }
924
- function serverMessage(body) {
925
- const parsed = ErrorMessage.safeParse(jsonValue(body));
926
- if (parsed.success) return parsed.data.message;
927
- const text = body.text.trim();
928
- return text.length === 0 ? `HTTP ${body.status}` : text;
929
- }
930
- function serverErrors(body) {
931
- const parsed = ErrorFields.safeParse(jsonValue(body));
932
- return parsed.success ? parsed.data.errors : null;
933
- }
934
- function failureFor(path, body) {
935
- if (body.status === SERVICE_UNAVAILABLE_STATUS) {
936
- const initializing = Initializing.safeParse(jsonValue(body));
937
- if (initializing.success) return new MetabaseInitializingError(initializing.data.progress);
938
- }
939
- return new MetabaseApiError({
940
- status: body.status,
941
- path,
942
- message: serverMessage(body),
943
- errors: serverErrors(body)
944
- });
945
- }
946
- function shapeError(path, body, reason) {
947
- return new MetabaseApiError({
948
- status: body.status,
949
- path,
950
- message: reason,
951
- errors: null
952
- });
953
- }
954
- function isTimeout(error) {
955
- return error instanceof Error && error.name === "TimeoutError";
956
- }
957
- function describeCause(error) {
958
- if (error instanceof Error && error.cause instanceof Error) return error.cause.message;
959
- return error instanceof Error ? error.message : String(error);
960
- }
961
- function createHttp(options) {
962
- const fetchImpl = options.fetchImpl ?? fetch;
963
- const baseUrl = options.baseUrl.replace(/\/+$/, "");
964
- async function send(method, path, body) {
965
- const url = `${baseUrl}${path}`;
966
- const headers = {
967
- accept: JSON_CONTENT_TYPE,
968
- ...authHeaders(options.auth)
969
- };
970
- const init = {
971
- method,
972
- headers,
973
- signal: AbortSignal.any([options.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
974
- };
975
- if (body !== void 0) {
976
- headers["content-type"] = JSON_CONTENT_TYPE;
977
- init.body = JSON.stringify(body);
978
- }
979
- try {
980
- return await fetchImpl(url, init);
981
- } catch (error) {
982
- if (options.signal.aborted) throw new AbortError();
983
- if (isTimeout(error)) throw new MetabaseUnreachableError(url, `no response within ${REQUEST_TIMEOUT_MS}ms`);
984
- throw new MetabaseUnreachableError(url, describeCause(error));
985
- }
986
- }
987
- async function requestBody(method, path, body) {
988
- const response = await send(method, path, body);
989
- const parsed = parseBody(response.status, await response.text());
990
- if (!response.ok) throw failureFor(path, parsed);
991
- return parsed;
992
- }
993
- return {
994
- async requestJson(method, path, request) {
995
- const body = await requestBody(method, path, request.body);
996
- if (!body.json.parsed) throw shapeError(path, body, "expected a JSON body");
997
- try {
998
- return request.schema.parse(body.json.value);
999
- } catch (error) {
1000
- if (error instanceof ZodError) throw shapeError(path, body, `unexpected response shape:\n${z.prettifyError(error)}`);
1001
- throw error;
1002
- }
1003
- },
1004
- async requestNoContent(method, path, request = {}) {
1005
- await requestBody(method, path, request.body);
1006
- }
1007
- };
1008
- }
1009
- function contentLength(response) {
1010
- const header = response.headers.get(CONTENT_LENGTH_HEADER);
1011
- if (header === null) return null;
1012
- const total = Number.parseInt(header, 10);
1013
- return Number.isNaN(total) ? null : total;
1014
- }
1015
- function progressCounter(total, onProgress) {
1016
- let received = 0;
1017
- return (chunk) => {
1018
- received += chunk.byteLength;
1019
- onProgress({
1020
- received,
1021
- total
1022
- });
1023
- return chunk;
1024
- };
1025
- }
1026
- async function downloadFile(url, destination, options) {
1027
- const fetchImpl = options.fetchImpl ?? fetch;
1028
- let response;
1029
- try {
1030
- response = await fetchImpl(url, { signal: options.signal });
1031
- } catch (error) {
1032
- if (options.signal.aborted) throw new AbortError();
1033
- throw new DownloadError(url, describeCause(error));
1034
- }
1035
- if (!response.ok) throw new DownloadError(url, `HTTP ${response.status}`);
1036
- if (response.body === null) throw new DownloadError(url, "empty response");
1037
- const partial = `${destination}${PARTIAL_SUFFIX}`;
1038
- const count = progressCounter(contentLength(response), options.onProgress);
1039
- try {
1040
- await pipeline(Readable.fromWeb(response.body), async function* (chunks) {
1041
- for await (const chunk of chunks) yield count(chunk);
1042
- }, createWriteStream(partial), { signal: options.signal });
1043
- } catch (error) {
1044
- await promises.rm(partial, { force: true });
1045
- if (options.signal.aborted) throw new AbortError();
1046
- throw new DownloadError(url, describeCause(error));
1047
- }
1048
- await promises.rename(partial, destination);
1049
- }
745
+ await promises.rename(partial, destination);
746
+ }
1050
747
  //#endregion
1051
748
  //#region src/metabase/domain/permissions-group.ts
1052
749
  const PermissionsGroup = z.object({
@@ -1094,293 +791,657 @@ function apiKeyResource(http) {
1094
791
  };
1095
792
  }
1096
793
  //#endregion
1097
- //#region src/metabase/domain/database.ts
1098
- const InitialSyncStatus = z.enum([
1099
- "incomplete",
1100
- "complete",
1101
- "aborted"
1102
- ]);
1103
- const Database = z.object({
1104
- id: z.int(),
1105
- name: z.string(),
1106
- engine: z.string(),
1107
- initial_sync_status: InitialSyncStatus
1108
- }).loose();
1109
- const DatabaseList = z.object({
1110
- data: z.array(Database),
1111
- total: z.int()
1112
- }).loose();
1113
- const DatabaseValidation = z.discriminatedUnion("valid", [z.object({ valid: z.literal(true) }).loose(), z.object({
1114
- valid: z.literal(false),
1115
- message: z.string().optional(),
1116
- errors: z.record(z.string(), z.string()).optional()
1117
- }).loose()]);
794
+ //#region src/metabase/domain/database.ts
795
+ const InitialSyncStatus = z.enum([
796
+ "incomplete",
797
+ "complete",
798
+ "aborted"
799
+ ]);
800
+ const Database = z.object({
801
+ id: z.int(),
802
+ name: z.string(),
803
+ engine: z.string(),
804
+ initial_sync_status: InitialSyncStatus
805
+ }).loose();
806
+ const DatabaseList = z.object({
807
+ data: z.array(Database),
808
+ total: z.int()
809
+ }).loose();
810
+ const DatabaseValidation = z.discriminatedUnion("valid", [z.object({ valid: z.literal(true) }).loose(), z.object({
811
+ valid: z.literal(false),
812
+ message: z.string().optional(),
813
+ errors: z.record(z.string(), z.string()).optional()
814
+ }).loose()]);
815
+ //#endregion
816
+ //#region src/metabase/resources/database.ts
817
+ function databaseResource(http) {
818
+ /** POST /api/database/validate */
819
+ async function validate(connection) {
820
+ return http.requestJson("POST", "/api/database/validate", {
821
+ body: { details: connection },
822
+ schema: DatabaseValidation
823
+ });
824
+ }
825
+ /** POST /api/database */
826
+ async function create(request) {
827
+ return http.requestJson("POST", "/api/database", {
828
+ body: {
829
+ ...request,
830
+ is_full_sync: true
831
+ },
832
+ schema: Database
833
+ });
834
+ }
835
+ /** GET /api/database */
836
+ async function list() {
837
+ return http.requestJson("GET", "/api/database", { schema: DatabaseList });
838
+ }
839
+ /** GET /api/database/:id */
840
+ async function get(id) {
841
+ return http.requestJson("GET", `/api/database/${id}`, { schema: Database });
842
+ }
843
+ return {
844
+ validate,
845
+ create,
846
+ list,
847
+ get
848
+ };
849
+ }
850
+ //#endregion
851
+ //#region src/metabase/resources/health.ts
852
+ function healthResource(http) {
853
+ /** GET /api/health */
854
+ async function get() {
855
+ return http.requestJson("GET", "/api/health", { schema: Health });
856
+ }
857
+ return { get };
858
+ }
859
+ //#endregion
860
+ //#region src/metabase/resources/permissions-group.ts
861
+ function permissionsGroupResource(http) {
862
+ /** GET /api/permissions/group */
863
+ async function list() {
864
+ return http.requestJson("GET", "/api/permissions/group", { schema: PermissionsGroupList });
865
+ }
866
+ return { list };
867
+ }
868
+ z.enum(["read-only", "read-write"]);
869
+ const RemoteSyncSettingsResult = z.object({
870
+ success: z.boolean(),
871
+ task_id: z.int().optional()
872
+ }).loose();
873
+ const RemoteSyncTestConnection = z.object({ status: z.literal("success") }).loose();
874
+ const SyncTaskStatus$1 = z.enum([
875
+ "running",
876
+ "successful",
877
+ "errored",
878
+ "cancelled",
879
+ "timed-out",
880
+ "conflict"
881
+ ]);
882
+ const SyncTask = z.object({
883
+ id: z.int(),
884
+ sync_task_type: z.enum(["import", "export"]),
885
+ progress: z.number().nullable(),
886
+ status: SyncTaskStatus$1,
887
+ error_message: z.string().nullable().optional()
888
+ }).loose();
889
+ //#endregion
890
+ //#region src/metabase/resources/remote-sync.ts
891
+ function remoteSyncResource(http) {
892
+ /** PUT /api/ee/remote-sync/settings */
893
+ async function updateSettings(settings) {
894
+ return http.requestJson("PUT", "/api/ee/remote-sync/settings", {
895
+ body: settings,
896
+ schema: RemoteSyncSettingsResult
897
+ });
898
+ }
899
+ /** POST /api/ee/remote-sync/test-connection; an empty body tests the saved url and token. */
900
+ async function testConnection(connection = {}) {
901
+ return http.requestJson("POST", "/api/ee/remote-sync/test-connection", {
902
+ body: connection,
903
+ schema: RemoteSyncTestConnection
904
+ });
905
+ }
906
+ /** GET /api/ee/remote-sync/current-task */
907
+ async function currentTask() {
908
+ return http.requestJson("GET", "/api/ee/remote-sync/current-task", { schema: SyncTask.nullable() });
909
+ }
910
+ return {
911
+ updateSettings,
912
+ testConnection,
913
+ currentTask
914
+ };
915
+ }
916
+ //#endregion
917
+ //#region src/metabase/domain/session.ts
918
+ const Session = z.object({ id: z.string() }).loose();
919
+ //#endregion
920
+ //#region src/metabase/domain/session-properties.ts
921
+ const EngineFieldOption = z.object({
922
+ name: z.string(),
923
+ value: z.unknown()
924
+ }).loose();
925
+ const EngineField = z.object({
926
+ name: z.string(),
927
+ "display-name": z.string().optional(),
928
+ type: z.string().optional(),
929
+ required: z.boolean().optional(),
930
+ default: z.unknown().optional(),
931
+ placeholder: z.unknown().optional(),
932
+ "visible-if": z.record(z.string(), z.unknown()).optional(),
933
+ options: z.array(EngineFieldOption).optional(),
934
+ "helper-text": z.string().optional()
935
+ }).loose();
936
+ const EngineFieldGroup = z.object({
937
+ type: z.literal("group"),
938
+ fields: z.array(EngineField)
939
+ }).loose();
940
+ const Engine = z.object({
941
+ "driver-name": z.string(),
942
+ "details-fields": z.array(z.union([EngineFieldGroup, EngineField])).optional(),
943
+ "superseded-by": z.string().nullable(),
944
+ "creatable?": z.boolean().optional()
945
+ }).loose();
946
+ const SessionProperties = z.object({
947
+ "setup-token": z.string().nullable(),
948
+ "has-user-setup": z.boolean(),
949
+ version: z.object({ tag: z.string() }).loose(),
950
+ "token-features": z.record(z.string(), z.boolean()),
951
+ engines: z.record(z.string(), Engine)
952
+ }).loose();
953
+ //#endregion
954
+ //#region src/metabase/resources/session.ts
955
+ function sessionResource(http) {
956
+ /** GET /api/session/properties */
957
+ async function properties() {
958
+ return http.requestJson("GET", "/api/session/properties", { schema: SessionProperties });
959
+ }
960
+ /** POST /api/session */
961
+ async function create(credentials) {
962
+ return http.requestJson("POST", "/api/session", {
963
+ body: credentials,
964
+ schema: Session
965
+ });
966
+ }
967
+ return {
968
+ properties,
969
+ create
970
+ };
971
+ }
972
+ //#endregion
973
+ //#region src/metabase/resources/setting.ts
974
+ const PREMIUM_EMBEDDING_TOKEN_SETTING = "premium-embedding-token";
975
+ const TRANSFORMS_ENABLED_SETTING = "transforms-enabled";
976
+ function settingResource(http) {
977
+ /** PUT /api/setting/premium-embedding-token */
978
+ async function setPremiumEmbeddingToken(value) {
979
+ await http.requestNoContent("PUT", `/api/setting/${PREMIUM_EMBEDDING_TOKEN_SETTING}`, { body: { value } });
980
+ }
981
+ /** PUT /api/setting/transforms-enabled */
982
+ async function enableTransforms() {
983
+ await http.requestNoContent("PUT", `/api/setting/${TRANSFORMS_ENABLED_SETTING}`, { body: { value: true } });
984
+ }
985
+ return {
986
+ setPremiumEmbeddingToken,
987
+ enableTransforms
988
+ };
989
+ }
990
+ //#endregion
991
+ //#region src/metabase/domain/setup.ts
992
+ const SetupResult = z.object({ id: z.string() }).loose();
993
+ //#endregion
994
+ //#region src/metabase/resources/setup.ts
995
+ function setupResource(http) {
996
+ /** POST /api/setup */
997
+ async function create(request) {
998
+ return http.requestJson("POST", "/api/setup", {
999
+ body: request,
1000
+ schema: SetupResult
1001
+ });
1002
+ }
1003
+ return { create };
1004
+ }
1005
+ //#endregion
1006
+ //#region src/metabase/domain/token-status.ts
1007
+ const TokenStatus = z.object({
1008
+ valid: z.boolean(),
1009
+ status: z.string(),
1010
+ features: z.array(z.string()).default([]),
1011
+ "valid-thru": z.string().optional(),
1012
+ "plan-alias": z.string().optional()
1013
+ }).loose();
1014
+ //#endregion
1015
+ //#region src/metabase/resources/token-status.ts
1016
+ function tokenStatusResource(http) {
1017
+ /** GET /api/premium-features/token/status */
1018
+ async function get() {
1019
+ return http.requestJson("GET", "/api/premium-features/token/status", { schema: TokenStatus });
1020
+ }
1021
+ return { get };
1022
+ }
1023
+ //#endregion
1024
+ //#region src/metabase/domain/user.ts
1025
+ const CurrentUser = z.object({ id: z.int() }).loose();
1118
1026
  //#endregion
1119
- //#region src/metabase/resources/database.ts
1120
- function databaseResource(http) {
1121
- /** POST /api/database/validate */
1122
- async function validate(connection) {
1123
- return http.requestJson("POST", "/api/database/validate", {
1124
- body: { details: connection },
1125
- schema: DatabaseValidation
1126
- });
1027
+ //#region src/metabase/resources/user.ts
1028
+ function userResource(http) {
1029
+ /** GET /api/user/current */
1030
+ async function current() {
1031
+ return http.requestJson("GET", "/api/user/current", { schema: CurrentUser });
1127
1032
  }
1128
- /** POST /api/database */
1129
- async function create(request) {
1130
- return http.requestJson("POST", "/api/database", {
1131
- body: {
1132
- ...request,
1133
- is_full_sync: true
1134
- },
1135
- schema: Database
1136
- });
1033
+ return { current };
1034
+ }
1035
+ //#endregion
1036
+ //#region src/metabase/client.ts
1037
+ const HEALTH_POLL_INTERVAL_MS = 2e3;
1038
+ function createMetabaseClient(baseUrl, auth, signal, options = {}) {
1039
+ const http = createHttp({
1040
+ baseUrl,
1041
+ auth,
1042
+ signal,
1043
+ ...options
1044
+ });
1045
+ return {
1046
+ baseUrl,
1047
+ signal,
1048
+ health: healthResource(http),
1049
+ session: sessionResource(http),
1050
+ setup: setupResource(http),
1051
+ permissionsGroup: permissionsGroupResource(http),
1052
+ apiKey: apiKeyResource(http),
1053
+ setting: settingResource(http),
1054
+ tokenStatus: tokenStatusResource(http),
1055
+ database: databaseResource(http),
1056
+ remoteSync: remoteSyncResource(http),
1057
+ user: userResource(http)
1058
+ };
1059
+ }
1060
+ function isNotReadyYet(error) {
1061
+ return error instanceof MetabaseInitializingError || error instanceof MetabaseUnreachableError;
1062
+ }
1063
+ async function waitForHealth(client, options) {
1064
+ const deadline = Date.now() + options.timeoutMs;
1065
+ const interval = options.pollIntervalMs ?? HEALTH_POLL_INTERVAL_MS;
1066
+ for (;;) {
1067
+ try {
1068
+ await client.health.get();
1069
+ return;
1070
+ } catch (error) {
1071
+ if (!isNotReadyYet(error)) throw error;
1072
+ if (error instanceof MetabaseInitializingError) options.onProgress(error.progress);
1073
+ }
1074
+ if (Date.now() >= deadline) throw new MetabaseUnreachableError(client.baseUrl, `not healthy after ${Math.round(options.timeoutMs / 1e3)}s`);
1075
+ try {
1076
+ await setTimeout$1(interval, void 0, { signal: client.signal });
1077
+ } catch {
1078
+ throw new AbortError();
1079
+ }
1137
1080
  }
1138
- /** GET /api/database */
1139
- async function list() {
1140
- return http.requestJson("GET", "/api/database", { schema: DatabaseList });
1081
+ }
1082
+ //#endregion
1083
+ //#region src/steps/credentials.ts
1084
+ const LEGACY_NOTE = "This is the public default password; when the instance holds nothing you need, `rde uninstall` then `rde init` recreates it with a generated one";
1085
+ const PROMPTED_NOTE = "The password is the one entered during `rde init`; rde never stored it";
1086
+ function storedPassword(state, secrets) {
1087
+ if (secrets === null || secrets.admin === null) throw new ConfigError(`the secrets file of instance ${state.name} holds no admin password; \`rde doctor\` says what to do`);
1088
+ return secrets.admin.password;
1089
+ }
1090
+ function storedApiKey(state, secrets) {
1091
+ if (secrets === null || secrets.apiKey === null) throw new ConfigError(`the secrets file of instance ${state.name} holds no API key; run \`rde init --only api-key\``);
1092
+ return secrets.apiKey;
1093
+ }
1094
+ function hostCredentialsClient(signal) {
1095
+ return (baseUrl, auth) => createMetabaseClient(baseUrl, auth, signal);
1096
+ }
1097
+ function isNotReady$1(error) {
1098
+ return error instanceof MetabaseUnreachableError || error instanceof MetabaseInitializingError;
1099
+ }
1100
+ async function hasAdminOutsideRde(state, client) {
1101
+ if (state.url === null) return false;
1102
+ try {
1103
+ return (await client(state.url, NO_AUTH).session.properties())["has-user-setup"];
1104
+ } catch (error) {
1105
+ if (isNotReady$1(error)) return false;
1106
+ throw error;
1141
1107
  }
1142
- /** GET /api/database/:id */
1143
- async function get(id) {
1144
- return http.requestJson("GET", `/api/database/${id}`, { schema: Database });
1108
+ }
1109
+ async function credentialsReport(state, secrets, client) {
1110
+ const admin = state.admin;
1111
+ if (admin === null) {
1112
+ if (await hasAdminOutsideRde(state, client)) {
1113
+ if (secrets !== null && secrets.admin !== null) throw new ConfigError(`instance ${state.name} has an admin user rde has not recorded yet; run \`rde init\` to record it`);
1114
+ throw new ConfigError(`instance ${state.name} has an admin user created outside rde; rde has no password for it`);
1115
+ }
1116
+ throw new ConfigError(`instance ${state.name} has no admin user yet; run \`rde init\``);
1145
1117
  }
1146
- return {
1147
- validate,
1148
- create,
1149
- list,
1150
- get
1118
+ const login = {
1119
+ instance: state.name,
1120
+ url: state.url,
1121
+ email: admin.email
1151
1122
  };
1123
+ switch (admin.kind) {
1124
+ case "generated": return {
1125
+ ...login,
1126
+ password: storedPassword(state, secrets),
1127
+ note: null
1128
+ };
1129
+ case "legacy-default": return {
1130
+ ...login,
1131
+ password: storedPassword(state, secrets),
1132
+ note: LEGACY_NOTE
1133
+ };
1134
+ case "prompted": return {
1135
+ ...login,
1136
+ password: null,
1137
+ note: PROMPTED_NOTE
1138
+ };
1139
+ }
1140
+ }
1141
+ function credentialsLines(report) {
1142
+ const url = report.url === null ? [] : [`Metabase: ${report.url}`];
1143
+ const password = report.password === null ? [] : [`Password: ${report.password}`];
1144
+ const note = report.note === null ? [] : [report.note];
1145
+ return [
1146
+ ...url,
1147
+ `Email: ${report.email}`,
1148
+ ...password,
1149
+ ...note
1150
+ ];
1152
1151
  }
1153
1152
  //#endregion
1154
- //#region src/metabase/resources/health.ts
1155
- function healthResource(http) {
1156
- /** GET /api/health */
1157
- async function get() {
1158
- return http.requestJson("GET", "/api/health", { schema: Health });
1153
+ //#region src/commands/define.ts
1154
+ function repeatedValues(rawArgs, flag) {
1155
+ const values = [];
1156
+ const inline = `${flag}=`;
1157
+ for (let index = 0; index < rawArgs.length; index += 1) {
1158
+ const arg = rawArgs[index];
1159
+ if (arg === void 0) continue;
1160
+ if (arg.startsWith(inline)) values.push(arg.slice(inline.length));
1161
+ else if (arg === flag) {
1162
+ const next = rawArgs[index + 1];
1163
+ if (next !== void 0) {
1164
+ values.push(next);
1165
+ index += 1;
1166
+ }
1167
+ }
1159
1168
  }
1160
- return { get };
1169
+ return values;
1170
+ }
1171
+ function defineRdeCommand(def) {
1172
+ return defineCommand({
1173
+ meta: def.meta,
1174
+ ...def.args !== void 0 && { args: def.args },
1175
+ async run({ args, rawArgs }) {
1176
+ try {
1177
+ await def.run(args, rawArgs);
1178
+ } catch (error) {
1179
+ reportError(error);
1180
+ }
1181
+ }
1182
+ });
1161
1183
  }
1162
1184
  //#endregion
1163
- //#region src/metabase/resources/permissions-group.ts
1164
- function permissionsGroupResource(http) {
1165
- /** GET /api/permissions/group */
1166
- async function list() {
1167
- return http.requestJson("GET", "/api/permissions/group", { schema: PermissionsGroupList });
1185
+ //#region src/commands/instance.ts
1186
+ async function defaultInstanceName() {
1187
+ const config = await readGlobalConfig();
1188
+ if (config === null) throw new ConfigError("no Metabase instance yet; run `rde init` first");
1189
+ return config.defaultInstance;
1190
+ }
1191
+ async function instanceNameOrDefault() {
1192
+ try {
1193
+ return (await readGlobalConfig())?.defaultInstance ?? "default";
1194
+ } catch (error) {
1195
+ if (error instanceof ConfigError) return DEFAULT_INSTANCE_NAME;
1196
+ throw error;
1168
1197
  }
1169
- return { list };
1170
1198
  }
1171
- z.enum(["read-only", "read-write"]);
1172
- const RemoteSyncSettingsResult = z.object({
1173
- success: z.boolean(),
1174
- task_id: z.int().optional()
1175
- }).loose();
1176
- const RemoteSyncTestConnection = z.object({ status: z.literal("success") }).loose();
1177
- const SyncTaskStatus$1 = z.enum([
1178
- "running",
1179
- "successful",
1180
- "errored",
1181
- "cancelled",
1182
- "timed-out",
1183
- "conflict"
1184
- ]);
1185
- const SyncTask = z.object({
1186
- id: z.int(),
1187
- sync_task_type: z.enum(["import", "export"]),
1188
- progress: z.number().nullable(),
1189
- status: SyncTaskStatus$1,
1190
- error_message: z.string().nullable().optional()
1191
- }).loose();
1192
1199
  //#endregion
1193
- //#region src/metabase/resources/remote-sync.ts
1194
- function remoteSyncResource(http) {
1195
- /** PUT /api/ee/remote-sync/settings */
1196
- async function updateSettings(settings) {
1197
- return http.requestJson("PUT", "/api/ee/remote-sync/settings", {
1198
- body: settings,
1199
- schema: RemoteSyncSettingsResult
1200
- });
1200
+ //#region src/commands/credentials.ts
1201
+ const credentials = defineRdeCommand({
1202
+ meta: {
1203
+ name: "credentials",
1204
+ description: "Print the admin login of the instance: URL, email, and password"
1205
+ },
1206
+ args: {
1207
+ json: {
1208
+ type: "boolean",
1209
+ description: "Print the login as JSON",
1210
+ default: false
1211
+ },
1212
+ "api-key": {
1213
+ type: "boolean",
1214
+ description: "Print only the API key rde and mb use, for a script to read",
1215
+ default: false
1216
+ }
1217
+ },
1218
+ async run(args) {
1219
+ const name = await defaultInstanceName();
1220
+ const state = await requireInstanceState(name);
1221
+ const secrets = await readInstanceSecrets(name);
1222
+ if (args["api-key"]) {
1223
+ printLines([storedApiKey(state, secrets)]);
1224
+ return;
1225
+ }
1226
+ const report = await credentialsReport(state, secrets, hostCredentialsClient(interruptSignal));
1227
+ if (args.json) {
1228
+ printJson(report);
1229
+ return;
1230
+ }
1231
+ printLines(credentialsLines(report));
1201
1232
  }
1202
- /** POST /api/ee/remote-sync/test-connection; an empty body tests the saved url and token. */
1203
- async function testConnection(connection = {}) {
1204
- return http.requestJson("POST", "/api/ee/remote-sync/test-connection", {
1205
- body: connection,
1206
- schema: RemoteSyncTestConnection
1207
- });
1233
+ });
1234
+ //#endregion
1235
+ //#region src/connections/parse-uri.ts
1236
+ var UriParseError = class extends Error {
1237
+ engine;
1238
+ reason;
1239
+ example;
1240
+ constructor(engine, reason) {
1241
+ const example = EXAMPLE_URIS[engine] ?? null;
1242
+ super(describeFailure(engine, reason, example));
1243
+ this.name = "UriParseError";
1244
+ this.engine = engine;
1245
+ this.reason = reason;
1246
+ this.example = example;
1208
1247
  }
1209
- /** GET /api/ee/remote-sync/current-task */
1210
- async function currentTask() {
1211
- return http.requestJson("GET", "/api/ee/remote-sync/current-task", { schema: SyncTask.nullable() });
1248
+ };
1249
+ function describeFailure(engine, reason, example) {
1250
+ if (reason === "unsupported-engine") return `Connection strings for ${engine} can't be parsed here; enter the fields one by one instead`;
1251
+ return `That doesn't look like a ${engine} connection string${example === null ? "" : `, for example ${example}`}`;
1252
+ }
1253
+ const EXAMPLE_URIS = {
1254
+ athena: "jdbc:athena://WorkGroup=primary;Region=us-east-1;",
1255
+ "bigquery-cloud-sdk": "jdbc:bigquery://https://www.googleapis.com/bigquery/v2:443;ProjectId=MyBigQueryProject;OAuthType=1;",
1256
+ clickhouse: "jdbc:clickhouse:https://localhost:8443?ssl=true",
1257
+ druid: "jdbc:avatica:remote:url=http://localhost:8888/druid/v2/sql/avatica/;transparent_reconnection=true",
1258
+ "druid-jdbc": "jdbc:avatica:remote:url=http://localhost:8888/druid/v2/sql/avatica/;transparent_reconnection=true",
1259
+ databricks: "jdbc:databricks://127.0.0.1:8123;httpPath=/sql/1.0/endpoints/abc;OAuthSecret=1234567890;OAuth2ClientId=xyz",
1260
+ mongo: "mongodb://user:pass@localhost:27017/dbname?authSource=admin",
1261
+ mysql: "jdbc:mysql://user:pass@host:3306/dbname?ssl=true",
1262
+ oracle: "jdbc:oracle:thin:@mydbhost:1521/mydbservice?ssl_server_cert_dn=ServerDN",
1263
+ postgres: "jdbc:postgresql://localhost:5432/mydb",
1264
+ "presto-jdbc": "jdbc:presto://host:1234/sample-catalog/sample-schema?SSL=true&SSLTrustStorePassword=1234",
1265
+ redshift: "jdbc:redshift://examplecluster.abc123xyz789.us-west-2.redshift.amazonaws.com:5439/dev",
1266
+ snowflake: "snowflake://example.snowflakecomputing.com/?db=maindb&warehouse=mainwarehouse",
1267
+ sparksql: "jdbc:sparksql:Server=127.0.0.1;",
1268
+ sqlite: "jdbc:sqlite:///C:/path/to/database.db",
1269
+ sqlserver: "jdbc:sqlserver://mydbhost:1433;databaseName=mydb",
1270
+ starburst: "jdbc:trino://starburst.example.com:43011/hive/sales?user=test&password=secret&SSL=true&roles=system:myrole",
1271
+ vertica: "jdbc:vertica://vertica.example.com:1234/databaseName?user=jane"
1272
+ };
1273
+ const jdbcPrefix = "(?<hasJdbcPrefix>jdbc:)?";
1274
+ const userPass = "(?:(?<username>[^:/?#]+)(?::(?<password>[^@/?#]*))?@)?";
1275
+ const hostAndPort = "(?<host>[^:/?#]+)?(?::(?<port>\\d+)?)?";
1276
+ const params = "(?:(?<params>.*))?";
1277
+ const semicolonParams = ";?(?<semicolonParams>.*)?;?";
1278
+ const druidRegex = new RegExp("^" + jdbcPrefix + "(?<protocol>avatica)" + ":remote:url=(https?://)?(?<host>[^:/]+):(?<port>\\d+)(?<path>/[^;]*)" + semicolonParams + "$", "i");
1279
+ const mongoRegex = new RegExp("^(?<protocol>mongodb(?:\\+srv)?)://" + userPass + "(?<host>[^/?#]+)?" + "(?:/(?<database>[^/?#]*))?" + "(?:\\?(?<params>.*))?" + "$", "i");
1280
+ const CONNECTION_STRING_REGEXES = {
1281
+ athena: [/* @__PURE__ */ new RegExp("^(?<hasJdbcPrefix>jdbc:)?(?<protocol>awsathena|athena)://((?<host>athena.*com)(?::(?<port>\\d+)?)?)?;?(?<semicolonParams>.*)?$", "i")],
1282
+ redshift: [new RegExp("^" + jdbcPrefix + "(?<protocol>redshift)://" + userPass + hostAndPort + "(?:/(?<database>[^/?#;]*))?" + ";?" + "(?<semicolonParams>.*)?" + "$", "i")],
1283
+ "bigquery-cloud-sdk": [new RegExp("^" + jdbcPrefix + "(?<protocol>bigquery)://" + "(https?://)?(?<host>[^:/]+)(?<path>/[^;]*):(?<port>\\d+)" + semicolonParams + "$", "i")],
1284
+ clickhouse: [new RegExp("^" + jdbcPrefix + "(?<protocol>clickhouse|ch):(?:https?:)?//" + userPass + hostAndPort + "(?:/(?<database>[^/?#]*))?" + params)],
1285
+ databricks: [new RegExp("^" + jdbcPrefix + "(?<protocol>databricks)://" + hostAndPort + "(?:/(?<schema>[^/?#]*))?" + semicolonParams + "$", "i")],
1286
+ druid: [druidRegex],
1287
+ "druid-jdbc": [druidRegex],
1288
+ mongo: [mongoRegex],
1289
+ mysql: [new RegExp("^" + jdbcPrefix + "(?<protocol>mysql)://" + userPass + hostAndPort + "(?:/(?<database>[^/?#]*))?" + params + "$", "i")],
1290
+ postgres: [new RegExp("^" + jdbcPrefix + "(?<protocol>postgres(?:ql)?)://" + userPass + hostAndPort + "(?:/(?<database>[^/?#]*))?" + params + "$", "i")],
1291
+ "presto-jdbc": [new RegExp("^" + jdbcPrefix + "(?<protocol>presto)://" + hostAndPort + "(?:/(?<catalog>[^/?#]*)/(?<schema>[^/?#]*))?" + params + "$", "i")],
1292
+ oracle: [new RegExp("^" + jdbcPrefix + "(?<protocol>oracle):(?<subprotocol>thin):" + "(?:(?<username>[^@/]+)/(?<password>[^@/]+))?" + "@" + "(?<host>[^:/?#]+)(?::(?<port>\\d+))?(?:/(?<database>[^/?#]+))?" + params + "$", "i")],
1293
+ snowflake: [new RegExp("^" + jdbcPrefix + "(?<protocol>snowflake)://" + userPass + hostAndPort + "(?:/(?<database>[^/?#]*)?)?" + params + "$", "i")],
1294
+ sparksql: [new RegExp("^" + jdbcPrefix + "(?<protocol>sparksql):" + semicolonParams + "$", "i"), new RegExp("^" + jdbcPrefix + "(?<protocol>hive2)://" + hostAndPort + "(?:/(?<database>[^/?#;]*))?" + semicolonParams + "$", "i")],
1295
+ sqlite: [new RegExp("^" + jdbcPrefix + "(?<protocol>sqlite):///(?<path>.+)$", "i")],
1296
+ sqlserver: [new RegExp("^" + jdbcPrefix + "(?<protocol>sqlserver)://" + hostAndPort + semicolonParams + "$", "i")],
1297
+ starburst: [new RegExp("^" + jdbcPrefix + "(?<protocol>trino)://" + hostAndPort + "(?:/(?<catalog>[^/?#]*)/(?<schema>[^/?#]*))?" + params + "$", "i")],
1298
+ vertica: [new RegExp("^" + jdbcPrefix + "(?<protocol>vertica)://" + hostAndPort + "(?:/(?<database>[^/?#]*))?" + params + "$", "i")]
1299
+ };
1300
+ function parseUri(uri, engine) {
1301
+ const regexes = CONNECTION_STRING_REGEXES[engine];
1302
+ if (regexes === void 0) return new UriParseError(engine, "unsupported-engine");
1303
+ const trimmed = uri.trim();
1304
+ for (const regex of regexes) {
1305
+ const match = trimmed.match(regex);
1306
+ if (match !== null) return fromMatch(engine, trimmed, match);
1212
1307
  }
1308
+ return new UriParseError(engine, "no-match");
1309
+ }
1310
+ function detectEngine(uri) {
1311
+ const trimmed = uri.trim();
1312
+ return Object.entries(CONNECTION_STRING_REGEXES).filter(([, regexes]) => regexes.some((regex) => regex.test(trimmed))).map(([engine]) => engine);
1313
+ }
1314
+ function fromMatch(engine, uri, match) {
1315
+ const groups = match.groups;
1316
+ if (groups === void 0) throw new Error(`the ${engine} connection string pattern has no named groups`);
1317
+ const group = (name) => groups[name] ?? null;
1318
+ const protocol = group("protocol");
1319
+ if (protocol === null) throw new Error(`the ${engine} connection string pattern has no protocol group`);
1213
1320
  return {
1214
- updateSettings,
1215
- testConnection,
1216
- currentTask
1321
+ engine,
1322
+ uri,
1323
+ protocol,
1324
+ hasJdbcPrefix: group("hasJdbcPrefix") !== null,
1325
+ username: safeDecode(group("username")),
1326
+ password: safeDecode(group("password")),
1327
+ host: group("host"),
1328
+ port: group("port"),
1329
+ database: group("database"),
1330
+ catalog: group("catalog"),
1331
+ schema: group("schema"),
1332
+ path: group("path"),
1333
+ ...parseParams(group("params"), group("semicolonParams"))
1217
1334
  };
1218
1335
  }
1219
- //#endregion
1220
- //#region src/metabase/domain/session.ts
1221
- const Session = z.object({ id: z.string() }).loose();
1222
- //#endregion
1223
- //#region src/metabase/domain/session-properties.ts
1224
- const EngineFieldOption = z.object({
1225
- name: z.string(),
1226
- value: z.unknown()
1227
- }).loose();
1228
- const EngineField = z.object({
1229
- name: z.string(),
1230
- "display-name": z.string().optional(),
1231
- type: z.string().optional(),
1232
- required: z.boolean().optional(),
1233
- default: z.unknown().optional(),
1234
- placeholder: z.unknown().optional(),
1235
- "visible-if": z.record(z.string(), z.unknown()).optional(),
1236
- options: z.array(EngineFieldOption).optional(),
1237
- "helper-text": z.string().optional()
1238
- }).loose();
1239
- const EngineFieldGroup = z.object({
1240
- type: z.literal("group"),
1241
- fields: z.array(EngineField)
1242
- }).loose();
1243
- const Engine = z.object({
1244
- "driver-name": z.string(),
1245
- "details-fields": z.array(z.union([EngineFieldGroup, EngineField])).optional(),
1246
- "superseded-by": z.string().nullable(),
1247
- "creatable?": z.boolean().optional()
1248
- }).loose();
1249
- const SessionProperties = z.object({
1250
- "setup-token": z.string().nullable(),
1251
- "has-user-setup": z.boolean(),
1252
- version: z.object({ tag: z.string() }).loose(),
1253
- "token-features": z.record(z.string(), z.boolean()),
1254
- engines: z.record(z.string(), Engine)
1255
- }).loose();
1256
- //#endregion
1257
- //#region src/metabase/resources/session.ts
1258
- function sessionResource(http) {
1259
- /** GET /api/session/properties */
1260
- async function properties() {
1261
- return http.requestJson("GET", "/api/session/properties", { schema: SessionProperties });
1262
- }
1263
- /** POST /api/session */
1264
- async function create(credentials) {
1265
- return http.requestJson("POST", "/api/session", {
1266
- body: credentials,
1267
- schema: Session
1268
- });
1269
- }
1336
+ function parseParams(query, semicolon) {
1337
+ if (semicolon !== null) return {
1338
+ params: parseSemicolonParams(semicolon),
1339
+ paramSeparator: ";"
1340
+ };
1270
1341
  return {
1271
- properties,
1272
- create
1342
+ params: query === null ? {} : Object.fromEntries(new URLSearchParams(query)),
1343
+ paramSeparator: "&"
1273
1344
  };
1274
1345
  }
1275
- //#endregion
1276
- //#region src/metabase/resources/setting.ts
1277
- const PREMIUM_EMBEDDING_TOKEN_SETTING = "premium-embedding-token";
1278
- const TRANSFORMS_ENABLED_SETTING = "transforms-enabled";
1279
- function settingResource(http) {
1280
- /** PUT /api/setting/premium-embedding-token */
1281
- async function setPremiumEmbeddingToken(value) {
1282
- await http.requestNoContent("PUT", `/api/setting/${PREMIUM_EMBEDDING_TOKEN_SETTING}`, { body: { value } });
1346
+ function parseSemicolonParams(text) {
1347
+ const result = {};
1348
+ for (const param of text.split(";")) {
1349
+ const separator = param.indexOf("=");
1350
+ const key = separator === -1 ? param : param.slice(0, separator);
1351
+ if (key.length === 0) continue;
1352
+ result[key] = decodeComponent(separator === -1 ? "" : param.slice(separator + 1));
1283
1353
  }
1284
- /** PUT /api/setting/transforms-enabled */
1285
- async function enableTransforms() {
1286
- await http.requestNoContent("PUT", `/api/setting/${TRANSFORMS_ENABLED_SETTING}`, { body: { value: true } });
1354
+ return result;
1355
+ }
1356
+ function safeDecode(text) {
1357
+ return text === null ? null : decodeComponent(text);
1358
+ }
1359
+ function decodeComponent(text) {
1360
+ try {
1361
+ return decodeURIComponent(text);
1362
+ } catch {
1363
+ return text;
1287
1364
  }
1288
- return {
1289
- setPremiumEmbeddingToken,
1290
- enableTransforms
1291
- };
1292
1365
  }
1293
1366
  //#endregion
1294
- //#region src/metabase/domain/setup.ts
1295
- const SetupResult = z.object({ id: z.string() }).loose();
1296
- //#endregion
1297
- //#region src/metabase/resources/setup.ts
1298
- function setupResource(http) {
1299
- /** POST /api/setup */
1300
- async function create(request) {
1301
- return http.requestJson("POST", "/api/setup", {
1302
- body: request,
1303
- schema: SetupResult
1304
- });
1305
- }
1306
- return { create };
1367
+ //#region src/connections/engines.ts
1368
+ const ELEVATED_ENGINES = [
1369
+ "postgres",
1370
+ "mysql",
1371
+ "clickhouse",
1372
+ "snowflake",
1373
+ "bigquery-cloud-sdk",
1374
+ "redshift",
1375
+ "databricks",
1376
+ "sqlserver",
1377
+ "athena",
1378
+ "starburst",
1379
+ "presto-jdbc",
1380
+ "mongo",
1381
+ "sqlite",
1382
+ "druid-jdbc"
1383
+ ];
1384
+ const HIDDEN_ENGINES = /* @__PURE__ */ new Set(["h2"]);
1385
+ const PROMPT_KINDS = {
1386
+ string: "text",
1387
+ password: "password",
1388
+ integer: "integer",
1389
+ boolean: "boolean",
1390
+ select: "select",
1391
+ text: "multiline",
1392
+ textFile: "file",
1393
+ section: "none",
1394
+ info: "none",
1395
+ hidden: "none"
1396
+ };
1397
+ function promptKindFor(field) {
1398
+ if (field.type === void 0) return "text";
1399
+ return PROMPT_KINDS[field.type] ?? "text";
1307
1400
  }
1308
- //#endregion
1309
- //#region src/metabase/domain/token-status.ts
1310
- const TokenStatus = z.object({
1311
- valid: z.boolean(),
1312
- status: z.string(),
1313
- features: z.array(z.string()).default([]),
1314
- "valid-thru": z.string().optional(),
1315
- "plan-alias": z.string().optional()
1316
- }).loose();
1317
- //#endregion
1318
- //#region src/metabase/resources/token-status.ts
1319
- function tokenStatusResource(http) {
1320
- /** GET /api/premium-features/token/status */
1321
- async function get() {
1322
- return http.requestJson("GET", "/api/premium-features/token/status", { schema: TokenStatus });
1323
- }
1324
- return { get };
1401
+ function fieldLabel(field) {
1402
+ return field["display-name"] ?? field.name;
1325
1403
  }
1326
- //#endregion
1327
- //#region src/metabase/domain/user.ts
1328
- const CurrentUser = z.object({ id: z.int() }).loose();
1329
- //#endregion
1330
- //#region src/metabase/resources/user.ts
1331
- function userResource(http) {
1332
- /** GET /api/user/current */
1333
- async function current() {
1334
- return http.requestJson("GET", "/api/user/current", { schema: CurrentUser });
1335
- }
1336
- return { current };
1404
+ function flattenFields(fields) {
1405
+ return fields.flatMap((field) => isFieldGroup(field) ? field.fields : [field]);
1337
1406
  }
1338
- //#endregion
1339
- //#region src/metabase/client.ts
1340
- const HEALTH_POLL_INTERVAL_MS = 2e3;
1341
- function createMetabaseClient(baseUrl, auth, signal, options = {}) {
1342
- const http = createHttp({
1343
- baseUrl,
1344
- auth,
1345
- signal,
1346
- ...options
1347
- });
1407
+ function isFieldGroup(field) {
1408
+ return "fields" in field && field.type === "group";
1409
+ }
1410
+ function toEntry(key, engine) {
1348
1411
  return {
1349
- baseUrl,
1350
- signal,
1351
- health: healthResource(http),
1352
- session: sessionResource(http),
1353
- setup: setupResource(http),
1354
- permissionsGroup: permissionsGroupResource(http),
1355
- apiKey: apiKeyResource(http),
1356
- setting: settingResource(http),
1357
- tokenStatus: tokenStatusResource(http),
1358
- database: databaseResource(http),
1359
- remoteSync: remoteSyncResource(http),
1360
- user: userResource(http)
1412
+ key,
1413
+ displayName: engine["driver-name"],
1414
+ creatable: engine["creatable?"] !== false,
1415
+ supersededBy: engine["superseded-by"],
1416
+ exampleUri: EXAMPLE_URIS[key] ?? null
1361
1417
  };
1362
1418
  }
1363
- function isNotReadyYet(error) {
1364
- return error instanceof MetabaseInitializingError || error instanceof MetabaseUnreachableError;
1419
+ function rank(key) {
1420
+ const position = ELEVATED_ENGINES.indexOf(key);
1421
+ return position === -1 ? ELEVATED_ENGINES.length : position;
1365
1422
  }
1366
- async function waitForHealth(client, options) {
1367
- const deadline = Date.now() + options.timeoutMs;
1368
- const interval = options.pollIntervalMs ?? HEALTH_POLL_INTERVAL_MS;
1369
- for (;;) {
1370
- try {
1371
- await client.health.get();
1372
- return;
1373
- } catch (error) {
1374
- if (!isNotReadyYet(error)) throw error;
1375
- if (error instanceof MetabaseInitializingError) options.onProgress(error.progress);
1376
- }
1377
- if (Date.now() >= deadline) throw new MetabaseUnreachableError(client.baseUrl, `not healthy after ${Math.round(options.timeoutMs / 1e3)}s`);
1378
- try {
1379
- await setTimeout$1(interval, void 0, { signal: client.signal });
1380
- } catch {
1381
- throw new AbortError();
1382
- }
1423
+ function compareEntries(a, b) {
1424
+ const byRank = rank(a.key) - rank(b.key);
1425
+ return byRank !== 0 ? byRank : a.displayName.localeCompare(b.displayName);
1426
+ }
1427
+ function engineCatalog(engines) {
1428
+ const entries = /* @__PURE__ */ new Map();
1429
+ const fieldsByEngine = /* @__PURE__ */ new Map();
1430
+ for (const [key, engine] of Object.entries(engines)) {
1431
+ if (HIDDEN_ENGINES.has(key)) continue;
1432
+ entries.set(key, toEntry(key, engine));
1433
+ fieldsByEngine.set(key, flattenFields(engine["details-fields"] ?? []));
1383
1434
  }
1435
+ const ordered = [...entries.values()].toSorted(compareEntries);
1436
+ return {
1437
+ list: () => [...ordered],
1438
+ entry: (engine) => entries.get(engine) ?? null,
1439
+ fields: (engine) => {
1440
+ const fields = fieldsByEngine.get(engine);
1441
+ if (fields === void 0) throw new Error(`unknown engine ${engine}`);
1442
+ return [...fields];
1443
+ }
1444
+ };
1384
1445
  }
1385
1446
  //#endregion
1386
1447
  //#region src/connections/map-fields.ts
@@ -1747,8 +1808,11 @@ const BACK_PLACEHOLDER = "leave empty to go back";
1747
1808
  function stdinIsTty() {
1748
1809
  return process.stdin.isTTY === true;
1749
1810
  }
1811
+ function refusal(prompt) {
1812
+ return `cannot prompt "${prompt}" — stdin is not a TTY`;
1813
+ }
1750
1814
  function requireTty(prompt) {
1751
- if (!stdinIsTty()) throw new ConfigError(`cannot prompt "${prompt}" — stdin is not a TTY`);
1815
+ if (!stdinIsTty()) throw new ConfigError(refusal(prompt));
1752
1816
  }
1753
1817
  function settled(value) {
1754
1818
  if (isCancel(value)) throw new AbortError();
@@ -1790,7 +1854,8 @@ async function askText(opts) {
1790
1854
  ...validate !== void 0 && { validate }
1791
1855
  }));
1792
1856
  }
1793
- function clackPrompts() {
1857
+ const CONFIRM_WITH_YES = "pass --yes to confirm without a terminal";
1858
+ function clackPrompts(options) {
1794
1859
  return {
1795
1860
  text: askText,
1796
1861
  async textOrBack(opts) {
@@ -1810,7 +1875,10 @@ function clackPrompts() {
1810
1875
  }));
1811
1876
  },
1812
1877
  async confirm(opts) {
1813
- requireTty(opts.message);
1878
+ if (!stdinIsTty()) {
1879
+ const message = refusal(opts.message);
1880
+ throw new ConfigError(options.confirmsWithYes ? `${message}; ${CONFIRM_WITH_YES}` : message);
1881
+ }
1814
1882
  return settled(await confirm({
1815
1883
  message: opts.message,
1816
1884
  ...opts.initialValue !== void 0 && { initialValue: opts.initialValue }
@@ -2711,11 +2779,15 @@ const CHECKLIST_MARK = {
2711
2779
  "not-applicable": "–"
2712
2780
  };
2713
2781
  const SPINNER_FAILURE_CODE = 1;
2782
+ const PLAIN_PROGRESS_INTERVAL_MS = 1e4;
2783
+ const MS_PER_SECOND = 1e3;
2784
+ const PLAIN_INDENT = " ";
2714
2785
  function checklistLines(items) {
2715
2786
  return items.map((item) => `${CHECKLIST_MARK[item.status]} ${item.title}`);
2716
2787
  }
2717
2788
  function clackUi() {
2718
2789
  return {
2790
+ childOutput: "inherit",
2719
2791
  intro,
2720
2792
  outro,
2721
2793
  note: (message, title) => note(message, title),
@@ -2736,6 +2808,55 @@ function clackUi() {
2736
2808
  }
2737
2809
  };
2738
2810
  }
2811
+ function indented(text) {
2812
+ return text.split("\n").map((line) => PLAIN_INDENT + line).join("\n");
2813
+ }
2814
+ function plainUi(output) {
2815
+ const line = (text) => output.write(text + "\n");
2816
+ return {
2817
+ childOutput: "capture",
2818
+ intro: line,
2819
+ outro: line,
2820
+ note(message, title) {
2821
+ if (title !== void 0) line(title);
2822
+ line(indented(message));
2823
+ },
2824
+ info: line,
2825
+ warn: (message) => line(`Warning: ${message}`),
2826
+ success: line,
2827
+ spinner(label) {
2828
+ const startedAt = output.now();
2829
+ let printedAt = startedAt;
2830
+ const elapsed = () => `${Math.round((output.now() - startedAt) / MS_PER_SECOND)}s`;
2831
+ line(label);
2832
+ return {
2833
+ update(message) {
2834
+ const now = output.now();
2835
+ if (now - printedAt < PLAIN_PROGRESS_INTERVAL_MS) return;
2836
+ printedAt = now;
2837
+ line(indented(message));
2838
+ },
2839
+ stop: (message) => line(`${message} (${elapsed()})`),
2840
+ fail: (message) => line(`Failed: ${message} (${elapsed()})`)
2841
+ };
2842
+ },
2843
+ checklist: (items) => line(checklistLines(items).join("\n"))
2844
+ };
2845
+ }
2846
+ function noColorRequested() {
2847
+ const value = readEnv(ENV_NO_COLOR);
2848
+ return value !== void 0 && value !== "";
2849
+ }
2850
+ function drawsOnTerminal() {
2851
+ return process.stdout.isTTY === true && !noColorRequested();
2852
+ }
2853
+ function terminalUi() {
2854
+ if (drawsOnTerminal()) return clackUi();
2855
+ return plainUi({
2856
+ write: (text) => process.stdout.write(text),
2857
+ now: Date.now
2858
+ });
2859
+ }
2739
2860
  //#endregion
2740
2861
  //#region src/wizard/context.ts
2741
2862
  async function createWizardContext(instanceName, options) {
@@ -2757,8 +2878,8 @@ async function createWizardContext(instanceName, options) {
2757
2878
  await writeInstanceSecrets(instanceName, next);
2758
2879
  ctx.secrets = next;
2759
2880
  },
2760
- prompts: options.prompts ?? clackPrompts(),
2761
- ui: clackUi(),
2881
+ prompts: options.prompts ?? clackPrompts({ confirmsWithYes: false }),
2882
+ ui: terminalUi(),
2762
2883
  signal: options.signal,
2763
2884
  session: new SessionCache()
2764
2885
  };
@@ -2811,7 +2932,8 @@ const db = defineCommand({
2811
2932
  subCommands: {
2812
2933
  add: dbAdd,
2813
2934
  list: dbList
2814
- }
2935
+ },
2936
+ default: "list"
2815
2937
  });
2816
2938
  //#endregion
2817
2939
  //#region src/metabase/domain/version-info.ts
@@ -3017,7 +3139,7 @@ async function spawnDetached(command, args, options) {
3017
3139
  log.fd
3018
3140
  ],
3019
3141
  cwd: options.cwd,
3020
- env: environmentWith(options.env),
3142
+ env: options.env,
3021
3143
  detached: true
3022
3144
  });
3023
3145
  child.on("error", (error) => {
@@ -3060,6 +3182,29 @@ function toolFailure(tool, argv, result) {
3060
3182
  stderrTail: stderrTail(result.stderr)
3061
3183
  });
3062
3184
  }
3185
+ const HAS_TEXT = /[\p{L}\p{N}]/u;
3186
+ async function runShown(runner, output, command, args) {
3187
+ if (output === "inherit") {
3188
+ const exitCode = await runner.stream(command, args);
3189
+ if (exitCode !== 0) throw new ExternalToolError({
3190
+ tool: command,
3191
+ argv: args,
3192
+ exitCode,
3193
+ stderrTail: ""
3194
+ });
3195
+ return;
3196
+ }
3197
+ const result = await runner.run(command, args, { stdoutToFile: true });
3198
+ if (result.exitCode !== 0) {
3199
+ const printed = withoutEscapes(`${result.stdout}\n${result.stderr}`).split(LINE_BREAK);
3200
+ throw new ExternalToolError({
3201
+ tool: command,
3202
+ argv: args,
3203
+ exitCode: result.exitCode,
3204
+ stderrTail: stderrTail(printed.filter((line) => HAS_TEXT.test(line)).join("\n"))
3205
+ });
3206
+ }
3207
+ }
3063
3208
  function parseJsonOutput(tool, argv, stdout, schema) {
3064
3209
  let raw;
3065
3210
  try {
@@ -3203,6 +3348,7 @@ function javaTool(runner) {
3203
3348
  const DOCKER = "docker";
3204
3349
  const CONTAINER_DATA_DIR = "/rde";
3205
3350
  const LOOPBACK_HOST = "127.0.0.1";
3351
+ const HOME_LABEL = "rde.home";
3206
3352
  const ENV_HOST_UID = "MUID";
3207
3353
  const ENV_HOST_GID = "MGID";
3208
3354
  const ADD_HOST_GATEWAY = `--add-host=${DOCKER_HOST_ALIAS}:host-gateway`;
@@ -3215,7 +3361,10 @@ const PortBinding = z.array(z.object({
3215
3361
  }).loose()).nullable();
3216
3362
  const InspectedContainer = z.object({
3217
3363
  State: z.object({ Running: z.boolean() }).loose(),
3218
- Config: z.object({ Image: z.string() }).loose(),
3364
+ Config: z.object({
3365
+ Image: z.string(),
3366
+ Labels: z.record(z.string(), z.string()).nullish()
3367
+ }).loose(),
3219
3368
  HostConfig: z.object({ PortBindings: z.record(z.string(), PortBinding).nullable() }).loose()
3220
3369
  }).loose();
3221
3370
  const InspectOutput = z.array(InspectedContainer);
@@ -3237,6 +3386,8 @@ function createArgs(options) {
3237
3386
  "create",
3238
3387
  "--name",
3239
3388
  options.name,
3389
+ "--label",
3390
+ `${HOME_LABEL}=${options.home}`,
3240
3391
  "-p",
3241
3392
  `${LOOPBACK_HOST}:${options.port.host}:${options.port.container}`
3242
3393
  ];
@@ -3315,7 +3466,8 @@ function dockerTool(runner) {
3315
3466
  exists: true,
3316
3467
  running: container.State.Running,
3317
3468
  image: container.Config.Image,
3318
- ports: portMappings(container.HostConfig.PortBindings)
3469
+ ports: portMappings(container.HostConfig.PortBindings),
3470
+ home: container.Config.Labels?.[HOME_LABEL] ?? null
3319
3471
  };
3320
3472
  }
3321
3473
  async function containerImageId(name) {
@@ -3439,6 +3591,23 @@ function logPathOf(instanceDir) {
3439
3591
  function pidPathOf(instanceDir) {
3440
3592
  return join(instanceDir, PID_FILE);
3441
3593
  }
3594
+ const METABASE_VARIABLE_PREFIX = "MB_";
3595
+ function jarEnvironment(host, server) {
3596
+ const env = {};
3597
+ const ignored = [];
3598
+ for (const [name, value] of Object.entries(host)) if (name.startsWith(METABASE_VARIABLE_PREFIX)) ignored.push(name);
3599
+ else env[name] = value;
3600
+ return {
3601
+ env: {
3602
+ ...env,
3603
+ ...server
3604
+ },
3605
+ ignored: ignored.toSorted()
3606
+ };
3607
+ }
3608
+ function ignoredVariablesNotice(ignored) {
3609
+ return `Ignoring ${ignored.join(", ")} from your shell; rde configures Metabase itself`;
3610
+ }
3442
3611
  async function pathExists$1(path) {
3443
3612
  try {
3444
3613
  await promises.access(path);
@@ -3510,24 +3679,26 @@ function jarRuntime(state, deps) {
3510
3679
  await promises.mkdir(join(deps.dataDir, DB_DIR), { recursive: true });
3511
3680
  await ensureJar(options);
3512
3681
  }
3513
- async function start() {
3682
+ async function start(options) {
3514
3683
  if (await runningPid() !== null) return;
3515
3684
  if (!await pathExists$1(jarPath)) throw new ConfigError(`${jarPath} does not exist; run \`rde init\``);
3516
3685
  const java = await requireJava(deps.java);
3517
3686
  await promises.mkdir(deps.instanceDir, { recursive: true });
3687
+ const { env, ignored } = jarEnvironment(deps.hostEnv, {
3688
+ ...serverEnv({
3689
+ dbFile: join(deps.dataDir, DB_DIR, DB_FILE),
3690
+ pluginsDir: join(deps.dataDir, PLUGINS_DIR),
3691
+ port: state.port
3692
+ }),
3693
+ ...jettyEnv(state.port)
3694
+ });
3695
+ if (ignored.length > 0) options.onNotice(ignoredVariablesNotice(ignored));
3518
3696
  const pid = await daemon.start({
3519
3697
  command: java.path,
3520
3698
  args: ["-jar", jarPath],
3521
3699
  options: {
3522
3700
  cwd: deps.dataDir,
3523
- env: {
3524
- ...serverEnv({
3525
- dbFile: join(deps.dataDir, DB_DIR, DB_FILE),
3526
- pluginsDir: join(deps.dataDir, PLUGINS_DIR),
3527
- port: state.port
3528
- }),
3529
- ...jettyEnv(state.port)
3530
- },
3701
+ env,
3531
3702
  logPath
3532
3703
  }
3533
3704
  });
@@ -3646,8 +3817,10 @@ function daemonTool(runner) {
3646
3817
  const DEFAULT_IMAGE = "metabase/metabase-enterprise-head:latest";
3647
3818
  const CONTAINER_NAME_PREFIX$1 = "rde-metabase";
3648
3819
  const CONTAINER_PORT = 3e3;
3649
- function containerName(instanceName) {
3650
- return `${CONTAINER_NAME_PREFIX$1}-${instanceName}`;
3820
+ function containerName(instanceName, home) {
3821
+ const tag = homeTag(home);
3822
+ const name = `${CONTAINER_NAME_PREFIX$1}-${instanceName}`;
3823
+ return tag === null ? name : `${name}-${tag}`;
3651
3824
  }
3652
3825
  function publishesPortOnLoopback(inspection, port) {
3653
3826
  return inspection.ports.some((mapping) => mapping.container === CONTAINER_PORT && mapping.host === port && mapping.hostIp === "127.0.0.1");
@@ -3675,6 +3848,7 @@ function dockerRuntime(state, deps) {
3675
3848
  await docker.imagePull(state.image, { onLine: options.onProgress });
3676
3849
  await docker.containerCreate({
3677
3850
  name: state.containerName,
3851
+ home: deps.rdeHome,
3678
3852
  image: state.image,
3679
3853
  port: {
3680
3854
  host: state.port,
@@ -3740,11 +3914,13 @@ function hostRuntimeDeps(instanceName, signal) {
3740
3914
  dataDir: instanceDataDir(instanceName),
3741
3915
  instanceDir: instanceDir(instanceName),
3742
3916
  jarsDir: jarsDir(),
3917
+ rdeHome: rdeHome(),
3743
3918
  platform: process.platform,
3744
3919
  hostUser: {
3745
3920
  uid: user.uid,
3746
3921
  gid: user.gid
3747
- }
3922
+ },
3923
+ hostEnv: hostEnvironment()
3748
3924
  };
3749
3925
  }
3750
3926
  function runtimeFromState(state, deps) {
@@ -3764,6 +3940,7 @@ const SAMPLE_DATABASE_READY_TIMEOUT_MS = 12e4;
3764
3940
  const POSTGRES_PORT = 5432;
3765
3941
  const CONTAINER_NAME_PREFIX = "rde-sampledb";
3766
3942
  const READY_POLL_MS = 1e3;
3943
+ const PORT_TAKEN = /port is already allocated|address already in use/;
3767
3944
  const READY_PROBE = [
3768
3945
  "pg_isready",
3769
3946
  "-h",
@@ -3773,8 +3950,10 @@ const READY_PROBE = [
3773
3950
  "-d",
3774
3951
  SAMPLE_DATABASE_NAME
3775
3952
  ];
3776
- function sampleContainerName(instanceName) {
3777
- return `${CONTAINER_NAME_PREFIX}-${instanceName}`;
3953
+ function sampleContainerName(instanceName, home) {
3954
+ const tag = homeTag(home);
3955
+ const name = `${CONTAINER_NAME_PREFIX}-${instanceName}`;
3956
+ return tag === null ? name : `${name}-${tag}`;
3778
3957
  }
3779
3958
  function sampleDatabaseUrl(port) {
3780
3959
  return `postgres://${SAMPLE_DATABASE_USER}:${SAMPLE_DATABASE_PASSWORD}@localhost:${port}/${SAMPLE_DATABASE_NAME}`;
@@ -3782,7 +3961,22 @@ function sampleDatabaseUrl(port) {
3782
3961
  function publishesOnLoopback$1(inspection, port) {
3783
3962
  return inspection.ports.some((mapping) => mapping.container === POSTGRES_PORT && mapping.host === port && mapping.hostIp === "127.0.0.1");
3784
3963
  }
3785
- function sampleDatabaseContainer(state, docker) {
3964
+ function createOptions(state, home) {
3965
+ return {
3966
+ name: state.containerName,
3967
+ home,
3968
+ image: state.image,
3969
+ port: {
3970
+ host: state.port,
3971
+ container: POSTGRES_PORT
3972
+ },
3973
+ mount: null,
3974
+ env: {},
3975
+ addHostGateway: false,
3976
+ hostUser: null
3977
+ };
3978
+ }
3979
+ function sampleDatabaseContainer(state, docker, home) {
3786
3980
  const name = state.containerName;
3787
3981
  function conflict(reason) {
3788
3982
  return new ConfigError(`container ${name} already exists ${reason}; remove it with \`docker rm -f ${name}\` and run \`rde init\` again`);
@@ -3795,18 +3989,7 @@ function sampleDatabaseContainer(state, docker) {
3795
3989
  return;
3796
3990
  }
3797
3991
  await docker.imagePull(state.image, { onLine: options.onProgress });
3798
- await docker.containerCreate({
3799
- name,
3800
- image: state.image,
3801
- port: {
3802
- host: state.port,
3803
- container: POSTGRES_PORT
3804
- },
3805
- mount: null,
3806
- env: {},
3807
- addHostGateway: false,
3808
- hostUser: null
3809
- });
3992
+ await docker.containerCreate(createOptions(state, home));
3810
3993
  }
3811
3994
  async function start() {
3812
3995
  const existing = await docker.containerInspect(name);
@@ -3849,6 +4032,32 @@ function sampleDatabaseContainer(state, docker) {
3849
4032
  waitReady
3850
4033
  };
3851
4034
  }
4035
+ function isPortTaken(error) {
4036
+ return error instanceof ExternalToolError && PORT_TAKEN.test(error.stderrTail);
4037
+ }
4038
+ async function startOnFreePort(state, docker, ports, home) {
4039
+ try {
4040
+ return {
4041
+ outcome: await sampleDatabaseContainer(state, docker, home).start(),
4042
+ state
4043
+ };
4044
+ } catch (error) {
4045
+ if (!isPortTaken(error)) throw error;
4046
+ }
4047
+ const existing = await docker.containerInspect(state.containerName);
4048
+ if (existing.exists && existing.home !== null && existing.home !== home) throw new ConfigError(`container ${state.containerName} belongs to the rde home ${existing.home} and is left alone`);
4049
+ const moved = {
4050
+ ...state,
4051
+ port: await ports.findFree(SAMPLE_DATABASE_PORT)
4052
+ };
4053
+ await docker.containerRemove(state.containerName);
4054
+ await docker.containerCreate(createOptions(moved, home));
4055
+ await docker.containerStart(moved.containerName);
4056
+ return {
4057
+ outcome: "started",
4058
+ state: moved
4059
+ };
4060
+ }
3852
4061
  //#endregion
3853
4062
  //#region src/tools/mb.ts
3854
4063
  const MB = "mb";
@@ -4259,7 +4468,15 @@ async function detectAgents(probe) {
4259
4468
  //#region src/tools/skills-cli.ts
4260
4469
  const NPX = "npx";
4261
4470
  const SKILLS_PACKAGE = "skills@latest";
4262
- const DEFAULT_RDE_SKILL_SOURCE = "https://github.com/metabase/agent-skills/tree/rde-skill";
4471
+ const DEFAULT_RDE_SKILL_SOURCE = "https://github.com/metabase/agent-skills/tree/rde-generic-skills";
4472
+ const RDE_SOURCE_SKILL_NAMES = ["rde", "rde-data-apps"];
4473
+ const DATA_APP_SKILLS_SOURCE = "metabase/metabase/skills#master";
4474
+ const DATA_APP_SKILL_NAMES = [
4475
+ "metabase-data-app-setup",
4476
+ "metabase-data-app-routing",
4477
+ "metabase-data-app-actions",
4478
+ "metabase-data-app-semantic-layer"
4479
+ ];
4263
4480
  function rdeSkillSource() {
4264
4481
  const override = readEnv(ENV_RDE_SKILL_SOURCE);
4265
4482
  return override === void 0 || override.length === 0 ? DEFAULT_RDE_SKILL_SOURCE : override;
@@ -4322,37 +4539,23 @@ function skillsCli(runner) {
4322
4539
  SKILLS_PACKAGE,
4323
4540
  "add",
4324
4541
  options.source,
4325
- "--skill",
4326
- options.skill,
4542
+ ...options.skills.flatMap((skill) => ["--skill", skill]),
4327
4543
  "-a",
4328
4544
  ...options.agents,
4329
4545
  "-g",
4330
4546
  "-y"
4331
4547
  ];
4332
- const exitCode = await runner.stream(NPX, argv);
4333
- if (exitCode !== 0) throw new ExternalToolError({
4334
- tool: NPX,
4335
- argv,
4336
- exitCode,
4337
- stderrTail: ""
4338
- });
4548
+ await runShown(runner, options.output, NPX, argv);
4339
4549
  }
4340
- async function remove(skill) {
4341
- const argv = [
4550
+ async function remove(skills, output) {
4551
+ await runShown(runner, output, NPX, [
4342
4552
  "-y",
4343
4553
  SKILLS_PACKAGE,
4344
4554
  "remove",
4345
- skill,
4555
+ ...skills,
4346
4556
  "-g",
4347
4557
  "-y"
4348
- ];
4349
- const exitCode = await runner.stream(NPX, argv);
4350
- if (exitCode !== 0) throw new ExternalToolError({
4351
- tool: NPX,
4352
- argv,
4353
- exitCode,
4354
- stderrTail: ""
4355
- });
4558
+ ]);
4356
4559
  }
4357
4560
  async function list() {
4358
4561
  const argv = [
@@ -4427,6 +4630,12 @@ function withStepRecord(state, id, record) {
4427
4630
  }
4428
4631
  };
4429
4632
  }
4633
+ function failureAt(step, error) {
4634
+ return error instanceof AbortError ? error : new StepFailedError({
4635
+ id: step.id,
4636
+ title: step.title
4637
+ }, error);
4638
+ }
4430
4639
  async function runWizard(steps, ctx, options = {}) {
4431
4640
  requireKnownStep(steps, options.from, "--from");
4432
4641
  requireKnownStep(steps, options.only, "--only");
@@ -4436,10 +4645,12 @@ async function runWizard(steps, ctx, options = {}) {
4436
4645
  title: step.title,
4437
4646
  status
4438
4647
  })));
4439
- for (const entry of detected) {
4648
+ for (const entry of detected) try {
4440
4649
  if (!await shouldRun(entry, ctx, options)) continue;
4441
4650
  const outcome = await entry.step.run(ctx);
4442
4651
  await ctx.saveState(withStepRecord(ctx.state, entry.step.id, recordFor(outcome, now())));
4652
+ } catch (error) {
4653
+ throw failureAt(entry.step, error);
4443
4654
  }
4444
4655
  }
4445
4656
  //#endregion
@@ -4587,13 +4798,7 @@ function installMbStep(deps = hostDeps$11) {
4587
4798
  if (before.installed) ctx.ui.warn(`mb ${before.version} is installed; ${MIN_MB_VERSION} or newer is required`);
4588
4799
  const install = mbInstallCommand(resolved.installedWith);
4589
4800
  ctx.ui.info(`Running ${displayCommand(install)}`);
4590
- const exitCode = await resolved.runner.stream(install.command, install.args);
4591
- if (exitCode !== 0) throw new ExternalToolError({
4592
- tool: install.command,
4593
- argv: install.args,
4594
- exitCode,
4595
- stderrTail: ""
4596
- });
4801
+ await runShown(resolved.runner, ctx.ui.childOutput, install.command, install.args);
4597
4802
  const after = await inspectMb(resolved.runner);
4598
4803
  if (!after.installed || !after.satisfies) throw await stillWrong(install, after, resolved);
4599
4804
  ctx.ui.success(`mb ${after.version} is installed`);
@@ -4605,7 +4810,8 @@ function installMbStep(deps = hostDeps$11) {
4605
4810
  //#region src/steps/agents-skills.ts
4606
4811
  const STEP_ID$7 = "agents-skills";
4607
4812
  const AGENTS_SKILLS_KEYS = { agents: `${STEP_ID$7}.agents` };
4608
- const MECHANICS_NOTE = "The skill's mechanics come from `mb skills get <name>` at runtime, so nothing else needs installing.";
4813
+ const MECHANICS_NOTE = "The rde skill's mechanics come from `mb skills get <name>` at runtime, so nothing else needs installing.";
4814
+ const REQUIRED_SKILL_NAMES = [...DATA_APP_SKILL_NAMES, ...RDE_SOURCE_SKILL_NAMES];
4609
4815
  const DETECT_AGENTS = { kind: "detected" };
4610
4816
  function hostDeps$10(ctx) {
4611
4817
  return {
@@ -4630,8 +4836,17 @@ function coversAny(entry, agents) {
4630
4836
  async function rdeSkillEntries(runner) {
4631
4837
  return (await skillsCli(runner).list()).filter((entry) => entry.name === "rde");
4632
4838
  }
4839
+ function requiredEntries(installed) {
4840
+ return installed.filter((entry) => REQUIRED_SKILL_NAMES.includes(entry.name));
4841
+ }
4842
+ function missingSkills(installed, agents) {
4843
+ return REQUIRED_SKILL_NAMES.filter((name) => !installed.some((entry) => entry.name === name && coversAny(entry, agents)));
4844
+ }
4845
+ function unlistedSkills(installed) {
4846
+ return REQUIRED_SKILL_NAMES.filter((name) => !installed.some((entry) => entry.name === name));
4847
+ }
4633
4848
  function installedLines(entries) {
4634
- return entries.map((entry) => `${entry.agents.join(", ")}: ${entry.path}`).join("\n");
4849
+ return entries.map((entry) => `${entry.name} (${entry.agents.join(", ")}): ${entry.path}`).join("\n");
4635
4850
  }
4636
4851
  const SOURCE_NOTES = {
4637
4852
  override: "from --agents",
@@ -4653,7 +4868,7 @@ async function askAgents(ctx, all) {
4653
4868
  const offered = all.filter((agent) => agent.detected || agent.id === "opencode");
4654
4869
  const picked = await ctx.prompts.multiselect({
4655
4870
  key: AGENTS_SKILLS_KEYS.agents,
4656
- message: "Which coding agents should get the rde skill?",
4871
+ message: "Which coding agents should get the rde and data-app skills?",
4657
4872
  choices: offered.map(offeredChoice),
4658
4873
  initialValues: detectedOnly(all).map((agent) => agent.id),
4659
4874
  required: false
@@ -4683,8 +4898,7 @@ async function installOpencode(ctx, deps) {
4683
4898
  const install = opencodeInstallCommand(deps.installedWith);
4684
4899
  const commandLine = [install.command, ...install.args].join(" ");
4685
4900
  ctx.ui.info(`Running ${commandLine}`);
4686
- const exitCode = await deps.runner.stream(install.command, install.args);
4687
- if (exitCode !== 0) throw new ConfigError(`\`${commandLine}\` exited with ${exitCode}`);
4901
+ await runShown(deps.runner, ctx.ui.childOutput, install.command, install.args);
4688
4902
  if (!(await detectAgents(deps.probe)).some((agent) => agent.id === "opencode" && agent.detected)) throw new ConfigError("OpenCode was installed but `opencode` is not on PATH; open a new terminal and run rde init again");
4689
4903
  }
4690
4904
  function listsMissingOpencode(all, options) {
@@ -4693,21 +4907,21 @@ function listsMissingOpencode(all, options) {
4693
4907
  function skipLine(all, source) {
4694
4908
  switch (source) {
4695
4909
  case "override":
4696
- case "chosen": return `Skipping the rde skill; run \`rde init --only ${STEP_ID$7}\` to install it later`;
4910
+ case "chosen": return `Skipping the skills; run \`rde init --only ${STEP_ID$7}\` to install it later`;
4697
4911
  case "detected": return `No coding agent found (${displayNames(all)}); run \`rde init --only ${STEP_ID$7} --agents <name>\` once one is installed`;
4698
4912
  }
4699
4913
  }
4700
4914
  function agentsSkillsStep(options, deps = hostDeps$10) {
4701
4915
  return {
4702
4916
  id: STEP_ID$7,
4703
- title: "Install the rde skill for your coding agents",
4917
+ title: "Install the rde and data-app skills for your coding agents",
4704
4918
  async detect(ctx) {
4705
4919
  const resolved = deps(ctx);
4706
4920
  const all = await detectAgents(resolved.probe);
4707
4921
  if (listsMissingOpencode(all, options)) return "todo";
4708
4922
  const detected = detectedOnly(all);
4709
4923
  if (detected.length === 0) return ctx.state.steps[STEP_ID$7]?.status === "skipped" ? "not-applicable" : "todo";
4710
- return (await rdeSkillEntries(resolved.runner)).some((entry) => coversAny(entry, detected)) ? "done" : "todo";
4924
+ return missingSkills(await skillsCli(resolved.runner).list(), detected).length === 0 ? "done" : "todo";
4711
4925
  },
4712
4926
  async run(ctx) {
4713
4927
  const resolved = deps(ctx);
@@ -4718,16 +4932,28 @@ function agentsSkillsStep(options, deps = hostDeps$10) {
4718
4932
  return "skipped";
4719
4933
  }
4720
4934
  if (selection.agents.some(isMissingOpencode)) await installOpencode(ctx, resolved);
4721
- const from = resolved.skillSource === "https://github.com/metabase/agent-skills/tree/rde-skill" ? "" : ` from ${resolved.skillSource}`;
4722
- ctx.ui.info(`Installing the rde skill for ${displayNames(selection.agents)} (${SOURCE_NOTES[selection.source]})${from}`);
4723
- await skillsCli(resolved.runner).add({
4935
+ const skills = skillsCli(resolved.runner);
4936
+ const agents = selection.agents.map((agent) => agent.skillsCliName);
4937
+ const forWhom = `for ${displayNames(selection.agents)} (${SOURCE_NOTES[selection.source]})`;
4938
+ ctx.ui.info(`Installing the data-app skills ${forWhom}`);
4939
+ await skills.add({
4940
+ source: DATA_APP_SKILLS_SOURCE,
4941
+ skills: DATA_APP_SKILL_NAMES,
4942
+ agents,
4943
+ output: ctx.ui.childOutput
4944
+ });
4945
+ const from = resolved.skillSource === "https://github.com/metabase/agent-skills/tree/rde-generic-skills" ? "" : ` from ${resolved.skillSource}`;
4946
+ ctx.ui.info(`Installing the rde skills ${forWhom}${from}`);
4947
+ await skills.add({
4724
4948
  source: resolved.skillSource,
4725
- skill: "rde",
4726
- agents: selection.agents.map((agent) => agent.skillsCliName)
4949
+ skills: RDE_SOURCE_SKILL_NAMES,
4950
+ agents,
4951
+ output: ctx.ui.childOutput
4727
4952
  });
4728
- const entries = await rdeSkillEntries(resolved.runner);
4729
- if (entries.length === 0) throw new ConfigError(`the skills installer finished but \`skills list\` does not show the rde skill`);
4730
- ctx.ui.note(installedLines(entries), "rde skill installed");
4953
+ const installed = await skills.list();
4954
+ const missing = unlistedSkills(installed);
4955
+ if (missing.length > 0) throw new ConfigError(`the skills installer finished but \`skills list\` does not show ${missing.join(", ")}`);
4956
+ ctx.ui.note(installedLines(requiredEntries(installed)), "Skills installed");
4731
4957
  ctx.ui.info(MECHANICS_NOTE);
4732
4958
  return "completed";
4733
4959
  }
@@ -5545,7 +5771,7 @@ const SAMPLE_LABEL = "sample database";
5545
5771
  async function sampleDatabaseRow(record, deps, dockerOk, access) {
5546
5772
  const id = "sample-database";
5547
5773
  if (!dockerOk) return skipped(id, SAMPLE_LABEL, "Docker is not available");
5548
- const status = await sampleDatabaseContainer(record, deps.runtime.docker).status();
5774
+ const status = await sampleDatabaseContainer(record, deps.runtime.docker, deps.runtime.rdeHome).status();
5549
5775
  if (!status.installed) return failed(id, SAMPLE_LABEL, {
5550
5776
  detail: `${record.containerName} does not exist`,
5551
5777
  hint: "`rde init` recreates it",
@@ -5608,6 +5834,7 @@ async function gitSyncRow(state, deps) {
5608
5834
  const SKILL_STEP = "agents-skills";
5609
5835
  const GLOBAL_SCOPE = "global";
5610
5836
  const SKILL_FILE = "SKILL.md";
5837
+ const SOURCE_SKILLS_DIR = "skills";
5611
5838
  const CLAUDE_CODE = "Claude Code";
5612
5839
  const CLAUDE_CODE_SKILL_RULE = "a personal skill wins over a project one: https://code.claude.com/docs/en/skills";
5613
5840
  function skillRowId(agent) {
@@ -5625,7 +5852,7 @@ async function judgeCopy(entry, origin, deps) {
5625
5852
  switch (origin.kind) {
5626
5853
  case "local": {
5627
5854
  const installed = await deps.skills.files.readText(join(entry.path, SKILL_FILE));
5628
- const wanted = await deps.skills.files.readText(join(origin.path, SKILL_FILE));
5855
+ const wanted = await deps.skills.files.readText(join(origin.path, SOURCE_SKILLS_DIR, entry.name, SKILL_FILE));
5629
5856
  const current = installed !== null && installed === wanted;
5630
5857
  return {
5631
5858
  entry,
@@ -5919,6 +6146,62 @@ function storedLogin(variant, user) {
5919
6146
  case "prompted": return null;
5920
6147
  }
5921
6148
  }
6149
+ async function resumeGeneratedAdmin(ctx, client) {
6150
+ const login = ctx.secrets.admin;
6151
+ if (login === null) return false;
6152
+ try {
6153
+ const session = await client.session.create({
6154
+ username: login.email,
6155
+ password: login.password
6156
+ });
6157
+ ctx.session.metabaseSession.set(session.id);
6158
+ } catch (error) {
6159
+ if (isRejectedCredential(error)) return false;
6160
+ throw error;
6161
+ }
6162
+ await ctx.saveState({
6163
+ ...ctx.state,
6164
+ admin: {
6165
+ kind: "generated",
6166
+ email: login.email
6167
+ }
6168
+ });
6169
+ ctx.ui.success(`Admin user ${login.email} recorded (created by an earlier rde init that was interrupted)`);
6170
+ return true;
6171
+ }
6172
+ async function recordExistingAdmin(ctx, variant, client, url) {
6173
+ if (variant !== "prompted") {
6174
+ if (await resumeGeneratedAdmin(ctx, client)) return "completed";
6175
+ throw new ConfigError(`Metabase at ${url} already has an admin that rde did not create; pass its login with --admin-email and --admin-password, or run \`rde uninstall\` then \`rde init\` for a fresh instance`);
6176
+ }
6177
+ const email = await ctx.prompts.text({
6178
+ key: ADMIN_USER_KEYS.email,
6179
+ message: "Admin email",
6180
+ validate: validateEmail
6181
+ });
6182
+ const password = await ctx.prompts.password({
6183
+ key: ADMIN_USER_KEYS.password,
6184
+ message: "Admin password"
6185
+ });
6186
+ const session = await client.session.create({
6187
+ username: email,
6188
+ password
6189
+ });
6190
+ ctx.session.metabaseSession.set(session.id);
6191
+ await ctx.saveSecrets({
6192
+ ...ctx.secrets,
6193
+ admin: null
6194
+ });
6195
+ await ctx.saveState({
6196
+ ...ctx.state,
6197
+ admin: {
6198
+ kind: "prompted",
6199
+ email
6200
+ }
6201
+ });
6202
+ ctx.ui.success(`Admin user ${email} recorded`);
6203
+ return "completed";
6204
+ }
5922
6205
  function adminUserStep(options, deps = hostDeps$8) {
5923
6206
  return {
5924
6207
  id: STEP_ID$6,
@@ -5927,7 +6210,7 @@ function adminUserStep(options, deps = hostDeps$8) {
5927
6210
  const configured = configuredRuntime(ctx.state);
5928
6211
  if (configured === null) return "todo";
5929
6212
  try {
5930
- return (await deps(ctx).client(configured.url, NO_AUTH).session.properties())["has-user-setup"] ? "done" : "todo";
6213
+ return (await deps(ctx).client(configured.url, NO_AUTH).session.properties())["has-user-setup"] && ctx.state.admin !== null ? "done" : "todo";
5931
6214
  } catch (error) {
5932
6215
  if (isNotReady(error)) return "todo";
5933
6216
  throw error;
@@ -5939,6 +6222,7 @@ function adminUserStep(options, deps = hostDeps$8) {
5939
6222
  const client = resolved.client(configured.url, NO_AUTH);
5940
6223
  const properties = await client.session.properties();
5941
6224
  if (properties["has-user-setup"]) {
6225
+ if (ctx.state.admin === null) return recordExistingAdmin(ctx, options.variant, client, configured.url);
5942
6226
  ctx.ui.info("Metabase already has an admin user");
5943
6227
  return "completed";
5944
6228
  }
@@ -6147,6 +6431,16 @@ const FALLBACK_REMOTE_NAME = "rde-remote";
6147
6431
  const README_NAME = "README.md";
6148
6432
  const README_CONTENT = "# Metabase content\n\nMetabase content synced by rde.\n";
6149
6433
  const INITIAL_COMMIT_MESSAGE = "Initial commit";
6434
+ const GITIGNORE_NAME = ".gitignore";
6435
+ const IGNORED_PATHS = [
6436
+ ".env",
6437
+ ".env.local",
6438
+ ".env.*.local",
6439
+ "node_modules/",
6440
+ ".vite/",
6441
+ ".scratch/",
6442
+ ".DS_Store"
6443
+ ];
6150
6444
  const HTTPS_PREFIX = "https://";
6151
6445
  const HOME_PREFIX = "~/";
6152
6446
  const BAD_REQUEST_STATUS = 400;
@@ -6356,10 +6650,20 @@ function hostedScreen(run) {
6356
6650
  }
6357
6651
  };
6358
6652
  }
6653
+ async function ignoreDataAppFiles(workdir) {
6654
+ const path = join(workdir, GITIGNORE_NAME);
6655
+ const existing = await readTextFile(path);
6656
+ const present = new Set(existing === null ? [] : existing.split("\n").map((line) => line.trim()));
6657
+ const missing = IGNORED_PATHS.filter((entry) => !present.has(entry));
6658
+ if (missing.length === 0) return;
6659
+ const separator = existing === null || existing.endsWith("\n") ? "" : "\n";
6660
+ await promises.appendFile(path, `${separator}${missing.join("\n")}\n`);
6661
+ }
6359
6662
  async function prepareWorkdir(run, workdir) {
6360
6663
  if (!await run.git.isRepo(workdir)) await run.git.init(workdir, { branch: DEFAULT_BRANCH });
6361
6664
  if (!await run.git.hasCommits(workdir)) {
6362
6665
  await promises.writeFile(join(workdir, README_NAME), README_CONTENT);
6666
+ await ignoreDataAppFiles(workdir);
6363
6667
  await run.git.commitAll(workdir, INITIAL_COMMIT_MESSAGE);
6364
6668
  }
6365
6669
  return {
@@ -6758,7 +7062,7 @@ async function chooseRuntime(ctx, options, deps) {
6758
7062
  return {
6759
7063
  kind,
6760
7064
  image,
6761
- containerName: containerName(ctx.state.name),
7065
+ containerName: containerName(ctx.state.name, deps.runtime.rdeHome),
6762
7066
  port,
6763
7067
  hostAlias: DOCKER_HOST_ALIAS
6764
7068
  };
@@ -6852,7 +7156,7 @@ async function recordedDatabase(record, client) {
6852
7156
  async function persistedRecord(ctx, deps) {
6853
7157
  const existing = ctx.state.sampleDatabase;
6854
7158
  if (existing !== null) {
6855
- if ((await sampleDatabaseContainer(existing, deps.runtime.docker).status()).installed || await deps.ports.isFree(existing.port)) return {
7159
+ if ((await sampleDatabaseContainer(existing, deps.runtime.docker, deps.runtime.rdeHome).status()).installed || await deps.ports.isFree(existing.port)) return {
6856
7160
  record: existing,
6857
7161
  staleDatabaseId: null
6858
7162
  };
@@ -6862,7 +7166,7 @@ async function persistedRecord(ctx, deps) {
6862
7166
  const port = await deps.ports.findFree(SAMPLE_DATABASE_PORT);
6863
7167
  if (port !== 15432) ctx.ui.warn(`Port ${SAMPLE_DATABASE_PORT} is in use; using ${port} for the sample database`);
6864
7168
  const record = {
6865
- containerName: sampleContainerName(ctx.state.name),
7169
+ containerName: sampleContainerName(ctx.state.name, deps.runtime.rdeHome),
6866
7170
  image: SAMPLE_DATABASE_IMAGE,
6867
7171
  port,
6868
7172
  databaseId: null
@@ -6876,16 +7180,30 @@ async function persistedRecord(ctx, deps) {
6876
7180
  staleDatabaseId: existing?.databaseId ?? null
6877
7181
  };
6878
7182
  }
6879
- async function installAndStart(ctx, sidecar, record, deps) {
6880
- const installing = ctx.ui.spinner(`Installing the sample database from ${record.image}`);
7183
+ async function recordSampleDatabaseMove(ctx, from, to) {
7184
+ ctx.ui.warn(`Port ${from.port} is taken; the sample database moved to ${to.port}`);
7185
+ const record = {
7186
+ ...to,
7187
+ databaseId: null
7188
+ };
7189
+ await ctx.saveState({
7190
+ ...ctx.state,
7191
+ sampleDatabase: record,
7192
+ databases: ctx.state.databases.filter((entry) => entry.id !== from.databaseId)
7193
+ });
7194
+ return record;
7195
+ }
7196
+ async function installAndStart(ctx, sidecar, installed, deps) {
7197
+ const installing = ctx.ui.spinner(`Installing the sample database from ${installed.image}`);
6881
7198
  try {
6882
7199
  await sidecar.install({ onProgress: (line) => installing.update(line) });
6883
7200
  } catch (error) {
6884
7201
  installing.fail("Sample database install failed");
6885
7202
  throw error;
6886
7203
  }
6887
- installing.stop(`Sample database container ${record.containerName} on port ${record.port}`);
6888
- await sidecar.start();
7204
+ installing.stop(`Sample database container ${installed.containerName} on port ${installed.port}`);
7205
+ const started = await startOnFreePort(installed, deps.runtime.docker, deps.ports, deps.runtime.rdeHome);
7206
+ const record = started.state.port === installed.port ? installed : await recordSampleDatabaseMove(ctx, installed, started.state);
6889
7207
  const waiting = ctx.ui.spinner("Waiting for Postgres to accept connections…");
6890
7208
  try {
6891
7209
  await sidecar.waitReady({
@@ -6897,6 +7215,7 @@ async function installAndStart(ctx, sidecar, record, deps) {
6897
7215
  throw error;
6898
7216
  }
6899
7217
  waiting.stop(`Postgres is ready on ${LOOPBACK_HOST}:${record.port}`);
7218
+ return record;
6900
7219
  }
6901
7220
  function connectionDetails(record, configured, deps) {
6902
7221
  const alias = runtimeFromState(configured.runtime, deps.runtime).hostAlias();
@@ -6961,7 +7280,7 @@ function sampleDatabaseStep(deps = hostDeps$3) {
6961
7280
  const record = ctx.state.sampleDatabase;
6962
7281
  const configured = configuredRuntime(ctx.state);
6963
7282
  if (record === null || configured === null || ctx.secrets.apiKey === null) return "todo";
6964
- if (!(await sampleDatabaseContainer(record, resolved.runtime.docker).status()).installed) return "todo";
7283
+ if (!(await sampleDatabaseContainer(record, resolved.runtime.docker, resolved.runtime.rdeHome).status()).installed) return "todo";
6965
7284
  const client = resolved.client(configured.url, {
6966
7285
  kind: "api-key",
6967
7286
  key: ctx.secrets.apiKey
@@ -6979,24 +7298,24 @@ function sampleDatabaseStep(deps = hostDeps$3) {
6979
7298
  const apiKey = requireApiKey(ctx);
6980
7299
  const docker = await resolved.runtime.docker.available();
6981
7300
  if (!docker.available) throw new ConfigError(`the sample database runs in Docker, which is not available: ${docker.reason}`);
6982
- const choice = await persistedRecord(ctx, resolved);
6983
- await installAndStart(ctx, sampleDatabaseContainer(choice.record, resolved.runtime.docker), choice.record, resolved);
7301
+ const persisted = await persistedRecord(ctx, resolved);
7302
+ const record = await installAndStart(ctx, sampleDatabaseContainer(persisted.record, resolved.runtime.docker, resolved.runtime.rdeHome), persisted.record, resolved);
7303
+ const choice = {
7304
+ ...persisted,
7305
+ record
7306
+ };
6984
7307
  const client = resolved.client(configured.url, {
6985
7308
  kind: "api-key",
6986
7309
  key: apiKey
6987
7310
  });
6988
7311
  await connectDatabase(ctx, choice, configured, client, resolved);
6989
7312
  await client.setting.enableTransforms();
6990
- ctx.ui.note(usageLines(choice.record), "Sample database");
7313
+ ctx.ui.note(usageLines(record), "Sample database");
6991
7314
  return "completed";
6992
7315
  }
6993
7316
  };
6994
7317
  }
6995
7318
  const DEFAULT_HEALTH_TIMING = { timeoutMs: 3e5 };
6996
- async function startAndWaitForHealth(runtime, client, options) {
6997
- await runtime.start();
6998
- await waitForHealth(client, options);
6999
- }
7000
7319
  //#endregion
7001
7320
  //#region src/steps/start-metabase.ts
7002
7321
  const LOG_TAIL_ON_TIMEOUT = 30;
@@ -7004,6 +7323,7 @@ const PERCENT = 100;
7004
7323
  function hostDeps$2(ctx) {
7005
7324
  return {
7006
7325
  runtime: hostRuntimeDeps(ctx.state.name, ctx.signal),
7326
+ ports: hostPortProbe,
7007
7327
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
7008
7328
  timing: DEFAULT_HEALTH_TIMING
7009
7329
  };
@@ -7020,7 +7340,9 @@ function progressLabel(progress) {
7020
7340
  async function startSampleDatabase(ctx, deps) {
7021
7341
  const record = ctx.state.sampleDatabase;
7022
7342
  if (record === null) return;
7023
- switch (await sampleDatabaseContainer(record, deps.runtime.docker).start()) {
7343
+ const started = await startOnFreePort(record, deps.runtime.docker, deps.ports, deps.runtime.rdeHome);
7344
+ if (started.state.port !== record.port) await recordSampleDatabaseMove(ctx, record, started.state);
7345
+ switch (started.outcome) {
7024
7346
  case "missing":
7025
7347
  ctx.ui.warn(`Sample database container ${record.containerName} is missing; \`rde init\` recreates it`);
7026
7348
  return;
@@ -7051,11 +7373,16 @@ function startMetabaseStep(deps = hostDeps$2) {
7051
7373
  const runtime = runtimeFromState(configured.runtime, resolved.runtime);
7052
7374
  const client = resolved.client(configured.url, NO_AUTH);
7053
7375
  await startSampleDatabase(ctx, resolved);
7376
+ await runtime.start({ onNotice: (line) => ctx.ui.info(line) });
7054
7377
  const spinner = ctx.ui.spinner("Starting Metabase…");
7378
+ let shown = 0;
7055
7379
  try {
7056
- await startAndWaitForHealth(runtime, client, {
7380
+ await waitForHealth(client, {
7057
7381
  ...resolved.timing,
7058
- onProgress: (progress) => spinner.update(progressLabel(progress))
7382
+ onProgress: (progress) => {
7383
+ shown = Math.max(shown, progress);
7384
+ spinner.update(progressLabel(shown));
7385
+ }
7059
7386
  });
7060
7387
  } catch (error) {
7061
7388
  if (error instanceof MetabaseUnreachableError) {
@@ -7154,7 +7481,7 @@ async function summaryLines(state, apiKey, deps) {
7154
7481
  const installed = await skillsCli(deps.runner).list();
7155
7482
  lines.push(skillLine(installed.filter((entry) => entry.name === "rde")));
7156
7483
  lines.push(...launchLines(await detectAgents(deps.probe), state.gitRemote?.workdir ?? null));
7157
- lines.push("Lifecycle: rde status, rde stop, rde start");
7484
+ lines.push("Lifecycle: rde status (--json for scripts), rde stop, rde start");
7158
7485
  return lines;
7159
7486
  }
7160
7487
  function summaryStep(deps = hostDeps$1) {
@@ -7354,7 +7681,7 @@ const doctor = defineRdeCommand({
7354
7681
  if (args.fix && report.failed > 0) {
7355
7682
  if (state === null) throw new ConfigError("no instance to fix yet; run `rde init`");
7356
7683
  print(report, args.json);
7357
- const prompts = answeringPrompts(clackPrompts(), /* @__PURE__ */ new Map(), { takeRecommended: args.yes || !stdinIsTty() });
7684
+ const prompts = answeringPrompts(clackPrompts({ confirmsWithYes: true }), /* @__PURE__ */ new Map(), { takeRecommended: args.yes || !stdinIsTty() });
7358
7685
  const ctx = await createWizardContext(name, {
7359
7686
  signal: interruptSignal,
7360
7687
  prompts
@@ -7998,7 +8325,7 @@ function promptsFor(answers, yes) {
7998
8325
  ...yes && { confirmAll: true },
7999
8326
  ...(yes || !terminal) && { takeRecommended: true }
8000
8327
  };
8001
- return answeringPrompts(clackPrompts(), keyedAnswers(answers), answering);
8328
+ return answeringPrompts(clackPrompts({ confirmsWithYes: true }), keyedAnswers(answers), answering);
8002
8329
  }
8003
8330
  const init = defineRdeCommand({
8004
8331
  meta: {
@@ -8016,7 +8343,7 @@ const init = defineRdeCommand({
8016
8343
  },
8017
8344
  runtime: {
8018
8345
  type: "string",
8019
- description: `How a new instance runs Metabase (${RUNTIME_LIST}); Docker when it is available`
8346
+ description: `How a new instance runs Metabase (${RUNTIME_LIST}); Docker when it is available; the sample database always runs in Docker`
8020
8347
  },
8021
8348
  image: {
8022
8349
  type: "string",
@@ -8065,7 +8392,7 @@ const init = defineRdeCommand({
8065
8392
  },
8066
8393
  "skip-databases": {
8067
8394
  type: "boolean",
8068
- description: "Connect no database",
8395
+ description: "Connect none of your own databases (the sample database is still connected)",
8069
8396
  default: false
8070
8397
  },
8071
8398
  "git-dir": {
@@ -8104,7 +8431,7 @@ const init = defineRdeCommand({
8104
8431
  },
8105
8432
  yes: {
8106
8433
  type: "boolean",
8107
- description: "Answer yes to every confirmation (skipping databases)",
8434
+ description: "Answer yes to every confirmation; skips the question about your own databases (the sample database is still connected)",
8108
8435
  default: false
8109
8436
  }
8110
8437
  },
@@ -8144,7 +8471,7 @@ const init = defineRdeCommand({
8144
8471
  const answers = await combinedAnswers(runOptions);
8145
8472
  const answering = promptsFor(answers, runOptions.yes);
8146
8473
  const recording = printTo === void 0 ? null : recordingPrompts(answering);
8147
- const ctx = await createWizardContext(DEFAULT_INSTANCE_NAME, {
8474
+ const ctx = await createWizardContext(await instanceNameOrDefault(), {
8148
8475
  signal: interruptSignal,
8149
8476
  prompts: recording?.prompts ?? answering
8150
8477
  });
@@ -8167,7 +8494,7 @@ async function loadInstance(name, signal) {
8167
8494
  state,
8168
8495
  url: configured.url,
8169
8496
  runtime: runtimeFromState(configured.runtime, deps),
8170
- sampleDatabase: state.sampleDatabase === null ? null : sampleDatabaseContainer(state.sampleDatabase, deps.docker)
8497
+ sampleDatabase: state.sampleDatabase === null ? null : sampleDatabaseContainer(state.sampleDatabase, deps.docker, deps.rdeHome)
8171
8498
  };
8172
8499
  }
8173
8500
  function openInstance(url) {
@@ -8252,6 +8579,16 @@ async function stepReports(ctx, deps) {
8252
8579
  }
8253
8580
  return reports;
8254
8581
  }
8582
+ function setupOf(ctx, steps) {
8583
+ const pending = steps.find((step) => step.status === "todo" && ctx.state.steps[step.id] === void 0);
8584
+ if (pending === void 0) return { complete: true };
8585
+ return {
8586
+ complete: false,
8587
+ step: pending.id,
8588
+ title: pending.title,
8589
+ resume: `rde init --from ${pending.id}`
8590
+ };
8591
+ }
8255
8592
  function isNotServing(error) {
8256
8593
  return error instanceof MetabaseUnreachableError || error instanceof MetabaseInitializingError;
8257
8594
  }
@@ -8267,7 +8604,7 @@ async function databasesOf(ctx, deps, health) {
8267
8604
  async function sampleDatabaseOf(ctx, deps, databases) {
8268
8605
  const record = ctx.state.sampleDatabase;
8269
8606
  if (record === null) return null;
8270
- const status = await sampleDatabaseContainer(record, deps.runtime.docker).status();
8607
+ const status = await sampleDatabaseContainer(record, deps.runtime.docker, deps.runtime.rdeHome).status();
8271
8608
  const listed = databases?.find((database) => database.id === record.databaseId) ?? null;
8272
8609
  return {
8273
8610
  container: status.running ? "running" : status.installed ? "stopped" : "missing",
@@ -8304,6 +8641,7 @@ async function collectStatus(ctx, deps) {
8304
8641
  const health = configured === null ? null : await healthOf(configured.url, deps);
8305
8642
  const databases = health === null ? null : await databasesOf(ctx, deps, health);
8306
8643
  const admin = ctx.state.admin;
8644
+ const steps = await stepReports(ctx, deps);
8307
8645
  return {
8308
8646
  instance: ctx.state.name,
8309
8647
  url: configured?.url ?? null,
@@ -8317,10 +8655,12 @@ async function collectStatus(ctx, deps) {
8317
8655
  },
8318
8656
  version: ctx.state.version,
8319
8657
  health,
8320
- steps: await stepReports(ctx, deps),
8658
+ steps,
8659
+ setup: setupOf(ctx, steps),
8321
8660
  databases,
8322
8661
  sampleDatabase: await sampleDatabaseOf(ctx, deps, databases),
8323
- gitSync: await gitSyncOf(ctx, deps)
8662
+ gitSync: await gitSyncOf(ctx, deps),
8663
+ contentRepository: ctx.state.gitRemote === null ? null : ctx.state.gitRemote.workdir
8324
8664
  };
8325
8665
  }
8326
8666
  function runtimeLine(report) {
@@ -8332,6 +8672,10 @@ function runtimeLine(report) {
8332
8672
  function versionLine(report) {
8333
8673
  return report.version === null ? "Version: not recorded yet; `rde start` records it" : `Version: ${report.version}`;
8334
8674
  }
8675
+ function setupLine(setup) {
8676
+ if (setup.complete) return "Setup: complete";
8677
+ return `Setup: incomplete; "${setup.title}" is not done; run \`${setup.resume}\``;
8678
+ }
8335
8679
  function isGitSyncFailure(value) {
8336
8680
  return "error" in value;
8337
8681
  }
@@ -8368,11 +8712,13 @@ function statusLines(report) {
8368
8712
  runtimeLine(report),
8369
8713
  versionLine(report),
8370
8714
  `Health: ${report.health ?? "unknown"}`,
8715
+ setupLine(report.setup),
8371
8716
  "Steps:",
8372
8717
  ...checklistLines(report.steps).map((line) => ` ${line}`),
8373
8718
  ...databaseLines(report),
8374
8719
  sampleDatabaseLine(report),
8375
- gitSyncLine(report)
8720
+ gitSyncLine(report),
8721
+ ...report.contentRepository === null ? [] : [`Content repository: ${report.contentRepository}`]
8376
8722
  ];
8377
8723
  }
8378
8724
  //#endregion
@@ -8421,12 +8767,13 @@ const stop = defineRdeCommand({
8421
8767
  //#endregion
8422
8768
  //#region src/steps/uninstall.ts
8423
8769
  const API_KEY_METHOD = "apiKey";
8424
- function hostUninstallDeps(signal) {
8770
+ function hostUninstallDeps(signal, childOutput) {
8425
8771
  const runner = processRunner(signal);
8426
8772
  return {
8427
8773
  docker: dockerTool(runner),
8428
8774
  daemon: daemonTool(runner),
8429
- runner
8775
+ runner,
8776
+ childOutput
8430
8777
  };
8431
8778
  }
8432
8779
  const NOT_TOUCHED = "The mb CLI, Docker, and Java themselves";
@@ -8472,7 +8819,7 @@ async function pathExists(path) {
8472
8819
  throw error;
8473
8820
  }
8474
8821
  }
8475
- async function findContainer(name, docker, stateImage) {
8822
+ async function findContainer(name, docker, stateImage, home) {
8476
8823
  try {
8477
8824
  const inspection = await docker.containerInspect(name);
8478
8825
  if (!inspection.exists) return {
@@ -8480,6 +8827,14 @@ async function findContainer(name, docker, stateImage) {
8480
8827
  image: stateImage,
8481
8828
  problem: null
8482
8829
  };
8830
+ if (inspection.home !== null && inspection.home !== home) return {
8831
+ removal: null,
8832
+ image: null,
8833
+ problem: {
8834
+ subject: `Container ${name}`,
8835
+ reason: `belongs to the rde home ${inspection.home} and is left alone`
8836
+ }
8837
+ };
8483
8838
  return {
8484
8839
  removal: {
8485
8840
  label: inspection.running ? `Container ${name} (running; it is stopped first)` : `Container ${name} (stopped)`,
@@ -8593,16 +8948,20 @@ async function findMbProfiles(candidates, url, runner) {
8593
8948
  }
8594
8949
  return removals;
8595
8950
  }
8596
- function skillLabel(entries) {
8597
- return `rde skill for ${[...new Set(entries.flatMap((entry) => entry.agents))].join(", ")}`;
8951
+ function skillLabel(names, entries) {
8952
+ const agents = [...new Set(entries.flatMap((entry) => entry.agents))];
8953
+ const noun = names.length === 1 ? "skill" : "skills";
8954
+ return `${names.join(" and ")} ${noun} for ${agents.join(", ")}`;
8598
8955
  }
8599
- async function findSkill(runner) {
8956
+ async function findSkill(runner, childOutput) {
8600
8957
  const skills = skillsCli(runner);
8601
- const entries = (await skills.list()).filter((entry) => entry.name === "rde");
8958
+ const owned = RDE_SOURCE_SKILL_NAMES;
8959
+ const entries = (await skills.list()).filter((entry) => owned.includes(entry.name));
8602
8960
  if (entries.length === 0) return null;
8961
+ const names = [...new Set(entries.map((entry) => entry.name))];
8603
8962
  return {
8604
- label: skillLabel(entries),
8605
- remove: () => skills.remove("rde")
8963
+ label: skillLabel(names, entries),
8964
+ remove: () => skills.remove(names, childOutput)
8606
8965
  };
8607
8966
  }
8608
8967
  function instanceDirLabel(path, state) {
@@ -8645,12 +9004,13 @@ async function planUninstall(options, deps) {
8645
9004
  const kept = [];
8646
9005
  const { state, problem } = await readStateLeniently(options.instance);
8647
9006
  if (problem !== null) problems.push(problem);
9007
+ const home = rdeHome();
8648
9008
  const runtime = state?.runtime?.kind === "docker" ? state.runtime : null;
8649
- const container = await findContainer(runtime?.containerName ?? containerName(options.instance), deps.docker, runtime?.image ?? null);
9009
+ const container = await findContainer(runtime?.containerName ?? containerName(options.instance, home), deps.docker, runtime?.image ?? null, home);
8650
9010
  if (container.removal !== null) removals.push(container.removal);
8651
9011
  if (container.problem !== null) problems.push(container.problem);
8652
9012
  const sample = state?.sampleDatabase ?? null;
8653
- const sidecar = await findContainer(sample?.containerName ?? sampleContainerName(options.instance), deps.docker, sample?.image ?? null);
9013
+ const sidecar = await findContainer(sample?.containerName ?? sampleContainerName(options.instance, home), deps.docker, sample?.image ?? null, home);
8654
9014
  if (sidecar.removal !== null) removals.push(sidecar.removal);
8655
9015
  if (sidecar.problem !== null) problems.push(sidecar.problem);
8656
9016
  const jarSource = state?.runtime?.kind === "jar" ? state.runtime.source : null;
@@ -8670,7 +9030,7 @@ async function planUninstall(options, deps) {
8670
9030
  const profiles = state === null ? await findMbProfiles([DEFAULT_MB_PROFILE, namedMbProfile(options.instance)], null, deps.runner) : await findMbProfiles([state.mbProfile], state.url, deps.runner);
8671
9031
  removals.push(...profiles);
8672
9032
  if (options.removeSkill) {
8673
- const skill = await findSkill(deps.runner);
9033
+ const skill = await findSkill(deps.runner, deps.childOutput);
8674
9034
  if (skill !== null) removals.push(skill);
8675
9035
  }
8676
9036
  const dir = instanceDir(options.instance);
@@ -8799,15 +9159,17 @@ const uninstallCommand = defineRdeCommand({
8799
9159
  }
8800
9160
  },
8801
9161
  async run(args) {
9162
+ const instance = args.instance ?? await instanceNameOrDefault();
9163
+ const ui = terminalUi();
8802
9164
  const report = await uninstall({
8803
- instance: args.instance ?? await instanceNameOrDefault(),
9165
+ instance,
8804
9166
  keepData: args["keep-data"],
8805
9167
  removeSkill: args["remove-skill"],
8806
9168
  removeImage: args["remove-image"],
8807
9169
  yes: args.yes
8808
- }, hostUninstallDeps(interruptSignal), {
8809
- prompts: clackPrompts(),
8810
- ui: clackUi()
9170
+ }, hostUninstallDeps(interruptSignal, ui.childOutput), {
9171
+ prompts: clackPrompts({ confirmsWithYes: true }),
9172
+ ui
8811
9173
  });
8812
9174
  if (report === null) return;
8813
9175
  const remaining = leftBehind(report);
@@ -8821,6 +9183,7 @@ const RELEASE_TAG = /^v?[01]\.(\d+\.\d+(?:\.\d+)?)/;
8821
9183
  function hostDeps(ctx) {
8822
9184
  return {
8823
9185
  runtime: hostRuntimeDeps(ctx.state.name, ctx.signal),
9186
+ ports: hostPortProbe,
8824
9187
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
8825
9188
  timing: DEFAULT_HEALTH_TIMING,
8826
9189
  releases: releaseLookup(ctx.signal),
@@ -9082,7 +9445,7 @@ const upgradeCommand = defineRdeCommand({
9082
9445
  const main = defineCommand({
9083
9446
  meta: {
9084
9447
  name: "rde",
9085
- version: "0.1.5",
9448
+ version: "0.1.7",
9086
9449
  description: "Prepares a local machine for data-engineering work with Metabase and coding agents"
9087
9450
  },
9088
9451
  subCommands: {
@@ -9100,15 +9463,24 @@ const main = defineCommand({
9100
9463
  }
9101
9464
  });
9102
9465
  //#endregion
9466
+ //#region src/output/usage.ts
9467
+ async function printUsage(cmd, parent) {
9468
+ const usage = await renderUsage(cmd, parent);
9469
+ process.stdout.write((drawsOnTerminal() ? usage : withoutEscapes(usage)) + "\n\n");
9470
+ }
9471
+ //#endregion
9103
9472
  //#region src/cli.ts
9104
9473
  async function run() {
9105
9474
  installInterruptHandler((code) => process.exit(code));
9106
9475
  const rawArgs = process.argv.slice(2);
9107
9476
  if (rawArgs.length === 0) {
9108
- await showUsage(main);
9477
+ await printUsage(main);
9109
9478
  return;
9110
9479
  }
9111
- await runMain(main, { rawArgs });
9480
+ await runMain(main, {
9481
+ rawArgs,
9482
+ showUsage: printUsage
9483
+ });
9112
9484
  }
9113
9485
  await run();
9114
9486
  //#endregion