@malloydata/malloyyo 0.2.29 → 0.2.31

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.
package/README.md CHANGED
@@ -74,6 +74,21 @@ malloyyo publish main --dry-run # show what would be sent
74
74
  malloyyo status main # what's live: version, commit, compile state
75
75
  ```
76
76
 
77
+ The target dataset must already exist; publishing to a missing one fails rather than
78
+ inventing it (a config typo would otherwise spawn junk datasets). To provision it from the
79
+ CLI instead of the UI, opt in explicitly:
80
+
81
+ ```bash
82
+ malloyyo publish main --create-dataset # create the dataset if it isn't there yet
83
+ ```
84
+
85
+ The dataset is created **only after the model compiles**, so a rejected publish still
86
+ creates nothing, and it is created **private** — visibility is a deliberate act in the UI,
87
+ and publishing never changes it. On an existing dataset the flag does nothing: you just get
88
+ the next version. The dataset name comes from the target's `dataset` in the config, and must
89
+ already be a valid name (lowercase letters, digits, underscores) — the CLI won't silently
90
+ create it under a slugified variant that later publishes wouldn't find.
91
+
77
92
  `publish` exits non-zero on a server-side compile failure, so it's safe to gate CI on.
78
93
 
79
94
  **Token precedence:** `--token` flag → the `malloyyo_token` env var from config (for CI) →
package/dist/index.js CHANGED
@@ -2608,6 +2608,36 @@ function printLintReport(report) {
2608
2608
  }
2609
2609
  }
2610
2610
 
2611
+ // src/shared/env-refs.ts
2612
+ function missingEnvRefs(configJson, env = process.env) {
2613
+ if (!configJson) return [];
2614
+ let parsed;
2615
+ try {
2616
+ parsed = JSON.parse(configJson);
2617
+ } catch {
2618
+ return [];
2619
+ }
2620
+ const missing = /* @__PURE__ */ new Set();
2621
+ const walk = (node) => {
2622
+ if (Array.isArray(node)) return void node.forEach(walk);
2623
+ if (typeof node !== "object" || node === null) return;
2624
+ const rec = node;
2625
+ if (typeof rec.env === "string" && !env[rec.env]) missing.add(rec.env);
2626
+ for (const v of Object.values(rec)) walk(v);
2627
+ };
2628
+ walk(parsed);
2629
+ return [...missing];
2630
+ }
2631
+ function missingEnvHint(missing, where) {
2632
+ if (missing.length === 0) return "";
2633
+ const vars = missing.map((v) => `$${v}`).join(", ");
2634
+ const isAre = missing.length > 1 ? "are" : "is";
2635
+ return `
2636
+ malloy-config.json references ${vars}, which ${isAre} NOT set on ${where}.
2637
+ Malloy reads an unset reference as an empty value, which usually shows up as
2638
+ the connection error above.`;
2639
+ }
2640
+
2611
2641
  // src/oauth.ts
2612
2642
  import http from "node:http";
2613
2643
  import crypto from "node:crypto";
@@ -2790,6 +2820,11 @@ async function refresh(baseUrl, creds) {
2790
2820
  saveCreds(baseUrl, updated);
2791
2821
  return updated;
2792
2822
  }
2823
+ function tokenSource(target, opts) {
2824
+ if (opts.tokenFlag) return "flag";
2825
+ if (target.tokenEnv && process.env[target.tokenEnv]) return "env";
2826
+ return "login";
2827
+ }
2793
2828
  async function getAccessToken(target, opts) {
2794
2829
  if (opts.tokenFlag) return opts.tokenFlag;
2795
2830
  if (target.tokenEnv && process.env[target.tokenEnv]) return process.env[target.tokenEnv];
@@ -4202,15 +4237,66 @@ async function launchCmd(mode, opts) {
4202
4237
  }
4203
4238
 
4204
4239
  // package.json
4205
- var version = "0.2.29";
4240
+ var version = "0.2.31";
4206
4241
 
4207
4242
  // src/index.ts
4208
4243
  function shortSha(sha) {
4209
4244
  return sha ? sha.slice(0, 7) : "";
4210
4245
  }
4246
+ function authHint(status2, t, source) {
4247
+ const login2 = `malloyyo login ${t.name}`;
4248
+ if (status2 === 403) {
4249
+ return `
4250
+ The token is valid, but that account isn't an admin on ${t.url} \u2014
4251
+ publishing is admin-only. Ask an admin there to grant access.`;
4252
+ }
4253
+ switch (source) {
4254
+ case "flag":
4255
+ return `
4256
+ That token came from --token. Drop the flag and run: ${login2}`;
4257
+ case "env":
4258
+ return `
4259
+ That token came from $${t.tokenEnv}. Re-issue it, or unset it and run: ${login2}`;
4260
+ default:
4261
+ return `
4262
+ Your saved login for ${t.url} is expired or revoked.
4263
+ Run: ${login2}`;
4264
+ }
4265
+ }
4266
+ function failureHint(out, t) {
4267
+ switch (out.kind) {
4268
+ case "missing-import":
4269
+ return `
4270
+ A file the model imports wasn't in the upload. Publish from the directory
4271
+ that holds index.malloy, and check the import path's spelling/case.`;
4272
+ case "connection":
4273
+ if (out.missingEnv?.length) {
4274
+ const vars = out.missingEnv.map((v) => `$${v}`).join(", ");
4275
+ return `
4276
+ malloy-config.json references ${vars}, which ${out.missingEnv.length > 1 ? "are" : "is"} NOT set on ${t.url}.
4277
+ Secrets don't travel with the model \u2014 set them in that deployment's environment
4278
+ (Vercel: Settings \u2192 Environment Variables), then publish again.`;
4279
+ }
4280
+ return `
4281
+ The server couldn't open the connection the model uses. Check the
4282
+ \`connections\` block in malloy-config.json, and that ${t.url} can reach it.`;
4283
+ case "persist":
4284
+ return `
4285
+ The model itself is fine \u2014 this failed writing to the server's database.
4286
+ Retry; if it repeats, the message above is the database's own.`;
4287
+ default:
4288
+ return "";
4289
+ }
4290
+ }
4291
+ function requestFailed(what, res, out, t, source) {
4292
+ const detail = out.error ?? `${res.status} ${res.statusText}`;
4293
+ const hint = res.status === 401 || res.status === 403 ? authHint(res.status, t, source) : failureHint(out, t);
4294
+ return new Error(`${what} failed: ${detail}${hint}`);
4295
+ }
4211
4296
  async function publish(target, dir, opts) {
4212
4297
  const root = resolve2(dir);
4213
4298
  const t = resolveTarget(root, target);
4299
+ const source = tokenSource(t, { tokenFlag: opts.token });
4214
4300
  const bearer = await getAccessToken(t, { tokenFlag: opts.token });
4215
4301
  const { files, config } = gatherDirectory(root);
4216
4302
  if (files.length === 0) {
@@ -4223,27 +4309,33 @@ async function publish(target, dir, opts) {
4223
4309
  printLintReport(report);
4224
4310
  }
4225
4311
  if (!report.ok) {
4226
- throw new Error("dashboard lint failed \u2014 fix the above, or pass --skip-lint");
4312
+ throw new Error(
4313
+ "dashboard lint failed \u2014 fix the above, or pass --skip-lint" + missingEnvHint(missingEnvRefs(config), "this shell")
4314
+ );
4227
4315
  }
4228
4316
  }
4229
4317
  const git = gitInfo(root);
4230
4318
  const dashboards = await gatherDashboards(root);
4231
4319
  const body = { files, config, git, dashboards };
4232
4320
  const provenance = git.sha ? `${git.branch}@${shortSha(git.sha)}${git.dirty ? " (dirty)" : ""}` : "(no git)";
4233
- console.log(`\u2192 ${t.url} dataset=${t.dataset}`);
4321
+ console.log(`\u2192 ${t.url} dataset=${t.dataset}${opts.createDataset ? " (create if missing)" : ""}`);
4234
4322
  console.log(` ${files.length} file(s) ${provenance}`);
4235
4323
  if (opts.dryRun) {
4236
4324
  console.log("dry run \u2014 not sending");
4237
4325
  return;
4238
4326
  }
4239
- const res = await fetch(`${t.url}/api/datasets/${t.dataset}/model/push`, {
4327
+ const push = `${t.url}/api/datasets/${t.dataset}/model/push${opts.createDataset ? "?create=1" : ""}`;
4328
+ const res = await fetch(push, {
4240
4329
  method: "POST",
4241
4330
  headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` },
4242
4331
  body: JSON.stringify(body)
4243
4332
  });
4244
4333
  const out = await res.json().catch(() => ({}));
4245
4334
  if (!res.ok || !out.ok) {
4246
- throw new Error(`publish failed: ${out.error ?? `${res.status} ${res.statusText}`}`);
4335
+ throw requestFailed("publish", res, out, t, source);
4336
+ }
4337
+ if (out.created) {
4338
+ console.log(`\u2713 created dataset ${out.dataset ?? t.dataset} (private) \u2014 ${t.url}/datasets/${out.dataset ?? t.dataset}`);
4247
4339
  }
4248
4340
  console.log(
4249
4341
  `\u2713 published version ${out.version} \u2014 ${out.sources?.length ?? 0} source(s)` + (dashboards.length ? `, ${dashboards.length} dashboard(s)` : "")
@@ -4251,12 +4343,14 @@ async function publish(target, dir, opts) {
4251
4343
  }
4252
4344
  async function status(target, opts) {
4253
4345
  const t = resolveTarget(resolve2("."), target);
4346
+ const source = tokenSource(t, { tokenFlag: opts.token });
4254
4347
  const bearer = await getAccessToken(t, { tokenFlag: opts.token });
4255
4348
  const res = await fetch(`${t.url}/api/datasets/${t.dataset}/model/status`, {
4256
4349
  headers: { authorization: `Bearer ${bearer}` }
4257
4350
  });
4258
4351
  if (!res.ok) {
4259
- throw new Error(`status failed: ${res.status} ${res.statusText}`);
4352
+ const body = await res.json().catch(() => ({}));
4353
+ throw requestFailed("status", res, body, t, source);
4260
4354
  }
4261
4355
  const s = await res.json();
4262
4356
  const git = s.git;
@@ -4277,15 +4371,20 @@ var program = new Command();
4277
4371
  program.name("malloyyo").description("Publish Malloy models to a Malloyyo instance").version(version);
4278
4372
  program.command("login").argument("[target]", "target name or instance URL (optional if the config has one target)").description("sign in to an instance in your browser (stores a token)").action(loginCmd);
4279
4373
  program.command("logout").argument("[target]", "target name or instance URL (optional if the config has one target)").description("forget the stored token for an instance").action(logoutCmd);
4280
- program.command("publish").argument("<target>", "named target from the `malloyyo` config block").argument("[dir]", "directory to publish", ".").option("--token <token>", "bearer token (overrides login/env)").option("--dry-run", "gather and report what would be sent, but don't POST").option("--skip-lint", "skip the pre-publish dashboard lint").description('push the Malloy model in <dir> (default ".") to <target>').action(publish);
4374
+ program.command("publish").argument("<target>", "named target from the `malloyyo` config block").argument("[dir]", "directory to publish", ".").option("--token <token>", "bearer token (overrides login/env)").option("--dry-run", "gather and report what would be sent, but don't POST").option("--skip-lint", "skip the pre-publish dashboard lint").option("--create-dataset", "create the target dataset if it doesn't exist yet (private)").description('push the Malloy model in <dir> (default ".") to <target>').action(publish);
4281
4375
  program.command("lint").argument("[dir]", "directory to lint", ".").description("validate ./dashboards against the model (manifest, query, givens, Dashboard.tsx)").action(async (dir) => {
4282
- const report = await lintDashboards(resolve2(dir));
4376
+ const root = resolve2(dir);
4377
+ const report = await lintDashboards(root);
4283
4378
  if (report.dashboards.length === 0) {
4284
4379
  console.log("no dashboards to lint");
4285
4380
  return;
4286
4381
  }
4287
4382
  printLintReport(report);
4288
- if (!report.ok) process.exit(1);
4383
+ if (!report.ok) {
4384
+ const hint = missingEnvHint(missingEnvRefs(gatherDirectory(root).config), "this shell");
4385
+ if (hint) console.error(hint.replace(/^\n/, ""));
4386
+ process.exit(1);
4387
+ }
4289
4388
  });
4290
4389
  program.command("status").argument("<target>", "named target from the `malloyyo` config block").option("--token <token>", "bearer token (overrides login/env)").description("show what's live on <target>: version, commit, compile state").action(status);
4291
4390
  program.command("mcp").option("-C, --root <dir>", "project root (default: current directory)").option("--develop", "author surface: compile/prettify/query any .malloy in the project").option("--explore", "explore surface: the claude.ai web preview (index.malloy only) [default]").description(
@@ -0,0 +1,45 @@
1
+ // Copyright (c) The Malloy Foundation
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // malloy-config.json can hold secrets by reference: `"password": { "env": "PG_PW" }`.
5
+ // Malloy resolves an UNSET reference to empty rather than failing, so the symptom
6
+ // is a baffling connection error — "connect ECONNREFUSED", "password
7
+ // authentication failed" — that never mentions the variable. Both the CLI (this
8
+ // shell) and the push route (that deployment) check for it and say the name.
9
+
10
+ /** `{"env": "NAME"}` references in `configJson` with no value in `env`. Deduped. */
11
+ export function missingEnvRefs(
12
+ configJson: string | undefined,
13
+ env: Record<string, string | undefined> = process.env,
14
+ ): string[] {
15
+ if (!configJson) return [];
16
+ let parsed: unknown;
17
+ try {
18
+ parsed = JSON.parse(configJson);
19
+ } catch {
20
+ return []; // Malloy reports the bad JSON itself.
21
+ }
22
+ const missing = new Set<string>();
23
+ const walk = (node: unknown): void => {
24
+ if (Array.isArray(node)) return void node.forEach(walk);
25
+ if (typeof node !== "object" || node === null) return;
26
+ const rec = node as Record<string, unknown>;
27
+ if (typeof rec.env === "string" && !env[rec.env]) missing.add(rec.env);
28
+ for (const v of Object.values(rec)) walk(v);
29
+ };
30
+ walk(parsed);
31
+ return [...missing];
32
+ }
33
+
34
+ /** One-line-per-var explanation, or "" when nothing is missing. `where` names
35
+ the environment the check ran against ("this shell", a server URL). */
36
+ export function missingEnvHint(missing: string[], where: string): string {
37
+ if (missing.length === 0) return "";
38
+ const vars = missing.map((v) => `$${v}`).join(", ");
39
+ const isAre = missing.length > 1 ? "are" : "is";
40
+ return (
41
+ `\n malloy-config.json references ${vars}, which ${isAre} NOT set on ${where}.` +
42
+ `\n Malloy reads an unset reference as an empty value, which usually shows up as` +
43
+ `\n the connection error above.`
44
+ );
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@malloydata/malloyyo",
3
- "version": "0.2.29",
3
+ "version": "0.2.31",
4
4
  "description": "Publish Malloy models to a Malloyyo instance",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,11 +33,11 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@duckdb/duckdb-wasm": "1.33.1-dev45.0",
36
- "@malloydata/db-duckdb": "^0.0.425",
37
- "@malloydata/malloy": "^0.0.425",
38
- "@malloydata/malloy-connections": "^0.0.425",
39
- "@malloydata/malloy-filter": "^0.0.425",
40
- "@malloydata/render": "^0.0.425",
36
+ "@malloydata/db-duckdb": "^0.0.430",
37
+ "@malloydata/malloy": "^0.0.430",
38
+ "@malloydata/malloy-connections": "^0.0.430",
39
+ "@malloydata/malloy-filter": "^0.0.430",
40
+ "@malloydata/render": "^0.0.430",
41
41
  "@modelcontextprotocol/sdk": "^1.29.0",
42
42
  "commander": "^12.1.0",
43
43
  "esbuild": "^0.24.0",