@metabase/cli 0.5.8-alpha.bryan-rci-28-lint-stage.198256c → 0.5.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.
@@ -3,7 +3,7 @@ import { t as defineCommandGroup } from "./group-DxaqX3Gg.mjs";
3
3
  var ci_default = defineCommandGroup({
4
4
  name: "ci",
5
5
  description: "Check a branch's transforms the way CI does",
6
- subCommands: { run: () => import("./run-DGG0ZqPA.mjs").then((mod) => mod.default) }
6
+ subCommands: { run: () => import("./run-Ch9KAm3W.mjs").then((mod) => mod.default) }
7
7
  });
8
8
  //#endregion
9
9
  export { ci_default as default };
package/dist/cli.mjs CHANGED
@@ -527,7 +527,7 @@ function trustSystemCa() {
527
527
  //#endregion
528
528
  //#region package.json
529
529
  var name = "@metabase/cli";
530
- var version = "0.5.8-alpha.bryan-rci-28-lint-stage.198256c";
530
+ var version = "0.5.9";
531
531
  var description = "Metabase CLI";
532
532
  //#endregion
533
533
  //#region src/runtime/command-augment.ts
@@ -557,7 +557,7 @@ const main = defineCommand({
557
557
  dashboard: () => import("./dashboard-CZTlBLWV.mjs").then((mod) => mod.default),
558
558
  subscription: () => import("./subscription-DfvU-S6P.mjs").then((mod) => mod.default),
559
559
  "data-action": () => import("./data-action-C4wpuM5b.mjs").then((mod) => mod.default),
560
- "data-app": () => import("./data-app-D8UNc8dc.mjs").then((mod) => mod.default),
560
+ "data-app": () => import("./data-app-DLKxuzzt.mjs").then((mod) => mod.default),
561
561
  "api-key": () => import("./api-key-fIfek7nB.mjs").then((mod) => mod.default),
562
562
  alert: () => import("./alert-DzgnuHeD.mjs").then((mod) => mod.default),
563
563
  collection: () => import("./collection-D_GyzwaY.mjs").then((mod) => mod.default),
@@ -568,7 +568,7 @@ const main = defineCommand({
568
568
  "transform-job": () => import("./transform-job-BTM0t8YZ.mjs").then((mod) => mod.default),
569
569
  "transform-tag": () => import("./transform-tag-DcJRURqM.mjs").then((mod) => mod.default),
570
570
  "transform-test": () => import("./transform-test-CSRDJsx3.mjs").then((mod) => mod.default),
571
- ci: () => import("./ci-diZ50wlb.mjs").then((mod) => mod.default),
571
+ ci: () => import("./ci-jYpJfw-b.mjs").then((mod) => mod.default),
572
572
  setting: () => import("./setting-h61KIVKH.mjs").then((mod) => mod.default),
573
573
  search: () => import("./search-oxkqbIhJ.mjs").then((mod) => mod.default),
574
574
  "git-sync": () => import("./git-sync-OiTQkETw.mjs").then((mod) => mod.default),
@@ -976,6 +976,7 @@ const FEATURE_RULES = {
976
976
  },
977
977
  transforms: { since: 59 },
978
978
  transformJobActivation: { since: 61 },
979
+ builtInTransformJobsStartInactive: { since: 65 },
979
980
  measures: { since: 59 },
980
981
  collectionItemMeasures: { since: 61 },
981
982
  explorations: { since: 65 },
@@ -9,7 +9,7 @@ var create_default = defineMetabaseCommand({
9
9
  name: "create",
10
10
  description: "Create a data app and its collection from JSON"
11
11
  },
12
- details: "The JSON body needs `name`, the slug. Optional: `display_name` (the slug by default), `description`, `allowed_hosts`, `enabled` (true by default), `bundle_path` and `version`. The app starts with an empty bundle; `mb data-app set-bundle` replaces it. The answer's `resource_collection_id` is the app's collection, where its questions and actions go. `mb data-app scaffold` copies the project template.",
12
+ details: "The JSON body needs `name`, the slug. Optional: `display_name` (the slug by default), `description`, `allowed_hosts`, `enabled` (true by default), `bundle_path` and `version`. The app starts with an empty bundle; a git-sync import replaces it with the `dist/index.js` committed in `data_apps/<slug>/`. The answer's `resource_collection_id` is the app's collection, where its questions and actions go. `mb data-app scaffold` copies the project template.",
13
13
  skills: [{
14
14
  skill: "data-app",
15
15
  purpose: "how an app is created, built and published"
@@ -26,13 +26,12 @@ var data_app_default = defineCommandGroup({
26
26
  }
27
27
  ],
28
28
  subCommands: {
29
- scaffold: () => import("./scaffold-UalCpCG0.mjs").then((mod) => mod.default),
29
+ scaffold: () => import("./scaffold-Pr1BC2uw.mjs").then((mod) => mod.default),
30
30
  list: () => import("./list-CSFgvwH_.mjs").then((mod) => mod.default),
31
31
  get: () => import("./get-DDco3nzW.mjs").then((mod) => mod.default),
32
- create: () => import("./create-BSIevt1L.mjs").then((mod) => mod.default),
33
- update: () => import("./update-C3AnMkS7.mjs").then((mod) => mod.default),
34
- "typed-schema": () => import("./typed-schema-MNW93duy.mjs").then((mod) => mod.default),
35
- "set-bundle": () => import("./set-bundle-CJed1vO8.mjs").then((mod) => mod.default),
32
+ create: () => import("./create-W6plJIxv.mjs").then((mod) => mod.default),
33
+ update: () => import("./update-D32XqpkV.mjs").then((mod) => mod.default),
34
+ "typed-schema": () => import("./typed-schema-DTL_8ggU.mjs").then((mod) => mod.default),
36
35
  "set-enabled": () => import("./set-enabled-Q7t_Jjev.mjs").then((mod) => mod.default),
37
36
  delete: () => import("./delete-z-gK8iJm.mjs").then((mod) => mod.default)
38
37
  }
@@ -4,17 +4,8 @@ import { z } from "zod";
4
4
  import { cp, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
6
  //#region src/runtime/data-app-project.ts
7
- const DEFAULT_BUNDLE_PATH = "build/index.js";
8
7
  const PackageManifest = z.object({}).loose();
9
8
  const SHIPPED_GITIGNORE = "gitignore";
10
- async function readBundle(path) {
11
- try {
12
- return await readFile(path, "utf8");
13
- } catch (error) {
14
- if (isFileNotFoundError(error)) throw new ConfigError(`bundle not found: ${path} — build the app first (npm run build)`);
15
- throw error;
16
- }
17
- }
18
9
  async function writeProjectFile(path, text) {
19
10
  await mkdir(dirname(path), { recursive: true });
20
11
  await writeFile(path, text, "utf8");
@@ -55,4 +46,4 @@ async function scaffoldDataAppProject({ dir, templateDir }) {
55
46
  await renamePackage(dir, packageName);
56
47
  }
57
48
  //#endregion
58
- export { writeProjectFile as i, readBundle as n, scaffoldDataAppProject as r, DEFAULT_BUNDLE_PATH as t };
49
+ export { writeProjectFile as n, scaffoldDataAppProject as t };
@@ -1,15 +1,13 @@
1
- import { c as InternalError, f as ResponseShapeError, g as errorMessage, h as ValidationError, i as AbortError, o as ConfigError, p as TimeoutError, r as isPlainObject, s as FailedResultError, u as NetworkError } from "./predicates-0C0TsWcJ.mjs";
1
+ import { c as InternalError, f as ResponseShapeError, h as ValidationError, i as AbortError, o as ConfigError, p as TimeoutError, r as isPlainObject, s as FailedResultError, u as NetworkError } from "./predicates-0C0TsWcJ.mjs";
2
2
  import { R as CapabilityError, Y as HttpError, Z as isHttpNotFound, a as exitCodeFor, c as lineWriter, ct as connectionFlags, lt as formatFlags, m as serializeJson, mt as profileFlag, s as jsonLineWriter, t as interruptSignal, vt as parseInteger } from "./cli.mjs";
3
3
  import { i as NotAuthenticatedError, t as defineMetabaseCommand } from "./runtime-fJ-A3m-R.mjs";
4
4
  import { i as TransformTestRunResult, o as isTransformTestCode } from "./transform-test-D6QIyu8C.mjs";
5
5
  import { a as runProcess, n as ProcessNotFoundError, o as runProcessBinary, r as ProcessTimeoutError, t as ProcessInterruptedError } from "./process-ByKCTa6A.mjs";
6
6
  import { t as parseMetabaseYaml } from "./yaml-Ctv1l2-P.mjs";
7
7
  import { z } from "zod";
8
- import { mkdtemp, rename, rm, writeFile } from "node:fs/promises";
8
+ import { rename, rm, writeFile } from "node:fs/promises";
9
9
  import { basename, dirname, join } from "node:path";
10
- import { tmpdir } from "node:os";
11
10
  import { randomUUID } from "node:crypto";
12
- import { lint } from "@metabase/representations";
13
11
  import { isDeepStrictEqual } from "node:util";
14
12
  import { performance } from "node:perf_hooks";
15
13
  //#region src/core/ci-coverage.ts
@@ -140,163 +138,6 @@ function coverageStage(assessment) {
140
138
  }
141
139
  };
142
140
  }
143
- //#endregion
144
- //#region src/core/ci-lint.ts
145
- const LINT_ERROR_RULES = [
146
- "schema",
147
- "duplicate-entity-id",
148
- "dangling-transform-id",
149
- "wrong-type-transform-id",
150
- "transform-cycle",
151
- "missing-input",
152
- "unused-input",
153
- "untestable-transform",
154
- "duplicate-input",
155
- "expectation-reads-other-table",
156
- "row-columns-mismatch",
157
- "duplicate-expectation-name",
158
- "transform-collection",
159
- "missing-incremental-table-tag",
160
- "missing-target-schema"
161
- ];
162
- const LINT_WARNING_RULES = [
163
- "unknown-table",
164
- "duplicate-target",
165
- "reads-not-checked",
166
- "no-expectations",
167
- "unsupported-transform-engine",
168
- "untestable-engine",
169
- "upstream-column-mismatch",
170
- "input-missing-read-column",
171
- "table-qualified-column",
172
- "live-sql-input"
173
- ];
174
- const LintEntity = z.object({
175
- model: z.string().nullable(),
176
- entity_id: z.string().nullable(),
177
- name: z.string().nullable()
178
- });
179
- const LintFindingFields = z.object({
180
- file: z.string(),
181
- entity: LintEntity,
182
- message: z.string(),
183
- related: z.array(z.string())
184
- });
185
- const LintError = LintFindingFields.extend({
186
- rule: z.enum(LINT_ERROR_RULES),
187
- severity: z.literal("error")
188
- });
189
- const LintWarning = LintFindingFields.extend({
190
- rule: z.enum(LINT_WARNING_RULES),
191
- severity: z.literal("warning")
192
- });
193
- const LintResult = z.object({
194
- files: z.number().int().nonnegative(),
195
- findings: z.array(z.discriminatedUnion("severity", [LintError, LintWarning]))
196
- });
197
- const ERROR_CHECK_KIND = {
198
- schema: "yaml",
199
- "transform-cycle": "cycles",
200
- "duplicate-entity-id": "broken-refs",
201
- "dangling-transform-id": "broken-refs",
202
- "wrong-type-transform-id": "broken-refs",
203
- "missing-input": "broken-refs",
204
- "unused-input": "broken-refs",
205
- "untestable-transform": "broken-refs",
206
- "duplicate-input": "broken-refs",
207
- "expectation-reads-other-table": "broken-refs",
208
- "row-columns-mismatch": "broken-refs",
209
- "duplicate-expectation-name": "broken-refs",
210
- "transform-collection": "broken-refs",
211
- "missing-incremental-table-tag": "broken-refs",
212
- "missing-target-schema": "broken-refs"
213
- };
214
- const LINT_RUN_CHECK = {
215
- kind: "lint",
216
- id: "lint",
217
- label: "Representation lint"
218
- };
219
- function failedLabel(finding) {
220
- if (finding.entity.name === null) return `${finding.file}: ${finding.rule}`;
221
- return `${finding.entity.name}: ${finding.rule}`;
222
- }
223
- function failedCheck(finding, occurrence) {
224
- return {
225
- check: {
226
- kind: ERROR_CHECK_KIND[finding.rule],
227
- id: `${finding.rule}:${finding.file}:${occurrence}`,
228
- label: failedLabel(finding)
229
- },
230
- verdict: "failed",
231
- warnings: [],
232
- ms: 0,
233
- detail: finding
234
- };
235
- }
236
- function lintWarning(finding) {
237
- return {
238
- code: finding.rule,
239
- message: `${finding.file}: ${finding.message}`
240
- };
241
- }
242
- function warningsCheck(files, findings) {
243
- return {
244
- check: LINT_RUN_CHECK,
245
- verdict: "passed",
246
- warnings: findings.map(lintWarning),
247
- ms: 0,
248
- detail: {
249
- files,
250
- findings
251
- }
252
- };
253
- }
254
- function crashedCheck(message) {
255
- return {
256
- check: LINT_RUN_CHECK,
257
- verdict: "error",
258
- warnings: [],
259
- ms: 0,
260
- detail: { message }
261
- };
262
- }
263
- function lintChecks(result) {
264
- const failed = [];
265
- const warnings = [];
266
- const occurrences = /* @__PURE__ */ new Map();
267
- for (const finding of result.findings) if (finding.severity === "error") {
268
- const key = `${finding.rule}:${finding.file}`;
269
- const previous = occurrences.get(key);
270
- const occurrence = previous === void 0 ? 1 : previous + 1;
271
- occurrences.set(key, occurrence);
272
- failed.push(failedCheck(finding, occurrence));
273
- } else warnings.push(finding);
274
- return [...failed, warningsCheck(result.files, warnings)];
275
- }
276
- async function runLint(lintTree) {
277
- let output;
278
- try {
279
- output = await lintTree();
280
- } catch (error) {
281
- if (error instanceof AbortError) throw error;
282
- return [crashedCheck(`the linter threw: ${errorMessage(error)}`)];
283
- }
284
- const parsed = LintResult.safeParse(output);
285
- if (!parsed.success) return [crashedCheck(`the linter answered an unreadable result: ${z.prettifyError(parsed.error)}`)];
286
- return lintChecks(parsed.data);
287
- }
288
- function lintStage(coverage, lintTree) {
289
- const coverageChecks = coverageStage(coverage);
290
- return {
291
- name: "lint",
292
- run: async (report) => {
293
- const stopped = await coverageChecks.run(report);
294
- if (stopped !== null) return stopped;
295
- for (const check of await runLint(lintTree)) report(check);
296
- return null;
297
- }
298
- };
299
- }
300
141
  const StageName = z.enum(["lint", "unit"]);
301
142
  const Verdict = z.enum([
302
143
  "passed",
@@ -328,7 +169,6 @@ const Snapshot = z.object({
328
169
  const Check = z.object({
329
170
  kind: z.enum([
330
171
  "coverage",
331
- "lint",
332
172
  "yaml",
333
173
  "cycles",
334
174
  "broken-refs",
@@ -456,34 +296,21 @@ const VERDICT_MARK = {
456
296
  error: "!",
457
297
  refused: "!"
458
298
  };
459
- const NO_FAILED_CHECKS = /* @__PURE__ */ new Set();
460
- const FIX_ORDER = [
461
- "transform",
462
- "tests",
463
- "lint"
464
- ];
465
- const FIX_TEXT = {
466
- transform: "fix the transform",
467
- tests: "add or restore transform tests",
468
- lint: "fix what the linter flagged"
299
+ const NO_FAILED_CHECKS = "none";
300
+ const FAILED_TEXT = {
301
+ none: "FAILED fix the transform",
302
+ transform: "FAILED fix the transform",
303
+ tests: "FAILED add or restore transform tests",
304
+ both: "FAILED fix the transform and add the missing tests"
469
305
  };
470
- function fixFor(kind) {
471
- switch (kind) {
472
- case "coverage": return "tests";
473
- case "transform-test": return "transform";
474
- case "lint":
475
- case "yaml":
476
- case "cycles":
477
- case "broken-refs": return "lint";
478
- default: throw new InternalError(`check of no known kind: ${JSON.stringify(kind)}`);
479
- }
480
- }
481
306
  const ERRORED_TEXT = "ERRORED fix the test or the setup";
482
307
  const CheckMessage = z.object({ message: z.string() });
483
308
  const HttpFailureMessage = CheckMessage.extend({ status: z.number().int() });
484
309
  function failedChecksAfter(failed, event) {
485
310
  if (event.type !== "check.result" || event.verdict !== "failed") return failed;
486
- return /* @__PURE__ */ new Set([...failed, fixFor(event.check.kind)]);
311
+ const kind = event.check.kind === "coverage" ? "tests" : "transform";
312
+ if (failed === "none" || failed === kind) return kind;
313
+ return "both";
487
314
  }
488
315
  function ciEventLines(event, maxRows, failed) {
489
316
  switch (event.type) {
@@ -508,9 +335,7 @@ function shortSha(sha) {
508
335
  }
509
336
  function jobEndText(state, failed) {
510
337
  if (state === "passed") return "PASSED";
511
- if (state !== "failed") return ERRORED_TEXT;
512
- const fixes = FIX_ORDER.filter((fix) => failed.has(fix));
513
- return `FAILED ${(fixes.length === 0 ? [FIX_TEXT.transform] : fixes.map((fix) => FIX_TEXT[fix])).join(" and ")}`;
338
+ return state === "failed" ? FAILED_TEXT[failed] : ERRORED_TEXT;
514
339
  }
515
340
  function jobExitCode(state) {
516
341
  return state === "passed" ? PASSED_EXIT_CODE : exitCodeFor("failed");
@@ -594,208 +419,6 @@ function cellText(value) {
594
419
  return value === null ? "null" : String(value);
595
420
  }
596
421
  //#endregion
597
- //#region src/runtime/git.ts
598
- const GIT = "git";
599
- const TAR = "tar";
600
- const NO_MATCH_EXIT_CODE = 1;
601
- const NO_MERGE_BASE_EXIT_CODE = 1;
602
- const UNKNOWN_REF_EXIT_CODE = 1;
603
- const GIT_TIMEOUT_MS = 12e4;
604
- async function withGit(repo, args, spawn) {
605
- try {
606
- return await spawn();
607
- } catch (error) {
608
- if (error instanceof ProcessNotFoundError) throw new ConfigError("git is not installed or not on PATH");
609
- if (error instanceof ProcessInterruptedError) throw new AbortError(`interrupted while git ${args.join(" ")} ran in ${repo}`);
610
- if (error instanceof ProcessTimeoutError) throw new ConfigError(`git ${args.join(" ")} did not finish within ${GIT_TIMEOUT_MS / 1e3} s in ${repo}`);
611
- throw error;
612
- }
613
- }
614
- function runGit(repo, args, signal) {
615
- return withGit(repo, args, () => runProcess(GIT, [
616
- "-C",
617
- repo,
618
- ...args
619
- ], {
620
- signal,
621
- timeoutMs: GIT_TIMEOUT_MS
622
- }));
623
- }
624
- async function git(repo, args, signal) {
625
- const result = await runGit(repo, args, signal);
626
- if (result.exitCode !== 0) throw new ConfigError(`git ${args.join(" ")} failed in ${repo}: ${result.stderr.trim()}`);
627
- return result.stdout;
628
- }
629
- async function repositoryRoot(repo, signal) {
630
- const result = await runGit(repo, ["rev-parse", "--show-toplevel"], signal);
631
- if (result.exitCode !== 0) throw new ConfigError(`--repo ${repo} is not inside a git work tree: ${result.stderr.trim()}`);
632
- return result.stdout.trim();
633
- }
634
- async function resolveCommit(repo, ref, flag, signal) {
635
- const result = await runGit(repo, [
636
- "rev-parse",
637
- "--verify",
638
- "--quiet",
639
- "--end-of-options",
640
- `${ref}^{commit}`
641
- ], signal);
642
- if (result.exitCode === UNKNOWN_REF_EXIT_CODE) throw new ConfigError(`${flag} ${ref} is not a commit in ${repo}`);
643
- if (result.exitCode !== 0) throw new ConfigError(`git rev-parse failed in ${repo}: ${result.stderr.trim()}`);
644
- return result.stdout.trim();
645
- }
646
- async function checkedOutCommit(repo, signal) {
647
- const result = await runGit(repo, [
648
- "rev-parse",
649
- "--verify",
650
- "--quiet",
651
- "HEAD"
652
- ], signal);
653
- if (result.exitCode === UNKNOWN_REF_EXIT_CODE) return null;
654
- if (result.exitCode !== 0) throw new ConfigError(`git rev-parse HEAD failed in ${repo}: ${result.stderr.trim()}`);
655
- return result.stdout.trim();
656
- }
657
- async function mergeBase(repo, base, head, signal) {
658
- const result = await runGit(repo, [
659
- "merge-base",
660
- base,
661
- head
662
- ], signal);
663
- if (result.exitCode === NO_MERGE_BASE_EXIT_CODE) throw new ConfigError(`--base and --head share no history in ${repo}: the clone may be shallow (fetch the full history, e.g. actions/checkout with fetch-depth: 0), or the histories are unrelated`);
664
- if (result.exitCode !== 0) throw new ConfigError(`git merge-base failed in ${repo}: ${result.stderr.trim()}`);
665
- return result.stdout.trim();
666
- }
667
- function diffNameStatus(repo, from, to, signal) {
668
- return git(repo, [
669
- "diff",
670
- "--name-status",
671
- "-z",
672
- "-M",
673
- from,
674
- to
675
- ], signal);
676
- }
677
- function statusPorcelain(repo, signal) {
678
- return git(repo, [
679
- "status",
680
- "--porcelain",
681
- "-z",
682
- "--untracked-files=all"
683
- ], signal);
684
- }
685
- async function fileModesAt(repo, commit, signal) {
686
- const output = await git(repo, [
687
- "ls-tree",
688
- "-r",
689
- "-z",
690
- "--full-tree",
691
- commit
692
- ], signal);
693
- const modes = /* @__PURE__ */ new Map();
694
- for (const record of output.split("\0")) {
695
- if (record === "") continue;
696
- const space = record.indexOf(" ");
697
- const tab = record.indexOf(" ");
698
- if (space === -1 || tab < space) throw new InternalError(`git ls-tree -z: malformed entry "${record}"`);
699
- modes.set(record.slice(tab + 1), record.slice(0, space));
700
- }
701
- return modes;
702
- }
703
- async function filesMatching(repo, commit, pattern, pathspecs, signal) {
704
- const result = await runGit(repo, [
705
- "grep",
706
- "--no-color",
707
- "-z",
708
- "-l",
709
- "-E",
710
- "-e",
711
- pattern,
712
- commit,
713
- "--",
714
- ...pathspecs
715
- ], signal);
716
- if (result.exitCode === NO_MATCH_EXIT_CODE) return [];
717
- if (result.exitCode !== 0) throw new ConfigError(`git grep failed in ${repo}: ${result.stderr.trim()}`);
718
- const prefix = `${commit}:`;
719
- return result.stdout.split("\0").filter((entry) => entry !== "").map((entry) => {
720
- if (!entry.startsWith(prefix)) throw new InternalError(`git grep printed "${entry}", which does not start with ${prefix}`);
721
- return entry.slice(prefix.length);
722
- });
723
- }
724
- const NEWLINE = 10;
725
- const utf8 = new TextDecoder("utf-8");
726
- async function readFilesAt(repo, commit, paths, signal) {
727
- const files = /* @__PURE__ */ new Map();
728
- if (paths.length === 0) return files;
729
- const multiline = paths.find((path) => path.includes("\n"));
730
- if (multiline !== void 0) throw new ConfigError(`cannot read ${JSON.stringify(multiline)}: a path with a newline cannot be batched`);
731
- const args = ["cat-file", "--batch"];
732
- const result = await withGit(repo, args, () => runProcessBinary(GIT, [
733
- "-C",
734
- repo,
735
- ...args
736
- ], {
737
- stdin: paths.map((path) => `${commit}:${path}\n`).join(""),
738
- signal,
739
- timeoutMs: GIT_TIMEOUT_MS
740
- }));
741
- if (result.exitCode !== 0) throw new ConfigError(`git cat-file --batch failed in ${repo}: ${result.stderr.trim()}`);
742
- const output = result.stdout;
743
- let offset = 0;
744
- for (const path of paths) {
745
- const headerEnd = output.indexOf(NEWLINE, offset);
746
- if (headerEnd === -1) throw new InternalError(`git cat-file --batch stopped before ${path}`);
747
- const header = utf8.decode(output.subarray(offset, headerEnd)).split(" ");
748
- const size = Number(header[2]);
749
- if (header[1] !== "blob" || !Number.isInteger(size)) throw new InternalError(`${path} is not a file at ${commit}, though git listed it there`);
750
- const start = headerEnd + 1;
751
- if (output[start + size] !== NEWLINE) throw new InternalError(`git cat-file --batch framed ${path} with ${size} bytes, but no newline follows them`);
752
- files.set(path, utf8.decode(output.subarray(start, start + size)));
753
- offset = start + size + 1;
754
- }
755
- if (offset !== output.length) throw new InternalError("git cat-file --batch printed more than the paths it was asked for");
756
- return files;
757
- }
758
- async function withCommittedTree(repo, commit, signal, use) {
759
- const root = await repositoryRoot(repo, signal);
760
- const args = [
761
- "archive",
762
- "--format=tar",
763
- commit
764
- ];
765
- const archive = await withGit(root, args, () => runProcessBinary(GIT, [
766
- "-C",
767
- root,
768
- ...args
769
- ], {
770
- signal,
771
- timeoutMs: GIT_TIMEOUT_MS
772
- }));
773
- if (archive.exitCode !== 0) throw new ConfigError(`git archive ${commit} failed in ${root}: ${archive.stderr.trim()}`);
774
- const folder = await mkdtemp(join(tmpdir(), "mb-ci-tree-"));
775
- try {
776
- const extract = await runProcess(TAR, [
777
- "-x",
778
- "-f",
779
- "-",
780
- "-C",
781
- folder
782
- ], {
783
- stdin: archive.stdout,
784
- signal
785
- }).catch((error) => {
786
- if (error instanceof ProcessInterruptedError) throw new AbortError(`interrupted while tar extracted ${commit}`);
787
- throw error;
788
- });
789
- if (extract.exitCode !== 0) throw new ConfigError(`tar could not extract ${commit}: ${extract.stderr.trim()}`);
790
- return await use(folder);
791
- } finally {
792
- await rm(folder, {
793
- recursive: true,
794
- force: true
795
- });
796
- }
797
- }
798
- //#endregion
799
422
  //#region src/core/ci-affected.ts
800
423
  const SINGLE_PATH_STATUSES = /* @__PURE__ */ new Map([
801
424
  ["A", "added"],
@@ -1034,6 +657,167 @@ function unreadContentWarning(reasons) {
1034
657
  };
1035
658
  }
1036
659
  //#endregion
660
+ //#region src/runtime/git.ts
661
+ const GIT = "git";
662
+ const NO_MATCH_EXIT_CODE = 1;
663
+ const NO_MERGE_BASE_EXIT_CODE = 1;
664
+ const UNKNOWN_REF_EXIT_CODE = 1;
665
+ const GIT_TIMEOUT_MS = 12e4;
666
+ async function withGit(repo, args, spawn) {
667
+ try {
668
+ return await spawn();
669
+ } catch (error) {
670
+ if (error instanceof ProcessNotFoundError) throw new ConfigError("git is not installed or not on PATH");
671
+ if (error instanceof ProcessInterruptedError) throw new AbortError(`interrupted while git ${args.join(" ")} ran in ${repo}`);
672
+ if (error instanceof ProcessTimeoutError) throw new ConfigError(`git ${args.join(" ")} did not finish within ${GIT_TIMEOUT_MS / 1e3} s in ${repo}`);
673
+ throw error;
674
+ }
675
+ }
676
+ function runGit(repo, args, signal) {
677
+ return withGit(repo, args, () => runProcess(GIT, [
678
+ "-C",
679
+ repo,
680
+ ...args
681
+ ], {
682
+ signal,
683
+ timeoutMs: GIT_TIMEOUT_MS
684
+ }));
685
+ }
686
+ async function git(repo, args, signal) {
687
+ const result = await runGit(repo, args, signal);
688
+ if (result.exitCode !== 0) throw new ConfigError(`git ${args.join(" ")} failed in ${repo}: ${result.stderr.trim()}`);
689
+ return result.stdout;
690
+ }
691
+ async function repositoryRoot(repo, signal) {
692
+ const result = await runGit(repo, ["rev-parse", "--show-toplevel"], signal);
693
+ if (result.exitCode !== 0) throw new ConfigError(`--repo ${repo} is not inside a git work tree: ${result.stderr.trim()}`);
694
+ return result.stdout.trim();
695
+ }
696
+ async function resolveCommit(repo, ref, flag, signal) {
697
+ const result = await runGit(repo, [
698
+ "rev-parse",
699
+ "--verify",
700
+ "--quiet",
701
+ "--end-of-options",
702
+ `${ref}^{commit}`
703
+ ], signal);
704
+ if (result.exitCode === UNKNOWN_REF_EXIT_CODE) throw new ConfigError(`${flag} ${ref} is not a commit in ${repo}`);
705
+ if (result.exitCode !== 0) throw new ConfigError(`git rev-parse failed in ${repo}: ${result.stderr.trim()}`);
706
+ return result.stdout.trim();
707
+ }
708
+ async function checkedOutCommit(repo, signal) {
709
+ const result = await runGit(repo, [
710
+ "rev-parse",
711
+ "--verify",
712
+ "--quiet",
713
+ "HEAD"
714
+ ], signal);
715
+ if (result.exitCode === UNKNOWN_REF_EXIT_CODE) return null;
716
+ if (result.exitCode !== 0) throw new ConfigError(`git rev-parse HEAD failed in ${repo}: ${result.stderr.trim()}`);
717
+ return result.stdout.trim();
718
+ }
719
+ async function mergeBase(repo, base, head, signal) {
720
+ const result = await runGit(repo, [
721
+ "merge-base",
722
+ base,
723
+ head
724
+ ], signal);
725
+ if (result.exitCode === NO_MERGE_BASE_EXIT_CODE) throw new ConfigError(`--base and --head share no history in ${repo}: the clone may be shallow (fetch the full history, e.g. actions/checkout with fetch-depth: 0), or the histories are unrelated`);
726
+ if (result.exitCode !== 0) throw new ConfigError(`git merge-base failed in ${repo}: ${result.stderr.trim()}`);
727
+ return result.stdout.trim();
728
+ }
729
+ function diffNameStatus(repo, from, to, signal) {
730
+ return git(repo, [
731
+ "diff",
732
+ "--name-status",
733
+ "-z",
734
+ "-M",
735
+ from,
736
+ to
737
+ ], signal);
738
+ }
739
+ function statusPorcelain(repo, signal) {
740
+ return git(repo, [
741
+ "status",
742
+ "--porcelain",
743
+ "-z",
744
+ "--untracked-files=all"
745
+ ], signal);
746
+ }
747
+ async function fileModesAt(repo, commit, signal) {
748
+ const output = await git(repo, [
749
+ "ls-tree",
750
+ "-r",
751
+ "-z",
752
+ "--full-tree",
753
+ commit
754
+ ], signal);
755
+ const modes = /* @__PURE__ */ new Map();
756
+ for (const record of output.split("\0")) {
757
+ if (record === "") continue;
758
+ const space = record.indexOf(" ");
759
+ const tab = record.indexOf(" ");
760
+ if (space === -1 || tab < space) throw new InternalError(`git ls-tree -z: malformed entry "${record}"`);
761
+ modes.set(record.slice(tab + 1), record.slice(0, space));
762
+ }
763
+ return modes;
764
+ }
765
+ async function filesMatching(repo, commit, pattern, pathspecs, signal) {
766
+ const result = await runGit(repo, [
767
+ "grep",
768
+ "--no-color",
769
+ "-z",
770
+ "-l",
771
+ "-E",
772
+ "-e",
773
+ pattern,
774
+ commit,
775
+ "--",
776
+ ...pathspecs
777
+ ], signal);
778
+ if (result.exitCode === NO_MATCH_EXIT_CODE) return [];
779
+ if (result.exitCode !== 0) throw new ConfigError(`git grep failed in ${repo}: ${result.stderr.trim()}`);
780
+ const prefix = `${commit}:`;
781
+ return result.stdout.split("\0").filter((entry) => entry !== "").map((entry) => {
782
+ if (!entry.startsWith(prefix)) throw new InternalError(`git grep printed "${entry}", which does not start with ${prefix}`);
783
+ return entry.slice(prefix.length);
784
+ });
785
+ }
786
+ const NEWLINE = 10;
787
+ const utf8 = new TextDecoder("utf-8");
788
+ async function readFilesAt(repo, commit, paths, signal) {
789
+ const files = /* @__PURE__ */ new Map();
790
+ if (paths.length === 0) return files;
791
+ const multiline = paths.find((path) => path.includes("\n"));
792
+ if (multiline !== void 0) throw new ConfigError(`cannot read ${JSON.stringify(multiline)}: a path with a newline cannot be batched`);
793
+ const args = ["cat-file", "--batch"];
794
+ const result = await withGit(repo, args, () => runProcessBinary(GIT, [
795
+ "-C",
796
+ repo,
797
+ ...args
798
+ ], {
799
+ stdin: paths.map((path) => `${commit}:${path}\n`).join(""),
800
+ signal,
801
+ timeoutMs: GIT_TIMEOUT_MS
802
+ }));
803
+ if (result.exitCode !== 0) throw new ConfigError(`git cat-file --batch failed in ${repo}: ${result.stderr.trim()}`);
804
+ const output = result.stdout;
805
+ let offset = 0;
806
+ for (const path of paths) {
807
+ const headerEnd = output.indexOf(NEWLINE, offset);
808
+ if (headerEnd === -1) throw new InternalError(`git cat-file --batch stopped before ${path}`);
809
+ const header = utf8.decode(output.subarray(offset, headerEnd)).split(" ");
810
+ const size = Number(header[2]);
811
+ if (header[1] !== "blob" || !Number.isInteger(size)) throw new InternalError(`${path} is not a file at ${commit}, though git listed it there`);
812
+ const start = headerEnd + 1;
813
+ if (output[start + size] !== NEWLINE) throw new InternalError(`git cat-file --batch framed ${path} with ${size} bytes, but no newline follows them`);
814
+ files.set(path, utf8.decode(output.subarray(start, start + size)));
815
+ offset = start + size + 1;
816
+ }
817
+ if (offset !== output.length) throw new InternalError("git cat-file --batch printed more than the paths it was asked for");
818
+ return files;
819
+ }
820
+ //#endregion
1037
821
  //#region src/commands/ci/job-plan.ts
1038
822
  const CONTENT_PATHSPECS = CONTENT_TOP_LEVEL_FOLDERS.map((folder) => `${folder}/*.yaml`);
1039
823
  const FILE_MODES = /* @__PURE__ */ new Set(["100644", "100755"]);
@@ -1512,7 +1296,7 @@ var run_default = defineMetabaseCommand({
1512
1296
  description: "Check the transforms a branch changed, reporting each check as it finishes"
1513
1297
  },
1514
1298
  details: [
1515
- "Compares --head with its merge base with --base and finds the transforms whose files or tests changed. Two stages check them: `lint` fails each transform the branch adds or whose logic (source or target) it edits that has no transform test, fails each transform the branch leaves at head without the last of its transform tests, and warns about untested transforms whose logic the branch did not change. It then runs the representation linter of `@metabase/representations` over every file committed at --head: each error finding fails a check of its own, with the finding as `detail`, and the warnings share one passing `lint` check, whose `detail` holds `files` and `findings`. A linter that cannot run, such as one missing a database's default schema (declare it in `.metabase-lint.json`), errors that check. A transform whose source type is not `query`, such as a Python transform, cannot have transform tests, so `lint` does not fail it: an `untestable-transform` warning lists each such transform the branch adds, edits or strips of its tests. A change to the transform's query text, whitespace included, is an edit; a change to the YAML layout alone is not. A transform test is a TransformTest file at --head whose `transform_id` names the transform and whose `expectations` list is not empty; a test without expectations checks nothing, so it does not count. `unit` runs the instance's tests of the transforms in `job.start.checked`, on the Metabase instance that --url, MB_URL or --profile names, in that order, which must already hold the branch's content; it is absent when `checked` is empty, so a job with nothing to test never connects to Metabase, and it runs even when `lint` fails, so one job reports both stages' findings. A checked transform the branch tests but the instance lists no test for is `refused` with `detail.error_code` `ci.no-tests-on-instance`. Content files are the `.yaml` files Metabase's import reads: not dotfiles, under a top-level serialization folder such as `transforms/` or `collections/`. Only committed content is checked: when --head is the checked-out commit, a content file with uncommitted changes is listed in an `uncommitted-content` warning, and its committed version is checked.",
1299
+ "Compares --head with its merge base with --base and finds the transforms whose files or tests changed. Two stages check them: `lint` fails each transform the branch adds or whose logic (source or target) it edits that has no transform test, fails each transform the branch leaves at head without the last of its transform tests, and warns about untested transforms whose logic the branch did not change. A transform whose source type is not `query`, such as a Python transform, cannot have transform tests, so `lint` does not fail it: an `untestable-transform` warning lists each such transform the branch adds, edits or strips of its tests. A change to the transform's query text, whitespace included, is an edit; a change to the YAML layout alone is not. A transform test is a TransformTest file at --head whose `transform_id` names the transform and whose `expectations` list is not empty; a test without expectations checks nothing, so it does not count. `unit` runs the instance's tests of the transforms in `job.start.checked`, on the Metabase instance that --url, MB_URL or --profile names, in that order, which must already hold the branch's content; it is absent when `checked` is empty, so a job with nothing to test never connects to Metabase, and it runs even when `lint` fails, so one job reports both stages' findings. A checked transform the branch tests but the instance lists no test for is `refused` with `detail.error_code` `ci.no-tests-on-instance`. Content files are the `.yaml` files Metabase's import reads: not dotfiles, under a top-level serialization folder such as `transforms/` or `collections/`. Only committed content is checked: when --head is the checked-out commit, a content file with uncommitted changes is listed in an `uncommitted-content` warning, and its committed version is checked.",
1516
1300
  "With --format json, the default when stdout is not a terminal, writes one JSON event per line (`job.start`, then each stage and check, then `job.end`); `job.start.checked` lists the transforms the job covered, and a `no-content-changes` warning says when the change touches no content file, so no transform test ran; an `unread-content` warning lists the content files at --head that the change did not touch and this check cannot read, which coverage skips; each transform test's result is passed through as the check's `detail`, with every row the server sent. With --format text, the default on a terminal, writes the same events as lines for a person: each failed expectation with up to --max-rows lines of its missing and extra rows, keyed by the first column, then a count of the rest. The --results file is JSON whatever the format, and holds every row the server sent.",
1517
1301
  "Exits 0 when the job passes. Exits 1 after `job.end` when it does not: `state` is `failed` when a check failed (fix the transform), and `errored` when no check failed but one could not judge the change (fix the test or the setup); a failure outranks errors, since it is a finding about the change. Exits 2 on a usage or config error: a bad flag, a --repo, --base or --head that git cannot resolve or relate, or, found after `job.start` when the unit stage connects, a configuration it cannot use, such as a browser-login profile bound to another URL; a missing credential is not one, since it ends the unit stage instead.",
1518
1302
  "A test run that times out, loses its connection or gets an HTTP error is an `error` check whose `detail.error_code` starts with `ci.`, and the next test runs. A failure every later request would repeat ends the unit stage early, `errored` unless a check failed, with the reason in `stage.end.stopped`: no Metabase credential is configured (`ci.no-credential`, before any request), Metabase rejects the credential, the server has no transform testing, the entity-id lookup fails, or Metabase cannot be reached before a test runs. Exits 1 on a crash, such as a content file the change touched that does not parse at --head, and 130 on an interrupt: the stream stops before `job.end`, the error is on stderr, and no --results file is left, not even an earlier run's. When the reader closes stdout early, the job still runs to the end, writes --results and exits by its state."
@@ -1564,10 +1348,10 @@ var run_default = defineMetabaseCommand({
1564
1348
  base: args.base,
1565
1349
  head: args.head,
1566
1350
  stages: async (plan) => {
1567
- const lint$1 = lintStage(plan.coverage, () => withCommittedTree(args.repo, plan.snapshot.head, interruptSignal, (folder) => lint({ folder })));
1568
- if (plan.checked.length === 0) return [lint$1];
1351
+ const lint = coverageStage(plan.coverage);
1352
+ if (plan.checked.length === 0) return [lint];
1569
1353
  const connect = async () => clientPort(await getClient());
1570
- return [lint$1, unitStage(plan.checked, untestedTransforms(plan.coverage), connect)];
1354
+ return [lint, unitStage(plan.checked, untestedTransforms(plan.coverage), connect)];
1571
1355
  },
1572
1356
  job: randomUUID(),
1573
1357
  now: () => /* @__PURE__ */ new Date(),
@@ -1,7 +1,7 @@
1
1
  import { f as renderSummary, pt as outputFlags } from "./cli.mjs";
2
2
  import { t as defineMetabaseCommand } from "./runtime-fJ-A3m-R.mjs";
3
3
  import { n as findSkillByName, r as loadAllSkills } from "./skills-Dt95bt39.mjs";
4
- import { r as scaffoldDataAppProject } from "./data-app-project-CiBHndNR.mjs";
4
+ import { t as scaffoldDataAppProject } from "./data-app-project-tbEA14ZN.mjs";
5
5
  import { z } from "zod";
6
6
  import { join } from "node:path";
7
7
  //#region src/commands/data-app/scaffold.ts
@@ -1,7 +1,7 @@
1
1
  import { ct as connectionFlags, g as writeText, mt as profileFlag } from "./cli.mjs";
2
2
  import { t as defineMetabaseCommand } from "./runtime-fJ-A3m-R.mjs";
3
3
  import { n as slugArg, t as parseSlug } from "./slug-COdwq63O.mjs";
4
- import { i as writeProjectFile } from "./data-app-project-CiBHndNR.mjs";
4
+ import { n as writeProjectFile } from "./data-app-project-tbEA14ZN.mjs";
5
5
  import { z } from "zod";
6
6
  //#region src/commands/data-app/typed-schema.ts
7
7
  const TypedSchemaModule = z.string().describe("The generated TypeScript module of the app's queries and actions.");
@@ -10,7 +10,7 @@ var update_default = defineMetabaseCommand({
10
10
  name: "update",
11
11
  description: "Update a data app's fields, or enable or disable it"
12
12
  },
13
- details: "The JSON body carries only the fields to change: `display_name`, `description`, `allowed_hosts`, `enabled`, `bundle_path`, `version`. A disabled app opens only in Data Studio (Data apps → the app → Overview). `mb data-app set-bundle` replaces the bundle from a built file.",
13
+ details: "The JSON body carries only the fields to change: `display_name`, `description`, `allowed_hosts`, `enabled`, `bundle_path`, `version`. A disabled app opens only in Data Studio (Data apps → the app → Overview).",
14
14
  requires: ["dataApp.update"],
15
15
  args: {
16
16
  ...outputFlags,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metabase/cli",
3
- "version": "0.5.8-alpha.bryan-rci-28-lint-stage.198256c",
3
+ "version": "0.5.9",
4
4
  "description": "Metabase CLI",
5
5
  "license": "AGPL-3.0",
6
6
  "repository": {
@@ -25,11 +25,10 @@
25
25
  "build": "tsdown",
26
26
  "dev": "tsdown --watch",
27
27
  "start": "node dist/cli.mjs",
28
- "prepublishOnly": "tsdown && publint && ! grep -rE \"(from|import|require)[[:space:]]*\\(?['\\\"]@metabase/client\" dist/"
28
+ "prepublishOnly": "tsdown && publint && ! grep -rE \"(from|import|require)[[:space:]]*\\(?['\\\"]@metabase\" dist/"
29
29
  },
30
30
  "dependencies": {
31
31
  "@clack/prompts": "^0.8.2",
32
- "@metabase/representations": "1.6.1-alpha.br-galdre-reference-lint.sha-f1106aa",
33
32
  "@napi-rs/keyring": "^1.3.0",
34
33
  "ajv": "^8.17.1",
35
34
  "ajv-formats": "^3.0.1",
@@ -12,17 +12,20 @@ A **data app** is a React app built into one JavaScript bundle, which Metabase r
12
12
  The work, in order:
13
13
 
14
14
  1. Create the app on the instance.
15
- 2. Scaffold the project and install the SDK.
15
+ 2. Scaffold the project in the synced git repository and install the SDK.
16
16
  3. Give it data: questions over the Library's published tables and metrics in the app's collection, then `src/metabase.ts` (`data-app-data`); writes are `data-app-actions`.
17
17
  4. Start the dev preview (_Dev preview_), then write the UI under `src/` (`data-app-ui`; filter controls in `data-app-filters`).
18
- 5. Build and publish (_Build and publish_).
18
+ 5. Build, test, and push the source and the bundle together (_Build and publish_).
19
19
 
20
- For every later change: first pass the checks in _An existing project_ (_Scaffold and install_), then regenerate `src/metabase.ts` if the app's collection changed, then build and set the bundle; in a synced repository, then export and commit the source as in _Move to another instance_. Metabase receives only the built bundle; the source stays in the project directory, which is the app's folder in the synced git repository when there is one (_Scaffold and install_).
20
+ For every later change: first pass the checks in _An existing project_ (_Scaffold and install_), then regenerate `src/metabase.ts` if the app's collection changed, then build and publish (_Build and publish_). The bundle reaches Metabase only through git: a git-sync import loads the `dist/index.js` committed in the app's folder.
21
21
 
22
22
  ## Before you start
23
23
 
24
24
  - Use an **admin** profile — only admins create apps and write the app's collection. `mb auth status --profile <name> --json | jq .user.isAdmin` prints `true` for one, and every command below takes that `--profile`; otherwise ask the user for an admin profile (`mb auth list`).
25
25
  - Run `mb data-app list`. A failure means the instance has no data apps: stop and tell the user.
26
+ - The app ships through the instance's synced git repository. Check both ends first; if either fails, stop and tell the user which, changing nothing:
27
+ - `mb git-sync status --profile <name> --json` reports `remote_configured: true` and `sync_type: "read-write"`.
28
+ - The working folder is a clone of it: `git rev-parse --show-toplevel` succeeds, and `git remote -v` lists the repository `mb setting get remote-sync-url --profile <name>` names, in HTTPS or SSH form. That top level is `<repo>` below.
26
29
  - Settle the **slug**, the `/apps/<slug>` URL: lowercase letters, digits and single dashes (`sales-overview`), at most 100 characters. If the app's purpose is unclear, ask "what's this app for?" and propose one.
27
30
 
28
31
  ## Create the app
@@ -39,14 +42,14 @@ mb data-app create --body '{"name":"sales-overview","display_name":"Sales overvi
39
42
  mb data-app scaffold <repo>/data_apps/sales-overview && cd <repo>/data_apps/sales-overview
40
43
  ```
41
44
 
42
- `scaffold [dir]` takes a **directory**, not the slug: it copies the Vite + React + TypeScript template into a new or empty directory (the current one when omitted) and needs no server. When the instance syncs with a git repository (`mb git-sync status`), the directory is `data_apps/<slug>/` in a local clone of that repository (ask the user for its path, or clone the remote), where an export writes the app's `data_app.yaml` and bundle: an export leaves the source alone and an import ignores it, so the repository keeps the source with the app. `scaffold` needs an empty directory, so scaffold before the app's first export; for an app already in the repository, scaffold into a new directory and copy its files, dotfiles included (`cp -R <dir>/. data_apps/<slug>/`), in beside `data_app.yaml` and `dist/`; check `.gitignore` is there before `npm install`. Without a repository, use any new directory, and once remote sync is set up, move the project (without `node_modules/`, `build/`, `dist/`, `.env.local` and a hand-written `data_app.yaml`) into `data_apps/<slug>/` before committing its source. Then install the SDK, then the rest:
45
+ `scaffold [dir]` takes a **directory**, not the slug: it copies the Vite + React + TypeScript template into a new or empty directory (the current one when omitted) and needs no server. The directory is `data_apps/<slug>/` in `<repo>`, where an export writes the app's `data_app.yaml` and bundle: an export leaves the source alone and an import ignores it, so the repository keeps the source with the app. `scaffold` needs an empty directory, so scaffold before the app's first export; for an app already in the repository, scaffold into a new directory and copy its files, dotfiles included (`cp -R <dir>/. data_apps/<slug>/`), in beside `data_app.yaml` and `dist/`; check `.gitignore` is there before `npm install`. Then install the SDK, then the rest:
43
46
 
44
47
  ```bash
45
48
  npm install @metabase/embedding-sdk-react@<major>-beta # <major>: 66 for a version tag v1.66.x (`mb auth status --profile <name> --json | jq -r .version.tag`)
46
- npm install && npm run typecheck && npm run build
49
+ npm install && npm run typecheck
47
50
  ```
48
51
 
49
- Skip the SDK line when `package.json` already points it at a local build (`file:` or `link:`), and never install `latest`. A tag without a version number, such as `vLOCAL_DEV`, is a development build: ask the user which SDK build to install. The starter renders "Hello, data app".
52
+ Skip the SDK line when `package.json` already points it at a local build (`file:` or `link:`), and never install `latest`. A tag without a version number, such as `vLOCAL_DEV`, is a development build: ask the user which SDK build to install. The starter renders "Hello, data app". Build only in _Build and publish_, after the export: both write `dist/index.js`.
50
53
 
51
54
  The template is the bundle contract. Don't edit `package.json` by hand, and never rewrite `vite.config.ts`, `tsconfig.json` or `src/index.tsx`: `vite.config.ts` stays `export default dataAppConfig()` (its only option is `port`), and `src/index.tsx` default-exports the `DataAppFactory` — change it only to add a `providerProps` key. Metabase and the dev preview each wrap the app in a `<MetabaseProvider>`, so the app never renders one.
52
55
 
@@ -56,11 +59,11 @@ The template is the bundle contract. Don't edit `package.json` by hand, and neve
56
59
  - `src/index.tsx` default-exports a `DataAppFactory` that takes no arguments and returns `{ component, providerProps? }`.
57
60
  - `npm ls react react-dom` exits 0 and reports 19.x. A 19.x it marks `invalid` fails. This check applies whatever the instance or SDK version; to skip it, ask the user first.
58
61
 
59
- Fix a failed React check yourself, before any other change: upgrade `react`, `react-dom`, `@types/react` and `@types/react-dom` to 19 until `npm run typecheck && npm run build` passes and the diagnostics feed is clean (_Dev preview_); then publish (_Build and publish_). If either other check fails, stop: tell the user the project differs from the template, and ask whether to scaffold a fresh project and port the code over, or go on at their own risk.
62
+ Fix a failed React check yourself, before any other change: upgrade `react`, `react-dom`, `@types/react` and `@types/react-dom` to 19 until `npm run typecheck` passes and the diagnostics feed is clean (_Dev preview_); then publish (_Build and publish_). If either other check fails, stop: tell the user the project differs from the template, and ask whether to scaffold a fresh project and port the code over, or go on at their own risk.
60
63
 
61
64
  ## Dev preview
62
65
 
63
- `npm run dev` serves the app at `http://localhost:5174` — or the next free port, in Vite's `Local:` line — through the same sandbox Metabase uses. An edit under `src/` rebuilds the bundle and remounts the app, resetting component state. The preview reads `DATA_APP_MB_URL` and an admin `DATA_APP_MB_API_KEY` from `.env.local` beside `package.json`. Write a new key straight into that file, never into the chat or the terminal:
66
+ `npm run dev` serves the app at `http://localhost:5174` — or the next free port, in Vite's `Local:` line — through the same sandbox Metabase uses. An edit under `src/` rebuilds the bundle and remounts the app, resetting component state. The preview reads `DATA_APP_MB_URL` and an admin `DATA_APP_MB_API_KEY` from `.env.local` beside `package.json`. Create that key only on the user's yes: ask with `AskUserQuestion` ("Create an admin API key `data-app <slug> dev` for the preview? It goes in `.env.local` and is deleted at handoff."). On a no, the user fills in `.env.local` from `.env.local.example`. Write a new key straight into that file, never into the chat or the terminal:
64
67
 
65
68
  ```bash
66
69
  ( umask 077
@@ -102,7 +105,7 @@ curl -s "http://localhost:5174/__data-app/diagnostics?startEventId=0"
102
105
  - `clients: 0` means no tab is open, so empty `entries` prove nothing.
103
106
  - Before an edit, note `nextEventId`; after it, read again with that value as `startEventId`.
104
107
  - The feed answers for the running bundle: entries from replaced builds are counted in `staleEntries`, and anything still broken reports again under the new `buildId` (`?includeStale=true` shows the withheld ones). A failed build doesn't advance `buildId` and doesn't reach the feed — read the `npm run dev` terminal.
105
- - Quote `summary` as it is when you report it, and follow `hint` when there is one — except that you never add the Metabase URL to `allowed_hosts`, and in a synced repository you set `allowed_hosts` with `mb data-app update`, export, and pull the export branch (_Move to another instance_) instead of editing `data_app.yaml`. A `frame-src` or `form-action` block can't be lifted in the preview (`data-app-ui`). The feed keeps the last 200 entries; `curl -X DELETE …/__data-app/diagnostics` clears it.
108
+ - Quote `summary` as it is when you report it, and follow `hint` when there is one — except that you never add the Metabase URL to `allowed_hosts`, and you set `allowed_hosts` with `mb data-app update`, then export and pull (_Build and publish_, step 1), instead of editing `data_app.yaml`. A `frame-src` or `form-action` block can't be lifted in the preview (`data-app-ui`). The feed keeps the last 200 entries; `curl -X DELETE …/__data-app/diagnostics` clears it.
106
109
 
107
110
  | `kind` | Fix |
108
111
  | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
@@ -113,12 +116,15 @@ curl -s "http://localhost:5174/__data-app/diagnostics?startEventId=0"
113
116
 
114
117
  ## Build and publish
115
118
 
116
- ```bash
117
- npm run typecheck && npm run build
118
- mb data-app set-bundle sales-overview
119
- ```
119
+ Build the bundle last, test it in the dev preview, then push it with the source in one commit:
120
+
121
+ 1. Export the app's `data_app.yaml` and the questions and actions in its collection: `mb git-sync export` (`git-sync`), then in `<repo>` `git fetch`, `git switch <branch>`, `git pull --ff-only`.
122
+ 2. `npm run typecheck && npm run build`, which overwrites the exported `dist/index.js`.
123
+ 3. With `npm run dev` running and a preview tab open, read the diagnostics feed (_Dev preview_) with every page mounted; fix what it reports and build again.
124
+ 4. Commit `data_apps/<slug>/` — the source and `dist/index.js` together — and push.
125
+ 5. `mb git-sync import --profile <name>`, whatever a cached `has-remote-changes` says. The new bundle is live at once.
120
126
 
121
- `set-bundle` uploads `build/index.js`, the file `npm run build` writes (`--bundle` for another path), and replaces the app's bundle, live at once. A new app is on from the start; `mb data-app set-enabled <slug> false` turns it off, which only the user decides. An app turned off opens only in Data Studio → Data apps → the app → Overview; `/apps/<slug>` says "not found", for admins too. Give the user `<metabase-url>/apps/<slug>`.
127
+ A new app is on from the start; `mb data-app set-enabled <slug> false` turns it off, which only the user decides. An app turned off opens only in Data Studio → Data apps → the app → Overview; `/apps/<slug>` says "not found", for admins too. Give the user `<metabase-url>/apps/<slug>`.
122
128
 
123
129
  A viewer needs one of their groups assigned to the app (otherwise "You don't have access"), which only the user can do (Data Studio → Data apps → the app → Permissions). A group reaches the app's collection and nothing more, so a viewer also needs data access to the tables its questions read.
124
130
 
@@ -126,7 +132,7 @@ A viewer needs one of their groups assigned to the app (otherwise "You don't hav
126
132
 
127
133
  | Symptom | Fix |
128
134
  | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
129
- | `set-bundle`: bundle not found | Run `npm run build` in the project directory first. |
135
+ | The app still runs the old bundle | `dist/index.js` was built before the export's pull, or isn't in the pushed commit: build, commit, push, import. |
130
136
  | Blank app, `Bundle did not assign a function to __dataAppFactory__` | `src/index.tsx` must `export default` the factory and nothing else; otherwise the diagnostics feed has the real error. |
131
137
  | "Failed to fetch the user, the session might be invalid." | A bad key in `.env.local`, or CORS (_Dev preview_). |
132
138
  | A bundle of several MB | `vite.config.ts` no longer uses `dataAppConfig()`, or the SDK isn't the `<major>-beta` tag. |
@@ -139,5 +145,5 @@ A viewer needs one of their groups assigned to the app (otherwise "You don't hav
139
145
 
140
146
  - **Turn off:** `mb data-app set-enabled <slug> false`. **Rename:** `mb data-app update <slug> --body '{"display_name":"…"}'` (also `description`, `allowed_hosts`).
141
147
  - **Outdated:** an app Data Studio lists as _Outdated_ targets an older data-app contract: it won't open for anyone, and only admins see it, until it's migrated. Tell the user; don't change its code ad hoc.
142
- - **Move to another instance:** the app, its collection and what the collection holds are git-synced content. `mb git-sync export` (`git-sync`) writes `data_apps/<slug>/` with the bundle, and the collection under `collections/data_apps/`; importing the repository on the other instance recreates them with the same entity IDs, so the same `src/metabase.ts` works there. The first import there creates the app on, like `create`, open to admins until the user assigns groups; later imports keep its on/off state. The import doesn't check that the questions' tables are published on that instance, so the Library content they read goes in the same export: flag the Library root once (`mb git-sync add-collection <library-id>`, `git-sync`), or its metrics, measures and segments stay out of the repository; a data action needs its database's toggle on there too. Once the app is done, commit its source in `data_apps/<slug>/` on the branch you exported to, after the export. Pull the export branch: `git fetch`, `git switch <branch>`, `git pull --ff-only`. Then `git add data_apps/<slug>`, commit and push, and `mb git-sync import` whatever a cached `has-remote-changes` says, or the next export ends in `conflict`. The export writes `data_app.yaml` and `dist/index.js`, the bundle you uploaded; never edit those two, and leave them out of your commits unless the bundle ships through git (below). The template's `.gitignore` keeps `node_modules/`, `build/` and `.env.local` out. A bundle can instead ship through git, from CI say: `npm run build:ci` writes `dist/index.js`, and once that is committed and pushed, the next import loads it. On a read-only synced instance only `set-enabled` works: creating, bundling, updating and deleting an app answer 403, and so does editing its collection; change it where it was built and export again.
143
- - **Delete:** confirm with the user, then `mb data-app delete <slug> --yes` — it removes the app, its bundle, its collection and the questions and actions in it. The project directory stays: in a synced repository, the next export removes the app's `data_app.yaml`, bundle and collection files; after that export, switch and pull the same way, delete the source in `data_apps/<slug>/` in a commit of your own, push, and import.
148
+ - **Move to another instance:** the app, its collection and what the collection holds are git-synced content. `mb git-sync export` (`git-sync`) writes `data_apps/<slug>/` with the bundle, and the collection under `collections/data_apps/`; importing the repository on the other instance recreates them with the same entity IDs, so the same `src/metabase.ts` works there. The first import there creates the app on, like `create`, open to admins until the user assigns groups; later imports keep its on/off state. The import doesn't check that the questions' tables are published on that instance, so the Library content they read goes in the same export: flag the Library root once (`mb git-sync add-collection <library-id>`, `git-sync`), or its metrics, measures and segments stay out of the repository; a data action needs its database's toggle on there too. Never edit `data_app.yaml`; `dist/index.js` changes only through `npm run build` (_Build and publish_). The template's `.gitignore` keeps `node_modules/` and `.env.local` out. On a read-only synced instance only `set-enabled` works: creating, updating and deleting an app answer 403, and so does editing its collection; change it where it was built, push, and import there.
149
+ - **Delete:** confirm with the user, then `mb data-app delete <slug> --yes` — it removes the app, its bundle, its collection and the questions and actions in it. The project directory stays: the next export removes the app's `data_app.yaml`, bundle and collection files; after that export, switch and pull the same way, delete the source in `data_apps/<slug>/` in a commit of your own, push, and import.
@@ -20,14 +20,13 @@ npm install
20
20
  ```bash
21
21
  npm run dev # preview at http://localhost:5174, in the sandbox Metabase runs apps in
22
22
  npm run typecheck # type-checks src/ and vite.config.ts
23
- npm run build # builds build/index.js
23
+ npm run build # builds dist/index.js, the app's bundle
24
24
  mb data-app typed-schema <slug> --out src/metabase.ts # the app's queries and actions
25
- mb data-app set-bundle <slug> # makes build/index.js the app's bundle
26
25
  ```
27
26
 
28
- To ship the bundle through git instead, for example from CI, run
29
- `npm run build:ci` and commit `dist/index.js`, the file the app's
30
- `data_app.yaml` names; the next git-sync import loads it.
27
+ The bundle ships through git: commit `dist/index.js` (the file
28
+ `data_app.yaml` names) with the source and push; the next git-sync import
29
+ loads it.
31
30
 
32
31
  `npm run dev` reads `DATA_APP_MB_URL` and `DATA_APP_MB_API_KEY` from
33
32
  `.env.local`; `.env.local.example` lists them.
@@ -1,5 +1,4 @@
1
1
  node_modules
2
- build
3
2
  .env.local
4
3
  .vite
5
4
  .idea
@@ -5,8 +5,7 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
8
- "build": "vite build",
9
- "build:ci": "vite build --outDir dist",
8
+ "build": "vite build --outDir dist",
10
9
  "typecheck": "tsc --noEmit"
11
10
  },
12
11
  "dependencies": {
@@ -38,7 +38,7 @@ Everything here runs inside the sandbox, the same in the dev preview as in Metab
38
38
  | `navigator.geolocation` and other device APIs | Nothing. |
39
39
  | Key, input, composition and clipboard listeners (`keydown`, `paste`, `input`, …) on `window` or `document` | A React handler (`onKeyDown`, `onPaste`, …) on your own element. |
40
40
 
41
- **External APIs.** A raw `fetch` reaches only the origins in the app's `allowed_hosts`, exact or with a `*.` subdomain wildcard: `mb data-app update <slug> --body '{"allowed_hosts":["https://api.example.com"]}'`. The preview reads the `allowed_hosts:` list in `data_app.yaml` beside `package.json`: in a synced repository that file is the app's exported manifest, so export again and pull the export branch (`data-app`, _Move to another instance_) to refresh it rather than writing it; without one, write the list there yourself. Then restart `npm run dev`. Never list the Metabase URL. In Metabase the same list governs `<iframe src>` and native `<form action>` submits, and the target must allow framing; the preview blocks both. Use `<form onSubmit>` with `preventDefault`: a native submit navigates the app away.
41
+ **External APIs.** A raw `fetch` reaches only the origins in the app's `allowed_hosts`, exact or with a `*.` subdomain wildcard: `mb data-app update <slug> --body '{"allowed_hosts":["https://api.example.com"]}'`. The preview reads the `allowed_hosts:` list in `data_app.yaml` beside `package.json`, the app's exported manifest: refresh it by exporting and pulling the export branch (`data-app`, _Build and publish_, step 1), never by writing it. Then restart `npm run dev`. Never list the Metabase URL. In Metabase the same list governs `<iframe src>` and native `<form action>` submits, and the target must allow framing; the preview blocks both. Use `<form onSubmit>` with `preventDefault`: a native submit navigates the app away.
42
42
 
43
43
  ## Layout
44
44
 
@@ -271,6 +271,6 @@ mb transform-job set-active false --profile <name> --json # disable every job a
271
271
 
272
272
  <!-- /requires -->
273
273
 
274
- Every job row carries `active`; it is `null` on a server that cannot switch jobs off, where every job runs on schedule.
274
+ Every job row carries `active`; it is `null` on a server that cannot switch jobs off, where every job runs on schedule. A job with `active: false` never runs on schedule, and a fresh install of a newer server starts the built-in jobs that no transform uses that way: before relying on one, check `active`, and turn it on only with the user's yes (`transform-job update <id> --body '{"active":true}'`, admin only).
275
275
 
276
276
  `transform-job run` is fire-and-forget — it returns `{message, started, run_id}` immediately (both `null` when the server does not number job runs: the request was accepted and whether a run started is unsaid), with no per-job-run polling (no `--wait`). Most ad-hoc agent work is one-off `transform run`, not job authoring.
@@ -1,40 +0,0 @@
1
- import { ct as connectionFlags, f as renderSummary, mt as profileFlag, pt as outputFlags } from "./cli.mjs";
2
- import { t as defineMetabaseCommand } from "./runtime-fJ-A3m-R.mjs";
3
- import { t as DataApp } from "./data-app-BUW-HlUo.mjs";
4
- import { t as dataAppView } from "./data-app-Blz61mG5.mjs";
5
- import { n as slugArg, t as parseSlug } from "./slug-COdwq63O.mjs";
6
- import { n as readBundle, t as DEFAULT_BUNDLE_PATH } from "./data-app-project-CiBHndNR.mjs";
7
- //#region src/commands/data-app/set-bundle.ts
8
- var set_bundle_default = defineMetabaseCommand({
9
- meta: {
10
- name: "set-bundle",
11
- description: "Replace a data app's bundle with a built one"
12
- },
13
- details: "Reads the built bundle (`npm run build` writes `build/index.js`) and makes it the bundle Metabase serves at /apps/<slug>, at once. A new app is enabled; viewers reach it once a group is assigned. A disabled app opens only in Data Studio (Data apps → the app → Overview).",
14
- skills: [{
15
- skill: "data-app",
16
- purpose: "build and publish an app"
17
- }],
18
- requires: ["dataApp.replaceBundle"],
19
- args: {
20
- ...outputFlags,
21
- ...profileFlag,
22
- ...connectionFlags,
23
- ...slugArg,
24
- bundle: {
25
- type: "string",
26
- description: "Built bundle file",
27
- default: DEFAULT_BUNDLE_PATH
28
- }
29
- },
30
- outputSchema: DataApp,
31
- examples: ["mb data-app set-bundle sales-overview", "mb data-app set-bundle sales-overview --bundle out/index.js"],
32
- async run({ args, ctx, getClient }) {
33
- const slug = parseSlug(args.slug);
34
- const bundle = await readBundle(args.bundle);
35
- const app = await (await getClient()).dataApp.replaceBundle(slug, bundle);
36
- renderSummary(app, dataAppView, `Set the bundle of data app ${slug}; it is ${app.enabled ? `served at /apps/${slug}` : "disabled, so it opens only in Data Studio"}.`, ctx);
37
- }
38
- });
39
- //#endregion
40
- export { set_bundle_default as default };