@metabase/rde 0.1.15 → 0.1.17

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 +51 -57
  2. package/dist/cli.mjs +917 -719
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -79,6 +79,8 @@ var MetabaseApiError = class extends Error {
79
79
  path;
80
80
  errors;
81
81
  reason;
82
+ code;
83
+ detail;
82
84
  constructor(failure) {
83
85
  super(`Metabase ${failure.status} on ${failure.path}: ${failure.message}`);
84
86
  this.name = "MetabaseApiError";
@@ -86,6 +88,8 @@ var MetabaseApiError = class extends Error {
86
88
  this.path = failure.path;
87
89
  this.errors = failure.errors;
88
90
  this.reason = failure.message;
91
+ this.code = failure.code;
92
+ this.detail = failure.detail;
89
93
  }
90
94
  };
91
95
  const UNAUTHORIZED_STATUS = 401;
@@ -123,12 +127,10 @@ var DownloadError = class extends Error {
123
127
  };
124
128
  //#endregion
125
129
  //#region src/output/error.ts
126
- const INTERRUPTED_MESSAGE = "Interrupted. Run `rde init` again to resume.";
127
130
  const FAILURE_EXIT_CODE = 1;
128
131
  const USAGE_EXIT_CODE = 2;
129
132
  function exitCodeFor(error) {
130
133
  if (error instanceof StepFailedError) return exitCodeFor(error.cause);
131
- if (error instanceof AbortError) return 130;
132
134
  if (error instanceof ConfigError) return USAGE_EXIT_CODE;
133
135
  return FAILURE_EXIT_CODE;
134
136
  }
@@ -140,7 +142,6 @@ function stepFailureMessage(step, cause) {
140
142
  }
141
143
  function messageFor$1(error) {
142
144
  if (error instanceof StepFailedError) return stepFailureMessage(error.step, messageFor$1(error.cause));
143
- if (error instanceof AbortError) return INTERRUPTED_MESSAGE;
144
145
  if (error instanceof Error) return error.message;
145
146
  return String(error);
146
147
  }
@@ -148,10 +149,15 @@ function reportError(error) {
148
149
  process.stderr.write(messageFor$1(error) + "\n");
149
150
  process.exitCode = exitCodeFor(error);
150
151
  }
152
+ function reportInterrupted(message) {
153
+ if (message !== null) process.stderr.write(message + "\n");
154
+ process.exitCode = 130;
155
+ }
151
156
  //#endregion
152
157
  //#region src/commands/define.ts
153
158
  const SHORT_PREFIX = "-";
154
159
  const END_OF_OPTIONS = "--";
160
+ const INTERRUPTED = "Interrupted.";
155
161
  function repeatedValues(rawArgs, flag) {
156
162
  const values = [];
157
163
  const inline = `${flag}=`;
@@ -174,9 +180,14 @@ function defineRdeCommand(def) {
174
180
  meta: def.meta,
175
181
  ...def.args !== void 0 && { args: def.args },
176
182
  async run({ args, rawArgs }) {
183
+ const interruption = { message: def.interrupted };
177
184
  try {
178
- await def.run(args, rawArgs);
185
+ await def.run(args, rawArgs, interruption);
179
186
  } catch (error) {
187
+ if (error instanceof AbortError) {
188
+ reportInterrupted(interruption.message);
189
+ return;
190
+ }
180
191
  reportError(error);
181
192
  }
182
193
  }
@@ -255,9 +266,9 @@ const StepId = z.enum([
255
266
  "admin-user",
256
267
  "api-key",
257
268
  "transforms",
258
- "sample-database",
259
269
  "license",
260
270
  "git-sync",
271
+ "sync-scope",
261
272
  "databases",
262
273
  "summary"
263
274
  ]);
@@ -326,18 +337,15 @@ const REMOTE_SYNC_FEATURE = "remote-sync";
326
337
  function hasRemoteSync(license) {
327
338
  return license.activated && license.features.includes("remote-sync");
328
339
  }
340
+ function hasLibrary(license) {
341
+ return license.activated && license.features.includes("library");
342
+ }
329
343
  const DatabaseRecord = z.object({
330
344
  id: z.int(),
331
345
  name: z.string(),
332
346
  engine: z.string(),
333
347
  createdAt: z.string()
334
348
  });
335
- const SampleDatabaseState = z.object({
336
- containerName: z.string(),
337
- image: z.string(),
338
- port: z.int().positive(),
339
- databaseId: z.int().nullable()
340
- });
341
349
  const GitRemoteState = z.discriminatedUnion("kind", [z.object({
342
350
  kind: z.literal("local-bare"),
343
351
  barePath: z.string(),
@@ -354,7 +362,7 @@ const StepRecord = z.object({
354
362
  completedAt: z.string()
355
363
  });
356
364
  const InstanceState = z.object({
357
- schemaVersion: z.literal(5),
365
+ schemaVersion: z.literal(7),
358
366
  name: z.string(),
359
367
  runtime: RuntimeState.nullable(),
360
368
  url: z.string().nullable(),
@@ -363,9 +371,9 @@ const InstanceState = z.object({
363
371
  mbProfile: z.string(),
364
372
  license: LicenseState,
365
373
  databases: z.array(DatabaseRecord),
366
- sampleDatabase: SampleDatabaseState.nullable(),
367
374
  gitRemote: GitRemoteState.nullable(),
368
375
  movedFrom: z.string().nullable(),
376
+ syncRoot: z.int().nullable(),
369
377
  steps: z.partialRecord(StepId, StepRecord)
370
378
  });
371
379
  function configuredRuntime(state) {
@@ -395,7 +403,7 @@ function mbCommand(profile) {
395
403
  }
396
404
  function emptyInstanceState(name) {
397
405
  return {
398
- schemaVersion: 5,
406
+ schemaVersion: 7,
399
407
  name,
400
408
  runtime: null,
401
409
  url: null,
@@ -404,9 +412,9 @@ function emptyInstanceState(name) {
404
412
  mbProfile: mbProfileFor(name),
405
413
  license: { activated: false },
406
414
  databases: [],
407
- sampleDatabase: null,
408
415
  gitRemote: null,
409
416
  movedFrom: null,
417
+ syncRoot: null,
410
418
  steps: {}
411
419
  };
412
420
  }
@@ -417,6 +425,8 @@ const V1 = 1;
417
425
  const V2 = 2;
418
426
  const V3 = 3;
419
427
  const V4 = 4;
428
+ const V5 = 5;
429
+ const V6 = 6;
420
430
  const StateV1 = z.object({ schemaVersion: z.literal(V1) }).loose();
421
431
  const StateV2 = z.object({ schemaVersion: z.literal(V2) }).loose();
422
432
  const AdminV3 = z.discriminatedUnion("kind", [z.object({
@@ -428,6 +438,11 @@ const AdminV3 = z.discriminatedUnion("kind", [z.object({
428
438
  password: z.string()
429
439
  })]);
430
440
  const StateV4 = z.object({ schemaVersion: z.literal(V4) }).loose();
441
+ const StateV5 = z.object({
442
+ schemaVersion: z.literal(V5),
443
+ steps: z.record(z.string(), z.unknown())
444
+ }).loose();
445
+ const StateV6 = z.object({ schemaVersion: z.literal(V6) }).loose();
431
446
  const StateV3 = z.object({
432
447
  schemaVersion: z.literal(V3),
433
448
  admin: AdminV3.nullable(),
@@ -443,17 +458,32 @@ function liftV1(raw) {
443
458
  function liftV2(raw) {
444
459
  return {
445
460
  ...StateV2.parse(raw),
446
- schemaVersion: V3,
447
- sampleDatabase: null
461
+ schemaVersion: V3
448
462
  };
449
463
  }
450
464
  function liftV4(raw) {
451
465
  return {
452
466
  ...StateV4.parse(raw),
453
- schemaVersion: 5,
467
+ schemaVersion: V5,
454
468
  movedFrom: null
455
469
  };
456
470
  }
471
+ function liftV5(raw) {
472
+ const { sampleDatabase: _sampleDatabase, steps, ...state } = StateV5.parse(raw);
473
+ const { "sample-database": _sampleDatabaseStep, ...remainingSteps } = steps;
474
+ return {
475
+ ...state,
476
+ schemaVersion: V6,
477
+ steps: remainingSteps
478
+ };
479
+ }
480
+ function liftV6(raw) {
481
+ return {
482
+ ...StateV6.parse(raw),
483
+ schemaVersion: 7,
484
+ syncRoot: null
485
+ };
486
+ }
457
487
  function adminV4(admin) {
458
488
  switch (admin.kind) {
459
489
  case "default": return {
@@ -473,11 +503,11 @@ function liftV3(raw) {
473
503
  password: admin.password
474
504
  } : null;
475
505
  return {
476
- state: InstanceState.parse(liftV4({
506
+ state: migrateInstanceState(liftV4({
477
507
  ...rest,
478
508
  schemaVersion: V4,
479
509
  admin: admin === null ? null : adminV4(admin)
480
- })),
510
+ })).state,
481
511
  movedSecrets: {
482
512
  schemaVersion: 1,
483
513
  admin: login,
@@ -491,15 +521,17 @@ function migrateInstanceState(raw) {
491
521
  case V1: return migrateInstanceState(liftV1(raw));
492
522
  case V2: return migrateInstanceState(liftV2(raw));
493
523
  case V3: return liftV3(raw);
494
- case V4: return {
495
- state: InstanceState.parse(liftV4(raw)),
524
+ case V4: return migrateInstanceState(liftV4(raw));
525
+ case V5: return migrateInstanceState(liftV5(raw));
526
+ case V6: return {
527
+ state: InstanceState.parse(liftV6(raw)),
496
528
  movedSecrets: null
497
529
  };
498
- case 5: return {
530
+ case 7: return {
499
531
  state: InstanceState.parse(raw),
500
532
  movedSecrets: null
501
533
  };
502
- default: throw new ConfigError(`state schemaVersion ${schemaVersion} is not supported by this build (expected 5)`);
534
+ default: throw new ConfigError(`state schemaVersion ${schemaVersion} is not supported by this build (expected 7)`);
503
535
  }
504
536
  }
505
537
  //#endregion
@@ -807,8 +839,11 @@ const SESSION_HEADER = "X-Metabase-Session";
807
839
  const API_KEY_HEADER = "X-Api-Key";
808
840
  const JSON_CONTENT_TYPE = "application/json";
809
841
  const SERVICE_UNAVAILABLE_STATUS = 503;
842
+ const NO_CONTENT_STATUS = 204;
810
843
  const NO_AUTH = { kind: "none" };
811
844
  const ErrorMessage = z.object({ message: z.string() }).loose();
845
+ const ErrorTitle = z.object({ error: z.string() }).loose();
846
+ const ErrorCode = z.object({ error_code: z.string() }).loose();
812
847
  const ErrorFields = z.object({ errors: z.record(z.string(), z.unknown()) }).loose();
813
848
  const ErrorText = z.string();
814
849
  const ErrorTexts = z.array(z.string());
@@ -871,6 +906,8 @@ function serverMessage(body) {
871
906
  const value = jsonValue(body);
872
907
  const parsed = ErrorMessage.safeParse(value);
873
908
  if (parsed.success) return parsed.data.message;
909
+ const title = ErrorTitle.safeParse(value);
910
+ if (title.success) return title.data.error;
874
911
  const errors = serverErrors(body);
875
912
  if (errors !== null && Object.keys(errors).length > 0) return Object.entries(errors).map(([name, problem]) => `${name}: ${problem}`).join("; ");
876
913
  const plain = ErrorText.safeParse(value);
@@ -879,6 +916,10 @@ function serverMessage(body) {
879
916
  if (body.json.parsed || text.length === 0) return `HTTP ${body.status}`;
880
917
  return text;
881
918
  }
919
+ function errorCode(body) {
920
+ const parsed = ErrorCode.safeParse(jsonValue(body));
921
+ return parsed.success ? parsed.data.error_code : null;
922
+ }
882
923
  function failureFor(path, body) {
883
924
  if (body.status === SERVICE_UNAVAILABLE_STATUS) {
884
925
  const initializing = Initializing.safeParse(jsonValue(body));
@@ -888,7 +929,9 @@ function failureFor(path, body) {
888
929
  status: body.status,
889
930
  path,
890
931
  message: serverMessage(body),
891
- errors: serverErrors(body)
932
+ errors: serverErrors(body),
933
+ code: errorCode(body),
934
+ detail: body.json.parsed ? body.json.value : null
892
935
  });
893
936
  }
894
937
  function shapeError(path, body, reason) {
@@ -896,7 +939,9 @@ function shapeError(path, body, reason) {
896
939
  status: body.status,
897
940
  path,
898
941
  message: reason,
899
- errors: null
942
+ errors: null,
943
+ code: null,
944
+ detail: null
900
945
  });
901
946
  }
902
947
  function isTimeout(error) {
@@ -946,19 +991,33 @@ function createHttp(options) {
946
991
  if (!received.ok) throw failureFor(path, received.body);
947
992
  return received.body;
948
993
  }
994
+ function parseJson(path, body, schema) {
995
+ if (!body.json.parsed) throw shapeError(path, body, "expected a JSON body");
996
+ return parseShape(path, body, schema, body.json.value);
997
+ }
998
+ function parseShape(path, body, schema, value) {
999
+ try {
1000
+ return schema.parse(value);
1001
+ } catch (error) {
1002
+ if (error instanceof ZodError) throw shapeError(path, body, `unexpected response shape:\n${z.prettifyError(error)}`);
1003
+ throw error;
1004
+ }
1005
+ }
949
1006
  return {
950
1007
  async requestJson(method, path, request) {
1008
+ return parseJson(path, await requestBody(method, path, request.body), request.schema);
1009
+ },
1010
+ async requestOptionalJson(method, path, request) {
951
1011
  const body = await requestBody(method, path, request.body);
952
- if (!body.json.parsed) throw shapeError(path, body, "expected a JSON body");
953
- try {
954
- return request.schema.parse(body.json.value);
955
- } catch (error) {
956
- if (error instanceof ZodError) throw shapeError(path, body, `unexpected response shape:\n${z.prettifyError(error)}`);
957
- throw error;
958
- }
1012
+ if (body.status === NO_CONTENT_STATUS && body.text.length === 0) return null;
1013
+ return parseJson(path, body, request.schema);
959
1014
  },
960
1015
  async requestNoContent(method, path, request = {}) {
961
1016
  await requestBody(method, path, request.body);
1017
+ },
1018
+ async requestText(method, path, request) {
1019
+ const body = await requestBody(method, path, request.body);
1020
+ return parseShape(path, body, request.schema, body.text);
962
1021
  }
963
1022
  };
964
1023
  }
@@ -1049,6 +1108,48 @@ function apiKeyResource(http) {
1049
1108
  regenerate
1050
1109
  };
1051
1110
  }
1111
+ const Collection = z.object({
1112
+ id: z.int(),
1113
+ name: z.string(),
1114
+ location: z.string(),
1115
+ type: z.string().nullable(),
1116
+ namespace: z.string().nullable(),
1117
+ personal_owner_id: z.int().nullable(),
1118
+ is_remote_synced: z.boolean()
1119
+ }).loose();
1120
+ const RootCollection = z.object({ id: z.literal("root") }).loose();
1121
+ const CollectionList = z.array(z.union([Collection, RootCollection]));
1122
+ const CollectionRef = z.object({
1123
+ id: z.int(),
1124
+ name: z.string()
1125
+ }).loose();
1126
+ //#endregion
1127
+ //#region src/metabase/resources/collection.ts
1128
+ function collectionResource(http) {
1129
+ /** GET /api/collection */
1130
+ async function list() {
1131
+ return http.requestJson("GET", "/api/collection", { schema: CollectionList });
1132
+ }
1133
+ /** POST /api/collection */
1134
+ async function create(request) {
1135
+ return http.requestJson("POST", "/api/collection", {
1136
+ body: {
1137
+ ...request,
1138
+ parent_id: null
1139
+ },
1140
+ schema: CollectionRef
1141
+ });
1142
+ }
1143
+ /** POST /api/ee/library; 400 when the Library exists */
1144
+ async function createLibrary() {
1145
+ return http.requestJson("POST", "/api/ee/library", { schema: CollectionRef });
1146
+ }
1147
+ return {
1148
+ list,
1149
+ create,
1150
+ createLibrary
1151
+ };
1152
+ }
1052
1153
  //#endregion
1053
1154
  //#region src/metabase/domain/database.ts
1054
1155
  const InitialSyncStatus = z.enum([
@@ -1141,7 +1242,9 @@ function permissionsGroupResource(http) {
1141
1242
  }
1142
1243
  return { list };
1143
1244
  }
1144
- z.enum(["read-only", "read-write"]);
1245
+ //#endregion
1246
+ //#region src/metabase/domain/remote-sync.ts
1247
+ const RemoteSyncType = z.enum(["read-only", "read-write"]);
1145
1248
  const RemoteSyncSettingsResult = z.object({
1146
1249
  success: z.boolean(),
1147
1250
  task_id: z.int().optional()
@@ -1162,6 +1265,38 @@ const SyncTask = z.object({
1162
1265
  status: SyncTaskStatus$1,
1163
1266
  error_message: z.string().nullable().optional()
1164
1267
  }).loose();
1268
+ const NamedCollection = z.object({
1269
+ id: z.int(),
1270
+ name: z.string()
1271
+ }).loose();
1272
+ const DependencyLocation = NamedCollection.nullable().optional();
1273
+ const DependencyItem = z.object({
1274
+ model: z.string(),
1275
+ id: z.int(),
1276
+ name: z.string().nullable()
1277
+ }).loose();
1278
+ const UnsyncedDependency = DependencyItem.extend({
1279
+ collection: DependencyLocation,
1280
+ used_by: z.array(DependencyItem)
1281
+ });
1282
+ const SyncRemedy = z.discriminatedUnion("type", [
1283
+ z.object({
1284
+ type: z.literal("collection"),
1285
+ collection: NamedCollection
1286
+ }).loose(),
1287
+ z.object({ type: z.literal("library") }).loose(),
1288
+ z.object({
1289
+ type: z.literal("none"),
1290
+ collection: DependencyLocation
1291
+ }).loose()
1292
+ ]);
1293
+ const RequiredSync = z.object({
1294
+ remedy: SyncRemedy,
1295
+ syncable: z.boolean(),
1296
+ blocks: z.array(NamedCollection),
1297
+ dependencies: z.array(UnsyncedDependency)
1298
+ }).loose();
1299
+ const UnsyncedDependencies = z.object({ errors: z.object({ required: z.array(RequiredSync) }).loose() }).loose();
1165
1300
  //#endregion
1166
1301
  //#region src/metabase/resources/remote-sync.ts
1167
1302
  function remoteSyncResource(http) {
@@ -1172,6 +1307,13 @@ function remoteSyncResource(http) {
1172
1307
  schema: RemoteSyncSettingsResult
1173
1308
  });
1174
1309
  }
1310
+ /** PUT /api/ee/remote-sync/settings, without the git settings, which it leaves as they are */
1311
+ async function updateScope(scope) {
1312
+ return http.requestJson("PUT", "/api/ee/remote-sync/settings", {
1313
+ body: scope,
1314
+ schema: RemoteSyncSettingsResult
1315
+ });
1316
+ }
1175
1317
  /** PUT /api/ee/remote-sync/settings with only `remote-sync-transforms`, which skips the repository check. */
1176
1318
  async function syncTransforms() {
1177
1319
  return http.requestJson("PUT", "/api/ee/remote-sync/settings", {
@@ -1188,10 +1330,11 @@ function remoteSyncResource(http) {
1188
1330
  }
1189
1331
  /** GET /api/ee/remote-sync/current-task */
1190
1332
  async function currentTask() {
1191
- return http.requestJson("GET", "/api/ee/remote-sync/current-task", { schema: SyncTask.nullable() });
1333
+ return http.requestOptionalJson("GET", "/api/ee/remote-sync/current-task", { schema: SyncTask });
1192
1334
  }
1193
1335
  return {
1194
1336
  updateSettings,
1337
+ updateScope,
1195
1338
  syncTransforms,
1196
1339
  testConnection,
1197
1340
  currentTask
@@ -1264,6 +1407,7 @@ const PREMIUM_EMBEDDING_TOKEN_SETTING = "premium-embedding-token";
1264
1407
  const ENGINES_SETTING = "engines";
1265
1408
  const TRANSFORMS_ENABLED_SETTING = "transforms-enabled";
1266
1409
  const TRANSFORMS_SETUP_COMPLETE_SETTING = "transforms-setup-complete";
1410
+ const REMOTE_SYNC_TYPE_SETTING = "remote-sync-type";
1267
1411
  function settingResource(http) {
1268
1412
  /** PUT /api/setting/premium-embedding-token */
1269
1413
  async function setPremiumEmbeddingToken(value) {
@@ -1281,11 +1425,16 @@ function settingResource(http) {
1281
1425
  async function engines() {
1282
1426
  return http.requestJson("GET", `/api/setting/${ENGINES_SETTING}`, { schema: Engines });
1283
1427
  }
1428
+ /** GET /api/setting/remote-sync-type; Metabase answers its default, read-only, while unset. */
1429
+ async function remoteSyncType() {
1430
+ return http.requestText("GET", `/api/setting/${REMOTE_SYNC_TYPE_SETTING}`, { schema: RemoteSyncType });
1431
+ }
1284
1432
  return {
1285
1433
  setPremiumEmbeddingToken,
1286
1434
  enableTransforms,
1287
1435
  transformsDecided,
1288
- engines
1436
+ engines,
1437
+ remoteSyncType
1289
1438
  };
1290
1439
  }
1291
1440
  //#endregion
@@ -1355,6 +1504,7 @@ function createMetabaseClient(baseUrl, auth, signal, options = {}) {
1355
1504
  tokenStatus: tokenStatusResource(http),
1356
1505
  database: databaseResource(http),
1357
1506
  remoteSync: remoteSyncResource(http),
1507
+ collection: collectionResource(http),
1358
1508
  user: userResource(http)
1359
1509
  };
1360
1510
  }
@@ -1479,6 +1629,7 @@ const credentials = defineRdeCommand({
1479
1629
  default: false
1480
1630
  }
1481
1631
  },
1632
+ interrupted: INTERRUPTED,
1482
1633
  async run(args) {
1483
1634
  const name = await defaultInstanceName();
1484
1635
  const state = await requireInstanceState(name);
@@ -2188,10 +2339,10 @@ function createArgs(options) {
2188
2339
  "--label",
2189
2340
  `${IMAGE_LABEL}=${options.image}`,
2190
2341
  "-p",
2191
- `${LOOPBACK_HOST}:${options.port.host ?? ""}:${options.port.container}`
2342
+ `${LOOPBACK_HOST}:${options.port.host}:${options.port.container}`,
2343
+ "-v",
2344
+ `${options.mount.source}:${options.mount.target}`
2192
2345
  ];
2193
- if (options.port.alsoOn !== null) args.push("-p", `${options.port.alsoOn}:${options.port.host ?? ""}:${options.port.container}`);
2194
- if (options.mount !== null) args.push("-v", `${options.mount.source}:${options.mount.target}`);
2195
2346
  for (const [name, value] of Object.entries(options.env)) args.push("-e", `${name}=${value}`);
2196
2347
  if (options.hostUser !== null) {
2197
2348
  args.push("-e", `${ENV_HOST_UID}=${options.hostUser.uid}`);
@@ -2250,17 +2401,6 @@ function dockerTool(runner) {
2250
2401
  if (NO_SUCH_IMAGE.test(result.stderr)) return null;
2251
2402
  throw toolFailure(DOCKER, args, result);
2252
2403
  }
2253
- async function bridgeGateway() {
2254
- const gateway = (await docker([
2255
- "network",
2256
- "inspect",
2257
- "bridge",
2258
- "--format",
2259
- "{{range .IPAM.Config}}{{.Gateway}}{{end}}"
2260
- ])).stdout.trim();
2261
- if (gateway.length === 0) throw new ConfigError("`docker network inspect bridge` names no gateway address");
2262
- return gateway;
2263
- }
2264
2404
  async function imageRemove(image) {
2265
2405
  await docker(["rmi", image]);
2266
2406
  }
@@ -2337,34 +2477,6 @@ function dockerTool(runner) {
2337
2477
  name
2338
2478
  ]);
2339
2479
  }
2340
- async function containerLoopbackPort(name, containerPort) {
2341
- const args = [
2342
- "port",
2343
- name,
2344
- `${containerPort}/tcp`
2345
- ];
2346
- const result = await docker(args);
2347
- const prefix = `${LOOPBACK_HOST}:`;
2348
- const line = result.stdout.split("\n").find((entry) => entry.startsWith(prefix));
2349
- if (line !== void 0) {
2350
- const port = Number.parseInt(line.slice(prefix.length), 10);
2351
- if (Number.isInteger(port)) return port;
2352
- }
2353
- throw new ExternalToolError({
2354
- tool: DOCKER,
2355
- argv: args,
2356
- envNames: [],
2357
- exitCode: 0,
2358
- stderrTail: `no port published on ${LOOPBACK_HOST} in: ${result.stdout.trim()}`
2359
- });
2360
- }
2361
- async function containerExecSucceeds(name, command) {
2362
- return (await runner.run(DOCKER, [
2363
- "exec",
2364
- name,
2365
- ...command
2366
- ])).exitCode === 0;
2367
- }
2368
2480
  async function containerLogs(name, options) {
2369
2481
  const args = ["logs"];
2370
2482
  if (options.follow) args.push("--follow");
@@ -2377,7 +2489,6 @@ function dockerTool(runner) {
2377
2489
  imagePull,
2378
2490
  imageId,
2379
2491
  imageRemove,
2380
- bridgeGateway,
2381
2492
  containerInspect,
2382
2493
  containerImageId,
2383
2494
  portClaims,
@@ -2387,8 +2498,6 @@ function dockerTool(runner) {
2387
2498
  containerRestart,
2388
2499
  containerRemove,
2389
2500
  containerForceRemove,
2390
- containerLoopbackPort,
2391
- containerExecSucceeds,
2392
2501
  containerLogs
2393
2502
  };
2394
2503
  }
@@ -2566,8 +2675,7 @@ function dockerRuntime(state, deps) {
2566
2675
  imageId,
2567
2676
  port: {
2568
2677
  host: state.port,
2569
- container: CONTAINER_PORT,
2570
- alsoOn: null
2678
+ container: CONTAINER_PORT
2571
2679
  },
2572
2680
  mount: {
2573
2681
  source: deps.dataDir,
@@ -2881,148 +2989,21 @@ function runtimeFromState(state, deps) {
2881
2989
  }
2882
2990
  }
2883
2991
  //#endregion
2884
- //#region src/runtime/sample-database.ts
2885
- const SAMPLE_DATABASE_IMAGE = "metabase/qa-databases:postgres-sample-15";
2886
- const SAMPLE_DATABASE_USER = "metabase";
2887
- const SAMPLE_DATABASE_PASSWORD = "metasample123";
2888
- const SAMPLE_DATABASE_NAME = "sample";
2889
- const SAMPLE_DATABASE_PORT = 25432;
2890
- const SAMPLE_DATABASE_READY_TIMEOUT_MS = 12e4;
2891
- const SAMPLE_DATABASE_STOP_GRACE_MS = 1e4;
2892
- const POSTGRES_PORT = 5432;
2992
+ //#region src/runtime/leftover-sample-database.ts
2893
2993
  const CONTAINER_NAME_PREFIX = "rde-sampledb";
2894
- const READY_POLL_MS = 1e3;
2895
- const READY_PROBE = [
2896
- "pg_isready",
2897
- "-h",
2898
- LOOPBACK_HOST,
2899
- "-U",
2900
- SAMPLE_DATABASE_USER,
2901
- "-d",
2902
- SAMPLE_DATABASE_NAME
2903
- ];
2904
- function sampleContainerName(instanceName, home) {
2994
+ const SAMPLE_DATABASE_STOP_GRACE_MS = 1e4;
2995
+ function sampleDatabaseContainerName(instanceName, home) {
2905
2996
  const tag = homeTag(home);
2906
2997
  const name = `${CONTAINER_NAME_PREFIX}-${instanceName}`;
2907
2998
  return tag === null ? name : `${name}-${tag}`;
2908
2999
  }
2909
- function sampleDatabaseUrl(port) {
2910
- return `postgres://${SAMPLE_DATABASE_USER}:${SAMPLE_DATABASE_PASSWORD}@localhost:${port}/${SAMPLE_DATABASE_NAME}`;
2911
- }
2912
- function publishesOnLoopback$1(inspection, port) {
2913
- return inspection.ports.some((mapping) => mapping.container === POSTGRES_PORT && mapping.host === port && mapping.hostIp === "127.0.0.1");
2914
- }
2915
- function createOptions(state, home, hostPort, alsoOn) {
2916
- return {
2917
- name: state.containerName,
2918
- home,
2919
- image: state.image,
2920
- imageId: null,
2921
- port: {
2922
- host: hostPort,
2923
- container: POSTGRES_PORT,
2924
- alsoOn
2925
- },
2926
- mount: null,
2927
- env: {},
2928
- addHostGateway: false,
2929
- hostUser: null
2930
- };
2931
- }
2932
- function requireOwnContainer(name, existing, home) {
2933
- if (existing.home !== null && existing.home !== home) throw new ConfigError(`container ${name} belongs to the rde home ${existing.home} and is left alone`);
2934
- }
2935
- function sampleDatabaseContainer(state, docker, home) {
2936
- const name = state.containerName;
2937
- function conflict(reason) {
2938
- return new ConfigError(`container ${name} already exists ${reason}; remove it with \`docker rm -f ${name}\` and run \`rde init\` again`);
2939
- }
2940
- async function install(options) {
2941
- const existing = await docker.containerInspect(name);
2942
- if (existing.exists) {
2943
- if (existing.image !== state.image) throw conflict(`with image ${existing.image}, not ${state.image}`);
2944
- if (publishesOnLoopback$1(existing, state.port)) return;
2945
- requireOwnContainer(name, existing, home);
2946
- await docker.containerForceRemove(name);
2947
- }
2948
- await docker.imagePull(state.image, { onLine: options.onProgress });
2949
- await docker.containerCreate(createOptions(state, home, state.port, options.alsoOn));
2950
- }
2951
- async function start() {
2952
- const existing = await docker.containerInspect(name);
2953
- if (!existing.exists) return "missing";
2954
- if (existing.running) return "running";
2955
- await docker.containerStart(name);
2956
- return "started";
2957
- }
2958
- async function stop() {
2959
- const existing = await docker.containerInspect(name);
2960
- if (existing.exists && existing.running) await docker.containerStop(name, { graceMs: SAMPLE_DATABASE_STOP_GRACE_MS });
2961
- }
2962
- async function status() {
2963
- const existing = await docker.containerInspect(name);
2964
- return {
2965
- installed: existing.exists,
2966
- running: existing.exists && existing.running
2967
- };
2968
- }
2969
- async function waitReady(options) {
2970
- const deadline = Date.now() + options.timeoutMs;
2971
- const interval = options.pollMs ?? READY_POLL_MS;
2972
- for (;;) {
2973
- if (await docker.containerExecSucceeds(name, READY_PROBE)) return;
2974
- const existing = await docker.containerInspect(name);
2975
- if (!existing.exists || !existing.running) throw new ConfigError(`container ${name} stopped before Postgres was ready; \`docker logs ${name}\` shows why`);
2976
- if (Date.now() >= deadline) throw new ConfigError(`Postgres in ${name} is not ready after ${Math.round(options.timeoutMs / 1e3)}s; \`docker logs ${name}\` shows what it is doing`);
2977
- try {
2978
- await setTimeout$1(interval, void 0, { signal: options.signal });
2979
- } catch {
2980
- throw new AbortError();
2981
- }
2982
- }
2983
- }
2984
- return {
2985
- install,
2986
- start,
2987
- stop,
2988
- status,
2989
- waitReady
2990
- };
2991
- }
2992
- function extraAddress(inspection) {
2993
- const extra = inspection.ports.find((mapping) => mapping.container === POSTGRES_PORT && mapping.hostIp !== "127.0.0.1");
2994
- return extra === void 0 ? null : extra.hostIp;
2995
- }
2996
- async function startOnDockerPort(state, docker, home, alsoOn) {
2997
- const name = state.containerName;
2998
- await docker.containerForceRemove(name);
2999
- await docker.containerCreate(createOptions(state, home, null, null));
3000
- await docker.containerStart(name);
3001
- const port = await docker.containerLoopbackPort(name, POSTGRES_PORT);
3000
+ async function removeLeftoverSampleDatabase(docker, instanceName, home) {
3001
+ if (!(await docker.available()).available) return null;
3002
+ const name = sampleDatabaseContainerName(instanceName, home);
3003
+ const existing = await docker.containerInspect(name);
3004
+ if (!existing.exists || existing.home !== null && existing.home !== home) return null;
3002
3005
  await docker.containerForceRemove(name);
3003
- const pinned = {
3004
- ...state,
3005
- port
3006
- };
3007
- await docker.containerCreate(createOptions(pinned, home, port, alsoOn));
3008
- await docker.containerStart(name);
3009
- return pinned;
3010
- }
3011
- async function startOnFreePort(state, docker, home) {
3012
- try {
3013
- return {
3014
- outcome: await sampleDatabaseContainer(state, docker, home).start(),
3015
- state
3016
- };
3017
- } catch (error) {
3018
- if (!isPortTakenError(error)) throw error;
3019
- }
3020
- const existing = await docker.containerInspect(state.containerName);
3021
- if (existing.exists) requireOwnContainer(state.containerName, existing, home);
3022
- return {
3023
- outcome: "started",
3024
- state: await startOnDockerPort(state, docker, home, existing.exists ? extraAddress(existing) : null)
3025
- };
3006
+ return name;
3026
3007
  }
3027
3008
  //#endregion
3028
3009
  //#region src/steps/lifecycle.ts
@@ -3033,10 +3014,14 @@ async function loadInstance(name, signal) {
3033
3014
  return {
3034
3015
  state,
3035
3016
  url: configured.url,
3036
- runtime: runtimeFromState(configured.runtime, deps),
3037
- sampleDatabase: state.sampleDatabase === null ? null : sampleDatabaseContainer(state.sampleDatabase, deps.docker, deps.rdeHome)
3017
+ runtime: runtimeFromState(configured.runtime, deps)
3038
3018
  };
3039
3019
  }
3020
+ async function removeLeftovers(name, signal) {
3021
+ const deps = hostRuntimeDeps(name, signal);
3022
+ const removed = await removeLeftoverSampleDatabase(deps.docker, name, deps.rdeHome);
3023
+ return removed === null ? null : `Removed container ${removed}, the sample database of an older rde`;
3024
+ }
3040
3025
  async function isInstanceRunning(state, runtime) {
3041
3026
  const configured = configuredRuntime(state);
3042
3027
  if (configured === null) return false;
@@ -3045,44 +3030,29 @@ async function isInstanceRunning(state, runtime) {
3045
3030
  function openInstance(url) {
3046
3031
  return openBrowser(url);
3047
3032
  }
3048
- function ownContainers(state) {
3049
- const names = /* @__PURE__ */ new Set();
3050
- if (state.runtime?.kind === "docker") names.add(state.runtime.containerName);
3051
- if (state.sampleDatabase !== null) names.add(state.sampleDatabase.containerName);
3052
- return names;
3053
- }
3054
- function instanceClaims(other) {
3055
- const claims = [];
3056
- if (other.runtime !== null) claims.push({
3057
- port: other.runtime.port,
3058
- holder: `rde instance ${other.name}`
3059
- });
3060
- if (other.sampleDatabase !== null) claims.push({
3061
- port: other.sampleDatabase.port,
3062
- holder: `the sample database of rde instance ${other.name}`
3063
- });
3064
- return claims;
3065
- }
3066
3033
  async function portClaims(state, runtime) {
3067
3034
  const claims = [];
3068
3035
  if ((await runtime.docker.available()).available) {
3069
- const own = ownContainers(state);
3070
- for (const claim of await runtime.docker.portClaims()) if (!own.has(claim.container)) claims.push({
3036
+ const own = state.runtime?.kind === "docker" ? state.runtime.containerName : null;
3037
+ for (const claim of await runtime.docker.portClaims()) if (claim.container !== own) claims.push({
3071
3038
  port: claim.port,
3072
3039
  holder: `container ${claim.container}`
3073
3040
  });
3074
3041
  }
3075
- for (const other of await runtime.instances()) if (other.name !== state.name) claims.push(...instanceClaims(other));
3042
+ for (const other of await runtime.instances()) if (other.name !== state.name && other.runtime !== null) claims.push({
3043
+ port: other.runtime.port,
3044
+ holder: `rde instance ${other.name}`
3045
+ });
3076
3046
  return claims;
3077
3047
  }
3078
3048
  //#endregion
3079
3049
  //#region src/steps/admin-user.ts
3080
- const STEP_ID$10 = "admin-user";
3050
+ const STEP_ID$9 = "admin-user";
3081
3051
  const ADMIN_USER_KEYS = {
3082
- email: `${STEP_ID$10}.email`,
3083
- password: `${STEP_ID$10}.password`,
3084
- firstName: `${STEP_ID$10}.first-name`,
3085
- lastName: `${STEP_ID$10}.last-name`
3052
+ email: `${STEP_ID$9}.email`,
3053
+ password: `${STEP_ID$9}.password`,
3054
+ firstName: `${STEP_ID$9}.first-name`,
3055
+ lastName: `${STEP_ID$9}.last-name`
3086
3056
  };
3087
3057
  const GENERATED_ADMIN_EMAIL = "admin@metabase.local";
3088
3058
  const GENERATED_PASSWORD_BYTES = 24;
@@ -3237,7 +3207,7 @@ async function recordExistingAdmin(ctx, variant, client, url) {
3237
3207
  }
3238
3208
  function adminUserStep(options, deps = hostDeps$14) {
3239
3209
  return {
3240
- id: STEP_ID$10,
3210
+ id: STEP_ID$9,
3241
3211
  title: "Create the admin user",
3242
3212
  redoHint: "An instance keeps the admin it was set up with; `rde credentials` shows it.",
3243
3213
  async detect(ctx) {
@@ -3502,10 +3472,15 @@ const GitSyncTask = z.object({
3502
3472
  progress: z.number().nullable(),
3503
3473
  error_message: z.string().nullable().optional()
3504
3474
  }).loose();
3475
+ const SyncedCollection = z.object({
3476
+ id: z.int(),
3477
+ name: z.string()
3478
+ }).loose();
3505
3479
  const GitSyncStatus = z.object({
3506
3480
  branch: z.string().nullable(),
3507
3481
  is_dirty: z.boolean(),
3508
- current_task: GitSyncTask.nullable()
3482
+ current_task: GitSyncTask.nullable(),
3483
+ synced_collections: z.array(SyncedCollection)
3509
3484
  }).loose();
3510
3485
  const GitSyncOutcome = z.object({
3511
3486
  message: z.string().nullable(),
@@ -3517,6 +3492,9 @@ const RemoteChanges = z.object({
3517
3492
  remote_version: z.string().nullable(),
3518
3493
  local_version: z.string().nullable()
3519
3494
  }).loose();
3495
+ function remoteMoved(changes) {
3496
+ return changes.has_changes && changes.local_version !== null;
3497
+ }
3520
3498
  function comparableUrl(url) {
3521
3499
  const parsed = new URL(url);
3522
3500
  return `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`;
@@ -3652,11 +3630,11 @@ function mbTool(runner) {
3652
3630
  }
3653
3631
  //#endregion
3654
3632
  //#region src/steps/api-key.ts
3655
- const STEP_ID$9 = "api-key";
3633
+ const STEP_ID$8 = "api-key";
3656
3634
  const API_KEY_KEYS = {
3657
- email: `${STEP_ID$9}.email`,
3658
- password: `${STEP_ID$9}.password`,
3659
- profile: `${STEP_ID$9}.profile`
3635
+ email: `${STEP_ID$8}.email`,
3636
+ password: `${STEP_ID$8}.password`,
3637
+ profile: `${STEP_ID$8}.profile`
3660
3638
  };
3661
3639
  const ADMINISTRATORS_GROUP_NAME = "Administrators";
3662
3640
  function hostDeps$13(ctx) {
@@ -3720,7 +3698,7 @@ async function authenticates(client) {
3720
3698
  }
3721
3699
  }
3722
3700
  async function forgetCompletion(ctx) {
3723
- const { [STEP_ID$9]: record, ...steps } = ctx.state.steps;
3701
+ const { [STEP_ID$8]: record, ...steps } = ctx.state.steps;
3724
3702
  if (record !== void 0) await ctx.saveState({
3725
3703
  ...ctx.state,
3726
3704
  steps
@@ -3799,13 +3777,13 @@ function usageNote(profile) {
3799
3777
  }
3800
3778
  function apiKeyStep(deps = hostDeps$13) {
3801
3779
  return {
3802
- id: STEP_ID$9,
3780
+ id: STEP_ID$8,
3803
3781
  title: "Connect the Metabase CLI",
3804
3782
  redoHint: "`rde status` shows the mb profile it uses.",
3805
3783
  async detect(ctx) {
3806
3784
  const configured = configuredRuntime(ctx.state);
3807
3785
  const apiKey = ctx.secrets.apiKey;
3808
- if (configured === null || apiKey === null || ctx.state.steps[STEP_ID$9]?.status !== "done") return "todo";
3786
+ if (configured === null || apiKey === null || ctx.state.steps[STEP_ID$8]?.status !== "done") return "todo";
3809
3787
  const resolved = deps(ctx);
3810
3788
  if (!await resolved.instanceRunning()) return "todo";
3811
3789
  if (!isLoggedInAt(await mbTool(resolved.runner).authStatusIfInstalled(ctx.state.mbProfile), configured.url)) return "todo";
@@ -4827,7 +4805,7 @@ const RSA_KEY_UNAVAILABLE_ERROR = "RSA public key is not available";
4827
4805
  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";
4828
4806
  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";
4829
4807
  const CONNECTION_PLACEHOLDER = "leave empty to skip";
4830
- const STUB_CONNECTION_PLACEHOLDER = "leave empty to pick the engine";
4808
+ const ENGINE_PLACEHOLDER = "leave empty to pick the engine";
4831
4809
  const START_SCREEN = {
4832
4810
  engine: "pick-engine",
4833
4811
  "connection-string": "connection-string"
@@ -4853,6 +4831,9 @@ function targeting(stub) {
4853
4831
  function restart(model) {
4854
4832
  return model.target === null ? FRESH$1 : targeting(model.target);
4855
4833
  }
4834
+ function mayFinish(run) {
4835
+ return !run.deps.required || run.deps.existingCount + run.created.length > 0;
4836
+ }
4856
4837
  function nextStub(run, model) {
4857
4838
  const stub = run.queue.shift();
4858
4839
  if (stub !== void 0) return {
@@ -4860,13 +4841,18 @@ function nextStub(run, model) {
4860
4841
  to: "connection-string",
4861
4842
  model: targeting(stub)
4862
4843
  };
4863
- return run.created.length > 0 ? {
4844
+ if (run.created.length > 0) return {
4864
4845
  type: "next",
4865
4846
  to: "another",
4866
4847
  model
4867
- } : {
4848
+ };
4849
+ return mayFinish(run) ? {
4868
4850
  type: "done",
4869
4851
  model
4852
+ } : {
4853
+ type: "next",
4854
+ to: "connection-string",
4855
+ model: FRESH$1
4870
4856
  };
4871
4857
  }
4872
4858
  function stubNamed(run, name) {
@@ -4956,9 +4942,13 @@ function pickStubsScreen(run) {
4956
4942
  run.queue = run.stubs.filter((stub) => picked.includes(stub.id));
4957
4943
  return nextStub(run, model);
4958
4944
  }
4959
- if (run.deps.start === "connection-string" && run.created.length === 0) return {
4945
+ if (run.deps.start === "connection-string" && run.created.length === 0) return mayFinish(run) ? {
4960
4946
  type: "done",
4961
4947
  model
4948
+ } : {
4949
+ type: "next",
4950
+ to: "connection-string",
4951
+ model: FRESH$1
4962
4952
  };
4963
4953
  return {
4964
4954
  type: "next",
@@ -4968,6 +4958,11 @@ function pickStubsScreen(run) {
4968
4958
  };
4969
4959
  }
4970
4960
  function connectionQuestion(run, target) {
4961
+ if (target === null && !mayFinish(run)) return {
4962
+ key: DATABASE_KEYS.connection,
4963
+ message: "Connection string of your database (Enter to pick its engine instead)",
4964
+ placeholder: ENGINE_PLACEHOLDER
4965
+ };
4971
4966
  if (target === null) return {
4972
4967
  key: DATABASE_KEYS.connection,
4973
4968
  message: "Connection string of your database (Enter to skip; `rde db add` connects one later)",
@@ -4976,7 +4971,7 @@ function connectionQuestion(run, target) {
4976
4971
  return {
4977
4972
  key: databaseKey(ordinalOf(run), "connection"),
4978
4973
  message: `Connection string of ${target.name} (Enter to pick its engine instead)`,
4979
- placeholder: STUB_CONNECTION_PLACEHOLDER
4974
+ placeholder: ENGINE_PLACEHOLDER
4980
4975
  };
4981
4976
  }
4982
4977
  function connectionStringScreen(run) {
@@ -4988,7 +4983,7 @@ function connectionStringScreen(run) {
4988
4983
  ...draft !== null && { initialValue: draft }
4989
4984
  })).trim();
4990
4985
  draft = uri;
4991
- if (uri.length === 0) return model.target === null ? {
4986
+ if (uri.length === 0) return model.target === null && mayFinish(run) ? {
4992
4987
  type: "done",
4993
4988
  model
4994
4989
  } : {
@@ -5027,22 +5022,28 @@ function connectionStringScreen(run) {
5027
5022
  }
5028
5023
  };
5029
5024
  }
5030
- function finishLabel(run, model) {
5031
- if (model.target !== null) return `Leave ${model.target.name} unconnected`;
5032
- return run.deps.existingCount + run.created.length > 0 ? "Finish" : "Skip for now";
5025
+ function finishChoices(run, model) {
5026
+ if (model.target !== null) return [{
5027
+ value: FINISH,
5028
+ label: `Leave ${model.target.name} unconnected`
5029
+ }];
5030
+ if (run.deps.existingCount + run.created.length > 0) return [{
5031
+ value: FINISH,
5032
+ label: "Finish"
5033
+ }];
5034
+ return mayFinish(run) ? [{
5035
+ value: FINISH,
5036
+ label: "Skip for now"
5037
+ }] : [];
5033
5038
  }
5034
5039
  function pickEngineScreen(run) {
5035
5040
  return async (model) => {
5036
5041
  const total = run.deps.existingCount + run.created.length;
5037
- const finishChoice = {
5038
- value: FINISH,
5039
- label: finishLabel(run, model)
5040
- };
5041
5042
  for (;;) {
5042
5043
  const picked = await run.deps.prompts.select({
5043
5044
  key: databaseKey(ordinalOf(run), "engine"),
5044
5045
  message: "Which database engine?",
5045
- choices: [...run.deps.catalog.list().map(engineChoice), finishChoice],
5046
+ choices: [...run.deps.catalog.list().map(engineChoice), ...finishChoices(run, model)],
5046
5047
  ...model.engine !== null && { initialValue: model.engine.key }
5047
5048
  });
5048
5049
  if (picked === "finish" && model.target !== null) return nextStub(run, model);
@@ -5518,6 +5519,13 @@ const METABASE_CONTENT_DIRS = [
5518
5519
  ];
5519
5520
  const TOKEN_USERNAMES = { "bitbucket.org": "x-token-auth" };
5520
5521
  const DEFAULT_TOKEN_USERNAME = "x-access-token";
5522
+ const MISSING_REF_EXIT_CODE = 1;
5523
+ const FIELD_SEPARATOR = "";
5524
+ const COMMIT_FORMAT = [
5525
+ "%H",
5526
+ "%h",
5527
+ "%s"
5528
+ ].join("%x1f");
5521
5529
  function credentialEnv(credentials) {
5522
5530
  const { origin, hostname } = new URL(credentials.url);
5523
5531
  const username = TOKEN_USERNAMES[hostname] ?? DEFAULT_TOKEN_USERNAME;
@@ -5531,6 +5539,18 @@ function credentialEnv(credentials) {
5531
5539
  GIT_TERMINAL_PROMPT: "0"
5532
5540
  };
5533
5541
  }
5542
+ function parseCommitLine(line) {
5543
+ const [sha, shortSha, subject] = line.split(FIELD_SEPARATOR);
5544
+ if (sha === void 0 || shortSha === void 0 || subject === void 0) throw new Error(`unexpected \`git log\` line: ${line}`);
5545
+ return {
5546
+ sha,
5547
+ shortSha,
5548
+ subject
5549
+ };
5550
+ }
5551
+ function outputLines(stdout) {
5552
+ return stdout.split("\n").filter((line) => line.length > 0);
5553
+ }
5534
5554
  async function directoryExists(path) {
5535
5555
  try {
5536
5556
  return (await promises.stat(path)).isDirectory();
@@ -5586,8 +5606,12 @@ function gitTool(runner) {
5586
5606
  ], { cwd: dir });
5587
5607
  return result.exitCode === 0 && result.stdout.trim().length > 0;
5588
5608
  }
5589
- async function commitAll(dir, message) {
5590
- await git(["add", "--all"], { cwd: dir });
5609
+ async function commitPaths(dir, paths, message) {
5610
+ await git([
5611
+ "add",
5612
+ "--",
5613
+ ...paths
5614
+ ], { cwd: dir });
5591
5615
  await git([
5592
5616
  ...await hasIdentity(dir) ? [] : [
5593
5617
  "-c",
@@ -5597,7 +5621,9 @@ function gitTool(runner) {
5597
5621
  ],
5598
5622
  "commit",
5599
5623
  "--message",
5600
- message
5624
+ message,
5625
+ "--",
5626
+ ...paths
5601
5627
  ], { cwd: dir });
5602
5628
  }
5603
5629
  async function addRemote(dir, name, url) {
@@ -5643,6 +5669,42 @@ function gitTool(runner) {
5643
5669
  if (result.exitCode !== 0) return null;
5644
5670
  return result.stdout.trim();
5645
5671
  }
5672
+ async function isBareRepo(path) {
5673
+ const args = [
5674
+ `--git-dir=${path}`,
5675
+ "rev-parse",
5676
+ "--is-bare-repository"
5677
+ ];
5678
+ const result = await runner.run(GIT, args);
5679
+ return result.exitCode === 0 && result.stdout.trim() === "true";
5680
+ }
5681
+ async function bareBranchCommits(path, branch) {
5682
+ const gitDir = `--git-dir=${path}`;
5683
+ const ref = `refs/heads/${branch}`;
5684
+ const verifyArgs = [
5685
+ gitDir,
5686
+ "rev-parse",
5687
+ "--verify",
5688
+ "--quiet",
5689
+ `${ref}^{commit}`
5690
+ ];
5691
+ const verified = await runner.run(GIT, verifyArgs);
5692
+ if (verified.exitCode === MISSING_REF_EXIT_CODE) return null;
5693
+ if (verified.exitCode !== 0) throw toolFailure(GIT, verifyArgs, verified);
5694
+ return outputLines((await git([
5695
+ gitDir,
5696
+ "-c",
5697
+ "log.showSignature=false",
5698
+ "log",
5699
+ `--format=${COMMIT_FORMAT}`,
5700
+ ref,
5701
+ "--"
5702
+ ])).stdout).map(parseCommitLine);
5703
+ }
5704
+ async function reachableCommits(dir) {
5705
+ const result = await git(["rev-list", "--all"], { cwd: dir });
5706
+ return new Set(outputLines(result.stdout));
5707
+ }
5646
5708
  async function hasMetabaseContent(dir) {
5647
5709
  for (const name of METABASE_CONTENT_DIRS) if (await directoryExists(join(dir, name))) return true;
5648
5710
  return false;
@@ -5653,24 +5715,27 @@ function gitTool(runner) {
5653
5715
  initBare,
5654
5716
  init,
5655
5717
  hasCommits,
5656
- commitAll,
5718
+ commitPaths,
5657
5719
  addRemote,
5658
5720
  clone,
5659
5721
  push,
5660
5722
  currentBranch,
5661
5723
  remoteUrl,
5724
+ isBareRepo,
5725
+ bareBranchCommits,
5726
+ reachableCommits,
5662
5727
  hasMetabaseContent
5663
5728
  };
5664
5729
  }
5665
5730
  //#endregion
5666
5731
  //#region src/steps/git-sync.ts
5667
- const STEP_ID$8 = "git-sync";
5732
+ const STEP_ID$7 = "git-sync";
5668
5733
  const GIT_SYNC_KEYS = {
5669
- location: `${STEP_ID$8}.location`,
5670
- path: `${STEP_ID$8}.path`,
5671
- remote: `${STEP_ID$8}.remote`,
5672
- url: `${STEP_ID$8}.url`,
5673
- token: `${STEP_ID$8}.token`
5734
+ location: `${STEP_ID$7}.location`,
5735
+ path: `${STEP_ID$7}.path`,
5736
+ remote: `${STEP_ID$7}.remote`,
5737
+ url: `${STEP_ID$7}.url`,
5738
+ token: `${STEP_ID$7}.token`
5674
5739
  };
5675
5740
  const BARE_REPO_NAME = "content.git";
5676
5741
  const DEFAULT_BRANCH = "main";
@@ -5956,7 +6021,7 @@ async function prepareWorkdir(run, workdir) {
5956
6021
  if (!await run.git.hasCommits(workdir)) {
5957
6022
  await promises.writeFile(join(workdir, README_NAME), README_CONTENT);
5958
6023
  await ignoreDataAppFiles(workdir);
5959
- await run.git.commitAll(workdir, INITIAL_COMMIT_MESSAGE);
6024
+ await run.git.commitPaths(workdir, [README_NAME, GITIGNORE_NAME], INITIAL_COMMIT_MESSAGE);
5960
6025
  }
5961
6026
  return {
5962
6027
  workdir,
@@ -6120,7 +6185,7 @@ function shellWord(value) {
6120
6185
  function exportCommand(profile, branch) {
6121
6186
  return `${mbCommand(profile)} git-sync export -b ${shellWord(branch)}`;
6122
6187
  }
6123
- function usageLines$1(state, gitRemote) {
6188
+ function usageLines(state, gitRemote) {
6124
6189
  const profile = state.mbProfile;
6125
6190
  return [
6126
6191
  `cd ${gitRemote.workdir}`,
@@ -6171,18 +6236,18 @@ async function runGitSync(ctx, deps) {
6171
6236
  ...ctx.state,
6172
6237
  gitRemote: remote.gitRemote
6173
6238
  });
6174
- ctx.ui.note(usageLines$1(ctx.state, remote.gitRemote).join("\n"), "Working with the repository");
6239
+ ctx.ui.note(usageLines(ctx.state, remote.gitRemote).join("\n"), "Working with the repository");
6175
6240
  return "completed";
6176
6241
  }
6177
6242
  function gitSyncStep(deps = hostDeps$12) {
6178
6243
  return {
6179
- id: STEP_ID$8,
6244
+ id: STEP_ID$7,
6180
6245
  title: "Connect a git repository",
6181
6246
  redoHint: "`rde init --only git-sync` with it points Metabase's remote sync at that repository instead.",
6182
6247
  async detect(ctx) {
6183
6248
  if (!hasRemoteSync(ctx.state.license)) return "not-applicable";
6184
- if (ctx.state.steps[STEP_ID$8]?.status === "skipped") return "not-applicable";
6185
- if (ctx.state.gitRemote === null || ctx.state.steps[STEP_ID$8]?.status !== "done") return "todo";
6249
+ if (ctx.state.steps[STEP_ID$7]?.status === "skipped") return "not-applicable";
6250
+ if (ctx.state.gitRemote === null || ctx.state.steps[STEP_ID$7]?.status !== "done") return "todo";
6186
6251
  const instance = mbInstanceOf(ctx.state, ctx.secrets);
6187
6252
  if (instance === null) return "todo";
6188
6253
  try {
@@ -6364,8 +6429,8 @@ function skillsCli(runner) {
6364
6429
  }
6365
6430
  //#endregion
6366
6431
  //#region src/steps/agents-skills.ts
6367
- const STEP_ID$7 = "agents-skills";
6368
- const AGENTS_SKILLS_KEYS = { agents: `${STEP_ID$7}.agents` };
6432
+ const STEP_ID$6 = "agents-skills";
6433
+ const AGENTS_SKILLS_KEYS = { agents: `${STEP_ID$6}.agents` };
6369
6434
  const MECHANICS_NOTE = "The rde skill's mechanics come from `mb skills get <name>` at runtime, so nothing else needs installing.";
6370
6435
  const REQUIRED_SKILL_NAMES = [...DATA_APP_SKILL_NAMES, ...RDE_SOURCE_SKILL_NAMES];
6371
6436
  const DETECT_AGENTS = { kind: "detected" };
@@ -6548,8 +6613,8 @@ function skipLine(all, source) {
6548
6613
  switch (source) {
6549
6614
  case "override":
6550
6615
  case "chosen":
6551
- case "installed": return `Skipping the skills; run \`rde init --only ${STEP_ID$7}\` to install it later`;
6552
- case "detected": return `No coding agent found (${displayNames(all)}); run \`rde init --only ${STEP_ID$7} --agents <name>\` once one is installed`;
6616
+ case "installed": return `Skipping the skills; run \`rde init --only ${STEP_ID$6}\` to install it later`;
6617
+ case "detected": return `No coding agent found (${displayNames(all)}); run \`rde init --only ${STEP_ID$6} --agents <name>\` once one is installed`;
6553
6618
  }
6554
6619
  }
6555
6620
  function agentsSkillsStep(options, updates, deps = hostDeps$11) {
@@ -6563,13 +6628,13 @@ function agentsSkillsStep(options, updates, deps = hostDeps$11) {
6563
6628
  };
6564
6629
  }
6565
6630
  return {
6566
- id: STEP_ID$7,
6631
+ id: STEP_ID$6,
6567
6632
  title: "Install the rde and data-app skills for your coding agents",
6568
6633
  redoHint: "`rde init --only agents-skills` with it installs the skills for those agents.",
6569
6634
  async detect(ctx) {
6570
6635
  const resolved = deps(ctx);
6571
6636
  const detected = detectedOnly(await detectAgents(resolved.probe));
6572
- if (detected.length === 0) return ctx.state.steps[STEP_ID$7]?.status === "skipped" ? "not-applicable" : "todo";
6637
+ if (detected.length === 0) return ctx.state.steps[STEP_ID$6]?.status === "skipped" ? "not-applicable" : "todo";
6573
6638
  if (missingSkills(await skillsCli(resolved.runner).list(), detected).length > 0) return "todo";
6574
6639
  const check = await setCheck(resolved);
6575
6640
  for (const set of skillSets(resolved)) if (await setBehind(set, check)) return "outdated";
@@ -6617,8 +6682,10 @@ function agentsSkillsStep(options, updates, deps = hostDeps$11) {
6617
6682
  }
6618
6683
  //#endregion
6619
6684
  //#region src/steps/databases.ts
6620
- const STEP_ID$6 = "databases";
6621
- const SAMPLE_STANDS_IN = { request: "sample-stands-in" };
6685
+ const DATABASE_REQUIRED = { request: "required" };
6686
+ const WRITE_ACCESS = "Transforms write their tables to it, so connect it as a user that can create tables.";
6687
+ const NONE_CONNECTED = `rde init needs a database of your own to go on. ${WRITE_ACCESS}`;
6688
+ const NONE_NAMED = `rde init needs a database of your own and asks for one only at a terminal without --yes: pass --database <connection string>, or list it under "databases" in the --answers file. ${WRITE_ACCESS}`;
6622
6689
  function hostDatabasesDeps(signal) {
6623
6690
  return {
6624
6691
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, signal),
@@ -6626,7 +6693,7 @@ function hostDatabasesDeps(signal) {
6626
6693
  now: () => /* @__PURE__ */ new Date()
6627
6694
  };
6628
6695
  }
6629
- function requireApiKey$2(state, apiKey) {
6696
+ function requireApiKey$1(state, apiKey) {
6630
6697
  if (apiKey === null) throw new ConfigError(`instance ${state.name} has no API key yet; run \`rde init\``);
6631
6698
  return apiKey;
6632
6699
  }
@@ -6636,7 +6703,7 @@ function hostAliasOf(runtime) {
6636
6703
  case "jar": return null;
6637
6704
  }
6638
6705
  }
6639
- function toRecord$1(database, createdAt) {
6706
+ function toRecord(database, createdAt) {
6640
6707
  return {
6641
6708
  id: database.id,
6642
6709
  name: database.name,
@@ -6648,7 +6715,7 @@ function authenticatedClient(state, apiKey, deps) {
6648
6715
  const configured = requireConfiguredRuntime(state);
6649
6716
  return deps.client(configured.url, {
6650
6717
  kind: "api-key",
6651
- key: requireApiKey$2(state, apiKey)
6718
+ key: requireApiKey$1(state, apiKey)
6652
6719
  });
6653
6720
  }
6654
6721
  async function listDatabases(state, apiKey, deps) {
@@ -6691,7 +6758,7 @@ async function connectDatabases(ctx, deps, opening) {
6691
6758
  existingCount: existing.length,
6692
6759
  readTextFile: deps.readTextFile,
6693
6760
  onCreated: async (database) => {
6694
- const record = toRecord$1(database, deps.now().toISOString());
6761
+ const record = toRecord(database, deps.now().toISOString());
6695
6762
  await ctx.saveState({
6696
6763
  ...ctx.state,
6697
6764
  databases: [...ctx.state.databases, record]
@@ -6708,6 +6775,7 @@ async function connectDatabases(ctx, deps, opening) {
6708
6775
  async function addDatabases(ctx, deps, options) {
6709
6776
  const connected = await connectDatabases(ctx, deps, {
6710
6777
  start: "engine",
6778
+ required: false,
6711
6779
  ...options
6712
6780
  });
6713
6781
  return {
@@ -6715,13 +6783,11 @@ async function addDatabases(ctx, deps, options) {
6715
6783
  result: { addedDatabases: connected.added }
6716
6784
  };
6717
6785
  }
6718
- async function askForOwnDatabase(ctx, deps) {
6719
- const connected = await connectDatabases(ctx, deps, {
6720
- start: "connection-string",
6721
- offerStubs: true
6722
- });
6786
+ async function connectRequired(ctx, deps, opening) {
6787
+ const connected = await connectDatabases(ctx, deps, opening);
6788
+ if (connected.existing + connected.created === 0) throw new ConfigError(NONE_CONNECTED);
6723
6789
  return {
6724
- outcome: connected.created > 0 ? "completed" : "skipped",
6790
+ outcome: "completed",
6725
6791
  result: { addedDatabases: connected.added }
6726
6792
  };
6727
6793
  }
@@ -6731,34 +6797,19 @@ function hostDeps$10(ctx) {
6731
6797
  instanceRunning: () => isInstanceRunning(ctx.state, hostRuntimeDeps(ctx.state.name, ctx.signal))
6732
6798
  };
6733
6799
  }
6734
- function sampleDatabaseId(state) {
6735
- return state.sampleDatabase === null ? null : state.sampleDatabase.databaseId;
6736
- }
6737
- function skippedBefore(state) {
6738
- return state.steps[STEP_ID$6]?.status === "skipped";
6739
- }
6740
- function ownDatabaseIds(state) {
6741
- const sampleId = sampleDatabaseId(state);
6742
- return new Set(state.databases.filter((record) => record.id !== sampleId).map((record) => record.id));
6743
- }
6744
6800
  function databasesStep(options, deps = hostDeps$10) {
6745
6801
  return {
6746
- id: STEP_ID$6,
6802
+ id: "databases",
6747
6803
  title: "Connect your databases",
6748
6804
  redoHint: "Connect another with `rde db add --url <connection string>`.",
6749
6805
  async detect(ctx) {
6750
6806
  const configured = configuredRuntime(ctx.state);
6751
6807
  if (configured === null || ctx.secrets.apiKey === null) return "todo";
6752
- const own = ownDatabaseIds(ctx.state);
6753
- const sampleId = sampleDatabaseId(ctx.state);
6754
- const asking = options.request === "ask-once" && !skippedBefore(ctx.state);
6755
- if (own.size === 0 && !asking) {
6756
- if (options.request === "named") return "todo";
6757
- return sampleId !== null || skippedBefore(ctx.state) ? "done" : "todo";
6758
- }
6808
+ const recorded = new Set(ctx.state.databases.map((record) => record.id));
6809
+ if (recorded.size === 0 && options.request === "named") return "todo";
6759
6810
  const resolved = deps(ctx);
6760
6811
  if (!await resolved.instanceRunning()) return "todo";
6761
- const isOwn = (id) => own.size > 0 ? own.has(id) : id !== sampleId;
6812
+ const isOwn = (id) => recorded.size === 0 || recorded.has(id);
6762
6813
  const client = resolved.client(configured.url, {
6763
6814
  kind: "api-key",
6764
6815
  key: ctx.secrets.apiKey
@@ -6772,9 +6823,19 @@ function databasesStep(options, deps = hostDeps$10) {
6772
6823
  },
6773
6824
  async run(ctx) {
6774
6825
  switch (options.request) {
6775
- case "ask-once": return askForOwnDatabase(ctx, deps(ctx));
6776
- case "named": return addDatabases(ctx, deps(ctx), { offerStubs: false });
6777
- case "sample-stands-in": return { outcome: "skipped" };
6826
+ case "ask":
6827
+ ctx.ui.info(NONE_CONNECTED);
6828
+ return connectRequired(ctx, deps(ctx), {
6829
+ start: "connection-string",
6830
+ offerStubs: true,
6831
+ required: true
6832
+ });
6833
+ case "named": return connectRequired(ctx, deps(ctx), {
6834
+ start: "engine",
6835
+ offerStubs: false,
6836
+ required: true
6837
+ });
6838
+ case "required": throw new ConfigError(NONE_NAMED);
6778
6839
  }
6779
6840
  }
6780
6841
  };
@@ -7028,23 +7089,44 @@ function installMbStep(updates, deps = hostDeps$9) {
7028
7089
  const STEP_ID$4 = "license";
7029
7090
  const LICENSE_KEYS = { token: `${STEP_ID$4}.token` };
7030
7091
  const TOKEN_PATTERN = /^[0-9a-f]{64}$/i;
7031
- const NOT_FOUND_STATUS$2 = 404;
7092
+ const NOT_FOUND_STATUS$1 = 404;
7093
+ const TOKEN_REFUSED_STATUS = 400;
7094
+ const STORE_UNREACHABLE_STATUS = 503;
7095
+ const TOKEN_CHECK_HOST = "token-check.metabase.com";
7096
+ const ACTIVATION_FAILED = "License activation failed";
7097
+ const LICENSED_FEATURES = {
7098
+ [REMOTE_SYNC_FEATURE]: "remote sync",
7099
+ "data-apps": "data apps",
7100
+ "transforms-testing": "transform tests"
7101
+ };
7102
+ const LICENSED_FEATURE_NAMES = Object.values(LICENSED_FEATURES);
7103
+ function licensedFeatureCoverage(features) {
7104
+ const entries = Object.entries(LICENSED_FEATURES);
7105
+ return {
7106
+ included: entries.filter(([key]) => features.includes(key)).map(([, name]) => name),
7107
+ missing: entries.filter(([key]) => !features.includes(key)).map(([, name]) => name)
7108
+ };
7109
+ }
7110
+ function listOf(names, type) {
7111
+ return new Intl.ListFormat("en", { type }).format(names);
7112
+ }
7032
7113
  function hostDeps$8(ctx) {
7033
7114
  return {
7034
7115
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
7035
7116
  runner: processRunner(ctx.signal),
7036
- instanceRunning: () => isInstanceRunning(ctx.state, hostRuntimeDeps(ctx.state.name, ctx.signal))
7117
+ instanceRunning: () => isInstanceRunning(ctx.state, hostRuntimeDeps(ctx.state.name, ctx.signal)),
7118
+ terminal: stdinIsTty()
7037
7119
  };
7038
7120
  }
7039
7121
  function isNoTokenYet(error) {
7040
- if (error instanceof MetabaseApiError) return error.status === NOT_FOUND_STATUS$2;
7122
+ if (error instanceof MetabaseApiError) return error.status === NOT_FOUND_STATUS$1;
7041
7123
  return isMetabaseNotServing(error);
7042
7124
  }
7043
7125
  function validateToken(value) {
7044
7126
  if (value.length === 0 || TOKEN_PATTERN.test(value)) return;
7045
7127
  return "A license token is 64 hexadecimal characters";
7046
7128
  }
7047
- function requireApiKey$1(ctx) {
7129
+ function requireApiKey(ctx) {
7048
7130
  if (ctx.secrets.apiKey === null) throw new ConfigError("no API key yet; the api-key step comes first");
7049
7131
  return ctx.secrets.apiKey;
7050
7132
  }
@@ -7052,6 +7134,67 @@ function activeLabel(status) {
7052
7134
  const plan = status["plan-alias"];
7053
7135
  return plan === void 0 ? "License active" : `License active (plan ${plan})`;
7054
7136
  }
7137
+ function refusalOf(error) {
7138
+ if (!(error instanceof MetabaseApiError)) return null;
7139
+ if (error.status === TOKEN_REFUSED_STATUS) return new ConfigError(`The license token was refused: ${error.reason}`);
7140
+ if (error.status === STORE_UNREACHABLE_STATUS) return new ConfigError(`The instance could not reach ${TOKEN_CHECK_HOST} to check the license token; it needs outbound network access`);
7141
+ return null;
7142
+ }
7143
+ async function activate(client, token) {
7144
+ try {
7145
+ await client.setting.setPremiumEmbeddingToken(token);
7146
+ } catch (error) {
7147
+ const refusal = refusalOf(error);
7148
+ if (refusal === null) throw error;
7149
+ return {
7150
+ kind: "refused",
7151
+ error: refusal
7152
+ };
7153
+ }
7154
+ const status = await client.tokenStatus.get();
7155
+ if (!status.valid) return {
7156
+ kind: "refused",
7157
+ error: new ConfigError(`Metabase reports the token as ${status.status}`)
7158
+ };
7159
+ return {
7160
+ kind: "activated",
7161
+ status
7162
+ };
7163
+ }
7164
+ async function activateAskedToken(ctx, client, terminal) {
7165
+ const token = await ctx.prompts.password({
7166
+ key: LICENSE_KEYS.token,
7167
+ message: "Paste your Metabase license token (Enter to skip)",
7168
+ validate: validateToken
7169
+ });
7170
+ if (token.length === 0) return null;
7171
+ const spinner = ctx.ui.spinner("Activating the license…");
7172
+ let activation;
7173
+ try {
7174
+ activation = await activate(client, token);
7175
+ } catch (error) {
7176
+ spinner.fail(ACTIVATION_FAILED);
7177
+ throw error;
7178
+ }
7179
+ if (activation.kind === "activated") {
7180
+ spinner.stop(activeLabel(activation.status));
7181
+ ctx.session.licenseToken.set(token);
7182
+ return activation.status;
7183
+ }
7184
+ spinner.fail(ACTIVATION_FAILED);
7185
+ if (!terminal) throw activation.error;
7186
+ ctx.ui.warn(activation.error.message);
7187
+ return activateAskedToken(ctx, client, terminal);
7188
+ }
7189
+ function reportFeatures(ctx, features) {
7190
+ const { missing } = licensedFeatureCoverage(features);
7191
+ const remoteSync = features.includes(REMOTE_SYNC_FEATURE);
7192
+ if (remoteSync) ctx.ui.info("Remote sync is included; the git step will set it up");
7193
+ if (missing.length > 0) {
7194
+ const gitNote = remoteSync ? "" : "; the git step will be skipped";
7195
+ ctx.ui.warn(`This license does not include ${listOf(missing, "disjunction")}${gitNote}`);
7196
+ }
7197
+ }
7055
7198
  function licenseStep(deps = hostDeps$8) {
7056
7199
  return {
7057
7200
  id: STEP_ID$4,
@@ -7076,36 +7219,17 @@ function licenseStep(deps = hostDeps$8) {
7076
7219
  },
7077
7220
  async run(ctx) {
7078
7221
  const configured = requireConfiguredRuntime(ctx.state);
7079
- const apiKey = requireApiKey$1(ctx);
7080
- const token = await ctx.prompts.password({
7081
- key: LICENSE_KEYS.token,
7082
- message: "Paste your Metabase license token (Enter to skip)",
7083
- validate: validateToken
7084
- });
7085
- if (token.length === 0) {
7086
- ctx.ui.info("Skipping the license; remote sync needs one. Run `rde init --from license` to add it later");
7087
- return { outcome: "skipped" };
7088
- }
7089
- ctx.session.licenseToken.set(token);
7222
+ const apiKey = requireApiKey(ctx);
7090
7223
  const resolved = deps(ctx);
7091
- const client = resolved.client(configured.url, {
7224
+ const status = await activateAskedToken(ctx, resolved.client(configured.url, {
7092
7225
  kind: "api-key",
7093
7226
  key: apiKey
7094
- });
7095
- const spinner = ctx.ui.spinner("Activating the license…");
7096
- let status;
7097
- try {
7098
- await client.setting.setPremiumEmbeddingToken(token);
7099
- status = await client.tokenStatus.get();
7100
- } catch (error) {
7101
- spinner.fail("License activation failed");
7102
- throw error;
7103
- }
7104
- if (!status.valid) {
7105
- spinner.fail("License activation failed");
7106
- throw new ConfigError(`Metabase reports the token as ${status.status}`);
7227
+ }), resolved.terminal);
7228
+ if (status === null) {
7229
+ const lost = listOf(LICENSED_FEATURE_NAMES, "conjunction");
7230
+ ctx.ui.info(`Skipping the license; ${lost} need one. Run \`rde init --from license\` to add it later`);
7231
+ return { outcome: "skipped" };
7107
7232
  }
7108
- spinner.stop(activeLabel(status));
7109
7233
  await ctx.saveState({
7110
7234
  ...ctx.state,
7111
7235
  license: {
@@ -7120,8 +7244,7 @@ function licenseStep(deps = hostDeps$8) {
7120
7244
  profile: ctx.state.mbProfile,
7121
7245
  apiKey
7122
7246
  });
7123
- if (status.features.includes("remote-sync")) ctx.ui.info("Remote sync is included; the git step will set it up");
7124
- else ctx.ui.warn("This license does not include remote sync; the git step will be skipped");
7247
+ reportFeatures(ctx, status.features);
7125
7248
  return { outcome: "completed" };
7126
7249
  }
7127
7250
  };
@@ -7134,7 +7257,7 @@ const NODE_HINT = `Install Node ${MIN_NODE_VERSION} or newer from https://nodejs
7134
7257
  const MARK_OK = "✓";
7135
7258
  const MARK_MISSING = "✗";
7136
7259
  const MARK_OPTIONAL = "–";
7137
- const DOCKER_OPTIONAL_NOTE = `Metabase can run as a jar with Java instead, without the sample database`;
7260
+ const DOCKER_OPTIONAL_NOTE = `Metabase can run as a jar with Java instead`;
7138
7261
  const JAVA_OPTIONAL_NOTE = `Metabase can run in Docker instead`;
7139
7262
  const PLATFORMS = {
7140
7263
  darwin: {
@@ -7460,210 +7583,12 @@ function runtimeStep(options, deps = hostDeps$6) {
7460
7583
  }
7461
7584
  };
7462
7585
  }
7463
- //#endregion
7464
- //#region src/steps/sample-database.ts
7465
- const STEP_ID$2 = "sample-database";
7466
- const SAMPLE_DATABASE_DISPLAY_NAME = "Sample Database";
7467
- const POSTGRES_ENGINE = "postgres";
7468
- const NOT_FOUND_STATUS$1 = 404;
7469
- function hostDeps$5(ctx) {
7470
- return {
7471
- runtime: hostRuntimeDeps(ctx.state.name, ctx.signal),
7472
- client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
7473
- readyTimeoutMs: SAMPLE_DATABASE_READY_TIMEOUT_MS,
7474
- now: () => /* @__PURE__ */ new Date()
7475
- };
7476
- }
7477
- function isNotFound$2(error) {
7478
- return error instanceof MetabaseApiError && error.status === NOT_FOUND_STATUS$1;
7479
- }
7480
- function requireApiKey(ctx) {
7481
- if (ctx.secrets.apiKey === null) throw new ConfigError(`instance ${ctx.state.name} has no API key yet; run \`rde init\``);
7482
- return ctx.secrets.apiKey;
7483
- }
7484
- async function recordedDatabase(record, client) {
7485
- if (record.databaseId === null) return null;
7486
- try {
7487
- return await client.database.get(record.databaseId);
7488
- } catch (error) {
7489
- if (isNotFound$2(error)) return null;
7490
- throw error;
7491
- }
7492
- }
7493
- async function persistedRecord(ctx, deps) {
7494
- const existing = ctx.state.sampleDatabase;
7495
- if (existing !== null) {
7496
- if ((await sampleDatabaseContainer(existing, deps.runtime.docker, deps.runtime.rdeHome).status()).installed || await deps.runtime.ports.isFree(existing.port)) return {
7497
- record: existing,
7498
- staleDatabaseId: null
7499
- };
7500
- const pointer = existing.databaseId === null ? "" : `; Metabase database ${existing.databaseId} still points at the old one`;
7501
- ctx.ui.warn(`Port ${existing.port} is in use by another program; the sample database moves to a new port${pointer}`);
7502
- }
7503
- const claims = await portClaims(ctx.state, deps.runtime);
7504
- const port = await deps.runtime.ports.findFree(SAMPLE_DATABASE_PORT, claims);
7505
- if (port !== 25432) ctx.ui.warn(`Port ${SAMPLE_DATABASE_PORT} is in use or claimed; using ${port} for the sample database`);
7506
- const record = {
7507
- containerName: sampleContainerName(ctx.state.name, deps.runtime.rdeHome),
7508
- image: SAMPLE_DATABASE_IMAGE,
7509
- port,
7510
- databaseId: null
7511
- };
7512
- await ctx.saveState({
7513
- ...ctx.state,
7514
- sampleDatabase: record
7515
- });
7516
- return {
7517
- record,
7518
- staleDatabaseId: existing?.databaseId ?? null
7519
- };
7520
- }
7521
- const LINUX = "linux";
7522
- async function gatewayAddress(ctx, deps) {
7523
- if (!(ctx.state.runtime?.kind === "docker") || deps.runtime.platform !== LINUX) return null;
7524
- return deps.runtime.docker.bridgeGateway();
7525
- }
7526
- async function recordSampleDatabaseMove(ctx, from, to) {
7527
- ctx.ui.warn(`Port ${from.port} is taken; the sample database moved to ${to.port}`);
7528
- const record = {
7529
- ...to,
7530
- databaseId: null
7531
- };
7532
- await ctx.saveState({
7533
- ...ctx.state,
7534
- sampleDatabase: record,
7535
- databases: ctx.state.databases.filter((entry) => entry.id !== from.databaseId)
7536
- });
7537
- return record;
7538
- }
7539
- async function installAndStart(ctx, sidecar, installed, deps) {
7540
- const installing = ctx.ui.spinner(`Installing the sample database from ${installed.image}`);
7541
- try {
7542
- await sidecar.install({
7543
- onProgress: (line) => installing.update(line),
7544
- alsoOn: await gatewayAddress(ctx, deps)
7545
- });
7546
- } catch (error) {
7547
- installing.fail("Sample database install failed");
7548
- throw error;
7549
- }
7550
- installing.stop(`Sample database container ${installed.containerName} on port ${installed.port}`);
7551
- const started = await startOnFreePort(installed, deps.runtime.docker, deps.runtime.rdeHome);
7552
- const record = started.state.port === installed.port ? installed : await recordSampleDatabaseMove(ctx, installed, started.state);
7553
- const waiting = ctx.ui.spinner("Waiting for Postgres to accept connections…");
7554
- try {
7555
- await sidecar.waitReady({
7556
- timeoutMs: deps.readyTimeoutMs,
7557
- signal: ctx.signal
7558
- });
7559
- } catch (error) {
7560
- waiting.fail("Postgres did not become ready");
7561
- throw error;
7562
- }
7563
- waiting.stop(`Postgres is ready on ${LOOPBACK_HOST}:${record.port}`);
7564
- return record;
7565
- }
7566
- function connectionDetails(record, configured, deps) {
7567
- const alias = runtimeFromState(configured.runtime, deps.runtime).hostAlias();
7568
- return applyHostAlias({
7569
- host: "localhost",
7570
- port: record.port,
7571
- dbname: SAMPLE_DATABASE_NAME,
7572
- user: SAMPLE_DATABASE_USER,
7573
- password: SAMPLE_DATABASE_PASSWORD
7574
- }, alias).details;
7575
- }
7576
- function toRecord(database, createdAt) {
7577
- return {
7578
- id: database.id,
7579
- name: database.name,
7580
- engine: database.engine,
7581
- createdAt
7582
- };
7583
- }
7584
- async function connectDatabase(ctx, choice, configured, client, deps) {
7585
- const { record } = choice;
7586
- const known = await recordedDatabase(record, client);
7587
- if (known !== null) {
7588
- ctx.ui.info(`${known.name} is already connected as database ${known.id}`);
7589
- return known;
7590
- }
7591
- const spinner = ctx.ui.spinner(`Connecting ${SAMPLE_DATABASE_DISPLAY_NAME}…`);
7592
- let database;
7593
- try {
7594
- database = await client.database.create({
7595
- name: SAMPLE_DATABASE_DISPLAY_NAME,
7596
- engine: POSTGRES_ENGINE,
7597
- details: connectionDetails(record, configured, deps)
7598
- });
7599
- } catch (error) {
7600
- spinner.fail(`Could not connect ${SAMPLE_DATABASE_DISPLAY_NAME}`);
7601
- throw error;
7602
- }
7603
- const stale = /* @__PURE__ */ new Set([record.databaseId, choice.staleDatabaseId]);
7604
- const others = ctx.state.databases.filter((entry) => !stale.has(entry.id));
7605
- await ctx.saveState({
7606
- ...ctx.state,
7607
- sampleDatabase: {
7608
- ...record,
7609
- databaseId: database.id
7610
- },
7611
- databases: [...others, toRecord(database, deps.now().toISOString())]
7612
- });
7613
- spinner.stop(`Connected ${database.name} as database ${database.id}`);
7614
- return database;
7615
- }
7616
- function usageLines(record) {
7617
- return [`psql "${sampleDatabaseUrl(record.port)}"`, "Transforms can write to this database."].join("\n");
7618
- }
7619
- function sampleDatabaseStep(deps = hostDeps$5) {
7620
- return {
7621
- id: STEP_ID$2,
7622
- title: "Connect a sample database",
7623
- async detect(ctx) {
7624
- const resolved = deps(ctx);
7625
- if (!(await resolved.runtime.docker.available()).available) return "not-applicable";
7626
- const record = ctx.state.sampleDatabase;
7627
- const configured = configuredRuntime(ctx.state);
7628
- if (record === null || configured === null || ctx.secrets.apiKey === null) return "todo";
7629
- if (!(await sampleDatabaseContainer(record, resolved.runtime.docker, resolved.runtime.rdeHome).status()).installed || !await isInstanceRunning(ctx.state, resolved.runtime)) return "todo";
7630
- const client = resolved.client(configured.url, {
7631
- kind: "api-key",
7632
- key: ctx.secrets.apiKey
7633
- });
7634
- try {
7635
- return await recordedDatabase(record, client) === null ? "todo" : "done";
7636
- } catch (error) {
7637
- if (isMetabaseNotServing(error) || isRejectedCredential(error)) return "todo";
7638
- throw error;
7639
- }
7640
- },
7641
- async run(ctx) {
7642
- const resolved = deps(ctx);
7643
- const configured = requireConfiguredRuntime(ctx.state);
7644
- const apiKey = requireApiKey(ctx);
7645
- const docker = await resolved.runtime.docker.available();
7646
- if (!docker.available) throw new ConfigError(`the sample database runs in Docker, which is not available: ${docker.reason}`);
7647
- const persisted = await persistedRecord(ctx, resolved);
7648
- const record = await installAndStart(ctx, sampleDatabaseContainer(persisted.record, resolved.runtime.docker, resolved.runtime.rdeHome), persisted.record, resolved);
7649
- await connectDatabase(ctx, {
7650
- ...persisted,
7651
- record
7652
- }, configured, resolved.client(configured.url, {
7653
- kind: "api-key",
7654
- key: apiKey
7655
- }), resolved);
7656
- ctx.ui.note(usageLines(record), "Sample database");
7657
- return { outcome: "completed" };
7658
- }
7659
- };
7660
- }
7661
7586
  const DEFAULT_HEALTH_TIMING = { timeoutMs: 3e5 };
7662
7587
  //#endregion
7663
7588
  //#region src/steps/start-metabase.ts
7664
7589
  const LOG_TAIL_ON_TIMEOUT = 30;
7665
7590
  const PERCENT = 100;
7666
- function hostDeps$4(ctx) {
7591
+ function hostDeps$5(ctx) {
7667
7592
  return {
7668
7593
  runtime: hostRuntimeDeps(ctx.state.name, ctx.signal),
7669
7594
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
@@ -7679,21 +7604,6 @@ async function runningVersion(client) {
7679
7604
  function progressLabel(progress) {
7680
7605
  return `Starting Metabase… ${Math.round(progress * PERCENT)}%`;
7681
7606
  }
7682
- async function startSampleDatabase(ctx, deps) {
7683
- const record = ctx.state.sampleDatabase;
7684
- if (record === null) return;
7685
- const started = await startOnFreePort(record, deps.runtime.docker, deps.runtime.rdeHome);
7686
- if (started.state.port !== record.port) await recordSampleDatabaseMove(ctx, record, started.state);
7687
- switch (started.outcome) {
7688
- case "missing":
7689
- ctx.ui.warn(`Sample database container ${record.containerName} is missing; \`rde init\` recreates it`);
7690
- return;
7691
- case "started":
7692
- ctx.ui.info(`Sample database container ${record.containerName} started`);
7693
- return;
7694
- case "running": return;
7695
- }
7696
- }
7697
7607
  async function withMoveHint(ctx, deps, taken) {
7698
7608
  const claims = await portClaims(ctx.state, deps.runtime);
7699
7609
  const free = await deps.runtime.ports.findFree(taken.port + 1, claims);
@@ -7707,7 +7617,7 @@ async function startRuntime(ctx, runtime, deps) {
7707
7617
  throw error;
7708
7618
  }
7709
7619
  }
7710
- function startMetabaseStep(deps = hostDeps$4) {
7620
+ function startMetabaseStep(deps = hostDeps$5) {
7711
7621
  return {
7712
7622
  id: "start-metabase",
7713
7623
  title: "Start Metabase",
@@ -7730,7 +7640,6 @@ function startMetabaseStep(deps = hostDeps$4) {
7730
7640
  const runtime = runtimeFromState(configured.runtime, resolved.runtime);
7731
7641
  const client = resolved.client(configured.url, NO_AUTH);
7732
7642
  await startRuntime(ctx, runtime, resolved);
7733
- await startSampleDatabase(ctx, resolved);
7734
7643
  const spinner = ctx.ui.spinner("Starting Metabase…");
7735
7644
  let shown = 0;
7736
7645
  try {
@@ -7766,6 +7675,261 @@ function startMetabaseStep(deps = hostDeps$4) {
7766
7675
  };
7767
7676
  }
7768
7677
  //#endregion
7678
+ //#region src/steps/sync-scope.ts
7679
+ const STEP_ID$2 = "sync-scope";
7680
+ const SYNC_SCOPE_KEYS = { export: `${STEP_ID$2}.export` };
7681
+ const AGENT_COLLECTION_NAME = "Analytics";
7682
+ const LIBRARY_NAME = "Library";
7683
+ const ROOT_COLLECTION_NAME = "Our analytics";
7684
+ const AGENT_COLLECTION_DESCRIPTION = "Dashboards, questions, models, and documents that reach git on export.";
7685
+ function hostDeps$4(ctx) {
7686
+ return {
7687
+ client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
7688
+ runner: processRunner(ctx.signal),
7689
+ instanceRunning: () => isInstanceRunning(ctx.state, hostRuntimeDeps(ctx.state.name, ctx.signal))
7690
+ };
7691
+ }
7692
+ function isCollection(entry) {
7693
+ return typeof entry.id === "number";
7694
+ }
7695
+ function isOrdinary(collection) {
7696
+ return collection.type === null && collection.namespace === null && collection.personal_owner_id === null;
7697
+ }
7698
+ function readScope(list, syncRoot) {
7699
+ const topLevel = list.filter(isCollection).filter((collection) => collection.location === "/");
7700
+ const ordinary = topLevel.filter(isOrdinary);
7701
+ const unsynced = ordinary.filter((collection) => !collection.is_remote_synced);
7702
+ return {
7703
+ library: topLevel.find((collection) => collection.type === "library") ?? null,
7704
+ syncedRoots: ordinary.filter((collection) => collection.is_remote_synced),
7705
+ unsyncedRecordedRoot: unsynced.find((collection) => collection.id === syncRoot) ?? null,
7706
+ foreignNamesake: unsynced.some((collection) => collection.id !== syncRoot && collection.name === "Analytics")
7707
+ };
7708
+ }
7709
+ function isComplete(scope, libraryLicensed) {
7710
+ return (!libraryLicensed || scope.library !== null && scope.library.is_remote_synced) && scope.syncedRoots.length > 0;
7711
+ }
7712
+ function isNotAnsweringYet(error) {
7713
+ return error instanceof MetabaseUnreachableError || error instanceof MetabaseInitializingError || isRejectedCredential(error);
7714
+ }
7715
+ function requireMbInstance(ctx) {
7716
+ const instance = mbInstanceOf(ctx.state, ctx.secrets);
7717
+ if (instance === null) throw new ConfigError("no API key yet; the api-key step comes first");
7718
+ return instance;
7719
+ }
7720
+ function requireGitRemote(ctx) {
7721
+ if (ctx.state.gitRemote === null) throw new ConfigError("no repository yet; the git-sync step comes first");
7722
+ return ctx.state.gitRemote;
7723
+ }
7724
+ async function requireSettledImport(ctx, client, gitRemote) {
7725
+ const task = await client.remoteSync.currentTask();
7726
+ if (task === null) return;
7727
+ if (task.status === "running") throw new ConfigError(`a remote sync ${task.sync_task_type} is running; run \`rde init --only ${STEP_ID$2}\` once it finishes`);
7728
+ if (task.sync_task_type === "import" && task.status !== "successful") {
7729
+ const importCommand = `${mbCommand(ctx.state.mbProfile)} git-sync import --branch ${shellWord(gitRemote.branch)}`;
7730
+ throw new ConfigError(`the last import from the repository ended ${task.status}; run \`${importCommand}\`, then \`rde init --only ${STEP_ID$2}\``);
7731
+ }
7732
+ }
7733
+ async function isReadOnly(client) {
7734
+ return await client.setting.remoteSyncType() === "read-only";
7735
+ }
7736
+ async function requireReadWrite(client) {
7737
+ if (await isReadOnly(client)) throw new ConfigError(`remote sync is read-only; the repository decides what syncs. Switch it to read-write in Admin, then \`rde init --only ${STEP_ID$2}\``);
7738
+ }
7739
+ async function ensureLibrary(client, scope, libraryLicensed) {
7740
+ if (!libraryLicensed) return null;
7741
+ return scope.library ?? client.collection.createLibrary();
7742
+ }
7743
+ async function ensureRoots(ctx, client, scope) {
7744
+ if (scope.syncedRoots.length > 0) return {
7745
+ roots: scope.syncedRoots,
7746
+ created: false,
7747
+ besideNamesake: false
7748
+ };
7749
+ if (scope.unsyncedRecordedRoot !== null) return {
7750
+ roots: [scope.unsyncedRecordedRoot],
7751
+ created: false,
7752
+ besideNamesake: false
7753
+ };
7754
+ const created = await client.collection.create({
7755
+ name: AGENT_COLLECTION_NAME,
7756
+ description: AGENT_COLLECTION_DESCRIPTION
7757
+ });
7758
+ await ctx.saveState({
7759
+ ...ctx.state,
7760
+ syncRoot: created.id
7761
+ });
7762
+ return {
7763
+ roots: [created],
7764
+ created: true,
7765
+ besideNamesake: scope.foreignNamesake
7766
+ };
7767
+ }
7768
+ function itemLabel(item) {
7769
+ return item.name === null ? `${item.model} ${item.id}` : `${item.model} ${item.id} "${item.name}"`;
7770
+ }
7771
+ function locationLabel(location) {
7772
+ if (location === void 0) return "in a collection Metabase could not find";
7773
+ return `in ${location === null ? ROOT_COLLECTION_NAME : location.name}`;
7774
+ }
7775
+ function dependencyLine(dependency) {
7776
+ const line = ` ${itemLabel(dependency)} ${locationLabel(dependency.collection)}`;
7777
+ if (dependency.used_by.length === 0) return line;
7778
+ return `${line}, used by ${dependency.used_by.map(itemLabel).join(", ")}`;
7779
+ }
7780
+ function remedyLine(required) {
7781
+ const remedy = required.remedy;
7782
+ switch (remedy.type) {
7783
+ case "collection":
7784
+ if (required.syncable) return ` Fix: sync ${remedy.collection.name} too, or move that content into a synced collection.`;
7785
+ return ` Fix: move that content into a synced collection; ${remedy.collection.name} cannot sync.`;
7786
+ case "library": return " Fix: stop using that content; it syncs only through a Library, and this instance has none.";
7787
+ case "none": return " Fix: move that content into a synced collection, or stop using it.";
7788
+ }
7789
+ }
7790
+ function requiredSyncLines(required) {
7791
+ return [
7792
+ `Metabase will not sync ${required.blocks.map((collection) => collection.name).join(", ")}, because content there uses:`,
7793
+ ...required.dependencies.map(dependencyLine),
7794
+ remedyLine(required)
7795
+ ];
7796
+ }
7797
+ function explainRefusal(error) {
7798
+ if (!(error instanceof MetabaseApiError) || error.code !== "unsynced-dependencies") return error;
7799
+ const refusal = UnsyncedDependencies.safeParse(error.detail);
7800
+ if (!refusal.success) return error;
7801
+ return new ConfigError([...refusal.data.errors.required.flatMap(requiredSyncLines), `After the fix, run \`rde init --only ${STEP_ID$2}\`.`].join("\n"));
7802
+ }
7803
+ function plannedNames(scope, libraryLicensed) {
7804
+ const library = libraryLicensed ? [scope.library?.name ?? LIBRARY_NAME] : [];
7805
+ if (scope.syncedRoots.length > 0) return [...library, ...scope.syncedRoots.map((collection) => collection.name)];
7806
+ return [...library, scope.unsyncedRecordedRoot?.name ?? "Analytics"];
7807
+ }
7808
+ function nameList(names) {
7809
+ if (names.length <= 2) return names.join(" and ");
7810
+ return `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`;
7811
+ }
7812
+ function exportAdvice(profile, branch, changes) {
7813
+ const command = exportCommand(profile, branch);
7814
+ if (!remoteMoved(changes)) return `\`${command}\``;
7815
+ return `the branch has commits Metabase has not imported; \`${mbCommand(profile)} git-sync export-preflight\`, then \`${command} --merge\``;
7816
+ }
7817
+ async function planExport(ctx, runner, instance, names) {
7818
+ const changes = await mbTool(runner).gitSyncHasRemoteChanges(instance);
7819
+ if (remoteMoved(changes)) return {
7820
+ changes,
7821
+ push: false
7822
+ };
7823
+ return {
7824
+ changes,
7825
+ push: await ctx.prompts.confirm({
7826
+ key: SYNC_SCOPE_KEYS.export,
7827
+ message: `Push ${nameList(names)} to ${requireGitRemote(ctx).branch} now?`,
7828
+ initialValue: true
7829
+ })
7830
+ };
7831
+ }
7832
+ function describeExport(task) {
7833
+ const ended = `the export ended ${task.status}`;
7834
+ return task.error_message === null || task.error_message === void 0 ? ended : `${ended}: ${task.error_message}`;
7835
+ }
7836
+ function reportPending(run) {
7837
+ const advice = exportAdvice(run.ctx.state.mbProfile, run.gitRemote.branch, run.changes);
7838
+ run.ctx.ui.info(`First export pending: ${advice}`);
7839
+ }
7840
+ async function exportScope(run, runner) {
7841
+ const mb = mbTool(runner);
7842
+ const label = nameList(run.names);
7843
+ const spinner = run.ctx.ui.spinner(`Pushing ${label} to ${run.gitRemote.branch}…`);
7844
+ try {
7845
+ const final = (await mb.gitSyncExport(run.instance, {
7846
+ branch: run.gitRemote.branch,
7847
+ message: `Sync ${label}`
7848
+ })).final ?? null;
7849
+ if (final !== null && final.status !== "successful") {
7850
+ spinner.fail(describeExport(final));
7851
+ reportPending(run);
7852
+ return;
7853
+ }
7854
+ const status = await mb.gitSyncStatus(run.instance);
7855
+ spinner.stop(`Pushed ${label} to ${run.gitRemote.branch}`);
7856
+ if (status.is_dirty) run.ctx.ui.warn(`Metabase still has changes the export did not carry; \`${mbCommand(run.ctx.state.mbProfile)} git-sync dirty\` lists them`);
7857
+ } catch (error) {
7858
+ if (!(error instanceof ExternalToolError)) throw error;
7859
+ spinner.fail(`Could not push ${label}: ${error.message}`);
7860
+ reportPending(run);
7861
+ }
7862
+ }
7863
+ function scopeLabel(synced) {
7864
+ return `Synced to git: ${synced.map((collection) => collection.name).join(", ")}`;
7865
+ }
7866
+ function syncScopeStep(deps = hostDeps$4) {
7867
+ return {
7868
+ id: STEP_ID$2,
7869
+ title: "Choose what syncs to git",
7870
+ async detect(ctx) {
7871
+ if (!hasRemoteSync(ctx.state.license) || ctx.state.gitRemote === null) return "not-applicable";
7872
+ const configured = configuredRuntime(ctx.state);
7873
+ if (configured === null || ctx.secrets.apiKey === null) return "todo";
7874
+ const resolved = deps(ctx);
7875
+ if (!await resolved.instanceRunning()) return "todo";
7876
+ const client = resolved.client(configured.url, {
7877
+ kind: "api-key",
7878
+ key: ctx.secrets.apiKey
7879
+ });
7880
+ try {
7881
+ if (isComplete(readScope(await client.collection.list(), ctx.state.syncRoot), hasLibrary(ctx.state.license))) return "done";
7882
+ return await isReadOnly(client) ? "not-applicable" : "todo";
7883
+ } catch (error) {
7884
+ if (isNotAnsweringYet(error)) return "todo";
7885
+ throw error;
7886
+ }
7887
+ },
7888
+ async run(ctx) {
7889
+ requireConfiguredRuntime(ctx.state);
7890
+ const gitRemote = requireGitRemote(ctx);
7891
+ const instance = requireMbInstance(ctx);
7892
+ const resolved = deps(ctx);
7893
+ const client = resolved.client(instance.url, {
7894
+ kind: "api-key",
7895
+ key: instance.apiKey
7896
+ });
7897
+ await requireReadWrite(client);
7898
+ await requireSettledImport(ctx, client, gitRemote);
7899
+ const libraryLicensed = hasLibrary(ctx.state.license);
7900
+ const scope = readScope(await client.collection.list(), ctx.state.syncRoot);
7901
+ const names = plannedNames(scope, libraryLicensed);
7902
+ const plan = isComplete(scope, libraryLicensed) ? null : await planExport(ctx, resolved.runner, instance, names);
7903
+ const spinner = ctx.ui.spinner("Choosing what syncs to git…");
7904
+ let synced;
7905
+ let chosen;
7906
+ try {
7907
+ const library = await ensureLibrary(client, scope, libraryLicensed);
7908
+ chosen = await ensureRoots(ctx, client, scope);
7909
+ synced = library === null ? chosen.roots : [library, ...chosen.roots];
7910
+ await client.remoteSync.updateScope({ collections: Object.fromEntries(synced.map((collection) => [collection.id, true])) });
7911
+ } catch (error) {
7912
+ spinner.fail("Could not choose what syncs to git");
7913
+ throw explainRefusal(error);
7914
+ }
7915
+ spinner.stop(scopeLabel(synced));
7916
+ if (chosen.created) ctx.ui.info(`Created the ${AGENT_COLLECTION_NAME} collection: dashboards, questions, models, and documents filed under it reach git on the next export`);
7917
+ if (chosen.besideNamesake) ctx.ui.info(`Another collection is also named ${AGENT_COLLECTION_NAME}; rde did not create it, so it stays out of git`);
7918
+ if (plan === null) return { outcome: "completed" };
7919
+ const exportRun = {
7920
+ ctx,
7921
+ instance,
7922
+ gitRemote,
7923
+ names,
7924
+ changes: plan.changes
7925
+ };
7926
+ if (plan.push) await exportScope(exportRun, resolved.runner);
7927
+ else reportPending(exportRun);
7928
+ return { outcome: "completed" };
7929
+ }
7930
+ };
7931
+ }
7932
+ //#endregion
7769
7933
  //#region src/steps/summary.ts
7770
7934
  const STEP_ID$1 = "summary";
7771
7935
  const GIT_SYNC_STEP_ID = "git-sync";
@@ -7823,6 +7987,19 @@ function gitLines(state) {
7823
7987
  if (state.gitRemote === null) return [`Remote sync: not configured (${notConfiguredReason(state)})`];
7824
7988
  return [`Content repository: ${state.gitRemote.workdir} (branch ${state.gitRemote.branch})`, remoteLine(state.gitRemote)];
7825
7989
  }
7990
+ async function pendingExportLines(state, secrets, runner) {
7991
+ const instance = mbInstanceOf(state, secrets);
7992
+ if (state.gitRemote === null || instance === null) return [];
7993
+ try {
7994
+ const mb = mbTool(runner);
7995
+ if (!(await mb.gitSyncStatus(instance)).is_dirty) return [];
7996
+ const changes = await mb.gitSyncHasRemoteChanges(instance);
7997
+ return [`Export pending: ${exportAdvice(state.mbProfile, state.gitRemote.branch, changes)}`];
7998
+ } catch (error) {
7999
+ if (error instanceof ProcessNotFoundError || error instanceof ExternalToolError) return [`Remote sync: ${error.message}`];
8000
+ throw error;
8001
+ }
8002
+ }
7826
8003
  function skillLine(entries, detected) {
7827
8004
  const agents = [...new Set(entries.flatMap((entry) => coveredAgentNames(entry, detected)))];
7828
8005
  if (agents.length === 0) return `rde skill: not installed; \`rde init --only agents-skills\``;
@@ -7834,15 +8011,15 @@ function launchLines(agents, workdir) {
7834
8011
  const prefix = workdir === null ? "" : `cd ${workdir} && `;
7835
8012
  return ["Start working:", ...detected.map((agent) => ` ${prefix}${agent.launch} (${agent.displayName})`)];
7836
8013
  }
7837
- async function summaryLines(state, apiKey, deps) {
8014
+ async function summaryLines(state, secrets, deps) {
7838
8015
  const profile = state.mbProfile;
7839
8016
  const lines = [];
7840
8017
  if (state.url !== null) lines.push(`Metabase: ${state.url}`);
7841
8018
  if (state.admin !== null) lines.push(loginLine(state.admin.kind, state.admin.email));
7842
8019
  lines.push(profile === "default" ? `Metabase CLI: ${mbCommand(profile)} <command>` : `Metabase CLI: ${mbCommand(profile)} <command> or export MB_PROFILE=${profile}`);
7843
- lines.push(...await databaseLines$1(state, apiKey, deps));
7844
- if (state.sampleDatabase !== null) lines.push(`Sample database: psql "${sampleDatabaseUrl(state.sampleDatabase.port)}"`);
8020
+ lines.push(...await databaseLines$1(state, secrets.apiKey, deps));
7845
8021
  lines.push(...gitLines(state));
8022
+ lines.push(...await pendingExportLines(state, secrets, deps.runner));
7846
8023
  const installed = await skillsCli(deps.runner).list();
7847
8024
  const agents = await detectAgents(deps.probe);
7848
8025
  const detected = agents.filter((agent) => agent.detected);
@@ -7859,7 +8036,7 @@ function summaryStep(deps = hostDeps$3) {
7859
8036
  return "todo";
7860
8037
  },
7861
8038
  async run(ctx) {
7862
- const lines = await summaryLines(ctx.state, ctx.secrets.apiKey, deps(ctx));
8039
+ const lines = await summaryLines(ctx.state, ctx.secrets, deps(ctx));
7863
8040
  ctx.ui.note(lines.join("\n"), "Your environment");
7864
8041
  return { outcome: "completed" };
7865
8042
  }
@@ -7917,7 +8094,7 @@ const DEFAULT_STEP_OPTIONS = {
7917
8094
  runtime: NO_RUNTIME_FLAGS,
7918
8095
  agents: DETECT_AGENTS,
7919
8096
  admin: DEFAULT_ADMIN,
7920
- databases: SAMPLE_STANDS_IN,
8097
+ databases: DATABASE_REQUIRED,
7921
8098
  updates: "published"
7922
8099
  };
7923
8100
  const LOCAL_STEP_OPTIONS = {
@@ -7933,10 +8110,10 @@ const STEP_BY_ID = {
7933
8110
  "admin-user": (options) => adminUserStep(options.admin),
7934
8111
  "api-key": () => apiKeyStep(),
7935
8112
  transforms: () => transformsStep(),
7936
- "sample-database": () => sampleDatabaseStep(),
7937
8113
  license: () => licenseStep(),
7938
8114
  databases: (options) => databasesStep(options.databases),
7939
8115
  "git-sync": () => gitSyncStep(),
8116
+ "sync-scope": () => syncScopeStep(),
7940
8117
  summary: () => summaryStep()
7941
8118
  };
7942
8119
  function initSteps(options) {
@@ -8009,7 +8186,7 @@ const SECTIONS = {
8009
8186
  }).optional(),
8010
8187
  "api-key": z.strictObject({ profile: MbProfileChoice }).optional(),
8011
8188
  license: z.strictObject({ token: EnvString.nullable() }).optional(),
8012
- databases: z.array(DatabaseEntry).optional(),
8189
+ databases: z.array(DatabaseEntry).min(1, "a database is required: list one, or leave the section out to be asked at a terminal").optional(),
8013
8190
  "git-sync": z.strictObject({
8014
8191
  location: z.enum([
8015
8192
  "here",
@@ -8021,7 +8198,8 @@ const SECTIONS = {
8021
8198
  remote: RemoteKind.optional(),
8022
8199
  url: EnvString.optional(),
8023
8200
  token: EnvString.optional()
8024
- }).optional()
8201
+ }).optional(),
8202
+ "sync-scope": z.strictObject({ export: z.boolean() }).optional()
8025
8203
  };
8026
8204
  const InitAnswers = z.strictObject(SECTIONS);
8027
8205
  const select$1 = (value) => ({
@@ -8081,8 +8259,8 @@ function runtimeKindOf(runtime) {
8081
8259
  return null;
8082
8260
  }
8083
8261
  function databasesRequest(databases, run) {
8084
- if (databases !== void 0) return databases.length > 0 ? "named" : "sample-stands-in";
8085
- return run.interactive ? "ask-once" : "sample-stands-in";
8262
+ if (databases !== void 0) return "named";
8263
+ return run.interactive ? "ask" : "required";
8086
8264
  }
8087
8265
  function agentsOption(agents, run) {
8088
8266
  if (agents !== void 0) return {
@@ -8130,6 +8308,8 @@ function keyedAnswers(answers) {
8130
8308
  if (git.url !== void 0) add(map, GIT_SYNC_KEYS.url, text$1(git.url));
8131
8309
  if (git.token !== void 0) add(map, GIT_SYNC_KEYS.token, password$1(git.token));
8132
8310
  }
8311
+ const scope = answers["sync-scope"];
8312
+ if (scope !== void 0) add(map, SYNC_SCOPE_KEYS.export, confirm$1(scope.export));
8133
8313
  return map;
8134
8314
  }
8135
8315
  function parseInitAnswers(raw, source) {
@@ -8144,7 +8324,6 @@ async function loadInitAnswers(path) {
8144
8324
  }
8145
8325
  function stepsAskingForWork(answers) {
8146
8326
  const license = answers.license;
8147
- const databases = answers.databases;
8148
8327
  const git = answers["git-sync"];
8149
8328
  const agents = answers["agents-skills"];
8150
8329
  return [
@@ -8166,12 +8345,16 @@ function stepsAskingForWork(answers) {
8166
8345
  },
8167
8346
  {
8168
8347
  step: "databases",
8169
- asks: databases !== void 0 && databases.length > 0
8348
+ asks: answers.databases !== void 0
8170
8349
  },
8171
8350
  {
8172
8351
  step: "git-sync",
8173
8352
  asks: git !== void 0 && git.location !== "skip"
8174
8353
  },
8354
+ {
8355
+ step: "sync-scope",
8356
+ asks: answers["sync-scope"]?.export === true
8357
+ },
8175
8358
  {
8176
8359
  step: "agents-skills",
8177
8360
  asks: agents !== void 0 && agents.agents.length > 0
@@ -8203,7 +8386,6 @@ const FLAG_FOR_KEY$1 = {
8203
8386
  [API_KEY_KEYS.password]: "--admin-password",
8204
8387
  [API_KEY_KEYS.profile]: "--mb-profile",
8205
8388
  [LICENSE_KEYS.token]: "--license-token or --skip-license",
8206
- [DATABASE_KEYS.skip]: "--skip-databases",
8207
8389
  [GIT_SYNC_KEYS.location]: "--git-dir",
8208
8390
  [GIT_SYNC_KEYS.path]: "--git-dir",
8209
8391
  [GIT_SYNC_KEYS.remote]: "--git-remote or --skip-git",
@@ -8211,7 +8393,7 @@ const FLAG_FOR_KEY$1 = {
8211
8393
  [GIT_SYNC_KEYS.token]: "--git-token"
8212
8394
  };
8213
8395
  const DATABASE_FLAG_FOR_NAME = {
8214
- engine: "--database or --skip-databases",
8396
+ engine: "--database",
8215
8397
  uri: "--database"
8216
8398
  };
8217
8399
  function howToAnswer(key) {
@@ -8302,13 +8484,6 @@ function licenseSection(flags, secrets) {
8302
8484
  }
8303
8485
  return secrets.has("--license-token") ? { token: secrets.get("--license-token") } : void 0;
8304
8486
  }
8305
- function databasesSection(flags) {
8306
- if (flags.skipDatabases) {
8307
- if (flags.database.length > 0) throw conflict("--skip-databases", "--database");
8308
- return [];
8309
- }
8310
- return flags.database.length > 0 ? flags.database.map(parseDatabase) : void 0;
8311
- }
8312
8487
  function gitRemoteKind(flag, url) {
8313
8488
  if (flag === void 0) return url === void 0 ? void 0 : "hosted";
8314
8489
  const kind = GIT_REMOTE_NAMES[flag];
@@ -8429,7 +8604,7 @@ async function answersFromFlags(flags, readStdin) {
8429
8604
  runtime: runtimeSection(flags),
8430
8605
  "admin-user": adminSection(flags, secrets),
8431
8606
  license: licenseSection(flags, secrets),
8432
- databases: databasesSection(flags),
8607
+ databases: flags.database.length > 0 ? flags.database.map(parseDatabase) : void 0,
8433
8608
  "git-sync": gitSection(flags, secrets),
8434
8609
  "agents-skills": agentsSection(flags),
8435
8610
  "api-key": flags.mbProfile === void 0 ? void 0 : { profile: flags.mbProfile }
@@ -8854,6 +9029,7 @@ const dbAdd = defineRdeCommand({
8854
9029
  description: "Engine of a --url that fits several engines; the error names them"
8855
9030
  }
8856
9031
  },
9032
+ interrupted: INTERRUPTED,
8857
9033
  async run(args) {
8858
9034
  if (args.url === void 0 && !stdinIsTty()) throw new ConfigError(NEEDS_URL_ERROR);
8859
9035
  const name = await defaultInstanceName();
@@ -8885,6 +9061,7 @@ const dbList = defineRdeCommand({
8885
9061
  description: "Print the list as JSON",
8886
9062
  default: false
8887
9063
  } },
9064
+ interrupted: INTERRUPTED,
8888
9065
  async run(args) {
8889
9066
  const name = await defaultInstanceName();
8890
9067
  const state = await requireInstanceState(name);
@@ -9508,6 +9685,11 @@ async function otherMetabasesRow(env, rdeProfile, profiles, mbUnusableReason) {
9508
9685
  function isNotFound$1(error) {
9509
9686
  return error instanceof MetabaseApiError && error.status === NOT_FOUND_STATUS;
9510
9687
  }
9688
+ function coverageDetail(coverage) {
9689
+ const valid = coverage.included.length === 0 ? "valid" : `valid, with ${listOf(coverage.included, "conjunction")}`;
9690
+ if (coverage.missing.length === 0) return valid;
9691
+ return `${valid}; without ${listOf(coverage.missing, "disjunction")}`;
9692
+ }
9511
9693
  async function licenseRow(client) {
9512
9694
  const label = "license";
9513
9695
  try {
@@ -9517,9 +9699,9 @@ async function licenseRow(client) {
9517
9699
  hint: "`rde init --from license` with a valid token",
9518
9700
  step: "license"
9519
9701
  });
9520
- return ok("license", label, `valid, ${status.features.includes("remote-sync") ? `with ${REMOTE_SYNC_FEATURE}` : `without ${REMOTE_SYNC_FEATURE}, so git sync is unavailable`}`);
9702
+ return ok("license", label, coverageDetail(licensedFeatureCoverage(status.features)));
9521
9703
  } catch (error) {
9522
- if (isNotFound$1(error)) return skipped("license", label, "not activated; `rde init --from license` adds one");
9704
+ if (isNotFound$1(error)) return skipped("license", label, `not activated, so ${listOf(LICENSED_FEATURE_NAMES, "conjunction")} are unavailable; \`rde init --from license\` adds one`);
9523
9705
  throw error;
9524
9706
  }
9525
9707
  }
@@ -9537,46 +9719,6 @@ async function databasesRow(state, client) {
9537
9719
  const names = state.databases.map((record) => record.name).join(", ");
9538
9720
  return ok("databases", label, `${state.databases.length} connected: ${names}`);
9539
9721
  }
9540
- const SAMPLE_DATABASE_STEP = "sample-database";
9541
- const SAMPLE_LABEL = "sample database";
9542
- async function sampleDatabaseRow(record, deps, dockerOk, access) {
9543
- const id = "sample-database";
9544
- if (!dockerOk) return skipped(id, SAMPLE_LABEL, "Docker is not available");
9545
- const status = await sampleDatabaseContainer(record, deps.runtime.docker, deps.runtime.rdeHome).status();
9546
- if (!status.installed) return failed(id, SAMPLE_LABEL, {
9547
- detail: `${record.containerName} does not exist`,
9548
- hint: "`rde init` recreates it",
9549
- step: SAMPLE_DATABASE_STEP
9550
- });
9551
- if (!status.running) return failed(id, SAMPLE_LABEL, {
9552
- detail: `${record.containerName} is stopped`,
9553
- hint: "`rde start`",
9554
- step: "start-metabase"
9555
- });
9556
- if (await deps.runtime.ports.isFree(record.port)) return failed(id, SAMPLE_LABEL, {
9557
- detail: `${record.containerName} runs but port ${record.port} is not answering`,
9558
- hint: `\`docker logs ${record.containerName}\` shows what Postgres is doing`,
9559
- step: null
9560
- });
9561
- const where = `${record.containerName} running on ${LOOPBACK_HOST}:${record.port}`;
9562
- if (record.databaseId === null) return failed(id, SAMPLE_LABEL, {
9563
- detail: `${where}; not connected to Metabase yet`,
9564
- hint: "`rde init` connects it",
9565
- step: SAMPLE_DATABASE_STEP
9566
- });
9567
- if (access.kind === "unusable") return skipped(id, SAMPLE_LABEL, `${where}; ${access.reason}, so the connection is unchecked`);
9568
- try {
9569
- const database = await access.client.database.get(record.databaseId);
9570
- return ok(id, SAMPLE_LABEL, `${where}, database ${database.id} ${syncLabel(database.initial_sync_status)}`);
9571
- } catch (error) {
9572
- if (isNotFound$1(error)) return failed(id, SAMPLE_LABEL, {
9573
- detail: `${where}; database ${record.databaseId} is gone from Metabase`,
9574
- hint: "`rde init` connects it again",
9575
- step: SAMPLE_DATABASE_STEP
9576
- });
9577
- throw error;
9578
- }
9579
- }
9580
9722
  function isMbFailure$1(error) {
9581
9723
  return error instanceof ProcessNotFoundError || error instanceof ExternalToolError;
9582
9724
  }
@@ -9730,7 +9872,6 @@ async function instanceRows(state, secrets, deps, dockerOk, profiles, mbUnusable
9730
9872
  skipped("mb-profile", "mb profile", NO_INSTANCE_REASON),
9731
9873
  skipped("license", "license", NO_INSTANCE_REASON),
9732
9874
  skipped("databases", "databases", NO_INSTANCE_REASON),
9733
- skipped("sample-database", SAMPLE_LABEL, NO_INSTANCE_REASON),
9734
9875
  skipped("git-sync", "remote sync", NO_INSTANCE_REASON)
9735
9876
  ];
9736
9877
  const runtime = await runtimeRow(configured.runtime, deps, dockerOk);
@@ -9749,7 +9890,6 @@ async function instanceRows(state, secrets, deps, dockerOk, profiles, mbUnusable
9749
9890
  const access = apiKey.access;
9750
9891
  if (access.kind === "unusable") rows.push(skipped("license", "license", access.reason), skipped("databases", "databases", access.reason));
9751
9892
  else rows.push(await licenseRow(access.client), await databasesRow(state, access.client));
9752
- rows.push(state.sampleDatabase === null ? skipped("sample-database", SAMPLE_LABEL, "not connected; `rde init` adds one") : await sampleDatabaseRow(state.sampleDatabase, deps, dockerOk, access));
9753
9893
  rows.push(await gitSyncRow(state, mbInstanceOf(state, secrets), deps));
9754
9894
  return rows;
9755
9895
  }
@@ -9759,7 +9899,7 @@ async function collectDoctor(instance, state, secrets, deps) {
9759
9899
  const mb = await mbFinding(deps);
9760
9900
  const profiles = sharedProfileList(deps);
9761
9901
  const checks = [...machine, mb.row];
9762
- if (state === null) checks.push(...noRuntimeRows(), skipped("version", "Metabase version", NO_INSTANCE_REASON), skipped("admin", "admin user", NO_INSTANCE_REASON), skipped("admin-password", "admin password", NO_INSTANCE_REASON), skipped("api-key", "API key", NO_INSTANCE_REASON), skipped("secrets-file", "secrets file", NO_INSTANCE_REASON), skipped("state-file", "state file", NO_INSTANCE_REASON), skipped("mb-profile", "mb profile", NO_INSTANCE_REASON), skipped("license", "license", NO_INSTANCE_REASON), skipped("databases", "databases", NO_INSTANCE_REASON), skipped("sample-database", SAMPLE_LABEL, NO_INSTANCE_REASON), skipped("git-sync", "remote sync", NO_INSTANCE_REASON));
9902
+ if (state === null) checks.push(...noRuntimeRows(), skipped("version", "Metabase version", NO_INSTANCE_REASON), skipped("admin", "admin user", NO_INSTANCE_REASON), skipped("admin-password", "admin password", NO_INSTANCE_REASON), skipped("api-key", "API key", NO_INSTANCE_REASON), skipped("secrets-file", "secrets file", NO_INSTANCE_REASON), skipped("state-file", "state file", NO_INSTANCE_REASON), skipped("mb-profile", "mb profile", NO_INSTANCE_REASON), skipped("license", "license", NO_INSTANCE_REASON), skipped("databases", "databases", NO_INSTANCE_REASON), skipped("git-sync", "remote sync", NO_INSTANCE_REASON));
9763
9903
  else checks.push(...await instanceRows(state, secrets, deps, dockerOk, profiles, mb.unusable));
9764
9904
  const rdeProfile = state === null ? null : state.mbProfile;
9765
9905
  checks.push(...await environmentRows(deps.environment, rdeProfile, profiles, mb.unusable), await otherMetabasesRow(deps.environment, rdeProfile, profiles, mb.unusable), ...await skillRows(state, deps));
@@ -9866,6 +10006,7 @@ const doctor = defineRdeCommand({
9866
10006
  default: false
9867
10007
  }
9868
10008
  },
10009
+ interrupted: INTERRUPTED,
9869
10010
  async run(args) {
9870
10011
  const name = await instanceNameOrDefault();
9871
10012
  const state = await readInstanceState(name);
@@ -9924,13 +10065,6 @@ function isLocation(value) {
9924
10065
  function isRemote(value) {
9925
10066
  return value === "local-bare" || value === "hosted";
9926
10067
  }
9927
- function applyDatabaseRecord(draft, record) {
9928
- const value = stringOf(record.answer);
9929
- const skippedConnection = record.key === DATABASE_KEYS.connection && value?.trim() === "";
9930
- const finished = parseDatabaseKey(record.key)?.name === "engine" && value === "finish";
9931
- const pickedNoStub = record.key === DATABASE_KEYS.stubs && record.answer.kind === "multiselect" && record.answer.values.length === 0;
9932
- if (skippedConnection || finished || pickedNoStub) draft.databasesFinished = true;
9933
- }
9934
10068
  function applyAdminRecord(draft, key, value) {
9935
10069
  if (key === ADMIN_USER_KEYS.email && value !== null) admin(draft).email = value;
9936
10070
  else if (key === ADMIN_USER_KEYS.password) admin(draft).password = envReference(ENV_ADMIN_PASSWORD);
@@ -9954,14 +10088,14 @@ function agentIds(values) {
9954
10088
  function applyRecord(draft, record) {
9955
10089
  const { key } = record;
9956
10090
  const value = stringOf(record.answer);
9957
- if (key.startsWith("databases.")) applyDatabaseRecord(draft, record);
9958
- else if (key.startsWith("admin-user.")) applyAdminRecord(draft, key, value);
10091
+ if (key.startsWith("admin-user.")) applyAdminRecord(draft, key, value);
9959
10092
  else if (key.startsWith("git-sync.")) applyGitRecord(draft, key, value);
9960
10093
  else if (key === AGENTS_SKILLS_KEYS.agents && record.answer.kind === "multiselect") draft.answers["agents-skills"] = { agents: agentIds(record.answer.values) };
9961
10094
  else if (key === API_KEY_KEYS.profile && value !== null) {
9962
10095
  const profile = MbProfileChoice.safeParse(value);
9963
10096
  if (profile.success) draft.answers["api-key"] = { profile: profile.data };
9964
- } else if (key === LICENSE_KEYS.token && value !== null) draft.answers.license = { token: value.length === 0 ? null : envReference(ENV_LICENSE_TOKEN) };
10097
+ } else if (key === SYNC_SCOPE_KEYS.export && record.answer.kind === "confirm") draft.answers["sync-scope"] = { export: record.answer.value };
10098
+ else if (key === LICENSE_KEYS.token && value !== null) draft.answers.license = { token: value.length === 0 ? null : envReference(ENV_LICENSE_TOKEN) };
9965
10099
  }
9966
10100
  function writableUri(uri, index) {
9967
10101
  const trimmed = uri.trim();
@@ -9981,13 +10115,9 @@ function databaseEntry(added, index) {
9981
10115
  };
9982
10116
  }
9983
10117
  function answersFromRecords(records, overrides, added) {
9984
- const draft = {
9985
- answers: {},
9986
- databasesFinished: false
9987
- };
10118
+ const draft = { answers: {} };
9988
10119
  for (const record of records) applyRecord(draft, record);
9989
- const databases = added.map(databaseEntry);
9990
- if (databases.length > 0 || draft.databasesFinished) draft.answers.databases = databases;
10120
+ if (added.length > 0) draft.answers.databases = added.map(databaseEntry);
9991
10121
  return {
9992
10122
  ...draft.answers,
9993
10123
  ...overrides.runtime !== void 0 && { runtime: overrides.runtime },
@@ -10113,7 +10243,7 @@ const init = defineRdeCommand({
10113
10243
  },
10114
10244
  runtime: {
10115
10245
  type: "string",
10116
- description: `How a new instance runs Metabase (${RUNTIME_LIST}); Docker when it is available; the sample database always runs in Docker`
10246
+ description: `How a new instance runs Metabase (${RUNTIME_LIST}); Docker when it is available`
10117
10247
  },
10118
10248
  image: {
10119
10249
  type: "string",
@@ -10158,12 +10288,7 @@ const init = defineRdeCommand({
10158
10288
  },
10159
10289
  database: {
10160
10290
  type: "string",
10161
- description: "Database to connect, a connection string or <engine>=<connection string> when it fits several engines; repeatable"
10162
- },
10163
- "skip-databases": {
10164
- type: "boolean",
10165
- description: "Connect none of your own databases (the sample database is still connected)",
10166
- default: false
10291
+ description: "Database to connect, a connection string or <engine>=<connection string> when it fits several engines; repeatable. init needs one, and asks for it only at a terminal"
10167
10292
  },
10168
10293
  "git-dir": {
10169
10294
  type: "string",
@@ -10201,10 +10326,11 @@ const init = defineRdeCommand({
10201
10326
  },
10202
10327
  yes: {
10203
10328
  type: "boolean",
10204
- description: "Answer yes to every confirmation and take the default of every setting; skips the question about your own databases (the sample database is still connected)",
10329
+ description: "Answer yes to every confirmation and take the default of every setting; the database init needs then comes from --database or the answers file",
10205
10330
  default: false
10206
10331
  }
10207
10332
  },
10333
+ interrupted: "Interrupted. Run `rde init` again to resume.",
10208
10334
  async run(args, rawArgs) {
10209
10335
  const options = stepSelection(args.from, args.only);
10210
10336
  const runOptions = {
@@ -10221,7 +10347,6 @@ const init = defineRdeCommand({
10221
10347
  licenseToken: args["license-token"],
10222
10348
  skipLicense: args["skip-license"],
10223
10349
  database: repeatedValues(rawArgs, "--database"),
10224
- skipDatabases: args["skip-databases"],
10225
10350
  gitDir: args["git-dir"],
10226
10351
  gitRemote: args["git-remote"],
10227
10352
  gitUrl: args["git-url"],
@@ -10268,6 +10393,7 @@ const logs = defineRdeCommand({
10268
10393
  description: "Keep streaming new lines",
10269
10394
  default: false
10270
10395
  } },
10396
+ interrupted: null,
10271
10397
  async run(args) {
10272
10398
  await (await loadInstance(await defaultInstanceName(), interruptSignal)).runtime.logs({
10273
10399
  follow: args.follow,
@@ -10282,6 +10408,7 @@ const open = defineRdeCommand({
10282
10408
  name: "open",
10283
10409
  description: "Open the Metabase instance in the browser"
10284
10410
  },
10411
+ interrupted: INTERRUPTED,
10285
10412
  async run() {
10286
10413
  const instance = await loadInstance(await defaultInstanceName(), interruptSignal);
10287
10414
  printLines([await openInstance(instance.url) ? `Opened ${instance.url}` : `Open ${instance.url} in your browser`]);
@@ -10361,6 +10488,7 @@ const start = defineRdeCommand({
10361
10488
  type: "string",
10362
10489
  description: "Move the instance to this host port first; the mb profile follows, the data stays"
10363
10490
  } },
10491
+ interrupted: INTERRUPTED,
10364
10492
  async run(args) {
10365
10493
  const port = args.port === void 0 ? null : requirePortFlag(args.port);
10366
10494
  const name = await defaultInstanceName();
@@ -10422,26 +10550,13 @@ async function databasesOf(ctx, deps, health) {
10422
10550
  throw error;
10423
10551
  }
10424
10552
  }
10425
- async function sampleDatabaseOf(ctx, deps, databases) {
10426
- const record = ctx.state.sampleDatabase;
10427
- if (record === null) return null;
10428
- const status = await sampleDatabaseContainer(record, deps.runtime.docker, deps.runtime.rdeHome).status();
10429
- const listed = databases?.find((database) => database.id === record.databaseId) ?? null;
10430
- return {
10431
- container: status.running ? "running" : status.installed ? "stopped" : "missing",
10432
- port: record.port,
10433
- url: sampleDatabaseUrl(record.port),
10434
- databaseId: record.databaseId,
10435
- initialSyncStatus: listed === null ? null : listed.initialSyncStatus
10436
- };
10437
- }
10438
10553
  function taskLabel(status) {
10439
10554
  const task = status.current_task;
10440
10555
  return task === null ? null : `${task.sync_task_type} ${task.status}`;
10441
10556
  }
10442
10557
  function remoteContentOf(changes) {
10443
10558
  if (!changes.has_changes) return "imported";
10444
- return changes.local_version === null ? "never-imported" : "changed";
10559
+ return remoteMoved(changes) ? "changed" : "never-imported";
10445
10560
  }
10446
10561
  function isMbFailure(error) {
10447
10562
  return error instanceof ProcessNotFoundError || error instanceof ExternalToolError;
@@ -10459,7 +10574,8 @@ async function gitSyncOf(ctx, deps) {
10459
10574
  branch: status.branch,
10460
10575
  dirty: status.is_dirty,
10461
10576
  remote: remoteContentOf(changes),
10462
- task: taskLabel(status)
10577
+ task: taskLabel(status),
10578
+ syncedCollections: status.synced_collections.map((collection) => collection.name)
10463
10579
  };
10464
10580
  } catch (error) {
10465
10581
  if (isMbFailure(error)) return { error: error.message };
@@ -10489,7 +10605,6 @@ async function collectStatus(ctx, deps) {
10489
10605
  steps,
10490
10606
  setup: setupOf(ctx, steps),
10491
10607
  databases,
10492
- sampleDatabase: await sampleDatabaseOf(ctx, deps, databases),
10493
10608
  gitSync: await gitSyncOf(ctx, deps),
10494
10609
  contentRepository: ctx.state.gitRemote === null ? null : ctx.state.gitRemote.workdir
10495
10610
  };
@@ -10520,7 +10635,8 @@ function gitSyncLine(report) {
10520
10635
  if (isGitSyncFailure(report.gitSync)) return `Remote sync: ${report.gitSync.error}`;
10521
10636
  const branch = report.gitSync.branch === null ? "no branch" : `branch ${report.gitSync.branch}`;
10522
10637
  const task = report.gitSync.task === null ? "no task running" : `task ${report.gitSync.task}`;
10523
- return `Remote sync: ${branch}, ${syncStateLabel(report.mbProfile, report.gitSync)}, ${task}`;
10638
+ const scope = report.gitSync.syncedCollections.length === 0 ? "nothing synced; `rde init --only sync-scope`" : `synced: ${report.gitSync.syncedCollections.join(", ")}`;
10639
+ return `Remote sync: ${branch}, ${syncStateLabel(report.mbProfile, report.gitSync)}, ${task}, ${scope}`;
10524
10640
  }
10525
10641
  const REMOTE_CONTENT_LABEL = {
10526
10642
  imported: null,
@@ -10533,22 +10649,6 @@ function syncStateLabel(profile, gitSync) {
10533
10649
  if (gitSync.dirty) return `unsynced changes, ${remote}`;
10534
10650
  return `${remote}; \`${mbCommand(profile)} git-sync import\``;
10535
10651
  }
10536
- const CONTAINER_STATE_LABEL = {
10537
- running: "container running",
10538
- stopped: "container stopped; `rde start`",
10539
- missing: "container missing; `rde init` recreates it"
10540
- };
10541
- function sampleConnectionLabel(report, sample) {
10542
- if (sample.databaseId === null) return "not connected to Metabase yet; `rde init`";
10543
- if (report.databases === null) return "sync unknown while Metabase is not running";
10544
- if (sample.initialSyncStatus === null) return `database ${sample.databaseId} is gone from Metabase; \`rde init\` connects it again`;
10545
- return syncLabel(sample.initialSyncStatus);
10546
- }
10547
- function sampleDatabaseLine(report) {
10548
- const sample = report.sampleDatabase;
10549
- if (sample === null) return "Sample database: not connected; `rde init` adds one";
10550
- return `Sample database: ${sample.url}, ${CONTAINER_STATE_LABEL[sample.container]}, ${sampleConnectionLabel(report, sample)}`;
10551
- }
10552
10652
  function databaseLines(report) {
10553
10653
  if (report.databases === null) return ["Databases: unavailable while Metabase is not running"];
10554
10654
  return ["Databases:", ...databaseListingLines(report.databases).map((line) => ` ${line}`)];
@@ -10565,7 +10665,6 @@ function statusLines(report) {
10565
10665
  "Steps:",
10566
10666
  ...checklistLines(report.steps).map((line) => ` ${line}`),
10567
10667
  ...databaseLines(report),
10568
- sampleDatabaseLine(report),
10569
10668
  gitSyncLine(report),
10570
10669
  ...report.contentRepository === null ? [] : [`Content repository: ${report.contentRepository}`]
10571
10670
  ];
@@ -10582,6 +10681,7 @@ const status = defineRdeCommand({
10582
10681
  description: "Print the report as JSON",
10583
10682
  default: false
10584
10683
  } },
10684
+ interrupted: INTERRUPTED,
10585
10685
  async run(args) {
10586
10686
  const name = await defaultInstanceName();
10587
10687
  await requireInstanceState(name);
@@ -10598,18 +10698,17 @@ const status = defineRdeCommand({
10598
10698
  const stop = defineRdeCommand({
10599
10699
  meta: {
10600
10700
  name: "stop",
10601
- description: "Stop the Metabase instance and its sample database"
10701
+ description: "Stop the Metabase instance"
10602
10702
  },
10703
+ interrupted: INTERRUPTED,
10603
10704
  async run() {
10604
- const instance = await loadInstance(await defaultInstanceName(), interruptSignal);
10705
+ const name = await defaultInstanceName();
10706
+ const instance = await loadInstance(name, interruptSignal);
10605
10707
  const before = await instance.runtime.status();
10606
10708
  await instance.runtime.stop();
10607
10709
  const lines = [before.running ? "Metabase stopped" : "Metabase is not running"];
10608
- if (instance.sampleDatabase !== null) {
10609
- const sidecar = await instance.sampleDatabase.status();
10610
- await instance.sampleDatabase.stop();
10611
- lines.push(sidecar.running ? "Sample database stopped" : "Sample database is not running");
10612
- }
10710
+ const removed = await removeLeftovers(name, interruptSignal);
10711
+ if (removed !== null) lines.push(removed);
10613
10712
  printLines(lines);
10614
10713
  }
10615
10714
  });
@@ -10620,11 +10719,13 @@ function hostUninstallDeps(signal, childOutput) {
10620
10719
  return {
10621
10720
  docker: dockerTool(runner),
10622
10721
  daemon: daemonTool(runner),
10722
+ git: gitTool(runner),
10623
10723
  runner,
10624
10724
  childOutput
10625
10725
  };
10626
10726
  }
10627
10727
  const NOT_TOUCHED_LINE = "The mb CLI, Docker, and Java themselves";
10728
+ const UNPULLED_COMMITS_SHOWN = 10;
10628
10729
  const ANY_JAR = "-jar";
10629
10730
  function isFileSystemError(error) {
10630
10731
  return error instanceof Error && "code" in error;
@@ -10830,6 +10931,71 @@ async function findSkill(runner, childOutput) {
10830
10931
  function instanceDirLabel(path, state) {
10831
10932
  return `Instance directory ${path} (${state?.gitRemote?.kind === "local-bare" ? "Metabase data and the remote-sync repository content.git" : "Metabase data"})`;
10832
10933
  }
10934
+ async function findUnpulled(remote, git) {
10935
+ if (!await pathExists(remote.barePath)) return null;
10936
+ try {
10937
+ if (!await git.isBareRepo(remote.barePath)) return null;
10938
+ const commits = await git.bareBranchCommits(remote.barePath, remote.branch);
10939
+ if (commits === null || commits.length === 0) return null;
10940
+ if (!await git.isRepo(remote.workdir)) return {
10941
+ kind: "orphaned",
10942
+ remote,
10943
+ commits
10944
+ };
10945
+ const reachable = await git.reachableCommits(remote.workdir);
10946
+ const missing = commits.filter((commit) => !reachable.has(commit.sha));
10947
+ return missing.length === 0 ? null : {
10948
+ kind: "behind",
10949
+ remote,
10950
+ commits: missing
10951
+ };
10952
+ } catch (error) {
10953
+ if (isToolFailure(error)) return {
10954
+ kind: "unchecked",
10955
+ remote,
10956
+ reason: error.message
10957
+ };
10958
+ throw error;
10959
+ }
10960
+ }
10961
+ function commitCount(commits) {
10962
+ return commits.length === 1 ? "1 commit" : `${commits.length} commits`;
10963
+ }
10964
+ function unpulledSummary(unpulled) {
10965
+ const { barePath, workdir, branch } = unpulled.remote;
10966
+ switch (unpulled.kind) {
10967
+ case "behind": return `${barePath} has ${commitCount(unpulled.commits)} on ${branch} that ${workdir} lacks; uninstalling deletes them`;
10968
+ case "orphaned": return `${barePath} has ${commitCount(unpulled.commits)} on ${branch}, and ${workdir} is gone or no longer a git repository; uninstalling deletes them`;
10969
+ case "unchecked": return `${barePath} cannot be compared with ${workdir} (${unpulled.reason}); uninstalling deletes it with any commits ${workdir} lacks`;
10970
+ }
10971
+ }
10972
+ function unpulledRemedy(unpulled) {
10973
+ const { barePath, workdir, branch } = unpulled.remote;
10974
+ switch (unpulled.kind) {
10975
+ case "behind": return `\`git -C ${workdir} pull\` brings them in first`;
10976
+ case "orphaned": return `\`git clone --branch ${branch} ${barePath} <directory>\` copies them first`;
10977
+ case "unchecked": return null;
10978
+ }
10979
+ }
10980
+ function commitLines(unpulled) {
10981
+ if (unpulled.kind === "unchecked") return [];
10982
+ const shown = unpulled.commits.slice(0, UNPULLED_COMMITS_SHOWN).map((commit) => ` ${commit.shortSha} ${commit.subject}`);
10983
+ const hidden = unpulled.commits.length - shown.length;
10984
+ return hidden > 0 ? [...shown, ` and ${hidden} more`] : shown;
10985
+ }
10986
+ function unpulledWarning(unpulled) {
10987
+ const remedy = unpulledRemedy(unpulled);
10988
+ return [
10989
+ unpulledSummary(unpulled),
10990
+ ...commitLines(unpulled),
10991
+ ...remedy === null ? [] : [remedy]
10992
+ ].join("\n");
10993
+ }
10994
+ function unpulledRefusal(unpulled) {
10995
+ const remedy = unpulledRemedy(unpulled);
10996
+ const force = "pass --force to uninstall anyway";
10997
+ return remedy === null ? `${unpulledSummary(unpulled)}; ${force}` : `${unpulledSummary(unpulled)}; ${remedy}, or ${force}`;
10998
+ }
10833
10999
  async function findConfigEntry(instance) {
10834
11000
  const path = globalConfigPath();
10835
11001
  try {
@@ -10872,14 +11038,13 @@ async function planUninstall(options, deps) {
10872
11038
  const container = await findContainer(runtime?.containerName ?? containerName(options.instance, home), deps.docker, runtime?.image ?? null, home, { graceMs: METABASE_STOP_GRACE_MS });
10873
11039
  if (container.removal !== null) removals.push(container.removal);
10874
11040
  if (container.problem !== null) problems.push(container.problem);
10875
- const sample = state?.sampleDatabase ?? null;
10876
- const sidecar = await findContainer(sample?.containerName ?? sampleContainerName(options.instance, home), deps.docker, sample?.image ?? null, home, { graceMs: SAMPLE_DATABASE_STOP_GRACE_MS });
10877
- if (sidecar.removal !== null) removals.push(sidecar.removal);
10878
- if (sidecar.problem !== null) problems.push(sidecar.problem);
11041
+ const sampleDatabase = await findContainer(sampleDatabaseContainerName(options.instance, home), deps.docker, null, home, { graceMs: SAMPLE_DATABASE_STOP_GRACE_MS });
11042
+ if (sampleDatabase.removal !== null) removals.push(sampleDatabase.removal);
11043
+ if (sampleDatabase.problem !== null) problems.push(sampleDatabase.problem);
10879
11044
  const jarSource = state?.runtime?.kind === "jar" ? state.runtime.source : null;
10880
11045
  const jarProcess = await findJarProcess(options.instance, jarSource, deps.daemon);
10881
11046
  if (jarProcess !== null) removals.push(jarProcess);
10882
- for (const image of [container.image, sidecar.image]) {
11047
+ for (const image of [container.image, sampleDatabase.image]) {
10883
11048
  if (image === null) continue;
10884
11049
  const finding = await findImage(image, deps.docker, options.removeImage);
10885
11050
  if (finding.removal !== null) removals.push(finding.removal);
@@ -10899,6 +11064,8 @@ async function planUninstall(options, deps) {
10899
11064
  }
10900
11065
  const dir = instanceDir(options.instance);
10901
11066
  const dirPresent = await pathExists(dir);
11067
+ const bareRemote = state?.gitRemote?.kind === "local-bare" ? state.gitRemote : null;
11068
+ const unpulled = options.keepData || !dirPresent || bareRemote === null ? null : await findUnpulled(bareRemote, deps.git);
10902
11069
  if (!options.keepData) {
10903
11070
  const secrets = instanceSecretsPath(options.instance);
10904
11071
  if (await pathExists(secrets)) removals.push({
@@ -10919,7 +11086,8 @@ async function planUninstall(options, deps) {
10919
11086
  return {
10920
11087
  removals,
10921
11088
  kept,
10922
- problems
11089
+ problems,
11090
+ unpulled
10923
11091
  };
10924
11092
  }
10925
11093
  function planLines$1(plan) {
@@ -10974,6 +11142,10 @@ async function uninstall(options, deps, io) {
10974
11142
  };
10975
11143
  }
10976
11144
  io.ui.note(planLines$1(plan).join("\n"), `rde uninstall ${options.instance}`);
11145
+ if (plan.unpulled !== null) {
11146
+ io.ui.warn(unpulledWarning(plan.unpulled));
11147
+ if ((options.yes || !options.terminal) && !options.force) throw new ConfigError(unpulledRefusal(plan.unpulled));
11148
+ }
10977
11149
  if (!options.yes) {
10978
11150
  if (!await io.prompts.confirm({
10979
11151
  message: "Remove everything listed?",
@@ -11020,8 +11192,14 @@ const uninstallCommand = defineRdeCommand({
11020
11192
  type: "boolean",
11021
11193
  description: "Do not ask for confirmation",
11022
11194
  default: false
11195
+ },
11196
+ force: {
11197
+ type: "boolean",
11198
+ description: "With --yes or without a terminal, also delete commits your working directory has not pulled",
11199
+ default: false
11023
11200
  }
11024
11201
  },
11202
+ interrupted: INTERRUPTED,
11025
11203
  async run(args) {
11026
11204
  const instance = args.instance ?? await instanceNameOrDefault();
11027
11205
  if (!isInstanceName(instance)) throw new ConfigError(`${args.instance === void 0 ? `the default instance in ${globalConfigPath()},` : "--instance"} ${instance} is not an instance name (${INSTANCE_NAME_RULE})`);
@@ -11031,7 +11209,9 @@ const uninstallCommand = defineRdeCommand({
11031
11209
  keepData: args["keep-data"],
11032
11210
  removeSkill: args["remove-skill"],
11033
11211
  removeImage: args["remove-image"],
11034
- yes: args.yes
11212
+ yes: args.yes,
11213
+ force: args.force,
11214
+ terminal: stdinIsTty()
11035
11215
  }, hostUninstallDeps(interruptSignal, ui.childOutput), {
11036
11216
  prompts: clackPrompts({ confirmsWithYes: true }),
11037
11217
  ui
@@ -11265,8 +11445,11 @@ async function upgrade(ctx, options, deps = hostDeps) {
11265
11445
  const stopping = ctx.ui.spinner("Stopping Metabase");
11266
11446
  await runtime.stop();
11267
11447
  stopping.stop("Metabase stopped");
11448
+ options.onPhase("backing-up", backup);
11268
11449
  await backUp(ctx, resolved.runtime.dataDir, backup);
11450
+ options.onPhase("switching", backup);
11269
11451
  await switchTo(ctx, resolved, target);
11452
+ options.onPhase("starting", backup);
11270
11453
  const to = await startUpgraded(ctx, resolved, client, configured, backup);
11271
11454
  ctx.ui.success(`Upgraded Metabase from ${current} to ${to}; the backup at ${backup} can go once you are happy with it`);
11272
11455
  return {
@@ -11278,6 +11461,14 @@ async function upgrade(ctx, options, deps = hostDeps) {
11278
11461
  }
11279
11462
  //#endregion
11280
11463
  //#region src/commands/upgrade.ts
11464
+ const INTERRUPTED_IN = {
11465
+ "backing-up": (backup) => `Interrupted before the backup finished; the instance is stopped on the version it had. The next \`rde upgrade\` starts it and takes a new backup; the partial copy at ${backup} can go.`,
11466
+ switching: (backup) => `Interrupted after the backup to ${backup} finished, while installing the new version. Run \`rde init\` to reinstall the instance; the next \`rde upgrade\` then finds it upgraded, or upgrades it if \`rde init\` put back the version it had.`,
11467
+ starting: (backup) => `Interrupted while Metabase starts on the new version, after the backup to ${backup} finished. The next \`rde upgrade\` waits for it to come up and finds it upgraded.`
11468
+ };
11469
+ function interruptedUpgrade(phase, backup) {
11470
+ return INTERRUPTED_IN[phase](backup);
11471
+ }
11281
11472
  const upgradeCommand = defineRdeCommand({
11282
11473
  meta: {
11283
11474
  name: "upgrade",
@@ -11294,14 +11485,21 @@ const upgradeCommand = defineRdeCommand({
11294
11485
  default: false
11295
11486
  }
11296
11487
  },
11297
- async run(args) {
11488
+ interrupted: "Interrupted before the backup; the instance is on the version it had. The next `rde upgrade` starts it if it is stopped and begins again.",
11489
+ async run(args, _rawArgs, interruption) {
11298
11490
  if (!args.yes && !stdinIsTty()) throw new ConfigError("`rde upgrade` asks for confirmation; pass --yes to upgrade without a terminal");
11299
11491
  const name = await defaultInstanceName();
11300
11492
  await requireInstanceState(name);
11301
- await upgrade(await createWizardContext(name, { signal: interruptSignal }), {
11493
+ const ctx = await createWizardContext(name, { signal: interruptSignal });
11494
+ await upgrade(ctx, {
11302
11495
  to: args.to ?? null,
11303
- yes: args.yes
11496
+ yes: args.yes,
11497
+ onPhase: (phase, backup) => {
11498
+ interruption.message = interruptedUpgrade(phase, backup);
11499
+ }
11304
11500
  });
11501
+ const removed = await removeLeftovers(name, interruptSignal);
11502
+ if (removed !== null) ctx.ui.info(removed);
11305
11503
  }
11306
11504
  });
11307
11505
  //#endregion
@@ -11309,7 +11507,7 @@ const upgradeCommand = defineRdeCommand({
11309
11507
  const main = defineCommand({
11310
11508
  meta: {
11311
11509
  name: "rde",
11312
- version: "0.1.15",
11510
+ version: "0.1.17",
11313
11511
  description: "Prepares a local machine for data-engineering work with Metabase and coding agents"
11314
11512
  },
11315
11513
  subCommands: {