@metabase/rde 0.1.8 → 0.1.9

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 +5 -5
  2. package/dist/cli.mjs +415 -254
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -59,7 +59,7 @@ The git step reads `–` until a license with remote sync is active; it is check
59
59
  | Start Metabase | Starts the container or the jar and waits for `/api/health`, showing Metabase's own progress |
60
60
  | Create the admin user | `admin@metabase.local` with a generated password that `rde credentials` shows, or your own with `--admin prompted`, see [Credentials](#credentials) |
61
61
  | Connect the Metabase CLI | Creates an API key named `rde` and logs `mb` in with it under the profile `default`, so plain `mb` uses the instance; when `default` already points at another Metabase it asks first, see [The `mb` profile](#the-mb-profile); a key Metabase rejects is replaced |
62
- | Connect a sample database | Runs Metabase's Postgres sample data (`metabase/qa-databases:postgres-sample-15`) in a container named `rde-sampledb-default` on the first free port from 15432, connects it as `Sample Database`, and switches transforms on. It replaces the H2 sample database Metabase would otherwise bundle, which cannot be a transform target. Needs Docker, so a jar instance on a machine without Docker keeps the H2 one instead |
62
+ | Connect a sample database | Runs Metabase's Postgres sample data (`metabase/qa-databases:postgres-sample-15`) in a container named `rde-sampledb-default` on the first free port from 15432 (or one Docker picks, when another program or a concurrent `rde init` takes that port first), connects it as `Sample Database`, and switches transforms on. It replaces the H2 sample database Metabase would otherwise bundle, which cannot be a transform target. Needs Docker, so a jar instance on a machine without Docker keeps the H2 one instead |
63
63
  | Activate your license | Optional. Paste a token, or press Enter to skip; remote sync needs one with the `remote-sync` feature |
64
64
  | Connect your databases | At a terminal, until you connect one of your own or skip it: paste a connection string (Enter skips), review, test, add; repeat. `--database` names them ahead of time; with `--yes` or without a terminal nothing is asked. `rde db add` starts from the engine list, where the fields can also be filled one by one |
65
65
  | Connect a git repository | Only with a license. Initializes or reuses a repository and points Metabase's remote sync at it |
@@ -102,7 +102,7 @@ The second run on the same machine detects every step as done and prints the sum
102
102
  └ rde init finished
103
103
  ```
104
104
 
105
- Ctrl-C at any prompt stops the run; `rde init` again continues from the first step that is not done. A step that fails ends the run with exit code 1 (2 for a missing answer) and a last line naming the step and the command that resumes there: `rde init stopped at "Connect a sample database": <reason>. Resume with rde init --from sample-database.` `rde init --from license` re-runs from a step on, `rde init --only databases` runs one step.
105
+ Ctrl-C at any prompt stops the run; `rde init` again continues from the first step that is not done. A step that fails ends the run with exit code 1 (2 for a missing answer) and a last line naming the step and the command that resumes there: `rde init stopped at "Connect a sample database": <reason>. Resume with rde init --from sample-database.` `rde init --from license` re-runs from a step on (a later step that does not apply, such as the git step without a license that has remote sync, stays skipped), `rde init --only databases` runs one step.
106
106
 
107
107
  ## The sample database
108
108
 
@@ -191,10 +191,10 @@ mb git-sync status
191
191
 
192
192
  ## The git repository
193
193
 
194
- With a license that includes remote sync, the last step connects a git repository. You choose where the working directory lives (here, a new directory, or an existing repository) and where Metabase pushes:
194
+ With a license that includes remote sync, the last step connects a git repository. You choose where Metabase pushes, then where the working directory lives (here, another directory, or an existing repository):
195
195
 
196
196
  - **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).
197
- - **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.
197
+ - **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.
198
198
 
199
199
  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. Afterwards:
200
200
 
@@ -257,7 +257,7 @@ rde init --license-token '$RDE_LICENSE_TOKEN' …
257
257
 
258
258
  ### Recording a run
259
259
 
260
- `rde init --print-answers <file>` writes, once an interactive run finishes, the answers file that reproduces it: the prompts as they were answered, plus any override given by a flag. Secrets are written as references, never as values: `$RDE_ADMIN_PASSWORD`, `$RDE_LICENSE_TOKEN`, `$RDE_GIT_TOKEN`, `$RDE_DATABASE_<n>_URI` for a connection string carrying a password, and `$RDE_DATABASE_<n>_<FIELD>` for a secret field (a password, or an SSH private key). Set those variables and run `rde init --answers <file>` on the next machine.
260
+ `rde init --print-answers <file>` writes, once an interactive run finishes, the answers file that reproduces it: the prompts as they were answered, each database as it was added (its connection string if used as pasted, otherwise its fields), plus any override given by a flag. Secrets are written as references, never as values: `$RDE_ADMIN_PASSWORD`, `$RDE_LICENSE_TOKEN`, `$RDE_GIT_TOKEN`, `$RDE_DATABASE_<n>_URI` for a connection string carrying a password, and `$RDE_DATABASE_<n>_<FIELD>` for a secret field (a password, or an SSH private key). Set those variables and run `rde init --answers <file>` on the next machine.
261
261
 
262
262
  ## Upgrading
263
263
 
package/dist/cli.mjs CHANGED
@@ -1600,6 +1600,10 @@ async function run$1(signal, command, args, options) {
1600
1600
  "pipe"
1601
1601
  ],
1602
1602
  ...options.cwd !== void 0 && { cwd: options.cwd },
1603
+ ...options.addedEnv !== void 0 && { env: {
1604
+ ...hostEnvironment(),
1605
+ ...options.addedEnv
1606
+ } },
1603
1607
  signal: combined,
1604
1608
  killSignal: "SIGKILL"
1605
1609
  });
@@ -2118,9 +2122,9 @@ function adminUserStep(options, deps = hostDeps$12) {
2118
2122
  const client = resolved.client(configured.url, NO_AUTH);
2119
2123
  const properties = await client.session.properties();
2120
2124
  if (properties["has-user-setup"]) {
2121
- if (ctx.state.admin === null) return recordExistingAdmin(ctx, options.variant, client, configured.url);
2125
+ if (ctx.state.admin === null) return { outcome: await recordExistingAdmin(ctx, options.variant, client, configured.url) };
2122
2126
  ctx.ui.info("Metabase already has an admin user");
2123
- return "completed";
2127
+ return { outcome: "completed" };
2124
2128
  }
2125
2129
  const token = properties["setup-token"];
2126
2130
  if (token === null) throw new ConfigError("Metabase has no admin user yet but offers no setup token");
@@ -2159,7 +2163,7 @@ function adminUserStep(options, deps = hostDeps$12) {
2159
2163
  }
2160
2164
  });
2161
2165
  ctx.ui.success(`Admin user ${user.email} created`);
2162
- return "completed";
2166
+ return { outcome: "completed" };
2163
2167
  }
2164
2168
  };
2165
2169
  }
@@ -2635,7 +2639,7 @@ function apiKeyStep(deps = hostDeps$11) {
2635
2639
  if (entry === void 0) throw new ConfigError(`\`mb auth list\` does not show profile ${profile} after login`);
2636
2640
  if (entry.status !== "ok") throw new ConfigError(`mb profile ${profile} reports ${entry.status} after login`);
2637
2641
  ctx.ui.note(usageNote(profile), "Metabase CLI connected");
2638
- return "completed";
2642
+ return { outcome: "completed" };
2639
2643
  }
2640
2644
  };
2641
2645
  }
