@metabase/rde 0.1.16 → 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 +12 -6
  2. package/dist/cli.mjs +518 -87
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -64,8 +64,9 @@ The git step reads `–` until a license with remote sync is active; it is check
64
64
  | Enable transforms | Turns transforms on, unless someone already set them either way: turned off in the admin settings, they stay off on every later `rde init`, and the admin settings turn them back on |
65
65
  | Activate your license | Optional. Paste a token, or press Enter to skip. The token unlocks remote sync (the `remote-sync` feature, which the git step needs), data apps (`data-apps`), and transform tests (`transforms-testing`); skipping the license goes without all three, and an activated token that lacks some is named with the ones it lacks. A token Metabase refuses is asked for again at a terminal |
66
66
  | Connect a git repository | Only with a license. Initializes or reuses a repository and points Metabase's remote sync at it, with transforms included in the sync (the Transforms row under Collections to sync), and imports the content it already holds |
67
+ | Choose what syncs to git | Only once a repository is connected. Creates the Library (when the license has the `library` feature) and an `Analytics` collection, unless the repository already brought synced top-level collections in, and marks them for sync. Before it changes anything it asks whether to push them to the branch (default yes); without the push, or when the branch has commits Metabase has not imported, it prints the export to run instead |
67
68
  | Connect your databases | Required: `init` goes no further until a database of your own is connected, as a user that can create the tables transforms write. At a terminal: pick the databases the imported content uses from a checklist, or, with none, paste a connection string (Enter goes to the engine list); review, test, add; repeat. `--database` names them ahead of time; with `--yes` or without a terminal and none named, the step fails naming `--database` and the answers file. `rde db add` starts from the same checklist, then the engine list, where the fields can also be filled one by one |
68
- | Summary | URL, login email, the `mb` command for the instance, databases, repository, and the command that opens each detected agent |
69
+ | Summary | URL, login email, the `mb` command for the instance, databases, repository, the export still to run when Metabase has unexported changes, and the command that opens each detected agent |
69
70
 
70
71
  The second run on the same machine detects every step as done and prints the summary only:
71
72
 
@@ -151,7 +152,7 @@ Docker is used whenever it is available: the image is self-contained, and the co
151
152
  | --- | --- |
