@metabase/rde 0.1.12 → 0.1.14

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 -13
  2. package/dist/cli.mjs +730 -229
  3. package/package.json +2 -2
package/dist/cli.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { defineCommand, renderUsage, runMain } from "citty";
3
3
  import { createHash, randomBytes, randomUUID } from "node:crypto";
4
4
  import { createWriteStream, promises } from "node:fs";
5
- import { delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
5
+ import { delimiter, dirname, isAbsolute, join, posix, resolve, sep } from "node:path";
6
6
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
7
  import { ZodError, z } from "zod";
8
8
  import { homedir, tmpdir, userInfo } from "node:os";
@@ -71,12 +71,14 @@ var MetabaseApiError = class extends Error {
71
71
  status;
72
72
  path;
73
73
  errors;
74
+ reason;
74
75
  constructor(failure) {
75
76
  super(`Metabase ${failure.status} on ${failure.path}: ${failure.message}`);
76
77
  this.name = "MetabaseApiError";
77
78
  this.status = failure.status;
78
79
  this.path = failure.path;
79
80
  this.errors = failure.errors;
81
+ this.reason = failure.message;
80
82
  }
81
83
  };
82
84
  const UNAUTHORIZED_STATUS = 401;
@@ -93,10 +95,12 @@ var MetabaseInitializingError = class extends Error {
93
95
  };
94
96
  var MetabaseUnreachableError = class extends Error {
95
97
  url;
98
+ reason;
96
99
  constructor(url, reason) {
97
100
  super(`Metabase at ${url} is unreachable: ${reason}`);
98
101
  this.name = "MetabaseUnreachableError";
99
102
  this.url = url;
103
+ this.reason = reason;
100
104
  }
101
105
  };
102
106
  var DownloadError = class extends Error {
@@ -240,10 +244,11 @@ const StepId = z.enum([
240
244
  "start-metabase",
241
245
  "admin-user",
242
246
  "api-key",
247
+ "transforms",
243
248
  "sample-database",
244
249
  "license",
245
- "databases",
246
250
  "git-sync",
251
+ "databases",
247
252
  "summary"
248
253
  ]);
249
254
  const GlobalConfig = z.object({
@@ -891,6 +896,11 @@ function describeCause(error) {
891
896
  if (error instanceof Error && error.cause instanceof Error) return error.cause.message;
892
897
  return error instanceof Error ? error.message : String(error);
893
898
  }
899
+ function lookupFailure(error) {
900
+ if (error instanceof MetabaseUnreachableError) return `${error.url} is unreachable: ${error.reason}`;
901
+ if (error instanceof MetabaseApiError) return `HTTP ${error.status} on ${error.path}: ${error.reason}`;
902
+ return null;
903
+ }
894
904
  function createHttp(options) {
895
905
  const fetchImpl = options.fetchImpl ?? fetch;
896
906
  const baseUrl = options.baseUrl.replace(/\/+$/, "");
@@ -910,7 +920,11 @@ function createHttp(options) {
910
920
  init.body = JSON.stringify(body);
911
921
  }
912
922
  try {
913
- return await fetchImpl(url, init);
923
+ const response = await fetchImpl(url, init);
924
+ return {
925
+ ok: response.ok,
926
+ body: parseBody(response.status, await response.text())
927
+ };
914
928
  } catch (error) {
915
929
  if (options.signal.aborted) throw new AbortError();
916
930
  if (isTimeout(error)) throw new MetabaseUnreachableError(url, `no response within ${REQUEST_TIMEOUT_MS}ms`);
@@ -918,10 +932,9 @@ function createHttp(options) {
918
932
  }
919
933
  }
920
934
  async function requestBody(method, path, body) {
921
- const response = await send(method, path, body);
922
- const parsed = parseBody(response.status, await response.text());
923
- if (!response.ok) throw failureFor(path, parsed);
924
- return parsed;
935
+ const received = await send(method, path, body);
936
+ if (!received.ok) throw failureFor(path, received.body);
937
+ return received.body;
925
938
  }
926
939
  return {
927
940
  async requestJson(method, path, request) {
@@ -1037,8 +1050,12 @@ const Database = z.object({
1037
1050
  id: z.int(),
1038
1051
  name: z.string(),
1039
1052
  engine: z.string(),
1040
- initial_sync_status: InitialSyncStatus
1053
+ initial_sync_status: InitialSyncStatus,
1054
+ is_stub: z.boolean().optional()
1041
1055
  }).loose();
1056
+ function isStub(database) {
1057
+ return database.is_stub === true;
1058
+ }
1042
1059
  const DatabaseList = z.object({
1043
1060
  data: z.array(Database),
1044
1061
  total: z.int()
@@ -1072,6 +1089,17 @@ function databaseResource(http) {
1072
1089
  async function list() {
1073
1090
  return http.requestJson("GET", "/api/database", { schema: DatabaseList });
1074
1091
  }
1092
+ /** GET /api/database?include_stubs=true */
1093
+ async function listWithStubs() {
1094
+ return http.requestJson("GET", "/api/database?include_stubs=true", { schema: DatabaseList });
1095
+ }
1096
+ /** PUT /api/database/:id — on a stub, new connection details make it a real database */
1097
+ async function connect(id, connection) {
1098
+ return http.requestJson("PUT", `/api/database/${id}`, {
1099
+ body: connection,
1100
+ schema: Database
1101
+ });
1102
+ }
1075
1103
  /** GET /api/database/:id */
1076
1104
  async function get(id) {
1077
1105
  return http.requestJson("GET", `/api/database/${id}`, { schema: Database });
@@ -1080,6 +1108,8 @@ function databaseResource(http) {
1080
1108
  validate,
1081
1109
  create,
1082
1110
  list,
1111
+ listWithStubs,
1112
+ connect,
1083
1113
  get
1084
1114
  };
1085
1115
  }
@@ -1132,6 +1162,13 @@ function remoteSyncResource(http) {
1132
1162
  schema: RemoteSyncSettingsResult
1133
1163
  });
1134
1164
  }
1165
+ /** PUT /api/ee/remote-sync/settings with only `remote-sync-transforms`, which skips the repository check. */
1166
+ async function syncTransforms() {
1167
+ return http.requestJson("PUT", "/api/ee/remote-sync/settings", {
1168
+ body: { "remote-sync-transforms": true },
1169
+ schema: RemoteSyncSettingsResult
1170
+ });
1171
+ }
1135
1172
  /** POST /api/ee/remote-sync/test-connection; an empty body tests the saved url and token. */
1136
1173
  async function testConnection(connection = {}) {
1137
1174
  return http.requestJson("POST", "/api/ee/remote-sync/test-connection", {
@@ -1145,6 +1182,7 @@ function remoteSyncResource(http) {
1145
1182
  }
1146
1183
  return {
1147
1184
  updateSettings,
1185
+ syncTransforms,
1148
1186
  testConnection,
1149
1187
  currentTask
1150
1188
  };
@@ -1154,6 +1192,33 @@ function remoteSyncResource(http) {
1154
1192
  const Session = z.object({ id: z.string() }).loose();
1155
1193
  //#endregion
1156
1194
  //#region src/metabase/domain/session-properties.ts
1195
+ const SessionProperties = z.object({
1196
+ "setup-token": z.string().nullable(),
1197
+ "has-user-setup": z.boolean(),
1198
+ version: z.object({ tag: z.string() }).loose(),
1199
+ "token-features": z.record(z.string(), z.boolean())
1200
+ }).loose();
1201
+ //#endregion
1202
+ //#region src/metabase/resources/session.ts
1203
+ function sessionResource(http) {
1204
+ /** GET /api/session/properties */
1205
+ async function properties() {
1206
+ return http.requestJson("GET", "/api/session/properties", { schema: SessionProperties });
1207
+ }
1208
+ /** POST /api/session */
1209
+ async function create(credentials) {
1210
+ return http.requestJson("POST", "/api/session", {
1211
+ body: credentials,
1212
+ schema: Session
1213
+ });
1214
+ }
1215
+ return {
1216
+ properties,
1217
+ create
1218
+ };
1219
+ }
1220
+ //#endregion
1221
+ //#region src/metabase/domain/engine.ts
1157
1222
  const EngineFieldOption = z.object({
1158
1223
  name: z.string(),
1159
1224
  value: z.unknown()
@@ -1179,36 +1244,16 @@ const Engine = z.object({
1179
1244
  "superseded-by": z.string().nullable(),
1180
1245
  "creatable?": z.boolean().optional()
1181
1246
  }).loose();
1182
- const SessionProperties = z.object({
1183
- "setup-token": z.string().nullable(),
1184
- "has-user-setup": z.boolean(),
1185
- version: z.object({ tag: z.string() }).loose(),
1186
- "token-features": z.record(z.string(), z.boolean()),
1187
- engines: z.record(z.string(), Engine)
1188
- }).loose();
1247
+ const Engines = z.record(z.string(), Engine);
1189
1248
  //#endregion
1190
- //#region src/metabase/resources/session.ts
1191
- function sessionResource(http) {
1192
- /** GET /api/session/properties */
1193
- async function properties() {
1194
- return http.requestJson("GET", "/api/session/properties", { schema: SessionProperties });
1195
- }
1196
- /** POST /api/session */
1197
- async function create(credentials) {
1198
- return http.requestJson("POST", "/api/session", {
1199
- body: credentials,
1200
- schema: Session
1201
- });
1202
- }
1203
- return {
1204
- properties,
1205
- create
1206
- };
1207
- }
1249
+ //#region src/metabase/domain/setting.ts
1250
+ const BooleanSetting = z.boolean();
1208
1251
  //#endregion
1209
1252
  //#region src/metabase/resources/setting.ts
1210
1253
  const PREMIUM_EMBEDDING_TOKEN_SETTING = "premium-embedding-token";
1254
+ const ENGINES_SETTING = "engines";
1211
1255
  const TRANSFORMS_ENABLED_SETTING = "transforms-enabled";
1256
+ const TRANSFORMS_SETUP_COMPLETE_SETTING = "transforms-setup-complete";
1212
1257
  function settingResource(http) {
1213
1258
  /** PUT /api/setting/premium-embedding-token */
1214
1259
  async function setPremiumEmbeddingToken(value) {
@@ -1218,9 +1263,19 @@ function settingResource(http) {
1218
1263
  async function enableTransforms() {
1219
1264
  await http.requestNoContent("PUT", `/api/setting/${TRANSFORMS_ENABLED_SETTING}`, { body: { value: true } });
1220
1265
  }
1266
+ /** GET /api/setting/transforms-setup-complete */
1267
+ async function transformsDecided() {
1268
+ return http.requestJson("GET", `/api/setting/${TRANSFORMS_SETUP_COMPLETE_SETTING}`, { schema: BooleanSetting });
1269
+ }
1270
+ /** GET /api/setting/engines; admins only, which the API key's user is. */
1271
+ async function engines() {
1272
+ return http.requestJson("GET", `/api/setting/${ENGINES_SETTING}`, { schema: Engines });
1273
+ }
1221
1274
  return {
1222
1275
  setPremiumEmbeddingToken,
1223
- enableTransforms
1276
+ enableTransforms,
1277
+ transformsDecided,
1278
+ engines
1224
1279
  };
1225
1280
  }
1226
1281
  //#endregion
@@ -2443,13 +2498,6 @@ const PLUGINS_DIR = "plugins";
2443
2498
  const METABASE_STOP_GRACE_MS = 3e4;
2444
2499
  const DB_DIR = "metabase.db";
2445
2500
  const DB_FILE = "metabase.db";
2446
- const STAGING_STORE_ENV = {
2447
- MB_RUN_MODE: "e2e",
2448
- MB_STORE_USE_STAGING: "true"
2449
- };
2450
- const ALLOW_LOCAL_WAREHOUSES_ENV = { MB_WAREHOUSE_ALLOWED_NETWORKS: "allow-all" };
2451
- const NO_H2_SAMPLE_ENV = { MB_LOAD_SAMPLE_CONTENT: "false" };
2452
- const NO_AUDIT_DB_ENV = { MB_INSTALL_ANALYTICS_DATABASE: "false" };
2453
2501
  function instanceUrl(port) {
2454
2502
  return `http://localhost:${port}`;
2455
2503
  }
@@ -2458,10 +2506,11 @@ function serverEnv(options) {
2458
2506
  MB_DB_FILE: options.dbFile,
2459
2507
  MB_PLUGINS_DIR: options.pluginsDir,
2460
2508
  MB_SITE_URL: instanceUrl(options.port),
2461
- ...STAGING_STORE_ENV,
2462
- ...ALLOW_LOCAL_WAREHOUSES_ENV,
2463
- ...NO_H2_SAMPLE_ENV,
2464
- ...NO_AUDIT_DB_ENV
2509
+ MB_RUN_MODE: "e2e",
2510
+ MB_STORE_USE_STAGING: "true",
2511
+ MB_WAREHOUSE_ALLOWED_NETWORKS: "allow-all",
2512
+ MB_LOAD_SAMPLE_CONTENT: "false",
2513
+ MB_INSTALL_ANALYTICS_DATABASE: "false"
2465
2514
  };
2466
2515
  }
2467
2516
  function jettyEnv(port) {
@@ -3018,12 +3067,12 @@ async function portClaims(state, runtime) {
3018
3067
  }
3019
3068
  //#endregion
3020
3069
  //#region src/steps/admin-user.ts
3021
- const STEP_ID$9 = "admin-user";
3070
+ const STEP_ID$10 = "admin-user";
3022
3071
  const ADMIN_USER_KEYS = {
3023
- email: `${STEP_ID$9}.email`,
3024
- password: `${STEP_ID$9}.password`,
3025
- firstName: `${STEP_ID$9}.first-name`,
3026
- lastName: `${STEP_ID$9}.last-name`
3072
+ email: `${STEP_ID$10}.email`,
3073
+ password: `${STEP_ID$10}.password`,
3074
+ firstName: `${STEP_ID$10}.first-name`,
3075
+ lastName: `${STEP_ID$10}.last-name`
3027
3076
  };
3028
3077
  const GENERATED_ADMIN_EMAIL = "admin@metabase.local";
3029
3078
  const GENERATED_PASSWORD_BYTES = 24;
@@ -3038,7 +3087,7 @@ function generatePassword(random) {
3038
3087
  if (DIGIT.test(candidate)) return candidate;
3039
3088
  }
3040
3089
  }
3041
- function hostDeps$13(ctx) {
3090
+ function hostDeps$14(ctx) {
3042
3091
  return {
3043
3092
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
3044
3093
  instanceRunning: () => isInstanceRunning(ctx.state, hostRuntimeDeps(ctx.state.name, ctx.signal)),
@@ -3176,9 +3225,9 @@ async function recordExistingAdmin(ctx, variant, client, url) {
3176
3225
  ctx.ui.success(`Admin user ${email} recorded`);
3177
3226
  return "completed";
3178
3227
  }
3179
- function adminUserStep(options, deps = hostDeps$13) {
3228
+ function adminUserStep(options, deps = hostDeps$14) {
3180
3229
  return {
3181
- id: STEP_ID$9,
3230
+ id: STEP_ID$10,
3182
3231
  title: "Create the admin user",
3183
3232
  redoHint: "An instance keeps the admin it was set up with; `rde credentials` shows it.",
3184
3233
  async detect(ctx) {
@@ -3247,7 +3296,9 @@ function adminUserStep(options, deps = hostDeps$13) {
3247
3296
  //#endregion
3248
3297
  //#region src/tools/mb.ts
3249
3298
  const MB = "mb";
3250
- const MB_INSTALL_SPEC = `@metabase/cli@alpha-release-65-integration-branch`;
3299
+ const MB_PACKAGE = "@metabase/cli";
3300
+ const MB_DIST_TAG = "alpha-release-65-integration-branch";
3301
+ const MB_INSTALL_SPEC = `${MB_PACKAGE}@${MB_DIST_TAG}`;
3251
3302
  const GLOBAL_INSTALL = {
3252
3303
  npm: {
3253
3304
  command: "npm",
@@ -3421,7 +3472,6 @@ const AuthProfileStatus = z.enum([
3421
3472
  const AuthProfile = z.object({
3422
3473
  profile: z.string(),
3423
3474
  url: z.string(),
3424
- authenticated: z.boolean(),
3425
3475
  status: AuthProfileStatus,
3426
3476
  user: ProbedUser.nullable()
3427
3477
  }).loose();
@@ -3503,7 +3553,14 @@ function mbTool(runner) {
3503
3553
  ], LogoutResult);
3504
3554
  }
3505
3555
  async function authList() {
3506
- return (await mbJson(["auth", "list"], AuthList)).data;
3556
+ return (await mbJson([
3557
+ "auth",
3558
+ "list",
3559
+ "--fields",
3560
+ "profile,url,status,user",
3561
+ "--max-bytes",
3562
+ "0"
3563
+ ], AuthList)).data;
3507
3564
  }
3508
3565
  async function gitSyncStatus(profile) {
3509
3566
  return mbJson([
@@ -3559,14 +3616,14 @@ function mbTool(runner) {
3559
3616
  }
3560
3617
  //#endregion
3561
3618
  //#region src/steps/api-key.ts
3562
- const STEP_ID$8 = "api-key";
3619
+ const STEP_ID$9 = "api-key";
3563
3620
  const API_KEY_KEYS = {
3564
- email: `${STEP_ID$8}.email`,
3565
- password: `${STEP_ID$8}.password`,
3566
- profile: `${STEP_ID$8}.profile`
3621
+ email: `${STEP_ID$9}.email`,
3622
+ password: `${STEP_ID$9}.password`,
3623
+ profile: `${STEP_ID$9}.profile`
3567
3624
  };
3568
3625
  const ADMINISTRATORS_GROUP_NAME = "Administrators";
3569
- function hostDeps$12(ctx) {
3626
+ function hostDeps$13(ctx) {
3570
3627
  return {
3571
3628
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
3572
3629
  runner: processRunner(ctx.signal)
@@ -3685,9 +3742,9 @@ function usageNote(profile) {
3685
3742
  if (profile === "default") return `${mbCommand(profile)} <command> uses this instance`;
3686
3743
  return `${mbCommand(profile)} <command>\nMB_PROFILE=${profile} for agents and scripts`;
3687
3744
  }
3688
- function apiKeyStep(deps = hostDeps$12) {
3745
+ function apiKeyStep(deps = hostDeps$13) {
3689
3746
  return {
3690
- id: STEP_ID$8,
3747
+ id: STEP_ID$9,
3691
3748
  title: "Connect the Metabase CLI",
3692
3749
  redoHint: "`rde status` shows the mb profile it uses.",
3693
3750
  async detect(ctx) {
@@ -4646,20 +4703,29 @@ function syncWithHint(database) {
4646
4703
  function existingDatabaseLines(databases) {
4647
4704
  return databases.map((db) => `${db.name} (${db.engine}, ${syncWithHint(db)})`);
4648
4705
  }
4706
+ function unconnectedStubsLine(stubs) {
4707
+ return `Not connected yet, used by your content: ${stubs.map((stub) => stub.name).join(", ")}; \`rde db add\` connects one`;
4708
+ }
4709
+ function listingStatus(entry) {
4710
+ if (entry.stub) return "not connected, your content uses it; `rde db add` connects it";
4711
+ return entry.initialSyncStatus === null ? "missing on the server" : syncLabel(entry.initialSyncStatus);
4712
+ }
4649
4713
  function databaseListingLines(listing) {
4650
4714
  if (listing.length === 0) return ["No databases connected yet; run `rde db add`"];
4651
4715
  return listing.map((entry) => {
4652
- const sync = entry.initialSyncStatus === null ? "missing on the server" : syncLabel(entry.initialSyncStatus);
4653
- return `${entry.name} ${entry.engine} id ${entry.id} ${sync}`;
4716
+ const engine = entry.stub ? "?" : entry.engine;
4717
+ return `${entry.name} ${engine} id ${entry.id} ${listingStatus(entry)}`;
4654
4718
  });
4655
4719
  }
4656
4720
  //#endregion
4657
4721
  //#region src/steps/databases/flow.ts
4658
4722
  const DATABASE_KEYS = {
4659
4723
  connection: "databases.connection",
4660
- skip: "databases.skip"
4724
+ skip: "databases.skip",
4725
+ stubs: "databases.stubs"
4661
4726
  };
4662
4727
  const DATABASE_KEY_NAMES = {
4728
+ connection: "connection",
4663
4729
  detectedEngine: "detected-engine",
4664
4730
  engine: "engine",
4665
4731
  mode: "mode",
@@ -4703,11 +4769,13 @@ const RSA_KEY_UNAVAILABLE_ERROR = "RSA public key is not available";
4703
4769
  const KEY_RETRIEVAL_NOTE = "allowPublicKeyRetrieval=true is added: MySQL 8 needs it to log in to a local server without SSL; to leave it out, paste a connection string with allowPublicKeyRetrieval=false instead";
4704
4770
  const KEY_RETRIEVAL_HINT = "MySQL 8 without SSL needs allowPublicKeyRetrieval=true: go back and add ?allowPublicKeyRetrieval=true to the connection string, or turn on SSL";
4705
4771
  const CONNECTION_PLACEHOLDER = "leave empty to skip";
4772
+ const STUB_CONNECTION_PLACEHOLDER = "leave empty to pick the engine";
4706
4773
  const START_SCREEN = {
4707
4774
  engine: "pick-engine",
4708
4775
  "connection-string": "connection-string"
4709
4776
  };
4710
4777
  const FRESH$1 = {
4778
+ target: null,
4711
4779
  engine: null,
4712
4780
  uri: null,
4713
4781
  pasted: null,
@@ -4717,6 +4785,35 @@ const FRESH$1 = {
4717
4785
  name: null,
4718
4786
  fieldErrors: null
4719
4787
  };
4788
+ function targeting(stub) {
4789
+ return {
4790
+ ...FRESH$1,
4791
+ target: stub,
4792
+ name: stub.name
4793
+ };
4794
+ }
4795
+ function restart(model) {
4796
+ return model.target === null ? FRESH$1 : targeting(model.target);
4797
+ }
4798
+ function nextStub(run, model) {
4799
+ const stub = run.queue.shift();
4800
+ if (stub !== void 0) return {
4801
+ type: "next",
4802
+ to: "connection-string",
4803
+ model: targeting(stub)
4804
+ };
4805
+ return run.created.length > 0 ? {
4806
+ type: "next",
4807
+ to: "another",
4808
+ model
4809
+ } : {
4810
+ type: "done",
4811
+ model
4812
+ };
4813
+ }
4814
+ function stubNamed(run, name) {
4815
+ return run.stubs.find((stub) => stub.name === name) ?? null;
4816
+ }
4720
4817
  function ordinalOf(run) {
4721
4818
  return run.created.length + 1;
4722
4819
  }
@@ -4786,20 +4883,60 @@ async function pickDetectedEngine(run, candidates) {
4786
4883
  if (entry === void 0) throw new Error(`engine ${picked} was not offered`);
4787
4884
  return entry;
4788
4885
  }
4886
+ function pickStubsScreen(run) {
4887
+ return async (model) => {
4888
+ const picked = await run.deps.prompts.multiselect({
4889
+ key: DATABASE_KEYS.stubs,
4890
+ message: "Your content uses these databases, not connected yet. Pick the ones to connect now (space picks, Enter goes on; the rest wait for `rde db add`)",
4891
+ choices: run.stubs.map((stub) => ({
4892
+ value: stub.id,
4893
+ label: stub.name
4894
+ })),
4895
+ required: false
4896
+ });
4897
+ if (picked.length > 0) {
4898
+ run.queue = run.stubs.filter((stub) => picked.includes(stub.id));
4899
+ return nextStub(run, model);
4900
+ }
4901
+ if (run.deps.start === "connection-string" && run.created.length === 0) return {
4902
+ type: "done",
4903
+ model
4904
+ };
4905
+ return {
4906
+ type: "next",
4907
+ to: "pick-engine",
4908
+ model: FRESH$1
4909
+ };
4910
+ };
4911
+ }
4912
+ function connectionQuestion(run, target) {
4913
+ if (target === null) return {
4914
+ key: DATABASE_KEYS.connection,
4915
+ message: "Connection string of your database (Enter to skip; `rde db add` connects one later)",
4916
+ placeholder: CONNECTION_PLACEHOLDER
4917
+ };
4918
+ return {
4919
+ key: databaseKey(ordinalOf(run), "connection"),
4920
+ message: `Connection string of ${target.name} (Enter to pick its engine instead)`,
4921
+ placeholder: STUB_CONNECTION_PLACEHOLDER
4922
+ };
4923
+ }
4789
4924
  function connectionStringScreen(run) {
4790
4925
  return async (model) => {
4791
4926
  let draft = model.uri;
4792
4927
  for (;;) {
4793
4928
  const uri = (await run.deps.prompts.text({
4794
- key: DATABASE_KEYS.connection,
4795
- message: "Connection string of your database (Enter to skip; `rde db add` connects one later)",
4796
- placeholder: CONNECTION_PLACEHOLDER,
4929
+ ...connectionQuestion(run, model.target),
4797
4930
  ...draft !== null && { initialValue: draft }
4798
4931
  })).trim();
4799
4932
  draft = uri;
4800
- if (uri.length === 0) return {
4933
+ if (uri.length === 0) return model.target === null ? {
4801
4934
  type: "done",
4802
4935
  model
4936
+ } : {
4937
+ type: "next",
4938
+ to: "pick-engine",
4939
+ model: restart(model)
4803
4940
  };
4804
4941
  const candidates = detectEngine(uri).map((key) => run.deps.catalog.entry(key)).filter(isCreatableEntry);
4805
4942
  const [first, ...others] = candidates;
@@ -4808,7 +4945,7 @@ function connectionStringScreen(run) {
4808
4945
  return {
4809
4946
  type: "next",
4810
4947
  to: "pick-engine",
4811
- model: FRESH$1
4948
+ model: restart(model)
4812
4949
  };
4813
4950
  }
4814
4951
  const engine = others.length === 0 ? first : await pickDetectedEngine(run, candidates);
@@ -4822,7 +4959,7 @@ function connectionStringScreen(run) {
4822
4959
  return {
4823
4960
  type: "next",
4824
4961
  to: "pick-engine",
4825
- model: FRESH$1
4962
+ model: restart(model)
4826
4963
  };
4827
4964
  }
4828
4965
  return toReview(run, model, {
@@ -4832,12 +4969,16 @@ function connectionStringScreen(run) {
4832
4969
  }
4833
4970
  };
4834
4971
  }
4972
+ function finishLabel(run, model) {
4973
+ if (model.target !== null) return `Leave ${model.target.name} unconnected`;
4974
+ return run.deps.existingCount + run.created.length > 0 ? "Finish" : "Skip for now";
4975
+ }
4835
4976
  function pickEngineScreen(run) {
4836
4977
  return async (model) => {
4837
4978
  const total = run.deps.existingCount + run.created.length;
4838
4979
  const finishChoice = {
4839
4980
  value: FINISH,
4840
- label: total > 0 ? "Finish" : "Skip for now"
4981
+ label: finishLabel(run, model)
4841
4982
  };
4842
4983
  for (;;) {
4843
4984
  const picked = await run.deps.prompts.select({
@@ -4846,6 +4987,7 @@ function pickEngineScreen(run) {
4846
4987
  choices: [...run.deps.catalog.list().map(engineChoice), finishChoice],
4847
4988
  ...model.engine !== null && { initialValue: model.engine.key }
4848
4989
  });
4990
+ if (picked === "finish" && model.target !== null) return nextStub(run, model);
4849
4991
  if (picked === "finish") {
4850
4992
  if (total > 0 || await confirmSkip(run)) return {
4851
4993
  type: "done",
@@ -4863,7 +5005,7 @@ function pickEngineScreen(run) {
4863
5005
  type: "next",
4864
5006
  to: "paste-uri",
4865
5007
  model: model.engine?.key === entry.key ? model : {
4866
- ...FRESH$1,
5008
+ ...restart(model),
4867
5009
  engine: entry
4868
5010
  }
4869
5011
  };
@@ -4894,6 +5036,10 @@ async function chooseAfterParseError(run, engine, uri, error) {
4894
5036
  if (entry === void 0) throw new Error(`engine ${picked} was not offered`);
4895
5037
  return entry;
4896
5038
  }
5039
+ function reviewName(model, sameString) {
5040
+ if (model.target !== null) return model.target.name;
5041
+ return sameString ? model.name : null;
5042
+ }
4897
5043
  function toReview(run, model, connection) {
4898
5044
  const mapped = mapDatabaseValues(connection.parsed);
4899
5045
  const sameString = model.uri === connection.parsed.uri;
@@ -4901,13 +5047,14 @@ function toReview(run, model, connection) {
4901
5047
  type: "next",
4902
5048
  to: "review",
4903
5049
  model: {
5050
+ target: model.target,
4904
5051
  engine: connection.engine,
4905
5052
  uri: connection.parsed.uri,
4906
5053
  pasted: mapped.details,
4907
5054
  details: mapped.details,
4908
5055
  failure: null,
4909
5056
  suggestedName: defaultDisplayName(mapped, connection.parsed, connection.engine),
4910
- name: sameString ? model.name : null,
5057
+ name: reviewName(model, sameString),
4911
5058
  fieldErrors: null
4912
5059
  }
4913
5060
  };
@@ -5050,6 +5197,33 @@ async function fixReportedFields(run, fields, details, errors) {
5050
5197
  }
5051
5198
  return current;
5052
5199
  }
5200
+ function reviewQuestion(target, name) {
5201
+ const edit = {
5202
+ value: "edit",
5203
+ label: "Edit a field"
5204
+ };
5205
+ if (target !== null) return {
5206
+ message: `Connect ${name}?`,
5207
+ choices: [{
5208
+ value: "test",
5209
+ label: "Test and connect"
5210
+ }, edit]
5211
+ };
5212
+ return {
5213
+ message: `Add ${name}?`,
5214
+ choices: [
5215
+ {
5216
+ value: "test",
5217
+ label: "Test and add"
5218
+ },
5219
+ edit,
5220
+ {
5221
+ value: "rename",
5222
+ label: "Change the display name"
5223
+ }
5224
+ ]
5225
+ };
5226
+ }
5053
5227
  function reviewScreen(run) {
5054
5228
  const review = reviewAttempt(run);
5055
5229
  return (model) => model.failure === null ? review(model) : orFailure(model.failure, () => review({
@@ -5075,21 +5249,7 @@ function reviewAttempt(run) {
5075
5249
  if (name === null) name = await promptName(run, current, chosen.engine);
5076
5250
  const action = await run.deps.prompts.selectOrBack({
5077
5251
  key: databaseKey(ordinalOf(run), "action"),
5078
- message: `Add ${name}?`,
5079
- choices: [
5080
- {
5081
- value: "test",
5082
- label: "Test and add"
5083
- },
5084
- {
5085
- value: "edit",
5086
- label: "Edit a field"
5087
- },
5088
- {
5089
- value: "rename",
5090
- label: "Change the display name"
5091
- }
5092
- ]
5252
+ ...reviewQuestion(model.target, name)
5093
5253
  });
5094
5254
  if (action === BACK) return { type: "back" };
5095
5255
  if (action === "edit") {
@@ -5168,26 +5328,44 @@ function testScreen(run) {
5168
5328
  function isRejectedDetails(error) {
5169
5329
  return error instanceof MetabaseApiError && error.status === BAD_REQUEST_STATUS$1;
5170
5330
  }
5331
+ function commitFor(run, stub) {
5332
+ if (stub === null) return {
5333
+ verb: "add",
5334
+ progress: "Adding",
5335
+ past: "Added",
5336
+ send: (request) => run.deps.database.create(request)
5337
+ };
5338
+ return {
5339
+ verb: "connect",
5340
+ progress: "Connecting",
5341
+ past: "Connected",
5342
+ send: ({ engine, details }) => run.deps.database.connect(stub.id, {
5343
+ engine,
5344
+ details
5345
+ })
5346
+ };
5347
+ }
5171
5348
  function createScreen(run) {
5172
5349
  return async (model) => {
5173
5350
  const chosen = requireEngine(run, model);
5174
5351
  if (model.name === null) throw new Error("no display name before create");
5175
- const spinner = run.deps.ui.spinner(`Adding ${model.name}…`);
5352
+ const commit = commitFor(run, model.target ?? stubNamed(run, model.name));
5353
+ const spinner = run.deps.ui.spinner(`${commit.progress} ${model.name}…`);
5176
5354
  let database;
5177
5355
  try {
5178
- database = await run.deps.database.create({
5356
+ database = await commit.send({
5179
5357
  name: model.name,
5180
5358
  engine: chosen.engine.key,
5181
5359
  details: sentDetails(run, chosen, model.details).details
5182
5360
  });
5183
5361
  } catch (error) {
5184
5362
  if (!isRejectedDetails(error)) {
5185
- spinner.fail(`Could not add ${model.name}`);
5363
+ spinner.fail(`Could not ${commit.verb} ${model.name}`);
5186
5364
  throw error;
5187
5365
  }
5188
5366
  spinner.fail(error.message);
5189
5367
  keyRetrievalHint(run, chosen, error.message);
5190
- const failure = `Metabase could not add ${model.name}: ${error.message}`;
5368
+ const failure = `Metabase could not ${commit.verb} ${model.name}: ${error.message}`;
5191
5369
  return {
5192
5370
  type: "next",
5193
5371
  to: "review",
@@ -5199,14 +5377,15 @@ function createScreen(run) {
5199
5377
  };
5200
5378
  }
5201
5379
  run.created.push(database);
5202
- spinner.stop(`Added ${database.name}. Initial sync is running in the background; \`rde status\` shows progress.`);
5380
+ run.stubs = run.stubs.filter((candidate) => candidate.id !== database.id);
5381
+ spinner.stop(`${commit.past} ${database.name}. Initial sync is running in the background; \`rde status\` shows progress.`);
5203
5382
  run.added.push(addedDatabase(chosen, model, model.name));
5204
5383
  await run.deps.onCreated(database);
5205
- return {
5384
+ return model.target === null ? {
5206
5385
  type: "next",
5207
5386
  to: "another",
5208
5387
  model
5209
- };
5388
+ } : nextStub(run, model);
5210
5389
  };
5211
5390
  }
5212
5391
  function anotherScreen(run) {
@@ -5227,18 +5406,24 @@ function anotherScreen(run) {
5227
5406
  };
5228
5407
  return {
5229
5408
  type: "next",
5230
- to: "pick-engine",
5409
+ to: offersStubs(run) ? "pick-stubs" : "pick-engine",
5231
5410
  model: FRESH$1
5232
5411
  };
5233
5412
  };
5234
5413
  }
5414
+ function offersStubs(run) {
5415
+ return run.deps.offerStubs && run.stubs.length > 0;
5416
+ }
5235
5417
  async function runDatabaseFlow(deps) {
5236
5418
  const run = {
5237
5419
  deps,
5238
5420
  created: [],
5239
- added: []
5421
+ added: [],
5422
+ stubs: [...deps.stubs],
5423
+ queue: []
5240
5424
  };
5241
5425
  await runFlow({
5426
+ "pick-stubs": pickStubsScreen(run),
5242
5427
  "connection-string": connectionStringScreen(run),
5243
5428
  "pick-engine": pickEngineScreen(run),
5244
5429
  "paste-uri": pasteUriScreen(run),
@@ -5246,10 +5431,11 @@ async function runDatabaseFlow(deps) {
5246
5431
  test: testScreen(run),
5247
5432
  create: createScreen(run),
5248
5433
  another: anotherScreen(run)
5249
- }, START_SCREEN[deps.start], FRESH$1);
5434
+ }, offersStubs(run) ? "pick-stubs" : START_SCREEN[deps.start], FRESH$1);
5250
5435
  return {
5251
5436
  created: run.created,
5252
- added: run.added
5437
+ added: run.added,
5438
+ stubsLeft: run.stubs
5253
5439
  };
5254
5440
  }
5255
5441
  //#endregion
@@ -5305,8 +5491,9 @@ function gitTool(runner) {
5305
5491
  }
5306
5492
  async function isRepo(dir) {
5307
5493
  if (!await directoryExists(dir)) return false;
5308
- const result = await runner.run(GIT, ["rev-parse", "--is-inside-work-tree"], { cwd: dir });
5309
- return result.exitCode === 0 && result.stdout.trim() === "true";
5494
+ const result = await runner.run(GIT, ["rev-parse", "--show-toplevel"], { cwd: dir });
5495
+ if (result.exitCode !== 0) return false;
5496
+ return result.stdout.trim() === await promises.realpath(dir);
5310
5497
  }
5311
5498
  async function initBare(path, options) {
5312
5499
  await git([
@@ -5419,13 +5606,13 @@ function gitTool(runner) {
5419
5606
  }
5420
5607
  //#endregion
5421
5608
  //#region src/steps/git-sync.ts
5422
- const STEP_ID$7 = "git-sync";
5609
+ const STEP_ID$8 = "git-sync";
5423
5610
  const GIT_SYNC_KEYS = {
5424
- location: `${STEP_ID$7}.location`,
5425
- path: `${STEP_ID$7}.path`,
5426
- remote: `${STEP_ID$7}.remote`,
5427
- url: `${STEP_ID$7}.url`,
5428
- token: `${STEP_ID$7}.token`
5611
+ location: `${STEP_ID$8}.location`,
5612
+ path: `${STEP_ID$8}.path`,
5613
+ remote: `${STEP_ID$8}.remote`,
5614
+ url: `${STEP_ID$8}.url`,
5615
+ token: `${STEP_ID$8}.token`
5429
5616
  };
5430
5617
  const BARE_REPO_NAME = "content.git";
5431
5618
  const DEFAULT_BRANCH = "main";
@@ -5455,7 +5642,11 @@ const TERMINAL_TASK_STATUSES = /* @__PURE__ */ new Set([
5455
5642
  "timed-out",
5456
5643
  "conflict"
5457
5644
  ]);
5458
- function hostDeps$11(ctx) {
5645
+ const SYNC_MODE_SETTINGS = {
5646
+ "remote-sync-type": "read-write",
5647
+ "remote-sync-transforms": true
5648
+ };
5649
+ function hostDeps$12(ctx) {
5459
5650
  return {
5460
5651
  runner: processRunner(ctx.signal),
5461
5652
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
@@ -5746,7 +5937,7 @@ async function configureLocalBare(run, runtime, workdir) {
5746
5937
  },
5747
5938
  settings: {
5748
5939
  "remote-sync-url": bareUrlFor(runtime, barePath),
5749
- "remote-sync-type": "read-write",
5940
+ ...SYNC_MODE_SETTINGS,
5750
5941
  "remote-sync-branch": prepared.branch
5751
5942
  }
5752
5943
  };
@@ -5789,7 +5980,7 @@ async function configureHosted(run, workdir, url) {
5789
5980
  settings: {
5790
5981
  "remote-sync-url": url,
5791
5982
  "remote-sync-token": token,
5792
- "remote-sync-type": "read-write",
5983
+ ...SYNC_MODE_SETTINGS,
5793
5984
  "remote-sync-branch": prepared.branch
5794
5985
  }
5795
5986
  };
@@ -5862,6 +6053,7 @@ async function importExistingContent(run, gitRemote) {
5862
6053
  }
5863
6054
  spinner.stop("Import finished");
5864
6055
  reportImport(run, outcome);
6056
+ await run.client.remoteSync.syncTransforms();
5865
6057
  }
5866
6058
  const SHELL_SAFE = /^[\w./-]+$/;
5867
6059
  function shellWord(value) {
@@ -5922,15 +6114,15 @@ async function runGitSync(ctx, deps) {
5922
6114
  ctx.ui.note(usageLines$1(ctx.state, remote.gitRemote).join("\n"), "Working with the repository");
5923
6115
  return "completed";
5924
6116
  }
5925
- function gitSyncStep(deps = hostDeps$11) {
6117
+ function gitSyncStep(deps = hostDeps$12) {
5926
6118
  return {
5927
- id: STEP_ID$7,
6119
+ id: STEP_ID$8,
5928
6120
  title: "Connect a git repository",
5929
6121
  redoHint: "`rde init --only git-sync` with it points Metabase's remote sync at that repository instead.",
5930
6122
  async detect(ctx) {
5931
6123
  if (!hasRemoteSync(ctx.state.license)) return "not-applicable";
5932
- if (ctx.state.steps[STEP_ID$7]?.status === "skipped") return "not-applicable";
5933
- if (ctx.state.gitRemote === null || ctx.state.steps[STEP_ID$7]?.status !== "done") return "todo";
6124
+ if (ctx.state.steps[STEP_ID$8]?.status === "skipped") return "not-applicable";
6125
+ if (ctx.state.gitRemote === null || ctx.state.steps[STEP_ID$8]?.status !== "done") return "todo";
5934
6126
  try {
5935
6127
  return (await mbTool(deps(ctx).runner).gitSyncStatus(ctx.state.mbProfile)).branch === null ? "todo" : "done";
5936
6128
  } catch (error) {
@@ -5944,17 +6136,45 @@ function gitSyncStep(deps = hostDeps$11) {
5944
6136
  };
5945
6137
  }
5946
6138
  //#endregion
6139
+ //#region src/metabase/domain/github-content.ts
6140
+ const GitHubContentEntry = z.object({
6141
+ name: z.string(),
6142
+ type: z.string(),
6143
+ sha: z.string()
6144
+ }).loose();
6145
+ const GitHubDirectory = z.array(GitHubContentEntry);
6146
+ //#endregion
6147
+ //#region src/metabase/github.ts
6148
+ const GITHUB_API = "https://api.github.com";
6149
+ const DIRECTORY_TYPE = "dir";
6150
+ function githubContents(signal, options = {}) {
6151
+ const http = createHttp({
6152
+ baseUrl: GITHUB_API,
6153
+ auth: NO_AUTH,
6154
+ signal,
6155
+ ...options
6156
+ });
6157
+ return { async subdirectoryHashes(repository, ref, directory) {
6158
+ const path = `/repos/${repository}/contents/${directory}${ref === null ? "" : `?ref=${encodeURIComponent(ref)}`}`;
6159
+ const entries = await http.requestJson("GET", path, { schema: GitHubDirectory });
6160
+ return new Map(entries.filter((entry) => entry.type === DIRECTORY_TYPE).map((entry) => [entry.name, entry.sha]));
6161
+ } };
6162
+ }
6163
+ //#endregion
5947
6164
  //#region src/tools/skills-cli.ts
5948
6165
  const NPX = "npx";
5949
6166
  const SKILLS_PACKAGE = "skills@latest";
5950
6167
  const DEFAULT_RDE_SKILL_SOURCE = "https://github.com/metabase/agent-skills/tree/main";
5951
6168
  const RDE_SOURCE_SKILL_NAMES = ["rde", "rde-data-apps"];
5952
- const DATA_APP_SKILLS_SOURCE = "metabase/metabase/skills#master";
6169
+ const DATA_APP_REPOSITORY = "metabase/metabase";
6170
+ const DATA_APP_BRANCH = "master";
6171
+ const DATA_APP_SKILLS_SOURCE = `${DATA_APP_REPOSITORY}/skills#${DATA_APP_BRANCH}`;
5953
6172
  const DATA_APP_SKILL_NAMES = [
5954
6173
  "metabase-data-app-setup",
5955
6174
  "metabase-data-app-routing",
5956
6175
  "metabase-data-app-actions",
5957
- "metabase-data-app-semantic-layer"
6176
+ "metabase-data-app-semantic-layer",
6177
+ "metabase-data-app-migrate"
5958
6178
  ];
5959
6179
  function rdeSkillSource() {
5960
6180
  const override = readEnv(ENV_RDE_SKILL_SOURCE);
@@ -5968,7 +6188,13 @@ const InstalledSkill = z.object({
5968
6188
  source: z.string().nullable()
5969
6189
  }).loose();
5970
6190
  const InstalledSkillList = z.array(InstalledSkill);
5971
- const SkillLock = z.object({ skills: z.record(z.string(), z.object({ ref: z.string().nullish() }).loose()) }).loose();
6191
+ const LockedSkill = z.object({
6192
+ source: z.string().nullish(),
6193
+ ref: z.string().nullish(),
6194
+ skillPath: z.string().nullish(),
6195
+ skillFolderHash: z.string().nullish()
6196
+ }).loose();
6197
+ const SkillLock = z.object({ skills: z.record(z.string(), LockedSkill) }).loose();
5972
6198
  const GLOBAL_LOCK_FILE = ".skill-lock.json";
5973
6199
  const PROJECT_LOCK_FILE = "skills-lock.json";
5974
6200
  function globalSkillLockPath(home, stateHome) {
@@ -5981,16 +6207,21 @@ function xdgStateHome() {
5981
6207
  const value = readEnv(ENV_XDG_STATE_HOME);
5982
6208
  return value === void 0 || value.length === 0 ? null : value;
5983
6209
  }
5984
- function lockedRef(path, raw, skill) {
6210
+ function lockedSkill(path, raw, skill) {
5985
6211
  if (raw === null) return null;
5986
6212
  const parsed = SkillLock.safeParse(raw);
5987
6213
  if (!parsed.success) throw new ConfigError(`${path} is not a skills lock file: ${parsed.error.message}`);
5988
- return parsed.data.skills[skill]?.ref ?? null;
6214
+ return parsed.data.skills[skill] ?? null;
5989
6215
  }
5990
6216
  const hostSkillFiles = {
5991
6217
  readJson: readJsonFile,
5992
6218
  readText: readTextFile
5993
6219
  };
6220
+ const DATA_APP_SKILLS_ORIGIN = {
6221
+ kind: "github",
6222
+ repository: DATA_APP_REPOSITORY,
6223
+ ref: DATA_APP_BRANCH
6224
+ };
5994
6225
  const GITHUB_URL = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+)(?:\/.*)?)?\/?$/;
5995
6226
  function skillOrigin(source, cwd) {
5996
6227
  const github = GITHUB_URL.exec(source);
@@ -6071,16 +6302,20 @@ function skillsCli(runner) {
6071
6302
  }
6072
6303
  //#endregion
6073
6304
  //#region src/steps/agents-skills.ts
6074
- const STEP_ID$6 = "agents-skills";
6075
- const AGENTS_SKILLS_KEYS = { agents: `${STEP_ID$6}.agents` };
6305
+ const STEP_ID$7 = "agents-skills";
6306
+ const AGENTS_SKILLS_KEYS = { agents: `${STEP_ID$7}.agents` };
6076
6307
  const MECHANICS_NOTE = "The rde skill's mechanics come from `mb skills get <name>` at runtime, so nothing else needs installing.";
6077
6308
  const REQUIRED_SKILL_NAMES = [...DATA_APP_SKILL_NAMES, ...RDE_SOURCE_SKILL_NAMES];
6078
6309
  const DETECT_AGENTS = { kind: "detected" };
6079
- function hostDeps$10(ctx) {
6310
+ function hostDeps$11(ctx) {
6080
6311
  return {
6081
6312
  runner: processRunner(ctx.signal),
6082
6313
  probe: hostAgentProbe(),
6083
- skillSource: rdeSkillSource()
6314
+ skillSource: rdeSkillSource(),
6315
+ stateHome: xdgStateHome(),
6316
+ files: hostSkillFiles,
6317
+ github: githubContents(ctx.signal),
6318
+ cwd: process.cwd()
6084
6319
  };
6085
6320
  }
6086
6321
  function detectedOnly(agents) {
@@ -6115,10 +6350,91 @@ function coveredAgentNames(entry, agents) {
6115
6350
  function installedLines(entries, agents) {
6116
6351
  return entries.map((entry) => `${entry.name} (${coveredAgentNames(entry, agents).join(", ")}): ${entry.path}`).join("\n");
6117
6352
  }
6353
+ function skillSets(deps) {
6354
+ return [{
6355
+ label: "data-app",
6356
+ source: DATA_APP_SKILLS_SOURCE,
6357
+ sourceNote: "",
6358
+ origin: DATA_APP_SKILLS_ORIGIN,
6359
+ skills: DATA_APP_SKILL_NAMES
6360
+ }, {
6361
+ label: "rde",
6362
+ source: deps.skillSource,
6363
+ sourceNote: deps.skillSource === "https://github.com/metabase/agent-skills/tree/main" ? "" : ` from ${deps.skillSource}`,
6364
+ origin: skillOrigin(deps.skillSource, deps.cwd),
6365
+ skills: RDE_SOURCE_SKILL_NAMES
6366
+ }];
6367
+ }
6368
+ function hashLookup(github) {
6369
+ const asked = /* @__PURE__ */ new Map();
6370
+ return (repository, ref, directory) => {
6371
+ const key = JSON.stringify([
6372
+ repository,
6373
+ ref,
6374
+ directory
6375
+ ]);
6376
+ let hashes = asked.get(key);
6377
+ if (hashes === void 0) {
6378
+ hashes = github.subdirectoryHashes(repository, ref, directory).catch((error) => {
6379
+ if (lookupFailure(error) === null) throw error;
6380
+ return null;
6381
+ });
6382
+ asked.set(key, hashes);
6383
+ }
6384
+ return hashes;
6385
+ };
6386
+ }
6387
+ async function readLockedSkills(deps) {
6388
+ const lockPath = globalSkillLockPath(deps.probe.home, deps.stateHome);
6389
+ try {
6390
+ const lock = await deps.files.readJson(lockPath);
6391
+ return new Map(REQUIRED_SKILL_NAMES.map((name) => [name, lockedSkill(lockPath, lock, name)]));
6392
+ } catch (error) {
6393
+ if (error instanceof ConfigError) return null;
6394
+ throw error;
6395
+ }
6396
+ }
6397
+ function lockedCopy(locked, origin) {
6398
+ if (locked === null || locked.source !== origin.repository) return null;
6399
+ const ref = locked.ref ?? null;
6400
+ if (origin.ref !== null && ref !== origin.ref) return null;
6401
+ if (locked.skillPath === null || locked.skillPath === void 0) return null;
6402
+ if (locked.skillFolderHash === null || locked.skillFolderHash === void 0) return null;
6403
+ const folder = posix.dirname(locked.skillPath);
6404
+ return {
6405
+ ref,
6406
+ directory: posix.dirname(folder),
6407
+ folder: posix.basename(folder),
6408
+ hash: locked.skillFolderHash
6409
+ };
6410
+ }
6411
+ async function setBehind(set, check) {
6412
+ const { origin } = set;
6413
+ if (origin.kind !== "github" || check.locked === null) return true;
6414
+ const copies = [];
6415
+ for (const skill of set.skills) {
6416
+ const copy = lockedCopy(check.locked.get(skill) ?? null, origin);
6417
+ if (copy === null) return true;
6418
+ copies.push(copy);
6419
+ }
6420
+ if (check.updates === "installed") return false;
6421
+ for (const copy of copies) {
6422
+ const current = await check.hashes(origin.repository, copy.ref, copy.directory);
6423
+ if (current !== null && current.get(copy.folder) !== copy.hash) return true;
6424
+ }
6425
+ return false;
6426
+ }
6427
+ function hasSkill(installed, skill, agent) {
6428
+ return installed.some((entry) => entry.name === skill && skillCoversAgent(entry, agent));
6429
+ }
6430
+ function coversAll(installed, skills, agents) {
6431
+ return agents.every((agent) => skills.every((skill) => hasSkill(installed, skill, agent)));
6432
+ }
6118
6433
  const SOURCE_NOTES = {
6119
6434
  override: "from --agents",
6120
6435
  detected: "detected on this machine",
6121
- chosen: "as chosen"
6436
+ chosen: "as chosen",
6437
+ installed: "updating the installed copies"
6122
6438
  };
6123
6439
  function agentChoice(agent) {
6124
6440
  return {
@@ -6136,7 +6452,7 @@ async function askAgents(ctx, detected) {
6136
6452
  });
6137
6453
  return detected.filter((agent) => picked.includes(agent.id));
6138
6454
  }
6139
- async function selectAgents(ctx, all, options) {
6455
+ async function selectAgents(ctx, all, options, installedNow) {
6140
6456
  switch (options.kind) {
6141
6457
  case "detected": return {
6142
6458
  agents: detectedOnly(all),
@@ -6155,6 +6471,10 @@ async function selectAgents(ctx, all, options) {
6155
6471
  agents: [],
6156
6472
  source: "detected"
6157
6473
  };
6474
+ if (coversAll(await installedNow(), REQUIRED_SKILL_NAMES, detected)) return {
6475
+ agents: detected,
6476
+ source: "installed"
6477
+ };
6158
6478
  return {
6159
6479
  agents: await askAgents(ctx, detected),
6160
6480
  source: "chosen"
@@ -6165,47 +6485,65 @@ async function selectAgents(ctx, all, options) {
6165
6485
  function skipLine(all, source) {
6166
6486
  switch (source) {
6167
6487
  case "override":
6168
- case "chosen": return `Skipping the skills; run \`rde init --only ${STEP_ID$6}\` to install it later`;
6169
- case "detected": return `No coding agent found (${displayNames(all)}); run \`rde init --only ${STEP_ID$6} --agents <name>\` once one is installed`;
6488
+ case "chosen":
6489
+ case "installed": return `Skipping the skills; run \`rde init --only ${STEP_ID$7}\` to install it later`;
6490
+ case "detected": return `No coding agent found (${displayNames(all)}); run \`rde init --only ${STEP_ID$7} --agents <name>\` once one is installed`;
6170
6491
  }
6171
6492
  }
6172
- function agentsSkillsStep(options, deps = hostDeps$10) {
6493
+ function agentsSkillsStep(options, updates, deps = hostDeps$11) {
6494
+ let hashes = null;
6495
+ async function setCheck(resolved) {
6496
+ hashes ??= hashLookup(resolved.github);
6497
+ return {
6498
+ updates,
6499
+ locked: await readLockedSkills(resolved),
6500
+ hashes
6501
+ };
6502
+ }
6173
6503
  return {
6174
- id: STEP_ID$6,
6504
+ id: STEP_ID$7,
6175
6505
  title: "Install the rde and data-app skills for your coding agents",
6176
6506
  redoHint: "`rde init --only agents-skills` with it installs the skills for those agents.",
6177
6507
  async detect(ctx) {
6178
6508
  const resolved = deps(ctx);
6179
6509
  const detected = detectedOnly(await detectAgents(resolved.probe));
6180
- if (detected.length === 0) return ctx.state.steps[STEP_ID$6]?.status === "skipped" ? "not-applicable" : "todo";
6181
- return missingSkills(await skillsCli(resolved.runner).list(), detected).length === 0 ? "done" : "todo";
6510
+ if (detected.length === 0) return ctx.state.steps[STEP_ID$7]?.status === "skipped" ? "not-applicable" : "todo";
6511
+ if (missingSkills(await skillsCli(resolved.runner).list(), detected).length > 0) return "todo";
6512
+ const check = await setCheck(resolved);
6513
+ for (const set of skillSets(resolved)) if (await setBehind(set, check)) return "outdated";
6514
+ return "done";
6182
6515
  },
6183
6516
  async run(ctx) {
6184
6517
  const resolved = deps(ctx);
6518
+ const skills = skillsCli(resolved.runner);
6185
6519
  const all = await detectAgents(resolved.probe);
6186
- const selection = await selectAgents(ctx, all, options);
6520
+ let listing = null;
6521
+ const installedNow = () => listing ??= skills.list();
6522
+ const selection = await selectAgents(ctx, all, options, installedNow);
6187
6523
  if (selection.agents.length === 0) {
6188
6524
  ctx.ui.info(skipLine(all, selection.source));
6189
6525
  return { outcome: "skipped" };
6190
6526
  }
6191
- const skills = skillsCli(resolved.runner);
6527
+ const before = await installedNow();
6528
+ const check = await setCheck(resolved);
6529
+ const pending = [];
6530
+ for (const set of skillSets(resolved)) if (!coversAll(before, set.skills, selection.agents) || await setBehind(set, check)) pending.push(set);
6531
+ const names = displayNames(selection.agents);
6532
+ if (pending.length === 0) {
6533
+ ctx.ui.success(`The rde and data-app skills are current for ${names}`);
6534
+ return { outcome: "completed" };
6535
+ }
6192
6536
  const agents = selection.agents.map((agent) => agent.skillsCliName);
6193
- const forWhom = `for ${displayNames(selection.agents)} (${SOURCE_NOTES[selection.source]})`;
6194
- ctx.ui.info(`Installing the data-app skills ${forWhom}`);
6195
- await skills.add({
6196
- source: DATA_APP_SKILLS_SOURCE,
6197
- skills: DATA_APP_SKILL_NAMES,
6198
- agents,
6199
- output: ctx.ui.childOutput
6200
- });
6201
- const from = resolved.skillSource === "https://github.com/metabase/agent-skills/tree/main" ? "" : ` from ${resolved.skillSource}`;
6202
- ctx.ui.info(`Installing the rde skills ${forWhom}${from}`);
6203
- await skills.add({
6204
- source: resolved.skillSource,
6205
- skills: RDE_SOURCE_SKILL_NAMES,
6206
- agents,
6207
- output: ctx.ui.childOutput
6208
- });
6537
+ const forWhom = `for ${names} (${SOURCE_NOTES[selection.source]})`;
6538
+ for (const set of pending) {
6539
+ ctx.ui.info(`Installing the ${set.label} skills ${forWhom}${set.sourceNote}`);
6540
+ await skills.add({
6541
+ source: set.source,
6542
+ skills: set.skills,
6543
+ agents,
6544
+ output: ctx.ui.childOutput
6545
+ });
6546
+ }
6209
6547
  const installed = await skills.list();
6210
6548
  const missing = unlistedSkills(installed);
6211
6549
  if (missing.length > 0) throw new ConfigError(`the skills installer finished but \`skills list\` does not show ${missing.join(", ")}`);
@@ -6217,7 +6555,7 @@ function agentsSkillsStep(options, deps = hostDeps$10) {
6217
6555
  }
6218
6556
  //#endregion
6219
6557
  //#region src/steps/databases.ts
6220
- const STEP_ID$5 = "databases";
6558
+ const STEP_ID$6 = "databases";
6221
6559
  const SAMPLE_STANDS_IN = { request: "sample-stands-in" };
6222
6560
  function hostDatabasesDeps(signal) {
6223
6561
  return {
@@ -6226,7 +6564,7 @@ function hostDatabasesDeps(signal) {
6226
6564
  now: () => /* @__PURE__ */ new Date()
6227
6565
  };
6228
6566
  }
6229
- function isNotServingYet$1(error) {
6567
+ function isNotServingYet$2(error) {
6230
6568
  return error instanceof MetabaseUnreachableError || error instanceof MetabaseInitializingError;
6231
6569
  }
6232
6570
  function requireApiKey$2(state, apiKey) {
@@ -6255,14 +6593,15 @@ function authenticatedClient(state, apiKey, deps) {
6255
6593
  });
6256
6594
  }
6257
6595
  async function listDatabases(state, apiKey, deps) {
6258
- const listed = await authenticatedClient(state, apiKey, deps).database.list();
6596
+ const listed = await authenticatedClient(state, apiKey, deps).database.listWithStubs();
6259
6597
  const records = new Map(state.databases.map((record) => [record.id, record]));
6260
6598
  const onServer = listed.data.map((database) => ({
6261
6599
  id: database.id,
6262
6600
  name: database.name,
6263
6601
  engine: database.engine,
6264
6602
  initialSyncStatus: database.initial_sync_status,
6265
- createdAt: records.get(database.id)?.createdAt ?? null
6603
+ createdAt: records.get(database.id)?.createdAt ?? null,
6604
+ stub: isStub(database)
6266
6605
  }));
6267
6606
  const serverIds = new Set(listed.data.map((database) => database.id));
6268
6607
  const missing = state.databases.filter((record) => !serverIds.has(record.id)).map((record) => ({
@@ -6270,24 +6609,27 @@ async function listDatabases(state, apiKey, deps) {
6270
6609
  name: record.name,
6271
6610
  engine: record.engine,
6272
6611
  initialSyncStatus: null,
6273
- createdAt: record.createdAt
6612
+ createdAt: record.createdAt,
6613
+ stub: false
6274
6614
  }));
6275
6615
  return [...onServer, ...missing];
6276
6616
  }
6277
- async function connectDatabases(ctx, deps, start) {
6617
+ async function connectDatabases(ctx, deps, opening) {
6278
6618
  const configured = requireConfiguredRuntime(ctx.state);
6279
6619
  const client = authenticatedClient(ctx.state, ctx.secrets.apiKey, deps);
6280
- const properties = await client.session.properties();
6281
- const existing = await client.database.list();
6282
- if (existing.data.length > 0) ctx.ui.note(existingDatabaseLines(existing.data).join("\n"), "Connected databases");
6620
+ const engines = await client.setting.engines();
6621
+ const listed = await client.database.listWithStubs();
6622
+ const existing = listed.data.filter((database) => !isStub(database));
6623
+ if (existing.length > 0) ctx.ui.note(existingDatabaseLines(existing).join("\n"), "Connected databases");
6283
6624
  const result = await runDatabaseFlow({
6284
- start,
6625
+ ...opening,
6626
+ stubs: listed.data.filter(isStub),
6285
6627
  prompts: ctx.prompts,
6286
6628
  ui: ctx.ui,
6287
- catalog: engineCatalog(properties.engines),
6629
+ catalog: engineCatalog(engines),
6288
6630
  hostAlias: hostAliasOf(configured.runtime),
6289
6631
  database: client.database,
6290
- existingCount: existing.data.length,
6632
+ existingCount: existing.length,
6291
6633
  readTextFile: deps.readTextFile,
6292
6634
  onCreated: async (database) => {
6293
6635
  const record = toRecord$1(database, deps.now().toISOString());
@@ -6297,27 +6639,34 @@ async function connectDatabases(ctx, deps, start) {
6297
6639
  });
6298
6640
  }
6299
6641
  });
6642
+ if (result.stubsLeft.length > 0) ctx.ui.info(unconnectedStubsLine(result.stubsLeft));
6300
6643
  return {
6301
- existing: existing.data.length,
6644
+ existing: existing.length,
6302
6645
  created: result.created.length,
6303
6646
  added: result.added
6304
6647
  };
6305
6648
  }
6306
- async function addDatabases(ctx, deps) {
6307
- const connected = await connectDatabases(ctx, deps, "engine");
6649
+ async function addDatabases(ctx, deps, options) {
6650
+ const connected = await connectDatabases(ctx, deps, {
6651
+ start: "engine",
6652
+ ...options
6653
+ });
6308
6654
  return {
6309
6655
  outcome: connected.existing + connected.created > 0 ? "completed" : "skipped",
6310
6656
  result: { addedDatabases: connected.added }
6311
6657
  };
6312
6658
  }
6313
6659
  async function askForOwnDatabase(ctx, deps) {
6314
- const connected = await connectDatabases(ctx, deps, "connection-string");
6660
+ const connected = await connectDatabases(ctx, deps, {
6661
+ start: "connection-string",
6662
+ offerStubs: true
6663
+ });
6315
6664
  return {
6316
6665
  outcome: connected.created > 0 ? "completed" : "skipped",
6317
6666
  result: { addedDatabases: connected.added }
6318
6667
  };
6319
6668
  }
6320
- function hostDeps$9(ctx) {
6669
+ function hostDeps$10(ctx) {
6321
6670
  return {
6322
6671
  ...hostDatabasesDeps(ctx.signal),
6323
6672
  instanceRunning: () => isInstanceRunning(ctx.state, hostRuntimeDeps(ctx.state.name, ctx.signal))
@@ -6327,15 +6676,15 @@ function sampleDatabaseId(state) {
6327
6676
  return state.sampleDatabase === null ? null : state.sampleDatabase.databaseId;
6328
6677
  }
6329
6678
  function skippedBefore(state) {
6330
- return state.steps[STEP_ID$5]?.status === "skipped";
6679
+ return state.steps[STEP_ID$6]?.status === "skipped";
6331
6680
  }
6332
6681
  function ownDatabaseIds(state) {
6333
6682
  const sampleId = sampleDatabaseId(state);
6334
6683
  return new Set(state.databases.filter((record) => record.id !== sampleId).map((record) => record.id));
6335
6684
  }
6336
- function databasesStep(options, deps = hostDeps$9) {
6685
+ function databasesStep(options, deps = hostDeps$10) {
6337
6686
  return {
6338
- id: STEP_ID$5,
6687
+ id: STEP_ID$6,
6339
6688
  title: "Connect your databases",
6340
6689
  redoHint: "Connect another with `rde db add --url <connection string>`.",
6341
6690
  async detect(ctx) {
@@ -6358,22 +6707,40 @@ function databasesStep(options, deps = hostDeps$9) {
6358
6707
  try {
6359
6708
  return (await client.database.list()).data.some((database) => isOwn(database.id)) ? "done" : "todo";
6360
6709
  } catch (error) {
6361
- if (isNotServingYet$1(error) || isRejectedCredential(error)) return "todo";
6710
+ if (isNotServingYet$2(error) || isRejectedCredential(error)) return "todo";
6362
6711
  throw error;
6363
6712
  }
6364
6713
  },
6365
6714
  async run(ctx) {
6366
6715
  switch (options.request) {
6367
6716
  case "ask-once": return askForOwnDatabase(ctx, deps(ctx));
6368
- case "named": return addDatabases(ctx, deps(ctx));
6717
+ case "named": return addDatabases(ctx, deps(ctx), { offerStubs: false });
6369
6718
  case "sample-stands-in": return { outcome: "skipped" };
6370
6719
  }
6371
6720
  }
6372
6721
  };
6373
6722
  }
6374
6723
  //#endregion
6724
+ //#region src/metabase/domain/npm-package.ts
6725
+ const NpmVersionManifest = z.object({ version: z.string() }).loose();
6726
+ //#endregion
6727
+ //#region src/metabase/npm-registry.ts
6728
+ const NPM_REGISTRY = "https://registry.npmjs.org";
6729
+ function npmRegistry(signal, options = {}) {
6730
+ const http = createHttp({
6731
+ baseUrl: NPM_REGISTRY,
6732
+ auth: NO_AUTH,
6733
+ signal,
6734
+ ...options
6735
+ });
6736
+ return { async distTagVersion(packageName, tag) {
6737
+ const path = `/${encodeURIComponent(packageName)}/${encodeURIComponent(tag)}`;
6738
+ return (await http.requestJson("GET", path, { schema: NpmVersionManifest })).version;
6739
+ } };
6740
+ }
6741
+ //#endregion
6375
6742
  //#region src/steps/install-mb.ts
6376
- const STEP_ID$4 = "install-mb";
6743
+ const STEP_ID$5 = "install-mb";
6377
6744
  const MIN_MB_VERSION = "0.3.0";
6378
6745
  const MB_RANGE = `>=${MIN_MB_VERSION}`;
6379
6746
  function installedWith(scriptPath) {
@@ -6382,11 +6749,12 @@ function installedWith(scriptPath) {
6382
6749
  function rdeInstalledWith() {
6383
6750
  return installedWith(process.argv[1]);
6384
6751
  }
6385
- function hostDeps$8(ctx) {
6752
+ function hostDeps$9(ctx) {
6386
6753
  return {
6387
6754
  runner: processRunner(ctx.signal),
6388
6755
  installedWith: rdeInstalledWith(),
6389
- locate: locateMbOnPath
6756
+ locate: locateMbOnPath,
6757
+ publishedVersion: () => npmRegistry(ctx.signal).distTagVersion(MB_PACKAGE, MB_DIST_TAG)
6390
6758
  };
6391
6759
  }
6392
6760
  async function inspectMb(runner) {
@@ -6400,6 +6768,38 @@ async function inspectMb(runner) {
6400
6768
  satisfies: semver.satisfies(version, MB_RANGE, { includePrerelease: true })
6401
6769
  };
6402
6770
  }
6771
+ const UNCHECKED = { kind: "unchecked" };
6772
+ async function publishedMb(deps) {
6773
+ let raw;
6774
+ try {
6775
+ raw = await deps.publishedVersion();
6776
+ } catch (error) {
6777
+ const reason = lookupFailure(error);
6778
+ if (reason === null) throw error;
6779
+ return {
6780
+ kind: "unknown",
6781
+ reason
6782
+ };
6783
+ }
6784
+ const version = semver.valid(raw);
6785
+ if (version === null) return {
6786
+ kind: "unknown",
6787
+ reason: `the registry names "${raw}" for ${MB_DIST_TAG}, which is not a version`
6788
+ };
6789
+ return {
6790
+ kind: "published",
6791
+ version
6792
+ };
6793
+ }
6794
+ function coreOf(version) {
6795
+ return `${semver.major(version)}.${semver.minor(version)}.${semver.patch(version)}`;
6796
+ }
6797
+ function updateTo(release, installed) {
6798
+ if (release.kind !== "published" || release.version === installed) return null;
6799
+ const order = semver.compare(coreOf(installed), coreOf(release.version));
6800
+ if (order === 0) return semver.prerelease(installed) === null ? null : release.version;
6801
+ return order < 0 ? release.version : null;
6802
+ }
6403
6803
  function displayCommand(install) {
6404
6804
  return [install.command, ...install.args].join(" ");
6405
6805
  }
@@ -6498,27 +6898,66 @@ async function stillWrong(install, after, deps) {
6498
6898
  `${stillWrongAdvice(diagnosis)}.`
6499
6899
  ].join(" "));
6500
6900
  }
6501
- function installMbStep(deps = hostDeps$8) {
6901
+ async function shadowedWarning(lead, deps) {
6902
+ const found = await inspectMbOnPath(deps.runner, deps.locate);
6903
+ const diagnosis = diagnoseMb(found, deps.installedWith);
6904
+ const advice = diagnosis.kind === "shadowed" ? ` ${shadowedHint(diagnosis)}.` : "";
6905
+ return `${lead} Every mb on PATH, in order: ${mbPathLines(found).join("; ")}.${advice}`;
6906
+ }
6907
+ async function updateFor(release, installed, deps) {
6908
+ const version = updateTo(release, installed);
6909
+ if (version === null) return { kind: "current" };
6910
+ return (await inspectMbOnPath(deps.runner, deps.locate)).some((entry) => entry.version === version) ? {
6911
+ kind: "shadowed",
6912
+ version
6913
+ } : {
6914
+ kind: "update",
6915
+ version
6916
+ };
6917
+ }
6918
+ function installMbStep(updates, deps = hostDeps$9) {
6919
+ let published = null;
6920
+ function release(resolved) {
6921
+ if (updates === "installed") return Promise.resolve(UNCHECKED);
6922
+ published ??= publishedMb(resolved);
6923
+ return published;
6924
+ }
6502
6925
  return {
6503
- id: STEP_ID$4,
6926
+ id: STEP_ID$5,
6504
6927
  title: "Install the Metabase CLI",
6505
6928
  async detect(ctx) {
6506
- const found = await inspectMb(deps(ctx).runner);
6507
- return found.installed && found.satisfies ? "done" : "todo";
6929
+ const resolved = deps(ctx);
6930
+ const found = await inspectMb(resolved.runner);
6931
+ if (!found.installed || !found.satisfies) return "todo";
6932
+ return (await updateFor(await release(resolved), found.version, resolved)).kind === "update" ? "outdated" : "done";
6508
6933
  },
6509
6934
  async run(ctx) {
6510
6935
  const resolved = deps(ctx);
6511
6936
  const before = await inspectMb(resolved.runner);
6937
+ const target = await release(resolved);
6512
6938
  if (before.installed && before.satisfies) {
6513
- ctx.ui.success(`mb ${before.version} is installed`);
6514
- return { outcome: "completed" };
6515
- }
6516
- if (before.installed) ctx.ui.warn(`mb ${before.version} is installed; ${MIN_MB_VERSION} or newer is required`);
6939
+ if (target.kind === "unknown") ctx.ui.warn(`Could not check for a newer mb: ${target.reason}`);
6940
+ const update = await updateFor(target, before.version, resolved);
6941
+ if (update.kind === "shadowed") {
6942
+ const lead = `mb ${before.version} runs first; ${update.version} is installed later on PATH.`;
6943
+ ctx.ui.warn(await shadowedWarning(lead, resolved));
6944
+ }
6945
+ if (update.kind !== "update") {
6946
+ ctx.ui.success(`mb ${before.version} is installed`);
6947
+ return { outcome: "completed" };
6948
+ }
6949
+ ctx.ui.info(`mb ${before.version} is installed; updating to ${update.version}`);
6950
+ } else if (before.installed) ctx.ui.warn(`mb ${before.version} is installed; ${MIN_MB_VERSION} or newer is required`);
6517
6951
  const install = mbInstallCommand(resolved.installedWith);
6518
6952
  ctx.ui.info(`Running ${displayCommand(install)}`);
6519
6953
  await runShown(resolved.runner, ctx.ui.childOutput, install.command, install.args);
6520
6954
  const after = await inspectMb(resolved.runner);
6521
6955
  if (!after.installed || !after.satisfies) throw await stillWrong(install, after, resolved);
6956
+ const stillNewer = updateTo(target, after.version);
6957
+ if (stillNewer !== null) {
6958
+ const lead = `mb ${after.version} still runs first, not the ${stillNewer} just installed.`;
6959
+ ctx.ui.warn(await shadowedWarning(lead, resolved));
6960
+ }
6522
6961
  ctx.ui.success(`mb ${after.version} is installed`);
6523
6962
  return { outcome: "completed" };
6524
6963
  }
@@ -6526,11 +6965,11 @@ function installMbStep(deps = hostDeps$8) {
6526
6965
  }
6527
6966
  //#endregion
6528
6967
  //#region src/steps/license.ts
6529
- const STEP_ID$3 = "license";
6530
- const LICENSE_KEYS = { token: `${STEP_ID$3}.token` };
6968
+ const STEP_ID$4 = "license";
6969
+ const LICENSE_KEYS = { token: `${STEP_ID$4}.token` };
6531
6970
  const TOKEN_PATTERN = /^[0-9a-f]{64}$/i;
6532
6971
  const NOT_FOUND_STATUS$2 = 404;
6533
- function hostDeps$7(ctx) {
6972
+ function hostDeps$8(ctx) {
6534
6973
  return {
6535
6974
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
6536
6975
  runner: processRunner(ctx.signal),
@@ -6553,13 +6992,13 @@ function activeLabel(status) {
6553
6992
  const plan = status["plan-alias"];
6554
6993
  return plan === void 0 ? "License active" : `License active (plan ${plan})`;
6555
6994
  }
6556
- function licenseStep(deps = hostDeps$7) {
6995
+ function licenseStep(deps = hostDeps$8) {
6557
6996
  return {
6558
- id: STEP_ID$3,
6997
+ id: STEP_ID$4,
6559
6998
  title: "Activate your license",
6560
6999
  redoHint: "`rde init --from license` with it activates the license and runs the steps after it again.",
6561
7000
  async detect(ctx) {
6562
- if (ctx.state.steps[STEP_ID$3]?.status === "skipped") return "not-applicable";
7001
+ if (ctx.state.steps[STEP_ID$4]?.status === "skipped") return "not-applicable";
6563
7002
  const configured = configuredRuntime(ctx.state);
6564
7003
  if (configured === null || ctx.secrets.apiKey === null) return "todo";
6565
7004
  const resolved = deps(ctx);
@@ -6650,7 +7089,7 @@ const PLATFORMS = {
6650
7089
  javaHint: `Install Java 21 or newer with your distribution's package manager (openjdk-21-jre) or from https://adoptium.net`
6651
7090
  }
6652
7091
  };
6653
- function hostDeps$6(ctx) {
7092
+ function hostDeps$7(ctx) {
6654
7093
  const runner = processRunner(ctx.signal);
6655
7094
  return {
6656
7095
  platform: process.platform,
@@ -6772,7 +7211,7 @@ function checkLine(check) {
6772
7211
  function checkLines(checks) {
6773
7212
  return checks.map(checkLine);
6774
7213
  }
6775
- function preflightStep(deps = hostDeps$6) {
7214
+ function preflightStep(deps = hostDeps$7) {
6776
7215
  return {
6777
7216
  id: "preflight",
6778
7217
  title: "Check this machine",
@@ -6791,7 +7230,7 @@ function preflightStep(deps = hostDeps$6) {
6791
7230
  }
6792
7231
  //#endregion
6793
7232
  //#region src/steps/runtime.ts
6794
- const STEP_ID$2 = "runtime";
7233
+ const STEP_ID$3 = "runtime";
6795
7234
  const MIN_PORT = 1;
6796
7235
  const MAX_PORT = 65535;
6797
7236
  const PORT_PATTERN = /^\d+$/;
@@ -6805,7 +7244,7 @@ const NO_RUNTIME_FLAGS = {
6805
7244
  jar: null,
6806
7245
  port: null
6807
7246
  };
6808
- function hostDeps$5(ctx) {
7247
+ function hostDeps$6(ctx) {
6809
7248
  return {
6810
7249
  runtime: hostRuntimeDeps(ctx.state.name, ctx.signal),
6811
7250
  releases: releaseLookup(ctx.signal)
@@ -6934,9 +7373,9 @@ async function persistedRuntime(ctx, options, deps) {
6934
7373
  });
6935
7374
  return chosen;
6936
7375
  }
6937
- function runtimeStep(options, deps = hostDeps$5) {
7376
+ function runtimeStep(options, deps = hostDeps$6) {
6938
7377
  return {
6939
- id: STEP_ID$2,
7378
+ id: STEP_ID$3,
6940
7379
  title: "Install Metabase",
6941
7380
  redoHint: "Move it with `rde start --port`, or change its Metabase with `rde upgrade --to`.",
6942
7381
  async detect(ctx) {
@@ -6962,11 +7401,11 @@ function runtimeStep(options, deps = hostDeps$5) {
6962
7401
  }
6963
7402
  //#endregion
6964
7403
  //#region src/steps/sample-database.ts
6965
- const STEP_ID$1 = "sample-database";
7404
+ const STEP_ID$2 = "sample-database";
6966
7405
  const SAMPLE_DATABASE_DISPLAY_NAME = "Sample Database";
6967
7406
  const POSTGRES_ENGINE = "postgres";
6968
7407
  const NOT_FOUND_STATUS$1 = 404;
6969
- function hostDeps$4(ctx) {
7408
+ function hostDeps$5(ctx) {
6970
7409
  return {
6971
7410
  runtime: hostRuntimeDeps(ctx.state.name, ctx.signal),
6972
7411
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
@@ -6974,7 +7413,7 @@ function hostDeps$4(ctx) {
6974
7413
  now: () => /* @__PURE__ */ new Date()
6975
7414
  };
6976
7415
  }
6977
- function isNotServingYet(error) {
7416
+ function isNotServingYet$1(error) {
6978
7417
  return error instanceof MetabaseUnreachableError || error instanceof MetabaseInitializingError;
6979
7418
  }
6980
7419
  function isNotFound$2(error) {
@@ -7117,11 +7556,11 @@ async function connectDatabase(ctx, choice, configured, client, deps) {
7117
7556
  return database;
7118
7557
  }
7119
7558
  function usageLines(record) {
7120
- return [`psql "${sampleDatabaseUrl(record.port)}"`, "Transforms are enabled; this database is where they can write."].join("\n");
7559
+ return [`psql "${sampleDatabaseUrl(record.port)}"`, "Transforms can write to this database."].join("\n");
7121
7560
  }
7122
- function sampleDatabaseStep(deps = hostDeps$4) {
7561
+ function sampleDatabaseStep(deps = hostDeps$5) {
7123
7562
  return {
7124
- id: STEP_ID$1,
7563
+ id: STEP_ID$2,
7125
7564
  title: "Connect a sample database",
7126
7565
  async detect(ctx) {
7127
7566
  const resolved = deps(ctx);
@@ -7137,7 +7576,7 @@ function sampleDatabaseStep(deps = hostDeps$4) {
7137
7576
  try {
7138
7577
  return await recordedDatabase(record, client) === null ? "todo" : "done";
7139
7578
  } catch (error) {
7140
- if (isNotServingYet(error) || isRejectedCredential(error)) return "todo";
7579
+ if (isNotServingYet$1(error) || isRejectedCredential(error)) return "todo";
7141
7580
  throw error;
7142
7581
  }
7143
7582
  },
@@ -7149,16 +7588,13 @@ function sampleDatabaseStep(deps = hostDeps$4) {
7149
7588
  if (!docker.available) throw new ConfigError(`the sample database runs in Docker, which is not available: ${docker.reason}`);
7150
7589
  const persisted = await persistedRecord(ctx, resolved);
7151
7590
  const record = await installAndStart(ctx, sampleDatabaseContainer(persisted.record, resolved.runtime.docker, resolved.runtime.rdeHome), persisted.record, resolved);
7152
- const choice = {
7591
+ await connectDatabase(ctx, {
7153
7592
  ...persisted,
7154
7593
  record
7155
- };
7156
- const client = resolved.client(configured.url, {
7594
+ }, configured, resolved.client(configured.url, {
7157
7595
  kind: "api-key",
7158
7596
  key: apiKey
7159
- });
7160
- await connectDatabase(ctx, choice, configured, client, resolved);
7161
- await client.setting.enableTransforms();
7597
+ }), resolved);
7162
7598
  ctx.ui.note(usageLines(record), "Sample database");
7163
7599
  return { outcome: "completed" };
7164
7600
  }
@@ -7169,7 +7605,7 @@ const DEFAULT_HEALTH_TIMING = { timeoutMs: 3e5 };
7169
7605
  //#region src/steps/start-metabase.ts
7170
7606
  const LOG_TAIL_ON_TIMEOUT = 30;
7171
7607
  const PERCENT = 100;
7172
- function hostDeps$3(ctx) {
7608
+ function hostDeps$4(ctx) {
7173
7609
  return {
7174
7610
  runtime: hostRuntimeDeps(ctx.state.name, ctx.signal),
7175
7611
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
@@ -7213,7 +7649,7 @@ async function startRuntime(ctx, runtime, deps) {
7213
7649
  throw error;
7214
7650
  }
7215
7651
  }
7216
- function startMetabaseStep(deps = hostDeps$3) {
7652
+ function startMetabaseStep(deps = hostDeps$4) {
7217
7653
  return {
7218
7654
  id: "start-metabase",
7219
7655
  title: "Start Metabase",
@@ -7273,9 +7709,9 @@ function startMetabaseStep(deps = hostDeps$3) {
7273
7709
  }
7274
7710
  //#endregion
7275
7711
  //#region src/steps/summary.ts
7276
- const STEP_ID = "summary";
7712
+ const STEP_ID$1 = "summary";
7277
7713
  const GIT_SYNC_STEP_ID = "git-sync";
7278
- function hostDeps$2(ctx) {
7714
+ function hostDeps$3(ctx) {
7279
7715
  return {
7280
7716
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
7281
7717
  runner: processRunner(ctx.signal),
@@ -7299,14 +7735,21 @@ async function databaseLines$1(state, apiKey, deps) {
7299
7735
  databases = (await deps.client(state.url, {
7300
7736
  kind: "api-key",
7301
7737
  key: apiKey
7302
- }).database.list()).data;
7738
+ }).database.listWithStubs()).data;
7303
7739
  } catch (error) {
7304
7740
  if (isNotServing$1(error)) return ["Databases: Metabase is not running; `rde start`"];
7305
7741
  if (isRejectedCredential(error)) return ["Databases: Metabase rejects the stored API key; `rde init --only api-key`"];
7306
7742
  throw error;
7307
7743
  }
7308
- if (databases.length === 0) return ["Databases: none connected yet; `rde db add`"];
7309
- return ["Databases:", ...existingDatabaseLines(databases).map((line) => ` ${line}`)];
7744
+ const connected = databases.filter((database) => !isStub(database));
7745
+ const stubs = databases.filter(isStub);
7746
+ const stubLines = stubs.length === 0 ? [] : [unconnectedStubsLine(stubs)];
7747
+ if (connected.length === 0) return ["Databases: none connected yet; `rde db add`", ...stubLines];
7748
+ return [
7749
+ "Databases:",
7750
+ ...existingDatabaseLines(connected).map((line) => ` ${line}`),
7751
+ ...stubLines
7752
+ ];
7310
7753
  }
7311
7754
  function remoteLine(remote) {
7312
7755
  switch (remote.kind) {
@@ -7353,9 +7796,9 @@ async function summaryLines(state, apiKey, deps) {
7353
7796
  lines.push("Lifecycle: rde status (--json for scripts), rde stop, rde start");
7354
7797
  return lines;
7355
7798
  }
7356
- function summaryStep(deps = hostDeps$2) {
7799
+ function summaryStep(deps = hostDeps$3) {
7357
7800
  return {
7358
- id: STEP_ID,
7801
+ id: STEP_ID$1,
7359
7802
  title: "Summary",
7360
7803
  async detect() {
7361
7804
  return "todo";
@@ -7368,21 +7811,76 @@ function summaryStep(deps = hostDeps$2) {
7368
7811
  };
7369
7812
  }
7370
7813
  //#endregion
7814
+ //#region src/steps/transforms.ts
7815
+ const STEP_ID = "transforms";
7816
+ function hostDeps$2(ctx) {
7817
+ return {
7818
+ client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
7819
+ instanceRunning: () => isInstanceRunning(ctx.state, hostRuntimeDeps(ctx.state.name, ctx.signal))
7820
+ };
7821
+ }
7822
+ function isNotServingYet(error) {
7823
+ return error instanceof MetabaseUnreachableError || error instanceof MetabaseInitializingError;
7824
+ }
7825
+ function transformsStep(deps = hostDeps$2) {
7826
+ return {
7827
+ id: STEP_ID,
7828
+ title: "Enable transforms",
7829
+ async detect(ctx) {
7830
+ const configured = configuredRuntime(ctx.state);
7831
+ if (configured === null || ctx.secrets.apiKey === null) return "todo";
7832
+ const resolved = deps(ctx);
7833
+ if (!await resolved.instanceRunning()) return "todo";
7834
+ const client = resolved.client(configured.url, {
7835
+ kind: "api-key",
7836
+ key: ctx.secrets.apiKey
7837
+ });
7838
+ try {
7839
+ return await client.setting.transformsDecided() ? "done" : "todo";
7840
+ } catch (error) {
7841
+ if (isNotServingYet(error) || isRejectedCredential(error)) return "todo";
7842
+ throw error;
7843
+ }
7844
+ },
7845
+ async run(ctx) {
7846
+ const configured = requireConfiguredRuntime(ctx.state);
7847
+ if (ctx.secrets.apiKey === null) throw new ConfigError("no API key yet; the api-key step comes first");
7848
+ const client = deps(ctx).client(configured.url, {
7849
+ kind: "api-key",
7850
+ key: ctx.secrets.apiKey
7851
+ });
7852
+ if (await client.setting.transformsDecided()) {
7853
+ ctx.ui.info("Transforms are already set in Metabase; leaving them as they are");
7854
+ return { outcome: "completed" };
7855
+ }
7856
+ await client.setting.enableTransforms();
7857
+ ctx.ui.success("Transforms enabled");
7858
+ return { outcome: "completed" };
7859
+ }
7860
+ };
7861
+ }
7862
+ //#endregion
7371
7863
  //#region src/steps/index.ts
7372
7864
  const DEFAULT_STEP_OPTIONS = {
7373
7865
  runtime: NO_RUNTIME_FLAGS,
7374
7866
  agents: DETECT_AGENTS,
7375
7867
  admin: DEFAULT_ADMIN,
7376
- databases: SAMPLE_STANDS_IN
7868
+ databases: SAMPLE_STANDS_IN,
7869
+ updates: "published"
7870
+ };
7871
+ const LOCAL_STEP_OPTIONS = {
7872
+ ...DEFAULT_STEP_OPTIONS,
7873
+ updates: "installed"
7377
7874
  };
7378
7875
  const STEP_BY_ID = {
7379
7876
  preflight: () => preflightStep(),
7380
- "install-mb": () => installMbStep(),
7381
- "agents-skills": (options) => agentsSkillsStep(options.agents),
7877
+ "install-mb": (options) => installMbStep(options.updates),
7878
+ "agents-skills": (options) => agentsSkillsStep(options.agents, options.updates),
7382
7879
  runtime: (options) => runtimeStep(options.runtime),
7383
7880
  "start-metabase": () => startMetabaseStep(),
7384
7881
  "admin-user": (options) => adminUserStep(options.admin),
7385
7882
  "api-key": () => apiKeyStep(),
7883
+ transforms: () => transformsStep(),
7386
7884
  "sample-database": () => sampleDatabaseStep(),
7387
7885
  license: () => licenseStep(),
7388
7886
  databases: (options) => databasesStep(options.databases),
@@ -7555,7 +8053,8 @@ function stepOptionsFrom(answers, run) {
7555
8053
  },
7556
8054
  agents: agentsOption(agents, run),
7557
8055
  admin: admin === void 0 ? DEFAULT_STEP_OPTIONS.admin : { variant: admin.variant },
7558
- databases: { request: databasesRequest(databases, run) }
8056
+ databases: { request: databasesRequest(databases, run) },
8057
+ updates: DEFAULT_STEP_OPTIONS.updates
7559
8058
  };
7560
8059
  }
7561
8060
  function keyedAnswers(answers) {
@@ -8161,6 +8660,7 @@ var SessionCache = class {
8161
8660
  const CHECKLIST_MARK = {
8162
8661
  done: "✓",
8163
8662
  todo: "○",
8663
+ outdated: "↑",
8164
8664
  "not-applicable": "–"
8165
8665
  };
8166
8666
  const SPINNER_FAILURE_CODE = 1;
@@ -8318,7 +8818,7 @@ const dbAdd = defineRdeCommand({
8318
8818
  await addDatabases(await createWizardContext(name, {
8319
8819
  signal: interruptSignal,
8320
8820
  prompts
8321
- }), hostDatabasesDeps(interruptSignal));
8821
+ }), hostDatabasesDeps(interruptSignal), { offerStubs: args.url === void 0 });
8322
8822
  }
8323
8823
  });
8324
8824
  //#endregion
@@ -8401,7 +8901,7 @@ async function turnOf(entry, ctx, options) {
8401
8901
  if (options.only !== void 0) return "not-selected";
8402
8902
  if (entry.status === "done") return "done";
8403
8903
  const status = await entry.step.detect(ctx);
8404
- return status === "todo" ? "run" : status;
8904
+ return status === "todo" || status === "outdated" ? "run" : status;
8405
8905
  }
8406
8906
  function unusedMessage(step, turn, given) {
8407
8907
  const hint = step.redoHint === void 0 ? "" : ` ${step.redoHint}`;
@@ -9049,7 +9549,7 @@ function withRef(source, ref) {
9049
9549
  }
9050
9550
  async function copyRef(entry, deps) {
9051
9551
  const path = entry.scope === GLOBAL_SCOPE ? globalSkillLockPath(deps.probe.home, deps.skills.stateHome) : projectSkillLockPath(deps.skills.cwd);
9052
- return lockedRef(path, await deps.skills.files.readJson(path), entry.name);
9552
+ return lockedSkill(path, await deps.skills.files.readJson(path), entry.name)?.ref ?? null;
9053
9553
  }
9054
9554
  async function judgeCopy(entry, origin, deps) {
9055
9555
  const where = `${entry.scope} ${entry.path}`;
@@ -9311,7 +9811,7 @@ const doctor = defineRdeCommand({
9311
9811
  signal: interruptSignal,
9312
9812
  prompts
9313
9813
  });
9314
- if (await fixDoctor(report, initSteps(DEFAULT_STEP_OPTIONS), ctx, { yes: args.yes })) report = await collectDoctor(name, ctx.state, ctx.secrets, deps);
9814
+ if (await fixDoctor(report, initSteps(LOCAL_STEP_OPTIONS), ctx, { yes: args.yes })) report = await collectDoctor(name, ctx.state, ctx.secrets, deps);
9315
9815
  }
9316
9816
  print(report, args.json);
9317
9817
  if (report.failed > 0) throw new Error(doctorSummary(report));
@@ -9360,7 +9860,8 @@ function applyDatabaseRecord(draft, record) {
9360
9860
  const value = stringOf(record.answer);
9361
9861
  const skippedConnection = record.key === DATABASE_KEYS.connection && value?.trim() === "";
9362
9862
  const finished = parseDatabaseKey(record.key)?.name === "engine" && value === "finish";
9363
- if (skippedConnection || finished) draft.databasesFinished = true;
9863
+ const pickedNoStub = record.key === DATABASE_KEYS.stubs && record.answer.kind === "multiselect" && record.answer.values.length === 0;
9864
+ if (skippedConnection || finished || pickedNoStub) draft.databasesFinished = true;
9364
9865
  }
9365
9866
  function applyAdminRecord(draft, key, value) {
9366
9867
  if (key === ADMIN_USER_KEYS.email && value !== null) admin(draft).email = value;
@@ -9812,7 +10313,7 @@ const start = defineRdeCommand({
9812
10313
  const SUMMARY_STEP_ID = "summary";
9813
10314
  function hostStatusDeps(instanceName, signal) {
9814
10315
  return {
9815
- steps: initSteps(DEFAULT_STEP_OPTIONS),
10316
+ steps: initSteps(LOCAL_STEP_OPTIONS),
9816
10317
  runtime: hostRuntimeDeps(instanceName, signal),
9817
10318
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, signal),
9818
10319
  runner: processRunner(signal)
@@ -10737,7 +11238,7 @@ const upgradeCommand = defineRdeCommand({
10737
11238
  const main = defineCommand({
10738
11239
  meta: {
10739
11240
  name: "rde",
10740
- version: "0.1.12",
11241
+ version: "0.1.14",
10741
11242
  description: "Prepares a local machine for data-engineering work with Metabase and coding agents"
10742
11243
  },
10743
11244
  subCommands: {