@@ -3266,6 +3270,43 @@ function withoutUnasked(fields, details) {
3266
3270
  function promptableFields(fields, details) {
3267
3271
  return visibleFields(fields, details).filter(isPromptable);
3268
3272
  }
3273
+ const SECRET_KINDS$1 = /* @__PURE__ */ new Set(["password", "file"]);
3274
+ function answerOf(field, details) {
3275
+ const kind = promptKindFor(field);
3276
+ const current = details[field.name];
3277
+ if (kind === "boolean") {
3278
+ const value = typeof current === "boolean" ? current : Boolean(field.default);
3279
+ return {
3280
+ name: field.name,
3281
+ value,
3282
+ secret: false
3283
+ };
3284
+ }
3285
+ if (kind === "select") {
3286
+ const value = current ?? field.default;
3287
+ return value === void 0 || value === null ? null : {
3288
+ name: field.name,
3289
+ value: String(value),
3290
+ secret: false
3291
+ };
3292
+ }
3293
+ if (current === void 0 || current === "") return {
3294
+ name: field.name,
3295
+ value: "",
3296
+ secret: false
3297
+ };
3298
+ return {
3299
+ name: field.name,
3300
+ value: current,
3301
+ secret: SECRET_KINDS$1.has(kind)
3302
+ };
3303
+ }
3304
+ function answeredFields(fields, details) {
3305
+ return promptableFields(fields, details).flatMap((field) => {
3306
+ const answer = answerOf(field, details);
3307
+ return answer === null ? [] : [answer];
3308
+ });
3309
+ }
3269
3310
  async function fillRequiredFields(deps, fields, details) {
3270
3311
  let current = details;
3271
3312
  const asked = /* @__PURE__ */ new Set();
@@ -3525,18 +3566,45 @@ function databaseListingLines(listing) {
3525
3566
  //#region src/steps/databases/flow.ts
3526
3567
  const DATABASE_KEYS = {
3527
3568
  connection: "databases.connection",
3528
- detectedEngine: "databases.detected-engine",
3529
- engine: "databases.engine",
3530
- skip: "databases.skip",
3531
- mode: "databases.mode",
3532
- uri: "databases.uri",
3533
- name: "databases.name",
3534
- action: "databases.action",
3535
- editField: "databases.edit-field",
3536
- testAction: "databases.test-action",
3537
- switchEngine: "databases.switch-engine",
3538
- another: "databases.another"
3569
+ skip: "databases.skip"
3570
+ };
3571
+ const DATABASE_KEY_NAMES = {
3572
+ detectedEngine: "detected-engine",
3573
+ engine: "engine",
3574
+ mode: "mode",
3575
+ uri: "uri",
3576
+ name: "name",
3577
+ action: "action",
3578
+ editField: "edit-field",
3579
+ testAction: "test-action",
3580
+ switchEngine: "switch-engine",
3581
+ another: "another"
3539
3582
  };
3583
+ function sameDetails(left, right) {
3584
+ return [.../* @__PURE__ */ new Set([...Object.keys(left), ...Object.keys(right)])].every((key) => left[key] === right[key]);
3585
+ }
3586
+ function addedDatabase(chosen, model, name) {
3587
+ const pastedAsIs = model.uri !== null && model.pasted !== null && sameDetails(model.details, model.pasted);
3588
+ return {
3589
+ engine: chosen.engine.key,
3590
+ name,
3591
+ uri: pastedAsIs ? model.uri : null,
3592
+ fields: pastedAsIs ? [] : answeredFields(chosen.fields, model.details)
3593
+ };
3594
+ }
3595
+ function databaseKey(ordinal, name) {
3596
+ return `databases.${ordinal}.${DATABASE_KEY_NAMES[name]}`;
3597
+ }
3598
+ const DATABASE_KEY = /^databases\.(?<ordinal>\d+)\.(?<name>[a-z-]+)$/;
3599
+ function parseDatabaseKey(key) {
3600
+ const groups = DATABASE_KEY.exec(key)?.groups;
3601
+ const ordinal = groups?.["ordinal"];
3602
+ const name = groups?.["name"];
3603
+ return ordinal === void 0 || name === void 0 ? null : {
3604
+ ordinal: Number(ordinal),
3605
+ name
3606
+ };
3607
+ }
3540
3608
  const FINISH = "finish";
3541
3609
  const RETRY = "try-again";
3542
3610
  const BAD_REQUEST_STATUS$1 = 400;
@@ -3558,13 +3626,16 @@ const FRESH$1 = {
3558
3626
  name: null,
3559
3627
  fieldErrors: null
3560
3628
  };
3629
+ function ordinalOf(run) {
3630
+ return run.created.length + 1;
3631
+ }
3561
3632
  function fieldDeps(run) {
3562
3633
  const { prompts, ui, readTextFile } = run.deps;
3563
3634
  return {
3564
3635
  prompts,
3565
3636
  ui,
3566
3637
  readTextFile,
3567
- ordinal: run.created.length + 1
3638
+ ordinal: ordinalOf(run)
3568
3639
  };
3569
3640
  }
3570
3641
  async function orFailure(failure, ask) {
@@ -3612,7 +3683,7 @@ function isCreatableEntry(entry) {
3612
3683
  }
3613
3684
  async function pickDetectedEngine(run, candidates) {
3614
3685
  const picked = await run.deps.prompts.selectOrBack({
3615
- key: DATABASE_KEYS.detectedEngine,
3686
+ key: databaseKey(ordinalOf(run), "detectedEngine"),
3616
3687
  message: "Which database is it?",
3617
3688
  choices: candidates.map((entry) => ({
3618
3689
  value: entry.key,
@@ -3679,7 +3750,7 @@ function pickEngineScreen(run) {
3679
3750
  };
3680
3751
  for (;;) {
3681
3752
  const picked = await run.deps.prompts.select({
3682
- key: DATABASE_KEYS.engine,
3753
+ key: databaseKey(ordinalOf(run), "engine"),
3683
3754
  message: "Which database engine?",
3684
3755
  choices: [...run.deps.catalog.list().map(engineChoice), finishChoice],
3685
3756
  ...model.engine !== null && { initialValue: model.engine.key }
@@ -3720,7 +3791,7 @@ async function chooseAfterParseError(run, engine, uri, error) {
3720
3791
  hint: "this looks like one of its connection strings"
3721
3792
  }));
3722
3793
  const picked = await orFailure(error.message, () => run.deps.prompts.selectOrBack({
3723
- key: DATABASE_KEYS.switchEngine,
3794
+ key: databaseKey(ordinalOf(run), "switchEngine"),
3724
3795
  message: "What now?",
3725
3796
  choices: [...switches, {
3726
3797
  value: RETRY,
@@ -3774,7 +3845,7 @@ function pasteUriScreen(run) {
3774
3845
  for (;;) {
3775
3846
  if (engine.exampleUri === null) return manualEntry(run, model, engine);
3776
3847
  const mode = await run.deps.prompts.selectOrBack({
3777
- key: DATABASE_KEYS.mode,
3848
+ key: databaseKey(ordinalOf(run), "mode"),
3778
3849
  message: `How do you want to connect to ${engine.displayName}?`,
3779
3850
  choices: [{
3780
3851
  value: "paste",
@@ -3787,7 +3858,7 @@ function pasteUriScreen(run) {
3787
3858
  if (mode === BACK) return { type: "back" };
3788
3859
  if (mode === "manual") return manualEntry(run, model, engine);
3789
3860
  const uri = await run.deps.prompts.textOrBack({
3790
- key: DATABASE_KEYS.uri,
3861
+ key: databaseKey(ordinalOf(run), "uri"),
3791
3862
  message: "Connection string",
3792
3863
  placeholder: engine.exampleUri,
3793
3864
  ...draft !== null && { initialValue: draft }
@@ -3852,7 +3923,7 @@ function reviewNote(run, chosen, model) {
3852
3923
  async function promptName(run, model, engine) {
3853
3924
  const suggested = model.name ?? model.suggestedName ?? engine.displayName;
3854
3925
  return run.deps.prompts.text({
3855
- key: DATABASE_KEYS.name,
3926
+ key: databaseKey(ordinalOf(run), "name"),
3856
3927
  message: "Display name",
3857
3928
  defaultValue: suggested,
3858
3929
  placeholder: suggested
@@ -3869,7 +3940,7 @@ async function editOneField(run, fields, details) {
3869
3940
  };
3870
3941
  });
3871
3942
  const picked = await run.deps.prompts.selectOrBack({
3872
- key: DATABASE_KEYS.editField,
3943
+ key: databaseKey(ordinalOf(run), "editField"),
3873
3944
  message: "Which field?",
3874
3945
  choices
3875
3946
  });
@@ -3912,7 +3983,7 @@ function reviewAttempt(run) {
3912
3983
  reviewNote(run, chosen, current);
3913
3984
  if (name === null) name = await promptName(run, current, chosen.engine);
3914
3985
  const action = await run.deps.prompts.selectOrBack({
3915
- key: DATABASE_KEYS.action,
3986
+ key: databaseKey(ordinalOf(run), "action"),
3916
3987
  message: `Add ${name}?`,
3917
3988
  choices: [
3918
3989
  {
@@ -3977,7 +4048,7 @@ function testScreen(run) {
3977
4048
  const lines = fieldErrorLines(chosen.fields, errors);
3978
4049
  if (lines.length > 0) run.deps.ui.warn(lines.join("\n"));
3979
4050
  const action = await orFailure(failure, () => run.deps.prompts.selectOrBack({
3980
- key: DATABASE_KEYS.testAction,
4051
+ key: databaseKey(ordinalOf(run), "testAction"),
3981
4052
  message: "What now?",
3982
4053
  choices: [{
3983
4054
  value: "fix",
@@ -4038,6 +4109,7 @@ function createScreen(run) {
4038
4109
  }
4039
4110
  run.created.push(database);
4040
4111
  spinner.stop(`Added ${database.name}. Initial sync is running in the background; \`rde status\` shows progress.`);
4112
+ run.added.push(addedDatabase(chosen, model, model.name));
4041
4113
  await run.deps.onCreated(database);
4042
4114
  return {
4043
4115
  type: "next",
@@ -4049,7 +4121,7 @@ function createScreen(run) {
4049
4121
  function anotherScreen(run) {
4050
4122
  return async (model) => {
4051
4123
  if (await run.deps.prompts.select({
4052
- key: DATABASE_KEYS.another,
4124
+ key: databaseKey(run.created.length, "another"),
4053
4125
  message: "Anything else?",
4054
4126
  choices: [{
4055
4127
  value: "another",
@@ -4072,7 +4144,8 @@ function anotherScreen(run) {
4072
4144
  async function runDatabaseFlow(deps) {
4073
4145
  const run = {
4074
4146
  deps,
4075
- created: []
4147
+ created: [],
4148
+ added: []
4076
4149
  };
4077
4150
  await runFlow({
4078
4151
  "connection-string": connectionStringScreen(run),
@@ -4083,7 +4156,10 @@ async function runDatabaseFlow(deps) {
4083
4156
  create: createScreen(run),
4084
4157
  another: anotherScreen(run)
4085
4158
  }, START_SCREEN[deps.start], FRESH$1);
4086
- return { created: run.created };
4159
+ return {
4160
+ created: run.created,
4161
+ added: run.added
4162
+ };
4087
4163
  }
4088
4164
  //#endregion
4089
4165
  //#region src/tools/docker.ts
@@ -4131,9 +4207,9 @@ function createArgs(options) {
4131
4207
  "--label",
4132
4208
  `${HOME_LABEL}=${options.home}`,
4133
4209
  "-p",
4134
- `${LOOPBACK_HOST}:${options.port.host}:${options.port.container}`
4210
+ `${LOOPBACK_HOST}:${options.port.host ?? ""}:${options.port.container}`
4135
4211
  ];
4136
- if (options.port.alsoOn !== null) args.push("-p", `${options.port.alsoOn}:${options.port.host}:${options.port.container}`);
4212
+ if (options.port.alsoOn !== null) args.push("-p", `${options.port.alsoOn}:${options.port.host ?? ""}:${options.port.container}`);
4137
4213
  if (options.mount !== null) args.push("-v", `${options.mount.source}:${options.mount.target}`);
4138
4214
  for (const [name, value] of Object.entries(options.env)) args.push("-e", `${name}=${value}`);
4139
4215
  if (options.hostUser !== null) {
@@ -4244,6 +4320,33 @@ function dockerTool(runner) {
4244
4320
  async function containerRemove(name) {
4245
4321
  await docker(["rm", name]);
4246
4322
  }
4323
+ async function containerForceRemove(name) {
4324
+ await docker([
4325
+ "rm",
4326
+ "-f",
4327
+ name
4328
+ ]);
4329
+ }
4330
+ async function containerLoopbackPort(name, containerPort) {
4331
+ const args = [
4332
+ "port",
4333
+ name,
4334
+ `${containerPort}/tcp`
4335
+ ];
4336
+ const result = await docker(args);
4337
+ const prefix = `${LOOPBACK_HOST}:`;
4338
+ const line = result.stdout.split("\n").find((entry) => entry.startsWith(prefix));
4339
+ if (line !== void 0) {
4340
+ const port = Number.parseInt(line.slice(prefix.length), 10);
4341
+ if (Number.isInteger(port)) return port;
4342
+ }
4343
+ throw new ExternalToolError({
4344
+ tool: DOCKER,
4345
+ argv: args,
4346
+ exitCode: 0,
4347
+ stderrTail: `no port published on ${LOOPBACK_HOST} in: ${result.stdout.trim()}`
4348
+ });
4349
+ }
4247
4350
  async function containerExecSucceeds(name, command) {
4248
4351
  return (await runner.run(DOCKER, [
4249
4352
  "exec",
@@ -4270,6 +4373,8 @@ function dockerTool(runner) {
4270
4373
  containerStart,
4271
4374
  containerStop,
4272
4375
  containerRemove,
4376
+ containerForceRemove,
4377
+ containerLoopbackPort,
4273
4378
  containerExecSucceeds,
4274
4379
  containerLogs
4275
4380
  };
@@ -4294,6 +4399,21 @@ const METABASE_CONTENT_DIRS = [
4294
4399
  "snippets",
4295
4400
  "transforms"
4296
4401
  ];
4402
+ const TOKEN_USERNAMES = { "bitbucket.org": "x-token-auth" };
4403
+ const DEFAULT_TOKEN_USERNAME = "x-access-token";
4404
+ function credentialEnv(credentials) {
4405
+ const { origin, hostname } = new URL(credentials.url);
4406
+ const username = TOKEN_USERNAMES[hostname] ?? DEFAULT_TOKEN_USERNAME;
4407
+ const basic = Buffer.from(`${username}:${credentials.token}`).toString("base64");
4408
+ return {
4409
+ GIT_CONFIG_COUNT: "2",
4410
+ GIT_CONFIG_KEY_0: `http.${origin}/.extraHeader`,
4411
+ GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
4412
+ GIT_CONFIG_KEY_1: "credential.helper",
4413
+ GIT_CONFIG_VALUE_1: "",
4414
+ GIT_TERMINAL_PROMPT: "0"
4415
+ };
4416
+ }
4297
4417
  async function directoryExists(path) {
4298
4418
  try {
4299
4419
  return (await promises.stat(path)).isDirectory();
@@ -4302,8 +4422,8 @@ async function directoryExists(path) {
4302
4422
  }
4303
4423
  }
4304
4424
  function gitTool(runner) {
4305
- async function git(args, cwd) {
4306
- const result = await runner.run(GIT, args, cwd === void 0 ? {} : { cwd });
4425
+ async function git(args, options = {}) {
4426
+ const result = await runner.run(GIT, args, options);
4307
4427
  if (result.exitCode !== 0) throw toolFailure(GIT, args, result);
4308
4428
  return result;
4309
4429
  }
@@ -4349,7 +4469,7 @@ function gitTool(runner) {
4349
4469
  return result.exitCode === 0 && result.stdout.trim().length > 0;
4350
4470
  }
4351
4471
  async function commitAll(dir, message) {
4352
- await git(["add", "--all"], dir);
4472
+ await git(["add", "--all"], { cwd: dir });
4353
4473
  await git([
4354
4474
  ...await hasIdentity(dir) ? [] : [
4355
4475
  "-c",
@@ -4360,7 +4480,7 @@ function gitTool(runner) {
4360
4480
  "commit",
4361
4481
  "--message",
4362
4482
  message
4363
- ], dir);
4483
+ ], { cwd: dir });
4364
4484
  }
4365
4485
  async function addRemote(dir, name, url) {
4366
4486
  await git([
@@ -4368,22 +4488,33 @@ function gitTool(runner) {
4368
4488
  "add",
4369
4489
  name,
4370
4490
  url
4371
- ], dir);
4491
+ ], { cwd: dir });
4492
+ }
4493
+ async function clone(credentials, dir) {
4494
+ await git([
4495
+ "clone",
4496
+ "--quiet",
4497
+ credentials.url,
4498
+ dir
4499
+ ], { addedEnv: credentialEnv(credentials) });
4372
4500
  }
4373
- async function push(dir, remote, branch) {
4501
+ async function push(dir, remote, branch, credentials) {
4374
4502
  await git([
4375
4503
  "push",
4376
4504
  "--set-upstream",
4377
4505
  remote,
4378
4506
  branch
4379
- ], dir);
4507
+ ], {
4508
+ cwd: dir,
4509
+ ...credentials !== void 0 && { addedEnv: credentialEnv(credentials) }
4510
+ });
4380
4511
  }
4381
4512
  async function currentBranch(dir) {
4382
4513
  return (await git([
4383
4514
  "rev-parse",
4384
4515
  "--abbrev-ref",
4385
4516
  "HEAD"
4386
- ], dir)).stdout.trim();
4517
+ ], { cwd: dir })).stdout.trim();
4387
4518
  }
4388
4519
  async function remoteUrl(dir, name) {
4389
4520
  const result = await runner.run(GIT, [
@@ -4406,6 +4537,7 @@ function gitTool(runner) {
4406
4537
  hasCommits,
4407
4538
  commitAll,
4408
4539
  addRemote,
4540
+ clone,
4409
4541
  push,
4410
4542
  currentBranch,
4411
4543
  remoteUrl,
@@ -4459,10 +4591,11 @@ function hostDeps$10(ctx) {
4459
4591
  taskPollIntervalMs: TASK_POLL_INTERVAL_MS
4460
4592
  };
4461
4593
  }
4594
+ const SKIP_GIT_SYNC = "skip";
4462
4595
  const FRESH = {
4463
- workdir: null,
4464
4596
  remoteKind: null,
4465
- hostedUrl: null
4597
+ hostedUrl: null,
4598
+ workdir: null
4466
4599
  };
4467
4600
  function expandHome(path) {
4468
4601
  return path.startsWith(HOME_PREFIX) ? join(homedir(), path.slice(2)) : path;
@@ -4478,6 +4611,10 @@ async function directoryState(path) {
4478
4611
  if (!stat.isDirectory()) return "file";
4479
4612
  return (await promises.readdir(path)).length === 0 ? "empty" : "populated";
4480
4613
  }
4614
+ async function isCloneTarget(path) {
4615
+ const state = await directoryState(path);
4616
+ return state === "missing" || state === "empty";
4617
+ }
4481
4618
  async function initTargetProblem(run, path) {
4482
4619
  switch (await directoryState(path)) {
4483
4620
  case "missing":
@@ -4488,78 +4625,132 @@ async function initTargetProblem(run, path) {
4488
4625
  case "populated": return await run.git.isRepo(path) ? null : `${path} is not empty and not a git repository; pick an empty directory or a repository`;
4489
4626
  }
4490
4627
  }
4628
+ async function cloneTargetProblem(path) {
4629
+ if (await isCloneTarget(path)) return null;
4630
+ return await directoryState(path) === "file" ? `${path} is not a directory` : `${path} is not empty; pick an empty directory, or use an existing repository`;
4631
+ }
4491
4632
  async function existingRepoProblem(run, path) {
4492
4633
  return await run.git.isRepo(path) ? null : `${path} is not a git repository`;
4493
4634
  }
4494
- const PATH_PROMPTS = {
4495
- new: "Directory for the repository (created if missing)",
4496
- existing: "Path of the existing repository"
4635
+ const FRESH_WORKDIRS = {
4636
+ "local-bare": {
4637
+ here: "Initialize a repository here",
4638
+ elsewhere: "Initialize in another directory",
4639
+ pathMessage: "Directory for the repository (created if missing)",
4640
+ problem: initTargetProblem
4641
+ },
4642
+ hosted: {
4643
+ here: "Clone the repository here",
4644
+ elsewhere: "Clone into another directory",
4645
+ pathMessage: "Directory to clone into (created if missing)",
4646
+ problem: (_run, path) => cloneTargetProblem(path)
4647
+ }
4497
4648
  };
4498
- async function askPath(run, model, kind) {
4649
+ async function askPath(run, question) {
4499
4650
  for (;;) {
4500
4651
  const typed = await run.ctx.prompts.textOrBack({
4501
4652
  key: GIT_SYNC_KEYS.path,
4502
- message: PATH_PROMPTS[kind],
4503
- ...model.workdir !== null && { initialValue: model.workdir }
4653
+ message: question.message
4504
4654
  });
4505
4655
  if (typed === BACK) return BACK;
4506
4656
  const path = resolve(run.deps.cwd, expandHome(typed));
4507
- const problem = kind === "new" ? await initTargetProblem(run, path) : await existingRepoProblem(run, path);
4657
+ const problem = await question.problem(path);
4508
4658
  if (problem === null) return path;
4509
4659
  run.ctx.ui.warn(problem);
4510
4660
  }
4511
4661
  }
4662
+ function remoteScreen(run) {
4663
+ return async (model) => {
4664
+ const choice = await run.ctx.prompts.select({
4665
+ key: GIT_SYNC_KEYS.remote,
4666
+ message: "Where should Metabase push the content?",
4667
+ choices: [
4668
+ {
4669
+ value: "local-bare",
4670
+ label: "Local remote inside ~/.rde",
4671
+ hint: "no account needed, recommended"
4672
+ },
4673
+ {
4674
+ value: "hosted",
4675
+ label: "Hosted remote (GitHub, GitLab, Bitbucket…)",
4676
+ hint: "needs a token"
4677
+ },
4678
+ {
4679
+ value: SKIP_GIT_SYNC,
4680
+ label: "Skip for now",
4681
+ hint: "rde init --only git-sync sets it up later"
4682
+ }
4683
+ ],
4684
+ ...model.remoteKind !== null && { initialValue: model.remoteKind }
4685
+ });
4686
+ if (choice === "skip") return {
4687
+ type: "done",
4688
+ model: FRESH
4689
+ };
4690
+ const next = {
4691
+ ...model,
4692
+ remoteKind: choice
4693
+ };
4694
+ return {
4695
+ type: "next",
4696
+ to: choice === "hosted" ? "hosted" : "workdir",
4697
+ model: next
4698
+ };
4699
+ };
4700
+ }
4512
4701
  function workdirScreen(run) {
4513
4702
  return async (model) => {
4703
+ if (model.remoteKind === null) throw new Error("no remote before the workdir screen");
4704
+ const fresh = FRESH_WORKDIRS[model.remoteKind];
4705
+ const questions = {
4706
+ new: {
4707
+ message: fresh.pathMessage,
4708
+ problem: (path) => fresh.problem(run, path)
4709
+ },
4710
+ existing: {
4711
+ message: "Path of the existing repository",
4712
+ problem: (path) => existingRepoProblem(run, path)
4713
+ }
4714
+ };
4514
4715
  for (;;) {
4515
- const choice = await run.ctx.prompts.select({
4716
+ const choice = await run.ctx.prompts.selectOrBack({
4516
4717
  key: GIT_SYNC_KEYS.location,
4517
4718
  message: "Where should the Metabase content repository live?",
4518
4719
  choices: [
4519
4720
  {
4520
4721
  value: "here",
4521
- label: "Initialize a repository here",
4722
+ label: fresh.here,
4522
4723
  hint: run.deps.cwd
4523
4724
  },
4524
4725
  {
4525
4726
  value: "new",
4526
- label: "Initialize in another directory"
4727
+ label: fresh.elsewhere
4527
4728
  },
4528
4729
  {
4529
4730
  value: "existing",
4530
4731
  label: "Use an existing repository"
4531
- },
4532
- {
4533
- value: "skip",
4534
- label: "Skip for now",
4535
- hint: "rde init --only git-sync sets it up later"
4536
4732
  }
4537
4733
  ]
4538
4734
  });
4539
- if (choice === "skip") return {
4540
- type: "done",
4541
- model: FRESH
4542
- };
4735
+ if (choice === BACK) return { type: "back" };
4543
4736
  if (choice === "here") {
4544
- const problem = await initTargetProblem(run, run.deps.cwd);
4737
+ const problem = await fresh.problem(run, run.deps.cwd);
4545
4738
  if (problem !== null) {
4546
4739
  run.ctx.ui.warn(problem);
4547
4740
  continue;
4548
4741
  }
4549
4742
  return {
4550
- type: "next",
4551
- to: "remote",
4743
+ type: "done",
4552
4744
  model: {
4553
4745
  ...model,
4554
4746
  workdir: run.deps.cwd
4555
4747
  }
4556
4748
  };
4557
4749
  }
4558
- const path = await askPath(run, model, choice);
4750
+ const path = await askPath(run, questions[choice]);
4559
4751
  if (path === BACK) continue;
4560
4752
  return {
4561
- type: "next",
4562
- to: "remote",
4753
+ type: "done",
4563
4754
  model: {
4564
4755
  ...model,
4565
4756
  workdir: path
@@ -4568,37 +4759,6 @@ function workdirScreen(run) {
4568
4759
  }
4569
4760
  };
4570
4761
  }
4571
- function remoteScreen(run) {
4572
- return async (model) => {
4573
- const kind = await run.ctx.prompts.selectOrBack({
4574
- key: GIT_SYNC_KEYS.remote,
4575
- message: "Where should Metabase push the content?",
4576
- choices: [{
4577
- value: "local-bare",
4578
- label: "Local remote inside ~/.rde",
4579
- hint: "no account needed, recommended"
4580
- }, {
4581
- value: "hosted",
4582
- label: "Hosted remote (GitHub, GitLab, Bitbucket…)",
4583
- hint: "needs a token"
4584
- }],
4585
- ...model.remoteKind !== null && { initialValue: model.remoteKind }
4586
- });
4587
- if (kind === BACK) return { type: "back" };
4588
- const next = {
4589
- ...model,
4590
- remoteKind: kind
4591
- };
4592
- return kind === "hosted" ? {
4593
- type: "next",
4594
- to: "hosted",
4595
- model: next
4596
- } : {
4597
- type: "done",
4598
- model: next
4599
- };
4600
- };
4601
- }
4602
4762
  function validateHttpsUrl(value) {
4603
4763
  return value.length === 0 || value.startsWith(HTTPS_PREFIX) ? void 0 : `The repository URL must start with ${HTTPS_PREFIX}`;
4604
4764
  }
@@ -4639,7 +4799,8 @@ function hostedScreen(run) {
4639
4799
  spinner.stop("Metabase can reach the repository");
4640
4800
  run.ctx.session.gitToken.set(token);
4641
4801
  return {
4642
- type: "done",
4802
+ type: "next",
4803
+ to: "workdir",
4643
4804
  model: {
4644
4805
  ...model,
4645
4806
  hostedUrl: url
@@ -4706,9 +4867,34 @@ async function configureLocalBare(run, runtime, workdir) {
4706
4867
  }
4707
4868
  };
4708
4869
  }
4709
- async function configureHosted(run, workdir, url) {
4870
+ async function cloneHosted(run, workdir, credentials) {
4871
+ const spinner = run.ctx.ui.spinner(`Cloning ${credentials.url}…`);
4872
+ try {
4873
+ await run.git.clone(credentials, workdir);
4874
+ } catch (error) {
4875
+ spinner.fail("Could not clone the repository");
4876
+ throw error;
4877
+ }
4878
+ spinner.stop(`Cloned into ${workdir}`);
4879
+ if (await run.git.hasCommits(workdir)) return {
4880
+ workdir,
4881
+ branch: await run.git.currentBranch(workdir)
4882
+ };
4883
+ const prepared = await prepareWorkdir(run, workdir);
4884
+ await run.git.push(workdir, ORIGIN, prepared.branch, credentials);
4885
+ return prepared;
4886
+ }
4887
+ async function adoptRepository(run, workdir, url) {
4710
4888
  const prepared = await prepareWorkdir(run, workdir);
4711
4889
  await ensureRemote(run, workdir, url);
4890
+ return prepared;
4891
+ }
4892
+ async function configureHosted(run, workdir, url) {
4893
+ const token = run.ctx.session.gitToken.get();
4894
+ const prepared = await isCloneTarget(workdir) ? await cloneHosted(run, workdir, {
4895
+ url,
4896
+ token
4897
+ }) : await adoptRepository(run, workdir, url);
4712
4898
  return {
4713
4899
  gitRemote: {
4714
4900
  kind: "hosted",
@@ -4718,7 +4904,7 @@ async function configureHosted(run, workdir, url) {
4718
4904
  },
4719
4905
  settings: {
4720
4906
  "remote-sync-url": url,
4721
- "remote-sync-token": run.ctx.session.gitToken.get(),
4907
+ "remote-sync-token": token,
4722
4908
  "remote-sync-type": "read-write",
4723
4909
  "remote-sync-branch": prepared.branch
4724
4910
  }
@@ -4836,10 +5022,10 @@ async function runGitSync(ctx, deps) {
4836
5022
  })
4837
5023
  };
4838
5024
  const flow = await runFlow({
4839
- workdir: workdirScreen(run),
4840
5025
  remote: remoteScreen(run),
4841
- hosted: hostedScreen(run)
4842
- }, "workdir", FRESH);
5026
+ hosted: hostedScreen(run),
5027
+ workdir: workdirScreen(run)
5028
+ }, "remote", FRESH);
4843
5029
  if (flow.type === "exited" || !isChosen(flow.model)) {
4844
5030
  ctx.ui.info("Skipping remote sync; run `rde init --only git-sync` to set it up later");
4845
5031
  return "skipped";
@@ -4865,8 +5051,8 @@ function gitSyncStep(deps = hostDeps$10) {
4865
5051
  throw error;
4866
5052
  }
4867
5053
  },
4868
- run(ctx) {
4869
- return runGitSync(ctx, deps(ctx));
5054
+ async run(ctx) {
5055
+ return { outcome: await runGitSync(ctx, deps(ctx)) };
4870
5056
  }
4871
5057
  };
4872
5058
  }
@@ -5112,7 +5298,7 @@ function agentsSkillsStep(options, deps = hostDeps$9) {
5112
5298
  const selection = await selectAgents(ctx, all, options);
5113
5299
  if (selection.agents.length === 0) {
5114
5300
  ctx.ui.info(skipLine(all, selection.source));
5115
- return "skipped";
5301
+ return { outcome: "skipped" };
5116
5302
  }
5117
5303
  const skills = skillsCli(resolved.runner);
5118
5304
  const agents = selection.agents.map((agent) => agent.skillsCliName);
@@ -5137,7 +5323,7 @@ function agentsSkillsStep(options, deps = hostDeps$9) {
5137
5323
  if (missing.length > 0) throw new ConfigError(`the skills installer finished but \`skills list\` does not show ${missing.join(", ")}`);
5138
5324
  ctx.ui.note(installedLines(requiredEntries(installed), selection.agents), "Skills installed");
5139
5325
  ctx.ui.info(MECHANICS_NOTE);
5140
- return "completed";
5326
+ return { outcome: "completed" };
5141
5327
  }
5142
5328
  };
5143
5329
  }
@@ -5225,15 +5411,23 @@ async function connectDatabases(ctx, deps, start) {
5225
5411
  });
5226
5412
  return {
5227
5413
  existing: existing.data.length,
5228
- created: result.created.length
5414
+ created: result.created.length,
5415
+ added: result.added
5229
5416
  };
5230
5417
  }
5231
5418
  async function addDatabases(ctx, deps) {
5232
5419
  const connected = await connectDatabases(ctx, deps, "engine");
5233
- return connected.existing + connected.created > 0 ? "completed" : "skipped";
5420
+ return {
5421
+ outcome: connected.existing + connected.created > 0 ? "completed" : "skipped",
5422
+ result: { addedDatabases: connected.added }
5423
+ };
5234
5424
  }
5235
5425
  async function askForOwnDatabase(ctx, deps) {
5236
- return (await connectDatabases(ctx, deps, "connection-string")).created > 0 ? "completed" : "skipped";
5426
+ const connected = await connectDatabases(ctx, deps, "connection-string");
5427
+ return {
5428
+ outcome: connected.created > 0 ? "completed" : "skipped",
5429
+ result: { addedDatabases: connected.added }
5430
+ };
5237
5431
  }
5238
5432
  function hostDeps$8(ctx) {
5239
5433
  return hostDatabasesDeps(ctx.signal);
@@ -5274,9 +5468,12 @@ function databasesStep(options, deps = hostDeps$8) {
5274
5468
  throw error;
5275
5469
  }
5276
5470
  },
5277
- run(ctx) {
5278
- if (options.request === "ask-once") return askForOwnDatabase(ctx, deps(ctx));
5279
- return addDatabases(ctx, deps(ctx));
5471
+ async run(ctx) {
5472
+ switch (options.request) {
5473
+ case "ask-once": return askForOwnDatabase(ctx, deps(ctx));
5474
+ case "named": return addDatabases(ctx, deps(ctx));
5475
+ case "sample-stands-in": return { outcome: "skipped" };
5476
+ }
5280
5477
  }
5281
5478
  };
5282
5479
  }
@@ -5420,7 +5617,7 @@ function installMbStep(deps = hostDeps$7) {
5420
5617
  const before = await inspectMb(resolved.runner);
5421
5618
  if (before.installed && before.satisfies) {
5422
5619
  ctx.ui.success(`mb ${before.version} is installed`);
5423
- return "completed";
5620
+ return { outcome: "completed" };
5424
5621
  }
5425
5622
  if (before.installed) ctx.ui.warn(`mb ${before.version} is installed; ${MIN_MB_VERSION} or newer is required`);
5426
5623
  const install = mbInstallCommand(resolved.installedWith);
@@ -5429,7 +5626,7 @@ function installMbStep(deps = hostDeps$7) {
5429
5626
  const after = await inspectMb(resolved.runner);
5430
5627
  if (!after.installed || !after.satisfies) throw await stillWrong(install, after, resolved);
5431
5628
  ctx.ui.success(`mb ${after.version} is installed`);
5432
- return "completed";
5629
+ return { outcome: "completed" };
5433
5630
  }
5434
5631
  };
5435
5632
  }
@@ -5487,7 +5684,7 @@ function licenseStep(deps = hostDeps$6) {
5487
5684
  });
5488
5685
  if (token.length === 0) {
5489
5686
  ctx.ui.info("Skipping the license; remote sync needs one. Run `rde init --from license` to add it later");
5490
- return "skipped";
5687
+ return { outcome: "skipped" };
5491
5688
  }
5492
5689
  ctx.session.licenseToken.set(token);
5493
5690
  const client = deps(ctx).client(configured.url, {
@@ -5518,7 +5715,7 @@ function licenseStep(deps = hostDeps$6) {
5518
5715
  });
5519
5716
  if (status.features.includes("remote-sync")) ctx.ui.info("Remote sync is included; the git step will set it up");
5520
5717
  else ctx.ui.warn("This license does not include remote sync; the git step will be skipped");
5521
- return "completed";
5718
+ return { outcome: "completed" };
5522
5719
  }
5523
5720
  };
5524
5721
  }
@@ -5742,7 +5939,7 @@ function preflightStep(deps = hostDeps$5) {
5742
5939
  const checks = await runChecks(resolved, resolved.platform);
5743
5940
  ctx.ui.note(checkLines(checks).join("\n"), "This machine");
5744
5941
  if (checks.some(blocks)) throw new ConfigError("Fix the items above and run rde init again");
5745
- return "completed";
5942
+ return { outcome: "completed" };
5746
5943
  }
5747
5944
  };
5748
5945
  }
@@ -6355,7 +6552,7 @@ function runtimeStep(options, deps = hostDeps$4) {
6355
6552
  throw error;
6356
6553
  }
6357
6554
  spinner.stop(`Metabase installed on port ${chosen.port}`);
6358
- return "completed";
6555
+ return { outcome: "completed" };
6359
6556
  }
6360
6557
  };
6361
6558
  }
@@ -6391,13 +6588,13 @@ function sampleDatabaseUrl(port) {
6391
6588
  function publishesOnLoopback$1(inspection, port) {
6392
6589
  return inspection.ports.some((mapping) => mapping.container === POSTGRES_PORT && mapping.host === port && mapping.hostIp === "127.0.0.1");
6393
6590
  }
6394
- function createOptions(state, home, alsoOn) {
6591
+ function createOptions(state, home, hostPort, alsoOn) {
6395
6592
  return {
6396
6593
  name: state.containerName,
6397
6594
  home,
6398
6595
  image: state.image,
6399
6596
  port: {
6400
- host: state.port,
6597
+ host: hostPort,
6401
6598
  container: POSTGRES_PORT,
6402
6599
  alsoOn
6403
6600
  },
@@ -6407,6 +6604,9 @@ function createOptions(state, home, alsoOn) {
6407
6604
  hostUser: null
6408
6605
  };
6409
6606
  }
6607
+ function requireOwnContainer(name, existing, home) {
6608
+ if (existing.home !== null && existing.home !== home) throw new ConfigError(`container ${name} belongs to the rde home ${existing.home} and is left alone`);
6609
+ }
6410
6610
  function sampleDatabaseContainer(state, docker, home) {
6411
6611
  const name = state.containerName;
6412
6612
  function conflict(reason) {
@@ -6416,11 +6616,12 @@ function sampleDatabaseContainer(state, docker, home) {
6416
6616
  const existing = await docker.containerInspect(name);
6417
6617
  if (existing.exists) {
6418
6618
  if (existing.image !== state.image) throw conflict(`with image ${existing.image}, not ${state.image}`);
6419
- if (!publishesOnLoopback$1(existing, state.port)) throw conflict(`without port ${state.port} published on ${LOOPBACK_HOST}`);
6420
- return;
6619
+ if (publishesOnLoopback$1(existing, state.port)) return;
6620
+ requireOwnContainer(name, existing, home);
6621
+ await docker.containerForceRemove(name);
6421
6622
  }
6422
6623
  await docker.imagePull(state.image, { onLine: options.onProgress });
6423
- await docker.containerCreate(createOptions(state, home, options.alsoOn));
6624
+ await docker.containerCreate(createOptions(state, home, state.port, options.alsoOn));
6424
6625
  }
6425
6626
  async function start() {
6426
6627
  const existing = await docker.containerInspect(name);
@@ -6470,7 +6671,22 @@ function extraAddress(inspection) {
6470
6671
  const extra = inspection.ports.find((mapping) => mapping.container === POSTGRES_PORT && mapping.hostIp !== "127.0.0.1");
6471
6672
  return extra === void 0 ? null : extra.hostIp;
6472
6673
  }
6473
- async function startOnFreePort(state, docker, ports, home) {
6674
+ async function startOnDockerPort(state, docker, home, alsoOn) {
6675
+ const name = state.containerName;
6676
+ await docker.containerForceRemove(name);
6677
+ await docker.containerCreate(createOptions(state, home, null, null));
6678
+ await docker.containerStart(name);
6679
+ const port = await docker.containerLoopbackPort(name, POSTGRES_PORT);
6680
+ await docker.containerForceRemove(name);
6681
+ const pinned = {
6682
+ ...state,
6683
+ port
6684
+ };
6685
+ await docker.containerCreate(createOptions(pinned, home, port, alsoOn));
6686
+ await docker.containerStart(name);
6687
+ return pinned;
6688
+ }
6689
+ async function startOnFreePort(state, docker, home) {
6474
6690
  try {
6475
6691
  return {
6476
6692
  outcome: await sampleDatabaseContainer(state, docker, home).start(),
@@ -6480,18 +6696,10 @@ async function startOnFreePort(state, docker, ports, home) {
6480
6696
  if (!isPortTaken(error)) throw error;
6481
6697
  }
6482
6698
  const existing = await docker.containerInspect(state.containerName);
6483
- if (existing.exists && existing.home !== null && existing.home !== home) throw new ConfigError(`container ${state.containerName} belongs to the rde home ${existing.home} and is left alone`);
6484
- const moved = {
6485
- ...state,
6486
- port: await ports.findFree(SAMPLE_DATABASE_PORT)
6487
- };
6488
- const alsoOn = existing.exists ? extraAddress(existing) : null;
6489
- await docker.containerRemove(state.containerName);
6490
- await docker.containerCreate(createOptions(moved, home, alsoOn));
6491
- await docker.containerStart(moved.containerName);
6699
+ if (existing.exists) requireOwnContainer(state.containerName, existing, home);
6492
6700
  return {
6493
6701
  outcome: "started",
6494
- state: moved
6702
+ state: await startOnDockerPort(state, docker, home, existing.exists ? extraAddress(existing) : null)
6495
6703
  };
6496
6704
  }
6497
6705
  //#endregion
@@ -6585,7 +6793,7 @@ async function installAndStart(ctx, sidecar, installed, deps) {
6585
6793
  throw error;
6586
6794
  }
6587
6795
  installing.stop(`Sample database container ${installed.containerName} on port ${installed.port}`);
6588
- const started = await startOnFreePort(installed, deps.runtime.docker, deps.ports, deps.runtime.rdeHome);
6796
+ const started = await startOnFreePort(installed, deps.runtime.docker, deps.runtime.rdeHome);
6589
6797
  const record = started.state.port === installed.port ? installed : await recordSampleDatabaseMove(ctx, installed, started.state);
6590
6798
  const waiting = ctx.ui.spinner("Waiting for Postgres to accept connections…");
6591
6799
  try {
@@ -6694,7 +6902,7 @@ function sampleDatabaseStep(deps = hostDeps$3) {
6694
6902
  await connectDatabase(ctx, choice, configured, client, resolved);
6695
6903
  await client.setting.enableTransforms();
6696
6904
  ctx.ui.note(usageLines(record), "Sample database");
6697
- return "completed";
6905
+ return { outcome: "completed" };
6698
6906
  }
6699
6907
  };
6700
6908
  }
@@ -6706,7 +6914,6 @@ const PERCENT = 100;
6706
6914
  function hostDeps$2(ctx) {
6707
6915
  return {
6708
6916
  runtime: hostRuntimeDeps(ctx.state.name, ctx.signal),
6709
- ports: hostPortProbe,
6710
6917
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
6711
6918
  timing: DEFAULT_HEALTH_TIMING
6712
6919
  };
@@ -6723,7 +6930,7 @@ function progressLabel(progress) {
6723
6930
  async function startSampleDatabase(ctx, deps) {
6724
6931
  const record = ctx.state.sampleDatabase;
6725
6932
  if (record === null) return;
6726
- const started = await startOnFreePort(record, deps.runtime.docker, deps.ports, deps.runtime.rdeHome);
6933
+ const started = await startOnFreePort(record, deps.runtime.docker, deps.runtime.rdeHome);
6727
6934
  if (started.state.port !== record.port) await recordSampleDatabaseMove(ctx, record, started.state);
6728
6935
  switch (started.outcome) {
6729
6936
  case "missing":
@@ -6783,7 +6990,7 @@ function startMetabaseStep(deps = hostDeps$2) {
6783
6990
  version
6784
6991
  });
6785
6992
  spinner.stop(`Metabase ${version} is running at ${configured.url}`);
6786
- return "completed";
6993
+ return { outcome: "completed" };
6787
6994
  }
6788
6995
  };
6789
6996
  }
@@ -6879,7 +7086,7 @@ function summaryStep(deps = hostDeps$1) {
6879
7086
  async run(ctx) {
6880
7087
  const lines = await summaryLines(ctx.state, ctx.secrets.apiKey, deps(ctx));
6881
7088
  ctx.ui.note(lines.join("\n"), "Your environment");
6882
- return "completed";
7089
+ return { outcome: "completed" };
6883
7090
  }
6884
7091
  };
6885
7092
  }
@@ -7016,16 +7223,16 @@ function fieldAnswer(value) {
7016
7223
  return text$1(String(value));
7017
7224
  }
7018
7225
  function addDatabase(map, database, ordinal, last) {
7019
- add(map, DATABASE_KEYS.engine, select$1(database.engine));
7226
+ add(map, databaseKey(ordinal, "engine"), select$1(database.engine));
7020
7227
  if (database.uri !== void 0) {
7021
- add(map, DATABASE_KEYS.mode, select$1("paste"));
7022
- add(map, DATABASE_KEYS.uri, text$1(database.uri));
7228
+ add(map, databaseKey(ordinal, "mode"), select$1("paste"));
7229
+ add(map, databaseKey(ordinal, "uri"), text$1(database.uri));
7023
7230
  }
7024
- if (database.uri === void 0 && EXAMPLE_URIS[database.engine] !== void 0) add(map, DATABASE_KEYS.mode, select$1("manual"));
7231
+ if (database.uri === void 0 && EXAMPLE_URIS[database.engine] !== void 0) add(map, databaseKey(ordinal, "mode"), select$1("manual"));
7025
7232
  if (database.fields !== void 0) for (const [name, value] of Object.entries(database.fields)) add(map, fieldKey(ordinal, name), fieldAnswer(value));
7026
- add(map, DATABASE_KEYS.name, text$1(database.name ?? ACCEPT_DEFAULT));
7027
- add(map, DATABASE_KEYS.action, select$1("test"));
7028
- add(map, DATABASE_KEYS.another, select$1(last ? "finish" : "another"));
7233
+ add(map, databaseKey(ordinal, "name"), text$1(database.name ?? ACCEPT_DEFAULT));
7234
+ add(map, databaseKey(ordinal, "action"), select$1("test"));
7235
+ add(map, databaseKey(ordinal, "another"), select$1(last ? "finish" : "another"));
7029
7236
  }
7030
7237
  function addAdmin(map, admin) {
7031
7238
  if (admin.variant !== "prompted") return;
@@ -7083,17 +7290,12 @@ function keyedAnswers(answers) {
7083
7290
  const license = answers.license;
7084
7291
  if (license !== void 0) add(map, LICENSE_KEYS.token, password$1(license.token ?? SKIP_LICENSE));
7085
7292
  const databases = answers.databases;
7086
- if (databases !== void 0) {
7087
- if (databases.length === 0) {
7088
- add(map, DATABASE_KEYS.engine, select$1(FINISH));
7089
- add(map, DATABASE_KEYS.skip, confirm$1(true));
7090
- }
7091
- databases.forEach((database, index) => {
7092
- addDatabase(map, database, index + 1, index === databases.length - 1);
7093
- });
7094
- }
7293
+ if (databases !== void 0) databases.forEach((database, index) => {
7294
+ addDatabase(map, database, index + 1, index === databases.length - 1);
7295
+ });
7095
7296
  const git = answers["git-sync"];
7096
- if (git !== void 0) {
7297
+ if (git?.location === "skip") add(map, GIT_SYNC_KEYS.remote, select$1(SKIP_GIT_SYNC));
7298
+ else if (git !== void 0) {
7097
7299
  add(map, GIT_SYNC_KEYS.location, select$1(git.location));
7098
7300
  if (git.path !== void 0) add(map, GIT_SYNC_KEYS.path, text$1(git.path));
7099
7301
  if (git.remote !== void 0) add(map, GIT_SYNC_KEYS.remote, select$1(git.remote));
@@ -7137,18 +7339,26 @@ const FLAG_FOR_KEY$1 = {
7137
7339
  [API_KEY_KEYS.password]: "--admin-password",
7138
7340
  [API_KEY_KEYS.profile]: "--mb-profile",
7139
7341
  [LICENSE_KEYS.token]: "--license-token or --skip-license",
7140
- [DATABASE_KEYS.engine]: "--database or --skip-databases",
7141
- [DATABASE_KEYS.uri]: "--database",
7142
7342
  [DATABASE_KEYS.skip]: "--skip-databases",
7143
- [GIT_SYNC_KEYS.location]: "--git-dir or --skip-git",
7343
+ [GIT_SYNC_KEYS.location]: "--git-dir",
7144
7344
  [GIT_SYNC_KEYS.path]: "--git-dir",
7145
- [GIT_SYNC_KEYS.remote]: "--git-remote",
7345
+ [GIT_SYNC_KEYS.remote]: "--git-remote or --skip-git",
7146
7346
  [GIT_SYNC_KEYS.url]: "--git-url",
7147
7347
  [GIT_SYNC_KEYS.token]: "--git-token"
7148
7348
  };
7349
+ const DATABASE_FLAG_FOR_NAME = {
7350
+ engine: "--database or --skip-databases",
7351
+ uri: "--database"
7352
+ };
7149
7353
  function howToAnswer(key) {
7150
7354
  const field = parseFieldKey(key);
7151
7355
  if (field !== null) return `set "${field.name}" under "fields" of database ${field.ordinal} in the --answers file (for a pasted database, in its connection string)`;
7356
+ const database = parseDatabaseKey(key);
7357
+ if (database !== null) {
7358
+ const flag = DATABASE_FLAG_FOR_NAME[database.name];
7359
+ const inFile = `set database ${database.ordinal} in the --answers file`;
7360
+ return flag === void 0 ? inFile : `pass ${flag}, or ${inFile}`;
7361
+ }
7152
7362
  const flag = FLAG_FOR_KEY$1[key];
7153
7363
  const inFile = `set "${key}" in the --answers file`;
7154
7364
  return flag === void 0 ? inFile : `pass ${flag}, or ${inFile}`;
@@ -7741,17 +7951,30 @@ function requireKnownStep(steps, id, flag) {
7741
7951
  if (id === void 0) return;
7742
7952
  if (!steps.some((step) => step.id === id)) throw new ConfigError(`${flag} ${id} is not one of the steps: ${steps.map((step) => step.id).join(", ")}`);
7743
7953
  }
7954
+ async function detectOne(step, ctx, options, fromHere) {
7955
+ if (step.id === (options.only ?? options.from)) return {
7956
+ step,
7957
+ status: "todo",
7958
+ forced: true
7959
+ };
7960
+ const status = await step.detect(ctx);
7961
+ if (options.only === void 0 && fromHere && status !== "not-applicable") return {
7962
+ step,
7963
+ status: "todo",
7964
+ forced: true
7965
+ };
7966
+ return {
7967
+ step,
7968
+ status,
7969
+ forced: false
7970
+ };
7971
+ }
7744
7972
  async function detectAll(steps, ctx, options) {
7745
7973
  const detected = [];
7746
7974
  let fromHere = false;
7747
7975
  for (const step of steps) {
7748
7976
  if (step.id === options.from) fromHere = true;
7749
- const forced = options.only !== void 0 ? step.id === options.only : fromHere;
7750
- detected.push({
7751
- step,
7752
- status: forced ? "todo" : await step.detect(ctx),
7753
- forced
7754
- });
7977
+ detected.push(await detectOne(step, ctx, options, fromHere));
7755
7978
  }
7756
7979
  return detected;
7757
7980
  }
@@ -7790,13 +8013,16 @@ async function runWizard(steps, ctx, options = {}) {
7790
8013
  title: step.title,
7791
8014
  status
7792
8015
  })));
8016
+ const results = [];
7793
8017
  for (const entry of detected) try {
7794
8018
  if (!await shouldRun(entry, ctx, options)) continue;
7795
- const outcome = await entry.step.run(ctx);
8019
+ const { outcome, result } = await entry.step.run(ctx);
8020
+ if (result !== void 0) results.push(result);
7796
8021
  await ctx.saveState(withStepRecord(ctx.state, entry.step.id, recordFor(outcome, now())));
7797
8022
  } catch (error) {
7798
8023
  throw failureAt(entry.step, error);
7799
8024
  }
8025
+ return results;
7800
8026
  }
7801
8027
  //#endregion
7802
8028
  //#region src/steps/doctor.ts
@@ -8690,73 +8916,11 @@ function isLocation(value) {
8690
8916
  function isRemote(value) {
8691
8917
  return value === "local-bare" || value === "hosted";
8692
8918
  }
8693
- function applyConnectionRecord(draft, value) {
8694
- draft.databases = draft.databases.filter((database) => !database.pasted);
8695
- if (value === null) return;
8696
- if (value.trim().length === 0) {
8697
- draft.databasesFinished = true;
8698
- return;
8699
- }
8700
- draft.databases.push({
8701
- engine: null,
8702
- uri: value.trim(),
8703
- fields: {},
8704
- name: null,
8705
- pasted: true,
8706
- added: false
8707
- });
8708
- }
8709
- function applyEngineRecord(draft, value) {
8710
- if (draft.databases.at(-1)?.added === false) draft.databases.pop();
8711
- if (value === "finish") draft.databasesFinished = true;
8712
- else if (value !== null) draft.databases.push({
8713
- engine: value,
8714
- uri: null,
8715
- fields: {},
8716
- name: null,
8717
- pasted: false,
8718
- added: false
8719
- });
8720
- }
8721
8919
  function applyDatabaseRecord(draft, record) {
8722
8920
  const value = stringOf(record.answer);
8723
- if (record.key === DATABASE_KEYS.connection) {
8724
- applyConnectionRecord(draft, value);
8725
- return;
8726
- }
8727
- if (record.key === DATABASE_KEYS.engine) {
8728
- applyEngineRecord(draft, value);
8729
- return;
8730
- }
8731
- const database = draft.databases.at(-1);
8732
- if (database === void 0) return;
8733
- const switched = record.key === DATABASE_KEYS.switchEngine && value !== "try-again";
8734
- if ((record.key === DATABASE_KEYS.detectedEngine || switched) && value !== null) {
8735
- database.engine = value;
8736
- return;
8737
- }
8738
- if (record.key === DATABASE_KEYS.another) {
8739
- database.added = true;
8740
- return;
8741
- }
8742
- if (record.key === DATABASE_KEYS.mode) {
8743
- database.fields = {};
8744
- if (value === "manual") database.uri = null;
8745
- return;
8746
- }
8747
- if (record.key === DATABASE_KEYS.uri && value !== null) {
8748
- database.uri = value;
8749
- return;
8750
- }
8751
- if (record.key === DATABASE_KEYS.name && value !== null && value.length > 0) {
8752
- database.name = value;
8753
- return;
8754
- }
8755
- const fieldName = parseFieldKey(record.key)?.name;
8756
- if (fieldName === void 0) return;
8757
- if (record.answer.kind === "confirm") database.fields[fieldName] = record.answer.value;
8758
- else if (record.answer.kind === "password") database.fields[fieldName] = envReference(databaseEnv(draft.databases.length - 1, fieldName));
8759
- else if (value !== null) database.fields[fieldName] = value;
8921
+ const skippedConnection = record.key === DATABASE_KEYS.connection && value?.trim() === "";
8922
+ const finished = parseDatabaseKey(record.key)?.name === "engine" && value === "finish";
8923
+ if (skippedConnection || finished) draft.databasesFinished = true;
8760
8924
  }
8761
8925
  function applyAdminRecord(draft, key, value) {
8762
8926
  if (key === ADMIN_USER_KEYS.email && value !== null) admin(draft).email = value;
@@ -8765,7 +8929,8 @@ function applyAdminRecord(draft, key, value) {
8765
8929
  else if (key === ADMIN_USER_KEYS.lastName && value !== null && value.length > 0) admin(draft)["last-name"] = value;
8766
8930
  }
8767
8931
  function applyGitRecord(draft, key, value) {
8768
- if (key === GIT_SYNC_KEYS.location && value !== null && isLocation(value)) gitSync(draft).location = value;
8932
+ if (key === GIT_SYNC_KEYS.remote && value === "skip") draft.answers["git-sync"] = { location: SKIP_GIT_SYNC };
8933
+ else if (key === GIT_SYNC_KEYS.location && value !== null && isLocation(value)) gitSync(draft).location = value;
8769
8934
  else if (key === GIT_SYNC_KEYS.path && value !== null) gitSync(draft).path = value;
8770
8935
  else if (key === GIT_SYNC_KEYS.remote && value !== null && isRemote(value)) gitSync(draft).remote = value;
8771
8936
  else if (key === GIT_SYNC_KEYS.url && value !== null) gitSync(draft).url = value;
@@ -8793,30 +8958,26 @@ function writableUri(uri, index) {
8793
8958
  const trimmed = uri.trim();
8794
8959
  return URI_PASSWORDS.some((pattern) => pattern.test(trimmed)) ? envReference(databaseEnv(index, "uri")) : trimmed;
8795
8960
  }
8796
- function engineOf(database) {
8797
- if (database.engine !== null) return database.engine;
8798
- const [only, ...others] = database.uri === null ? [] : detectEngine(database.uri);
8799
- return only !== void 0 && others.length === 0 ? only : null;
8800
- }
8801
- function databaseEntry(database, index) {
8802
- const hasFields = Object.keys(database.fields).length > 0;
8803
- const engine = engineOf(database);
8804
- if (!database.added || database.uri === null && !hasFields || engine === null) return null;
8961
+ function databaseEntry(added, index) {
8962
+ if (added.uri !== null) return {
8963
+ engine: added.engine,
8964
+ uri: writableUri(added.uri, index),
8965
+ name: added.name
8966
+ };
8967
+ const fields = added.fields.map(({ name, value, secret }) => [name, secret ? envReference(databaseEnv(index, name)) : value]);
8805
8968
  return {
8806
- engine,
8807
- ...database.uri !== null && { uri: writableUri(database.uri, index) },
8808
- ...database.uri === null && { fields: database.fields },
8809
- ...database.name !== null && { name: database.name }
8969
+ engine: added.engine,
8970
+ fields: Object.fromEntries(fields),
8971
+ name: added.name
8810
8972
  };
8811
8973
  }
8812
- function answersFromRecords(records, overrides) {
8974
+ function answersFromRecords(records, overrides, added) {
8813
8975
  const draft = {
8814
8976
  answers: {},
8815
- databases: [],
8816
8977
  databasesFinished: false
8817
8978
  };
8818
8979
  for (const record of records) applyRecord(draft, record);
8819
- const databases = draft.databases.map(databaseEntry).filter((entry) => entry !== null);
8980
+ const databases = added.map(databaseEntry);
8820
8981
  if (databases.length > 0 || draft.databasesFinished) draft.answers.databases = databases;
8821
8982
  return {
8822
8983
  ...draft.answers,
@@ -9063,9 +9224,10 @@ const init = defineRdeCommand({
9063
9224
  prompts: recording?.prompts ?? answering
9064
9225
  });
9065
9226
  ctx.ui.intro("rde init");
9066
- await runWizard(initSteps(stepOptionsFrom(answers, { interactive: stdinIsTty() && !runOptions.yes })), ctx, options);
9227
+ const results = await runWizard(initSteps(stepOptionsFrom(answers, { interactive: stdinIsTty() && !runOptions.yes })), ctx, options);
9067
9228
  if (recording !== null && printTo !== void 0) {
9068
- await writeAnswersFile(printTo, answersFromRecords(recording.records, answers));
9229
+ const added = results.flatMap((result) => result.addedDatabases);
9230
+ await writeAnswersFile(printTo, answersFromRecords(recording.records, answers, added));
9069
9231
  ctx.ui.info(`Answers written to ${printTo}`);
9070
9232
  }
9071
9233
  ctx.ui.outro("rde init finished");
@@ -9771,7 +9933,6 @@ const RELEASE_TAG = /^v?[01]\.(\d+\.\d+(?:\.\d+)?)/;
9771
9933
  function hostDeps(ctx) {
9772
9934
  return {
9773
9935
  runtime: hostRuntimeDeps(ctx.state.name, ctx.signal),
9774
- ports: hostPortProbe,
9775
9936
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
9776
9937
  timing: DEFAULT_HEALTH_TIMING,
9777
9938
  releases: releaseLookup(ctx.signal),
@@ -10033,7 +10194,7 @@ const upgradeCommand = defineRdeCommand({
10033
10194
  const main = defineCommand({
10034
10195
  meta: {
10035
10196
  name: "rde",
10036
- version: "0.1.8",
10197
+ version: "0.1.9",
10037
10198
  description: "Prepares a local machine for data-engineering work with Metabase and coding agents"
10038
10199
  },
10039
10200
  subCommands: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metabase/rde",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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": {