152
153
  | `rde start` | Starts the instance, and waits until Metabase is healthy; `--port <n>` moves the instance to another port first (see [The instance's port is taken](#troubleshooting)) |
153
154
  | `rde stop` | Stops the instance; the data stays |
154
- | `rde status` | The login email, the `mb` profile, the init checklist, whether setup is complete (or the first step `init` did not finish, with the `rde init --from` command that resumes there), runtime state, health, databases with their sync state, remote sync state and the content repository's working directory (`contentRepository`); `--json` for scripts and agents (`mbProfile`, `adminEmail`, `setup`, never a secret); exit 0 whatever it reports |
155
+ | `rde status` | The login email, the `mb` profile, the init checklist, whether setup is complete (or the first step `init` did not finish, with the `rde init --from` command that resumes there), runtime state, health, databases with their sync state, remote sync state with the collections it syncs, and the content repository's working directory (`contentRepository`); `--json` for scripts and agents (`mbProfile`, `adminEmail`, `setup`, never a secret); exit 0 whatever it reports |
155
156
  | `rde credentials` | The Metabase URL, admin email, and password; `--json` for scripts and agents; `--api-key` for the API key alone |
156
157
  | `rde logs` | The container's log, or the jar's `metabase.log`; `-f` keeps streaming |
157
158
  | `rde open` | Opens the instance in the browser |
@@ -184,7 +185,11 @@ With a license that includes remote sync, the step after the license connects a
184
185
  - **Local remote inside `~/.rde`** (default): a bare repository at `instances/default/data/content.git`, mounted into the container and reached as `file:///rde/content.git`. No account, no token. Your working directory gets it as `origin` (or as `rde-remote` when `origin` already points elsewhere).
185
186
  - **Hosted remote**: a GitHub, GitLab, or Bitbucket `https://` URL plus a token with read and write access. `rde` tests the connection through Metabase before saving anything, then clones the repository into the working directory (an existing repository gets the URL as `origin` instead); an empty hosted repository gets the first commit pushed to it. The token is handed to Metabase and, for the clone and that first push, to git in its environment; it never lands in the clone's config, the remote URL, or a credential helper, so a later `git pull` uses your own git credentials.
186
187
 
187
- A fresh repository gets a first commit with a `README.md` and a `.gitignore` that keeps out what a data app built in it must not push: `.env`, `.env.local`, `.env.*.local`, `node_modules/`, `.vite/`, the rde skill's `.scratch/`, and `.DS_Store` (a data app's built `dist/` stays tracked, since that bundle is what Metabase serves); a `.gitignore` already there keeps its lines and gets the missing ones, and a repository that already has commits is left as it is. A repository that already holds Metabase content is imported on the spot; each database it names that this instance lacks becomes a stub, which the databases step right after offers to connect. Afterwards:
188
+ A fresh repository gets a first commit with a `README.md` and a `.gitignore` that keeps out what a data app built in it must not push: `.env`, `.env.local`, `.env.*.local`, `node_modules/`, `.vite/`, the rde skill's `.scratch/`, and `.DS_Store` (a data app's built `dist/` stays tracked, since that bundle is what Metabase serves); a `.gitignore` already there keeps its lines and gets the missing ones, and a repository that already has commits is left as it is. A repository that already holds Metabase content is imported on the spot; each database it names that this instance lacks becomes a stub, which the databases step offers to connect.
189
+
190
+ Remote sync carries only what is marked for it, so the step after the repository decides what that is: the Library (published tables with their measures and segments in Data, metrics in Metrics), and one top-level collection for dashboards, questions, models, and documents; transforms already sync through the repository step. The collection is `Analytics`, created for the purpose (beside any `Analytics` of the user's, which stays out of git), unless the import brought top-level collections the repository already syncs, in which case those are used and nothing is created; the Library is created only when the import did not bring one, since a second Library would conflict with the repository's on the next import. Anything filed outside these collections stays on this instance. The step refuses to run after an import that did not finish cleanly, and names the import to run first. While remote sync is read-only, the repository decides what syncs: `rde init` skips the step, and `rde init --only sync-scope` stops and asks for read-write in Admin. `rde init --only sync-scope` runs it again; it creates nothing that is already there.
191
+
192
+ Marking the scope is itself a change Metabase has not exported, and until it is exported a plain `mb git-sync import` refuses ("There are unsaved changes in the Remote Sync collection"). So the step asks, before it creates or marks anything, whether to push the scope to the branch, and on yes runs `mb git-sync export` and checks that Metabase is in sync afterwards. It asks because the branch may be one others share. On no, or when the push fails, it prints `mb git-sync export -b <branch>` and the summary repeats it while Metabase has unexported changes. When the branch has commits Metabase has not imported, it asks nothing and pushes nothing: a plain export would end in a conflict, which counts the remote commit as synced, so it names `mb git-sync export-preflight` and `mb git-sync export -b <branch> --merge` instead. Afterwards:
188
193
 
189
194
  ```sh
190
195
  cd <your working directory>
@@ -195,7 +200,7 @@ git pull # after an export, to see the YAML files
195
200
 
196
201
  ## Running `init` without a terminal
197
202
 
198
- Every prompt can be answered ahead of time, by a flag or by a JSON answers file, so an agent or a CI job can run `rde init` unattended. The choices `init` makes on its own need no answer, so a run without a terminal only has to cover the license, the databases, and the git repository. When stdin is not a terminal, the first prompt nothing answers fails with exit code 2 and a message naming the step, the prompt, and the flag or answers key that would have answered it; nothing hangs. A text prompt with a default (a display name) takes its default, as Enter would.
203
+ Every prompt can be answered ahead of time, by a flag or by a JSON answers file, so an agent or a CI job can run `rde init` unattended. The choices `init` makes on its own need no answer, so a run without a terminal only has to cover the license, the databases, the git repository, and whether to push the sync scope. When stdin is not a terminal, the first prompt nothing answers fails with exit code 2 and a message naming the step, the prompt, and the flag or answers key that would have answered it; nothing hangs. A text prompt with a default (a display name) takes its default, as Enter would.
199
204
 
200
205
  ### Flags
201
206
 
@@ -237,11 +242,12 @@ rde init --license-token '$RDE_LICENSE_TOKEN' …
237
242
  { "engine": "postgres", "uri": "$RDE_DATABASE_1_URI" },
238
243
  { "engine": "bigquery-cloud-sdk", "fields": { "project-id": "acme", "service-account-json": "/keys/acme.json" }, "name": "Warehouse" }
239
244
  ],
240
- "git-sync": { "location": "new", "path": "~/analytics", "remote": "local-bare" }
245
+ "git-sync": { "location": "new", "path": "~/analytics", "remote": "local-bare" },
246
+ "sync-scope": { "export": true }
241
247
  }
242
248
  ```
243
249
 
244
- `"runtime"` takes `"kind": "docker"` or `"jar"`, which `"image"` and `"jar"` imply on their own (`"runtime": { "jar": "latest" }` runs the latest release with Java). `"license": { "token": null }` skips the license and `"git-sync": { "location": "skip" }` leaves the repository for later. `"databases"` lists at least one database: an empty list is refused, since `init` needs one; leave the section out to be asked at a terminal. A database is pasted as a `uri` or entered with `fields`, keyed by the driver's field names as the admin form shows them; a file field takes a path. Anything a `fields` entry leaves out is asked in the terminal, or named in the error without one. A field that engine does not ask with the others given, such as one behind the admin form's advanced options, is named in a warning and ignored.
250
+ `"runtime"` takes `"kind": "docker"` or `"jar"`, which `"image"` and `"jar"` imply on their own (`"runtime": { "jar": "latest" }` runs the latest release with Java). `"license": { "token": null }` skips the license and `"git-sync": { "location": "skip" }` leaves the repository for later. `"sync-scope": { "export": false }` leaves the first export of the sync scope to you. `"databases"` lists at least one database: an empty list is refused, since `init` needs one; leave the section out to be asked at a terminal. A database is pasted as a `uri` or entered with `fields`, keyed by the driver's field names as the admin form shows them; a file field takes a path. Anything a `fields` entry leaves out is asked in the terminal, or named in the error without one. A field that engine does not ask with the others given, such as one behind the admin form's advanced options, is named in a warning and ignored.
245
251
 
246
252
  ### Recording a run
247
253
 
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;
@@ -264,6 +268,7 @@ const StepId = z.enum([
264
268
  "transforms",
265
269
  "license",
266
270
  "git-sync",
271
+ "sync-scope",
267
272
  "databases",
268
273
  "summary"
269
274
  ]);
@@ -332,6 +337,9 @@ const REMOTE_SYNC_FEATURE = "remote-sync";
332
337
  function hasRemoteSync(license) {
333
338
  return license.activated && license.features.includes("remote-sync");
334
339
  }
340
+ function hasLibrary(license) {
341
+ return license.activated && license.features.includes("library");
342
+ }
335
343
  const DatabaseRecord = z.object({
336
344
  id: z.int(),
337
345
  name: 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(6),
365
+ schemaVersion: z.literal(7),
358
366
  name: z.string(),
359
367
  runtime: RuntimeState.nullable(),
360
368
  url: z.string().nullable(),
@@ -365,6 +373,7 @@ const InstanceState = z.object({
365
373
  databases: z.array(DatabaseRecord),
366
374
  gitRemote: GitRemoteState.nullable(),
367
375
  movedFrom: z.string().nullable(),
376
+ syncRoot: z.int().nullable(),
368
377
  steps: z.partialRecord(StepId, StepRecord)
369
378
  });
370
379
  function configuredRuntime(state) {
@@ -394,7 +403,7 @@ function mbCommand(profile) {
394
403
  }
395
404
  function emptyInstanceState(name) {
396
405
  return {
397
- schemaVersion: 6,
406
+ schemaVersion: 7,
398
407
  name,
399
408
  runtime: null,
400
409
  url: null,
@@ -405,6 +414,7 @@ function emptyInstanceState(name) {
405
414
  databases: [],
406
415
  gitRemote: null,
407
416
  movedFrom: null,
417
+ syncRoot: null,
408
418
  steps: {}
409
419
  };
410
420
  }
@@ -416,6 +426,7 @@ const V2 = 2;
416
426
  const V3 = 3;
417
427
  const V4 = 4;
418
428
  const V5 = 5;
429
+ const V6 = 6;
419
430
  const StateV1 = z.object({ schemaVersion: z.literal(V1) }).loose();
420
431
  const StateV2 = z.object({ schemaVersion: z.literal(V2) }).loose();
421
432
  const AdminV3 = z.discriminatedUnion("kind", [z.object({
@@ -427,7 +438,11 @@ const AdminV3 = z.discriminatedUnion("kind", [z.object({
427
438
  password: z.string()
428
439
  })]);
429
440
  const StateV4 = z.object({ schemaVersion: z.literal(V4) }).loose();
430
- const StateV5 = z.object({ schemaVersion: z.literal(V5) }).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(),
@@ -454,10 +469,19 @@ function liftV4(raw) {
454
469
  };
455
470
  }
456
471
  function liftV5(raw) {
457
- const { sampleDatabase: _sampleDatabase, ...state } = StateV5.parse(raw);
472
+ const { sampleDatabase: _sampleDatabase, steps, ...state } = StateV5.parse(raw);
473
+ const { "sample-database": _sampleDatabaseStep, ...remainingSteps } = steps;
458
474
  return {
459
475
  ...state,
460
- schemaVersion: 6
476
+ schemaVersion: V6,
477
+ steps: remainingSteps
478
+ };
479
+ }
480
+ function liftV6(raw) {
481
+ return {
482
+ ...StateV6.parse(raw),
483
+ schemaVersion: 7,
484
+ syncRoot: null
461
485
  };
462
486
  }
463
487
  function adminV4(admin) {
@@ -498,15 +522,16 @@ function migrateInstanceState(raw) {
498
522
  case V2: return migrateInstanceState(liftV2(raw));
499
523
  case V3: return liftV3(raw);
500
524
  case V4: return migrateInstanceState(liftV4(raw));
501
- case V5: return {
502
- state: InstanceState.parse(liftV5(raw)),
525
+ case V5: return migrateInstanceState(liftV5(raw));
526
+ case V6: return {
527
+ state: InstanceState.parse(liftV6(raw)),
503
528
  movedSecrets: null
504
529
  };
505
- case 6: return {
530
+ case 7: return {
506
531
  state: InstanceState.parse(raw),
507
532
  movedSecrets: null
508
533
  };
509
- default: throw new ConfigError(`state schemaVersion ${schemaVersion} is not supported by this build (expected 6)`);
534
+ default: throw new ConfigError(`state schemaVersion ${schemaVersion} is not supported by this build (expected 7)`);
510
535
  }
511
536
  }
512
537
  //#endregion
@@ -814,8 +839,11 @@ const SESSION_HEADER = "X-Metabase-Session";
814
839
  const API_KEY_HEADER = "X-Api-Key";
815
840
  const JSON_CONTENT_TYPE = "application/json";
816
841
  const SERVICE_UNAVAILABLE_STATUS = 503;
842
+ const NO_CONTENT_STATUS = 204;
817
843
  const NO_AUTH = { kind: "none" };
818
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();
819
847
  const ErrorFields = z.object({ errors: z.record(z.string(), z.unknown()) }).loose();
820
848
  const ErrorText = z.string();
821
849
  const ErrorTexts = z.array(z.string());
@@ -878,6 +906,8 @@ function serverMessage(body) {
878
906
  const value = jsonValue(body);
879
907
  const parsed = ErrorMessage.safeParse(value);
880
908
  if (parsed.success) return parsed.data.message;
909
+ const title = ErrorTitle.safeParse(value);
910
+ if (title.success) return title.data.error;
881
911
  const errors = serverErrors(body);
882
912
  if (errors !== null && Object.keys(errors).length > 0) return Object.entries(errors).map(([name, problem]) => `${name}: ${problem}`).join("; ");
883
913
  const plain = ErrorText.safeParse(value);
@@ -886,6 +916,10 @@ function serverMessage(body) {
886
916
  if (body.json.parsed || text.length === 0) return `HTTP ${body.status}`;
887
917
  return text;
888
918
  }
919
+ function errorCode(body) {
920
+ const parsed = ErrorCode.safeParse(jsonValue(body));
921
+ return parsed.success ? parsed.data.error_code : null;
922
+ }
889
923
  function failureFor(path, body) {
890
924
  if (body.status === SERVICE_UNAVAILABLE_STATUS) {
891
925
  const initializing = Initializing.safeParse(jsonValue(body));
@@ -895,7 +929,9 @@ function failureFor(path, body) {
895
929
  status: body.status,
896
930
  path,
897
931
  message: serverMessage(body),
898
- errors: serverErrors(body)
932
+ errors: serverErrors(body),
933
+ code: errorCode(body),
934
+ detail: body.json.parsed ? body.json.value : null
899
935
  });
900
936
  }
901
937
  function shapeError(path, body, reason) {
@@ -903,7 +939,9 @@ function shapeError(path, body, reason) {
903
939
  status: body.status,
904
940
  path,
905
941
  message: reason,
906
- errors: null
942
+ errors: null,
943
+ code: null,
944
+ detail: null
907
945
  });
908
946
  }
909
947
  function isTimeout(error) {
@@ -953,19 +991,33 @@ function createHttp(options) {
953
991
  if (!received.ok) throw failureFor(path, received.body);
954
992
  return received.body;
955
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
+ }
956
1006
  return {
957
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) {
958
1011
  const body = await requestBody(method, path, request.body);
959
- if (!body.json.parsed) throw shapeError(path, body, "expected a JSON body");
960
- try {
961
- return request.schema.parse(body.json.value);
962
- } catch (error) {
963
- if (error instanceof ZodError) throw shapeError(path, body, `unexpected response shape:\n${z.prettifyError(error)}`);
964
- throw error;
965
- }
1012
+ if (body.status === NO_CONTENT_STATUS && body.text.length === 0) return null;
1013
+ return parseJson(path, body, request.schema);
966
1014
  },
967
1015
  async requestNoContent(method, path, request = {}) {
968
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);
969
1021
  }
970
1022
  };
971
1023
  }
@@ -1056,6 +1108,48 @@ function apiKeyResource(http) {
1056
1108
  regenerate
1057
1109
  };
1058
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
+ }
1059
1153
  //#endregion
1060
1154
  //#region src/metabase/domain/database.ts
1061
1155
  const InitialSyncStatus = z.enum([
@@ -1148,7 +1242,9 @@ function permissionsGroupResource(http) {
1148
1242
  }
1149
1243
  return { list };
1150
1244
  }
1151
- 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"]);
1152
1248
  const RemoteSyncSettingsResult = z.object({
1153
1249
  success: z.boolean(),
1154
1250
  task_id: z.int().optional()
@@ -1169,6 +1265,38 @@ const SyncTask = z.object({
1169
1265
  status: SyncTaskStatus$1,
1170
1266
  error_message: z.string().nullable().optional()
1171
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();
1172
1300
  //#endregion
1173
1301
  //#region src/metabase/resources/remote-sync.ts
1174
1302
  function remoteSyncResource(http) {
@@ -1179,6 +1307,13 @@ function remoteSyncResource(http) {
1179
1307
  schema: RemoteSyncSettingsResult
1180
1308
  });
1181
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
+ }
1182
1317
  /** PUT /api/ee/remote-sync/settings with only `remote-sync-transforms`, which skips the repository check. */
1183
1318
  async function syncTransforms() {
1184
1319
  return http.requestJson("PUT", "/api/ee/remote-sync/settings", {
@@ -1195,10 +1330,11 @@ function remoteSyncResource(http) {
1195
1330
  }
1196
1331
  /** GET /api/ee/remote-sync/current-task */
1197
1332
  async function currentTask() {
1198
- 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 });
1199
1334
  }
1200
1335
  return {
1201
1336
  updateSettings,
1337
+ updateScope,
1202
1338
  syncTransforms,
1203
1339
  testConnection,
1204
1340
  currentTask
@@ -1271,6 +1407,7 @@ const PREMIUM_EMBEDDING_TOKEN_SETTING = "premium-embedding-token";
1271
1407
  const ENGINES_SETTING = "engines";
1272
1408
  const TRANSFORMS_ENABLED_SETTING = "transforms-enabled";
1273
1409
  const TRANSFORMS_SETUP_COMPLETE_SETTING = "transforms-setup-complete";
1410
+ const REMOTE_SYNC_TYPE_SETTING = "remote-sync-type";
1274
1411
  function settingResource(http) {
1275
1412
  /** PUT /api/setting/premium-embedding-token */
1276
1413
  async function setPremiumEmbeddingToken(value) {
@@ -1288,11 +1425,16 @@ function settingResource(http) {
1288
1425
  async function engines() {
1289
1426
  return http.requestJson("GET", `/api/setting/${ENGINES_SETTING}`, { schema: Engines });
1290
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
+ }
1291
1432
  return {
1292
1433
  setPremiumEmbeddingToken,
1293
1434
  enableTransforms,
1294
1435
  transformsDecided,
1295
- engines
1436
+ engines,
1437
+ remoteSyncType
1296
1438
  };
1297
1439
  }
1298
1440
  //#endregion
@@ -1362,6 +1504,7 @@ function createMetabaseClient(baseUrl, auth, signal, options = {}) {
1362
1504
  tokenStatus: tokenStatusResource(http),
1363
1505
  database: databaseResource(http),
1364
1506
  remoteSync: remoteSyncResource(http),
1507
+ collection: collectionResource(http),
1365
1508
  user: userResource(http)
1366
1509
  };
1367
1510
  }
@@ -2904,12 +3047,12 @@ async function portClaims(state, runtime) {
2904
3047
  }
2905
3048
  //#endregion
2906
3049
  //#region src/steps/admin-user.ts
2907
- const STEP_ID$8 = "admin-user";
3050
+ const STEP_ID$9 = "admin-user";
2908
3051
  const ADMIN_USER_KEYS = {
2909
- email: `${STEP_ID$8}.email`,
2910
- password: `${STEP_ID$8}.password`,
2911
- firstName: `${STEP_ID$8}.first-name`,
2912
- lastName: `${STEP_ID$8}.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`
2913
3056
  };
2914
3057
  const GENERATED_ADMIN_EMAIL = "admin@metabase.local";
2915
3058
  const GENERATED_PASSWORD_BYTES = 24;
@@ -2924,7 +3067,7 @@ function generatePassword(random) {
2924
3067
  if (DIGIT.test(candidate)) return candidate;
2925
3068
  }
2926
3069
  }
2927
- function hostDeps$13(ctx) {
3070
+ function hostDeps$14(ctx) {
2928
3071
  return {
2929
3072
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
2930
3073
  instanceRunning: () => isInstanceRunning(ctx.state, hostRuntimeDeps(ctx.state.name, ctx.signal)),
@@ -3062,9 +3205,9 @@ async function recordExistingAdmin(ctx, variant, client, url) {
3062
3205
  ctx.ui.success(`Admin user ${email} recorded`);
3063
3206
  return "completed";
3064
3207
  }
3065
- function adminUserStep(options, deps = hostDeps$13) {
3208
+ function adminUserStep(options, deps = hostDeps$14) {
3066
3209
  return {
3067
- id: STEP_ID$8,
3210
+ id: STEP_ID$9,
3068
3211
  title: "Create the admin user",
3069
3212
  redoHint: "An instance keeps the admin it was set up with; `rde credentials` shows it.",
3070
3213
  async detect(ctx) {
@@ -3329,10 +3472,15 @@ const GitSyncTask = z.object({
3329
3472
  progress: z.number().nullable(),
3330
3473
  error_message: z.string().nullable().optional()
3331
3474
  }).loose();
3475
+ const SyncedCollection = z.object({
3476
+ id: z.int(),
3477
+ name: z.string()
3478
+ }).loose();
3332
3479
  const GitSyncStatus = z.object({
3333
3480
  branch: z.string().nullable(),
3334
3481
  is_dirty: z.boolean(),
3335
- current_task: GitSyncTask.nullable()
3482
+ current_task: GitSyncTask.nullable(),
3483
+ synced_collections: z.array(SyncedCollection)
3336
3484
  }).loose();
3337
3485
  const GitSyncOutcome = z.object({
3338
3486
  message: z.string().nullable(),
@@ -3344,6 +3492,9 @@ const RemoteChanges = z.object({
3344
3492
  remote_version: z.string().nullable(),
3345
3493
  local_version: z.string().nullable()
3346
3494
  }).loose();
3495
+ function remoteMoved(changes) {
3496
+ return changes.has_changes && changes.local_version !== null;
3497
+ }
3347
3498
  function comparableUrl(url) {
3348
3499
  const parsed = new URL(url);
3349
3500
  return `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`;
@@ -3479,14 +3630,14 @@ function mbTool(runner) {
3479
3630
  }
3480
3631
  //#endregion
3481
3632
  //#region src/steps/api-key.ts
3482
- const STEP_ID$7 = "api-key";
3633
+ const STEP_ID$8 = "api-key";
3483
3634
  const API_KEY_KEYS = {
3484
- email: `${STEP_ID$7}.email`,
3485
- password: `${STEP_ID$7}.password`,
3486
- profile: `${STEP_ID$7}.profile`
3635
+ email: `${STEP_ID$8}.email`,
3636
+ password: `${STEP_ID$8}.password`,
3637
+ profile: `${STEP_ID$8}.profile`
3487
3638
  };
3488
3639
  const ADMINISTRATORS_GROUP_NAME = "Administrators";
3489
- function hostDeps$12(ctx) {
3640
+ function hostDeps$13(ctx) {
3490
3641
  return {
3491
3642
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
3492
3643
  runner: processRunner(ctx.signal),
@@ -3547,7 +3698,7 @@ async function authenticates(client) {
3547
3698
  }
3548
3699
  }
3549
3700
  async function forgetCompletion(ctx) {
3550
- const { [STEP_ID$7]: record, ...steps } = ctx.state.steps;
3701
+ const { [STEP_ID$8]: record, ...steps } = ctx.state.steps;
3551
3702
  if (record !== void 0) await ctx.saveState({
3552
3703
  ...ctx.state,
3553
3704
  steps
@@ -3624,15 +3775,15 @@ function usageNote(profile) {
3624
3775
  if (profile === "default") return `${mbCommand(profile)} <command> uses this instance`;
3625
3776
  return `${mbCommand(profile)} <command>\nMB_PROFILE=${profile} for agents and scripts`;
3626
3777
  }
3627
- function apiKeyStep(deps = hostDeps$12) {
3778
+ function apiKeyStep(deps = hostDeps$13) {
3628
3779
  return {
3629
- id: STEP_ID$7,
3780
+ id: STEP_ID$8,
3630
3781
  title: "Connect the Metabase CLI",
3631
3782
  redoHint: "`rde status` shows the mb profile it uses.",
3632
3783
  async detect(ctx) {
3633
3784
  const configured = configuredRuntime(ctx.state);
3634
3785
  const apiKey = ctx.secrets.apiKey;
3635
- if (configured === null || apiKey === null || ctx.state.steps[STEP_ID$7]?.status !== "done") return "todo";
3786
+ if (configured === null || apiKey === null || ctx.state.steps[STEP_ID$8]?.status !== "done") return "todo";
3636
3787
  const resolved = deps(ctx);
3637
3788
  if (!await resolved.instanceRunning()) return "todo";
3638
3789
  if (!isLoggedInAt(await mbTool(resolved.runner).authStatusIfInstalled(ctx.state.mbProfile), configured.url)) return "todo";
@@ -5578,13 +5729,13 @@ function gitTool(runner) {
5578
5729
  }
5579
5730
  //#endregion
5580
5731
  //#region src/steps/git-sync.ts
5581
- const STEP_ID$6 = "git-sync";
5732
+ const STEP_ID$7 = "git-sync";
5582
5733
  const GIT_SYNC_KEYS = {
5583
- location: `${STEP_ID$6}.location`,
5584
- path: `${STEP_ID$6}.path`,
5585
- remote: `${STEP_ID$6}.remote`,
5586
- url: `${STEP_ID$6}.url`,
5587
- token: `${STEP_ID$6}.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`
5588
5739
  };
5589
5740
  const BARE_REPO_NAME = "content.git";
5590
5741
  const DEFAULT_BRANCH = "main";
@@ -5618,7 +5769,7 @@ const SYNC_MODE_SETTINGS = {
5618
5769
  "remote-sync-type": "read-write",
5619
5770
  "remote-sync-transforms": true
5620
5771
  };
5621
- function hostDeps$11(ctx) {
5772
+ function hostDeps$12(ctx) {
5622
5773
  return {
5623
5774
  runner: processRunner(ctx.signal),
5624
5775
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
@@ -6088,15 +6239,15 @@ async function runGitSync(ctx, deps) {
6088
6239
  ctx.ui.note(usageLines(ctx.state, remote.gitRemote).join("\n"), "Working with the repository");
6089
6240
  return "completed";
6090
6241
  }
6091
- function gitSyncStep(deps = hostDeps$11) {
6242
+ function gitSyncStep(deps = hostDeps$12) {
6092
6243
  return {
6093
- id: STEP_ID$6,
6244
+ id: STEP_ID$7,
6094
6245
  title: "Connect a git repository",
6095
6246
  redoHint: "`rde init --only git-sync` with it points Metabase's remote sync at that repository instead.",
6096
6247
  async detect(ctx) {
6097
6248
  if (!hasRemoteSync(ctx.state.license)) return "not-applicable";
6098
- if (ctx.state.steps[STEP_ID$6]?.status === "skipped") return "not-applicable";
6099
- if (ctx.state.gitRemote === null || ctx.state.steps[STEP_ID$6]?.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";
6100
6251
  const instance = mbInstanceOf(ctx.state, ctx.secrets);
6101
6252
  if (instance === null) return "todo";
6102
6253
  try {
@@ -6278,12 +6429,12 @@ function skillsCli(runner) {
6278
6429
  }
6279
6430
  //#endregion
6280
6431
  //#region src/steps/agents-skills.ts
6281
- const STEP_ID$5 = "agents-skills";
6282
- const AGENTS_SKILLS_KEYS = { agents: `${STEP_ID$5}.agents` };
6432
+ const STEP_ID$6 = "agents-skills";
6433
+ const AGENTS_SKILLS_KEYS = { agents: `${STEP_ID$6}.agents` };
6283
6434
  const MECHANICS_NOTE = "The rde skill's mechanics come from `mb skills get <name>` at runtime, so nothing else needs installing.";
6284
6435
  const REQUIRED_SKILL_NAMES = [...DATA_APP_SKILL_NAMES, ...RDE_SOURCE_SKILL_NAMES];
6285
6436
  const DETECT_AGENTS = { kind: "detected" };
6286
- function hostDeps$10(ctx) {
6437
+ function hostDeps$11(ctx) {
6287
6438
  return {
6288
6439
  runner: processRunner(ctx.signal),
6289
6440
  probe: hostAgentProbe(),
@@ -6462,11 +6613,11 @@ function skipLine(all, source) {
6462
6613
  switch (source) {
6463
6614
  case "override":
6464
6615
  case "chosen":
6465
- case "installed": return `Skipping the skills; run \`rde init --only ${STEP_ID$5}\` to install it later`;
6466
- case "detected": return `No coding agent found (${displayNames(all)}); run \`rde init --only ${STEP_ID$5} --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`;
6467
6618
  }
6468
6619
  }
6469
- function agentsSkillsStep(options, updates, deps = hostDeps$10) {
6620
+ function agentsSkillsStep(options, updates, deps = hostDeps$11) {
6470
6621
  let hashes = null;
6471
6622
  async function setCheck(resolved) {
6472
6623
  hashes ??= hashLookup(resolved.github);
@@ -6477,13 +6628,13 @@ function agentsSkillsStep(options, updates, deps = hostDeps$10) {
6477
6628
  };
6478
6629
  }
6479
6630
  return {
6480
- id: STEP_ID$5,
6631
+ id: STEP_ID$6,
6481
6632
  title: "Install the rde and data-app skills for your coding agents",
6482
6633
  redoHint: "`rde init --only agents-skills` with it installs the skills for those agents.",
6483
6634
  async detect(ctx) {
6484
6635
  const resolved = deps(ctx);
6485
6636
  const detected = detectedOnly(await detectAgents(resolved.probe));
6486
- if (detected.length === 0) return ctx.state.steps[STEP_ID$5]?.status === "skipped" ? "not-applicable" : "todo";
6637
+ if (detected.length === 0) return ctx.state.steps[STEP_ID$6]?.status === "skipped" ? "not-applicable" : "todo";
6487
6638
  if (missingSkills(await skillsCli(resolved.runner).list(), detected).length > 0) return "todo";
6488
6639
  const check = await setCheck(resolved);
6489
6640
  for (const set of skillSets(resolved)) if (await setBehind(set, check)) return "outdated";
@@ -6640,13 +6791,13 @@ async function connectRequired(ctx, deps, opening) {
6640
6791
  result: { addedDatabases: connected.added }
6641
6792
  };
6642
6793
  }
6643
- function hostDeps$9(ctx) {
6794
+ function hostDeps$10(ctx) {
6644
6795
  return {
6645
6796
  ...hostDatabasesDeps(ctx.signal),
6646
6797
  instanceRunning: () => isInstanceRunning(ctx.state, hostRuntimeDeps(ctx.state.name, ctx.signal))
6647
6798
  };
6648
6799
  }
6649
- function databasesStep(options, deps = hostDeps$9) {
6800
+ function databasesStep(options, deps = hostDeps$10) {
6650
6801
  return {
6651
6802
  id: "databases",
6652
6803
  title: "Connect your databases",
@@ -6709,7 +6860,7 @@ function npmRegistry(signal, options = {}) {
6709
6860
  }
6710
6861
  //#endregion
6711
6862
  //#region src/steps/install-mb.ts
6712
- const STEP_ID$4 = "install-mb";
6863
+ const STEP_ID$5 = "install-mb";
6713
6864
  const MIN_MB_VERSION = "0.3.0";
6714
6865
  const MB_RANGE = `>=${MIN_MB_VERSION}`;
6715
6866
  function installedWith(scriptPath) {
@@ -6718,7 +6869,7 @@ function installedWith(scriptPath) {
6718
6869
  function rdeInstalledWith() {
6719
6870
  return installedWith(process.argv[1]);
6720
6871
  }
6721
- function hostDeps$8(ctx) {
6872
+ function hostDeps$9(ctx) {
6722
6873
  return {
6723
6874
  runner: processRunner(ctx.signal),
6724
6875
  installedWith: rdeInstalledWith(),
@@ -6885,7 +7036,7 @@ async function updateFor(release, installed, deps) {
6885
7036
  version
6886
7037
  };
6887
7038
  }
6888
- function installMbStep(updates, deps = hostDeps$8) {
7039
+ function installMbStep(updates, deps = hostDeps$9) {
6889
7040
  let published = null;
6890
7041
  function release(resolved) {
6891
7042
  if (updates === "installed") return Promise.resolve(UNCHECKED);
@@ -6893,7 +7044,7 @@ function installMbStep(updates, deps = hostDeps$8) {
6893
7044
  return published;
6894
7045
  }
6895
7046
  return {
6896
- id: STEP_ID$4,
7047
+ id: STEP_ID$5,
6897
7048
  title: "Install the Metabase CLI",
6898
7049
  async detect(ctx) {
6899
7050
  const resolved = deps(ctx);
@@ -6935,8 +7086,8 @@ function installMbStep(updates, deps = hostDeps$8) {
6935
7086
  }
6936
7087
  //#endregion
6937
7088
  //#region src/steps/license.ts
6938
- const STEP_ID$3 = "license";
6939
- const LICENSE_KEYS = { token: `${STEP_ID$3}.token` };
7089
+ const STEP_ID$4 = "license";
7090
+ const LICENSE_KEYS = { token: `${STEP_ID$4}.token` };
6940
7091
  const TOKEN_PATTERN = /^[0-9a-f]{64}$/i;
6941
7092
  const NOT_FOUND_STATUS$1 = 404;
6942
7093
  const TOKEN_REFUSED_STATUS = 400;
@@ -6959,7 +7110,7 @@ function licensedFeatureCoverage(features) {
6959
7110
  function listOf(names, type) {
6960
7111
  return new Intl.ListFormat("en", { type }).format(names);
6961
7112
  }
6962
- function hostDeps$7(ctx) {
7113
+ function hostDeps$8(ctx) {
6963
7114
  return {
6964
7115
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
6965
7116
  runner: processRunner(ctx.signal),
@@ -7044,13 +7195,13 @@ function reportFeatures(ctx, features) {
7044
7195
  ctx.ui.warn(`This license does not include ${listOf(missing, "disjunction")}${gitNote}`);
7045
7196
  }
7046
7197
  }
7047
- function licenseStep(deps = hostDeps$7) {
7198
+ function licenseStep(deps = hostDeps$8) {
7048
7199
  return {
7049
- id: STEP_ID$3,
7200
+ id: STEP_ID$4,
7050
7201
  title: "Activate your license",
7051
7202
  redoHint: "`rde init --from license` with it activates the license and runs the steps after it again.",
7052
7203
  async detect(ctx) {
7053
- if (ctx.state.steps[STEP_ID$3]?.status === "skipped") return "not-applicable";
7204
+ if (ctx.state.steps[STEP_ID$4]?.status === "skipped") return "not-applicable";
7054
7205
  const configured = configuredRuntime(ctx.state);
7055
7206
  if (configured === null || ctx.secrets.apiKey === null) return "todo";
7056
7207
  const resolved = deps(ctx);
@@ -7122,7 +7273,7 @@ const PLATFORMS = {
7122
7273
  javaHint: `Install Java 21 or newer with your distribution's package manager (openjdk-21-jre) or from https://adoptium.net`
7123
7274
  }
7124
7275
  };
7125
- function hostDeps$6(ctx) {
7276
+ function hostDeps$7(ctx) {
7126
7277
  const runner = processRunner(ctx.signal);
7127
7278
  return {
7128
7279
  platform: process.platform,
@@ -7244,7 +7395,7 @@ function checkLine(check) {
7244
7395
  function checkLines(checks) {
7245
7396
  return checks.map(checkLine);
7246
7397
  }
7247
- function preflightStep(deps = hostDeps$6) {
7398
+ function preflightStep(deps = hostDeps$7) {
7248
7399
  return {
7249
7400
  id: "preflight",
7250
7401
  title: "Check this machine",
@@ -7263,7 +7414,7 @@ function preflightStep(deps = hostDeps$6) {
7263
7414
  }
7264
7415
  //#endregion
7265
7416
  //#region src/steps/runtime.ts
7266
- const STEP_ID$2 = "runtime";
7417
+ const STEP_ID$3 = "runtime";
7267
7418
  const MIN_PORT = 1;
7268
7419
  const MAX_PORT = 65535;
7269
7420
  const PORT_PATTERN = /^\d+$/;
@@ -7277,7 +7428,7 @@ const NO_RUNTIME_FLAGS = {
7277
7428
  jar: null,
7278
7429
  port: null
7279
7430
  };
7280
- function hostDeps$5(ctx) {
7431
+ function hostDeps$6(ctx) {
7281
7432
  return {
7282
7433
  runtime: hostRuntimeDeps(ctx.state.name, ctx.signal),
7283
7434
  releases: releaseLookup(ctx.signal)
@@ -7406,9 +7557,9 @@ async function persistedRuntime(ctx, options, deps) {
7406
7557
  });
7407
7558
  return chosen;
7408
7559
  }
7409
- function runtimeStep(options, deps = hostDeps$5) {
7560
+ function runtimeStep(options, deps = hostDeps$6) {
7410
7561
  return {
7411
- id: STEP_ID$2,
7562
+ id: STEP_ID$3,
7412
7563
  title: "Install Metabase",
7413
7564
  redoHint: "Move it with `rde start --port`, or change its Metabase with `rde upgrade --to`.",
7414
7565
  async detect(ctx) {
@@ -7437,7 +7588,7 @@ const DEFAULT_HEALTH_TIMING = { timeoutMs: 3e5 };
7437
7588
  //#region src/steps/start-metabase.ts
7438
7589
  const LOG_TAIL_ON_TIMEOUT = 30;
7439
7590
  const PERCENT = 100;
7440
- function hostDeps$4(ctx) {
7591
+ function hostDeps$5(ctx) {
7441
7592
  return {
7442
7593
  runtime: hostRuntimeDeps(ctx.state.name, ctx.signal),
7443
7594
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
@@ -7466,7 +7617,7 @@ async function startRuntime(ctx, runtime, deps) {
7466
7617
  throw error;
7467
7618
  }
7468
7619
  }
7469
- function startMetabaseStep(deps = hostDeps$4) {
7620
+ function startMetabaseStep(deps = hostDeps$5) {
7470
7621
  return {
7471
7622
  id: "start-metabase",
7472
7623
  title: "Start Metabase",
@@ -7524,6 +7675,261 @@ function startMetabaseStep(deps = hostDeps$4) {
7524
7675
  };
7525
7676
  }
7526
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
7527
7933
  //#region src/steps/summary.ts
7528
7934
  const STEP_ID$1 = "summary";
7529
7935
  const GIT_SYNC_STEP_ID = "git-sync";
@@ -7581,6 +7987,19 @@ function gitLines(state) {
7581
7987
  if (state.gitRemote === null) return [`Remote sync: not configured (${notConfiguredReason(state)})`];
7582
7988
  return [`Content repository: ${state.gitRemote.workdir} (branch ${state.gitRemote.branch})`, remoteLine(state.gitRemote)];
7583
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
+ }
7584
8003
  function skillLine(entries, detected) {
7585
8004
  const agents = [...new Set(entries.flatMap((entry) => coveredAgentNames(entry, detected)))];
7586
8005
  if (agents.length === 0) return `rde skill: not installed; \`rde init --only agents-skills\``;
@@ -7592,14 +8011,15 @@ function launchLines(agents, workdir) {
7592
8011
  const prefix = workdir === null ? "" : `cd ${workdir} && `;
7593
8012
  return ["Start working:", ...detected.map((agent) => ` ${prefix}${agent.launch} (${agent.displayName})`)];
7594
8013
  }
7595
- async function summaryLines(state, apiKey, deps) {
8014
+ async function summaryLines(state, secrets, deps) {
7596
8015
  const profile = state.mbProfile;
7597
8016
  const lines = [];
7598
8017
  if (state.url !== null) lines.push(`Metabase: ${state.url}`);
7599
8018
  if (state.admin !== null) lines.push(loginLine(state.admin.kind, state.admin.email));
7600
8019
  lines.push(profile === "default" ? `Metabase CLI: ${mbCommand(profile)} <command>` : `Metabase CLI: ${mbCommand(profile)} <command> or export MB_PROFILE=${profile}`);
7601
- lines.push(...await databaseLines$1(state, apiKey, deps));
8020
+ lines.push(...await databaseLines$1(state, secrets.apiKey, deps));
7602
8021
  lines.push(...gitLines(state));
8022
+ lines.push(...await pendingExportLines(state, secrets, deps.runner));
7603
8023
  const installed = await skillsCli(deps.runner).list();
7604
8024
  const agents = await detectAgents(deps.probe);
7605
8025
  const detected = agents.filter((agent) => agent.detected);
@@ -7616,7 +8036,7 @@ function summaryStep(deps = hostDeps$3) {
7616
8036
  return "todo";
7617
8037
  },
7618
8038
  async run(ctx) {
7619
- const lines = await summaryLines(ctx.state, ctx.secrets.apiKey, deps(ctx));
8039
+ const lines = await summaryLines(ctx.state, ctx.secrets, deps(ctx));
7620
8040
  ctx.ui.note(lines.join("\n"), "Your environment");
7621
8041
  return { outcome: "completed" };
7622
8042
  }
@@ -7693,6 +8113,7 @@ const STEP_BY_ID = {
7693
8113
  license: () => licenseStep(),
7694
8114
  databases: (options) => databasesStep(options.databases),
7695
8115
  "git-sync": () => gitSyncStep(),
8116
+ "sync-scope": () => syncScopeStep(),
7696
8117
  summary: () => summaryStep()
7697
8118
  };
7698
8119
  function initSteps(options) {
@@ -7777,7 +8198,8 @@ const SECTIONS = {
7777
8198
  remote: RemoteKind.optional(),
7778
8199
  url: EnvString.optional(),
7779
8200
  token: EnvString.optional()
7780
- }).optional()
8201
+ }).optional(),
8202
+ "sync-scope": z.strictObject({ export: z.boolean() }).optional()
7781
8203
  };
7782
8204
  const InitAnswers = z.strictObject(SECTIONS);
7783
8205
  const select$1 = (value) => ({
@@ -7886,6 +8308,8 @@ function keyedAnswers(answers) {
7886
8308
  if (git.url !== void 0) add(map, GIT_SYNC_KEYS.url, text$1(git.url));
7887
8309
  if (git.token !== void 0) add(map, GIT_SYNC_KEYS.token, password$1(git.token));
7888
8310
  }
8311
+ const scope = answers["sync-scope"];
8312
+ if (scope !== void 0) add(map, SYNC_SCOPE_KEYS.export, confirm$1(scope.export));
7889
8313
  return map;
7890
8314
  }
7891
8315
  function parseInitAnswers(raw, source) {
@@ -7927,6 +8351,10 @@ function stepsAskingForWork(answers) {
7927
8351
  step: "git-sync",
7928
8352
  asks: git !== void 0 && git.location !== "skip"
7929
8353
  },
8354
+ {
8355
+ step: "sync-scope",
8356
+ asks: answers["sync-scope"]?.export === true
8357
+ },
7930
8358
  {
7931
8359
  step: "agents-skills",
7932
8360
  asks: agents !== void 0 && agents.agents.length > 0
@@ -9666,7 +10094,8 @@ function applyRecord(draft, record) {
9666
10094
  else if (key === API_KEY_KEYS.profile && value !== null) {
9667
10095
  const profile = MbProfileChoice.safeParse(value);
9668
10096
  if (profile.success) draft.answers["api-key"] = { profile: profile.data };
9669
- } 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) };
9670
10099
  }
9671
10100
  function writableUri(uri, index) {
9672
10101
  const trimmed = uri.trim();
@@ -10127,7 +10556,7 @@ function taskLabel(status) {
10127
10556
  }
10128
10557
  function remoteContentOf(changes) {
10129
10558
  if (!changes.has_changes) return "imported";
10130
- return changes.local_version === null ? "never-imported" : "changed";
10559
+ return remoteMoved(changes) ? "changed" : "never-imported";
10131
10560
  }
10132
10561
  function isMbFailure(error) {
10133
10562
  return error instanceof ProcessNotFoundError || error instanceof ExternalToolError;
@@ -10145,7 +10574,8 @@ async function gitSyncOf(ctx, deps) {
10145
10574
  branch: status.branch,
10146
10575
  dirty: status.is_dirty,
10147
10576
  remote: remoteContentOf(changes),
10148
- task: taskLabel(status)
10577
+ task: taskLabel(status),
10578
+ syncedCollections: status.synced_collections.map((collection) => collection.name)
10149
10579
  };
10150
10580
  } catch (error) {
10151
10581
  if (isMbFailure(error)) return { error: error.message };
@@ -10205,7 +10635,8 @@ function gitSyncLine(report) {
10205
10635
  if (isGitSyncFailure(report.gitSync)) return `Remote sync: ${report.gitSync.error}`;
10206
10636
  const branch = report.gitSync.branch === null ? "no branch" : `branch ${report.gitSync.branch}`;
10207
10637
  const task = report.gitSync.task === null ? "no task running" : `task ${report.gitSync.task}`;
10208
- 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}`;
10209
10640
  }
10210
10641
  const REMOTE_CONTENT_LABEL = {
10211
10642
  imported: null,
@@ -11076,7 +11507,7 @@ const upgradeCommand = defineRdeCommand({
11076
11507
  const main = defineCommand({
11077
11508
  meta: {
11078
11509
  name: "rde",
11079
- version: "0.1.16",
11510
+ version: "0.1.17",
11080
11511
  description: "Prepares a local machine for data-engineering work with Metabase and coding agents"
11081
11512
  },
11082
11513
  subCommands: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metabase/rde",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Prepares a local machine for data-engineering work with Metabase and coding agents",
5
5
  "license": "AGPL-3.0",
6
6
  "bin": {