@malloydata/malloyyo 0.2.30 → 0.2.32

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/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import { resolve as resolve2 } from "node:path";
5
+ import { resolve as resolve3 } from "node:path";
6
6
 
7
7
  // src/config.ts
8
8
  import { readFileSync, existsSync } from "node:fs";
@@ -51,11 +51,14 @@ The old shape still works for now.`
51
51
  targets
52
52
  };
53
53
  }
54
+ var TARGETS_BLOCK_EXAMPLE = ' "malloyyo": {\n "targets": { "prod": { "url": "https://<instance>", "dataset": "<dataset>" } }\n }';
54
55
  function readTargetMap(dir) {
55
56
  const raw = readMalloyyoBlock(dir);
56
57
  if (raw) return parseMalloyyoConfig(raw).targets;
57
58
  throw new Error(
58
- `No \`malloyyo\` config found in ${dir} (looked for a "malloyyo" block in malloy-config.json, then malloyyo.json).`
59
+ `No \`malloyyo\` config found in ${dir} (looked for a "malloyyo" block in malloy-config.json, then malloyyo.json).
60
+ Add one to malloy-config.json:
61
+ ${TARGETS_BLOCK_EXAMPLE}`
59
62
  );
60
63
  }
61
64
  function readMalloyyoBlock(dir) {
@@ -85,6 +88,25 @@ function readSiteConfig(dir) {
85
88
  var normalizeUrl = (u) => u.replace(/\/+$/, "");
86
89
  function resolveTarget(dir, name) {
87
90
  const targets = readTargetMap(dir);
91
+ const entries = Object.entries(targets);
92
+ if (name === void 0) {
93
+ if (entries.length === 0) {
94
+ throw new Error(
95
+ `No publish targets defined. Add one to malloy-config.json:
96
+ ${TARGETS_BLOCK_EXAMPLE}`
97
+ );
98
+ }
99
+ if (entries.length > 1) {
100
+ throw new Error(`Multiple targets \u2014 specify which: ${entries.map(([n]) => n).join(", ")}.`);
101
+ }
102
+ const [onlyName, onlyCfg] = entries[0];
103
+ return {
104
+ name: onlyName,
105
+ url: normalizeUrl(onlyCfg.url),
106
+ dataset: onlyCfg.dataset,
107
+ tokenEnv: onlyCfg.malloyyo_token?.env
108
+ };
109
+ }
88
110
  const cfg = targets[name];
89
111
  if (!cfg) {
90
112
  const available = Object.keys(targets).join(", ") || "(none defined)";
@@ -2608,6 +2630,36 @@ function printLintReport(report) {
2608
2630
  }
2609
2631
  }
2610
2632
 
2633
+ // src/shared/env-refs.ts
2634
+ function missingEnvRefs(configJson, env = process.env) {
2635
+ if (!configJson) return [];
2636
+ let parsed;
2637
+ try {
2638
+ parsed = JSON.parse(configJson);
2639
+ } catch {
2640
+ return [];
2641
+ }
2642
+ const missing = /* @__PURE__ */ new Set();
2643
+ const walk = (node) => {
2644
+ if (Array.isArray(node)) return void node.forEach(walk);
2645
+ if (typeof node !== "object" || node === null) return;
2646
+ const rec = node;
2647
+ if (typeof rec.env === "string" && !env[rec.env]) missing.add(rec.env);
2648
+ for (const v of Object.values(rec)) walk(v);
2649
+ };
2650
+ walk(parsed);
2651
+ return [...missing];
2652
+ }
2653
+ function missingEnvHint(missing, where) {
2654
+ if (missing.length === 0) return "";
2655
+ const vars = missing.map((v) => `$${v}`).join(", ");
2656
+ const isAre = missing.length > 1 ? "are" : "is";
2657
+ return `
2658
+ malloy-config.json references ${vars}, which ${isAre} NOT set on ${where}.
2659
+ Malloy reads an unset reference as an empty value, which usually shows up as
2660
+ the connection error above.`;
2661
+ }
2662
+
2611
2663
  // src/oauth.ts
2612
2664
  import http from "node:http";
2613
2665
  import crypto from "node:crypto";
@@ -2652,10 +2704,30 @@ function clearCreds(url6) {
2652
2704
  return true;
2653
2705
  }
2654
2706
 
2707
+ // package.json
2708
+ var version = "0.2.32";
2709
+
2710
+ // src/http.ts
2711
+ var USER_AGENT = `malloyyo/${version}`;
2712
+ var UPGRADE_REQUIRED = 426;
2713
+ async function upgradeRequiredMessage(res) {
2714
+ const body = await res.json().catch(() => ({}));
2715
+ const needs = body.minimum_version ? ` ${body.minimum_version} or newer` : " a newer version";
2716
+ return `This server requires${needs} of the malloyyo CLI (you have ${version}).
2717
+ Run: npm i -g @malloydata/malloyyo`;
2718
+ }
2719
+ async function apiFetch(url6, init = {}) {
2720
+ const headers = new Headers(init.headers);
2721
+ headers.set("user-agent", USER_AGENT);
2722
+ const res = await fetch(url6, { ...init, headers });
2723
+ if (res.status === UPGRADE_REQUIRED) throw new Error(await upgradeRequiredMessage(res));
2724
+ return res;
2725
+ }
2726
+
2655
2727
  // src/oauth.ts
2656
2728
  var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
2657
2729
  async function discover(baseUrl) {
2658
- const res = await fetch(`${baseUrl}/api/oauth/discovery/authorization-server`);
2730
+ const res = await apiFetch(`${baseUrl}/api/oauth/discovery/authorization-server`);
2659
2731
  if (!res.ok) throw new Error(`OAuth discovery failed at ${baseUrl}: ${res.status} ${res.statusText}`);
2660
2732
  return await res.json();
2661
2733
  }
@@ -2665,7 +2737,7 @@ function pkce() {
2665
2737
  return { verifier, challenge };
2666
2738
  }
2667
2739
  async function registerClient(registrationEndpoint, redirectUri) {
2668
- const res = await fetch(registrationEndpoint, {
2740
+ const res = await apiFetch(registrationEndpoint, {
2669
2741
  method: "POST",
2670
2742
  headers: { "content-type": "application/json" },
2671
2743
  body: JSON.stringify({
@@ -2743,7 +2815,7 @@ async function login(baseUrl) {
2743
2815
  `);
2744
2816
  openBrowser(authUrl.toString());
2745
2817
  const authCode = await code;
2746
- const res = await fetch(ep.token_endpoint, {
2818
+ const res = await apiFetch(ep.token_endpoint, {
2747
2819
  method: "POST",
2748
2820
  headers: { "content-type": "application/x-www-form-urlencoded" },
2749
2821
  body: new URLSearchParams({
@@ -2770,7 +2842,7 @@ async function login(baseUrl) {
2770
2842
  }
2771
2843
  async function refresh(baseUrl, creds) {
2772
2844
  const ep = await discover(baseUrl);
2773
- const res = await fetch(ep.token_endpoint, {
2845
+ const res = await apiFetch(ep.token_endpoint, {
2774
2846
  method: "POST",
2775
2847
  headers: { "content-type": "application/x-www-form-urlencoded" },
2776
2848
  body: new URLSearchParams({
@@ -2790,6 +2862,11 @@ async function refresh(baseUrl, creds) {
2790
2862
  saveCreds(baseUrl, updated);
2791
2863
  return updated;
2792
2864
  }
2865
+ function tokenSource(target, opts) {
2866
+ if (opts.tokenFlag) return "flag";
2867
+ if (target.tokenEnv && process.env[target.tokenEnv]) return "env";
2868
+ return "login";
2869
+ }
2793
2870
  async function getAccessToken(target, opts) {
2794
2871
  if (opts.tokenFlag) return opts.tokenFlag;
2795
2872
  if (target.tokenEnv && process.env[target.tokenEnv]) return process.env[target.tokenEnv];
@@ -3037,6 +3114,11 @@ function urlStateFromSearch(search) {
3037
3114
  return s;
3038
3115
  }
3039
3116
 
3117
+ // src/shared/html.ts
3118
+ function safeJson(value) {
3119
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
3120
+ }
3121
+
3040
3122
  // src/shared/nav.ts
3041
3123
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3042
3124
  var NAV_CSS = `
@@ -3217,7 +3299,7 @@ function makeInPageBundler() {
3217
3299
  return js;
3218
3300
  };
3219
3301
  }
3220
- var html = (body, title) => `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>${NAV_CSS}</style></head><body style="margin:0">${body}</body></html>`;
3302
+ var html = (body, title) => `<!doctype html><html><head><meta charset="utf-8"><title>${esc2(title)}</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>${NAV_CSS}</style></head><body style="margin:0">${body}</body></html>`;
3221
3303
  function navHtml2(dash, all) {
3222
3304
  return navHtml(dash.name, all, (n) => `/?d=${encodeURIComponent(n)}`);
3223
3305
  }
@@ -3234,14 +3316,14 @@ function inPageShell(dash, all, givenSpecs, initialGivens, initialUrlState, tile
3234
3316
  autorun: dash.autorun
3235
3317
  };
3236
3318
  return html(
3237
- navHtml2(dash, all) + `<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)};window.__INITIAL_URLSTATE__=${JSON.stringify(initialUrlState)}</script><script>try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}</script><script src="/inpage.js?d=${encodeURIComponent(dash.name)}"></script>`,
3319
+ navHtml2(dash, all) + `<div id="root"></div><script>window.__DASHBOARD__=${safeJson(info)};window.__GIVENS__=${safeJson(givenSpecs)};window.__INITIAL_GIVENS__=${safeJson(initialGivens)};window.__INITIAL_URLSTATE__=${safeJson(initialUrlState)}</script><script>try{new EventSource('/events').onmessage=()=>location.reload();}catch(e){}</script><script src="/inpage.js?d=${encodeURIComponent(dash.name)}"></script>`,
3238
3320
  dash.title
3239
3321
  );
3240
3322
  }
3241
3323
  function parentShell(dash, frameBase, all, initialGivens, initialUrlState) {
3242
3324
  const givensQs = Object.entries({ ...initialGivens, ...initialUrlState }).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("");
3243
- const d = JSON.stringify(dash.name);
3244
- const fb = JSON.stringify(frameBase);
3325
+ const d = safeJson(dash.name);
3326
+ const fb = safeJson(frameBase);
3245
3327
  const nav = navHtml2(dash, all);
3246
3328
  return html(
3247
3329
  `<div style="display:flex;flex-direction:column;height:100vh">` + nav + // allow-popups(+escape-sandbox): let a # link mark open its target in a
@@ -3316,7 +3398,7 @@ function frameDoc(dash, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
3316
3398
  autorun: dash.autorun
3317
3399
  };
3318
3400
  return html(
3319
- `<div id="root"></div><script>window.__DASHBOARD__=${JSON.stringify(info)};window.__GIVENS__=${JSON.stringify(givenSpecs)};window.__INITIAL_GIVENS__=${JSON.stringify(initialGivens)};window.__INITIAL_URLSTATE__=${JSON.stringify(initialUrlState)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
3401
+ `<div id="root"></div><script>window.__DASHBOARD__=${safeJson(info)};window.__GIVENS__=${safeJson(givenSpecs)};window.__INITIAL_GIVENS__=${safeJson(initialGivens)};window.__INITIAL_URLSTATE__=${safeJson(initialUrlState)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
3320
3402
  dash.title
3321
3403
  );
3322
3404
  }
@@ -3515,7 +3597,7 @@ function serveStatic(dir, port) {
3515
3597
  res.writeHead(200, { "Content-Type": type, "Content-Length": st.size, "Accept-Ranges": "bytes" });
3516
3598
  fs6.createReadStream(file).pipe(res);
3517
3599
  });
3518
- return new Promise((resolve3, reject) => {
3600
+ return new Promise((resolve4, reject) => {
3519
3601
  let attempt = 0;
3520
3602
  const tryPort = (p) => {
3521
3603
  server.once("error", (err) => {
@@ -3531,7 +3613,7 @@ function serveStatic(dir, port) {
3531
3613
  (port ${port} busy \u2014 using ${p})`);
3532
3614
  console.log(`
3533
3615
  serving ${path7.basename(dir)}/ on http://localhost:${p} (ctrl-c to stop)`);
3534
- resolve3();
3616
+ resolve4();
3535
3617
  });
3536
3618
  };
3537
3619
  tryPort(port);
@@ -3664,12 +3746,12 @@ ${analyticsSnippet(analytics)}
3664
3746
  ${navFor(dash, all, cleanUrls)}
3665
3747
  <div id="root"></div>
3666
3748
  <script>
3667
- window.__DASHBOARD__ = ${JSON.stringify(info)};
3749
+ window.__DASHBOARD__ = ${safeJson(info)};
3668
3750
  // Given SPECS (label/type/default/suggest) are introspected from the model's
3669
3751
  // given: declarations at BUILD time \u2014 the runtime reads them from here to draw
3670
3752
  // controls and seed initial values. Without them there are no controls and
3671
3753
  // every given starts empty.
3672
- window.__GIVENS__ = ${JSON.stringify(givenSpecs)};
3754
+ window.__GIVENS__ = ${safeJson(givenSpecs)};
3673
3755
  // __INITIAL_GIVENS__ is NOT set here on purpose: the entry bundle sets it from
3674
3756
  // location.search using shared/givens-url, the same encoder the dev server uses.
3675
3757
  // An inline copy is what drifted last time (it stripped the dollar-sign prefix
@@ -3684,7 +3766,7 @@ window.__GIVENS__ = ${JSON.stringify(givenSpecs)};
3684
3766
  function indexPage(dashboards, title, custom, cleanUrls, analytics) {
3685
3767
  const link = (n) => cleanUrls ? `./${encodeURIComponent(n)}` : `./${encodeURIComponent(n)}.html`;
3686
3768
  const body = custom ? `<div id="root"></div>
3687
- <script>window.__DASHBOARDS__ = ${JSON.stringify(
3769
+ <script>window.__DASHBOARDS__ = ${safeJson(
3688
3770
  dashboards.map((d) => ({ name: d.name, title: d.title, description: d.description, href: link(d.name) }))
3689
3771
  )};</script>
3690
3772
  <script type="module" src="./assets/index.js"></script>` : `<main class="index"><h1>${esc3(title)}</h1><ul>` + dashboards.map(
@@ -4187,30 +4269,942 @@ async function launchCmd(mode, opts) {
4187
4269
  stdio: "inherit",
4188
4270
  cwd: root
4189
4271
  });
4190
- await new Promise((resolve3) => {
4272
+ await new Promise((resolve4) => {
4191
4273
  child.on("error", (e) => {
4192
4274
  process.stderr.write(
4193
4275
  `\u2717 could not launch \`claude\`: ${e.message}
4194
4276
  (is Claude Code installed and on PATH?)
4195
4277
  `
4196
4278
  );
4197
- resolve3();
4279
+ resolve4();
4198
4280
  });
4199
- child.on("exit", () => resolve3());
4281
+ child.on("exit", () => resolve4());
4200
4282
  });
4201
4283
  fs10.rmSync(tmpDir, { recursive: true, force: true });
4202
4284
  }
4203
4285
 
4204
- // package.json
4205
- var version = "0.2.30";
4286
+ // src/cloud/index.ts
4287
+ import { resolve as resolve2 } from "node:path";
4288
+
4289
+ // src/cloud/infer.ts
4290
+ var HOSTED_DOMAIN_SUFFIX = ".malloyyo.com";
4291
+ var InstanceInferenceError = class extends Error {
4292
+ };
4293
+ function inferInstance(dir) {
4294
+ let resolved;
4295
+ try {
4296
+ resolved = resolveInstance(dir);
4297
+ } catch (error) {
4298
+ throw new InstanceInferenceError(
4299
+ `no instance given, and none could be inferred from malloy-config.json: ${error instanceof Error ? error.message : String(error)}`
4300
+ );
4301
+ }
4302
+ let hostname;
4303
+ try {
4304
+ hostname = new URL(resolved.url).hostname;
4305
+ } catch {
4306
+ throw new InstanceInferenceError(
4307
+ `no instance given, and the config target "${resolved.name}" has a URL this command cannot parse`
4308
+ );
4309
+ }
4310
+ if (!hostname.endsWith(HOSTED_DOMAIN_SUFFIX)) {
4311
+ throw new InstanceInferenceError(
4312
+ `no instance given, and the config target "${resolved.name}" points at ${hostname}, which is not a Malloyyo-hosted instance \u2014 name the instance explicitly`
4313
+ );
4314
+ }
4315
+ const slug = hostname.slice(0, hostname.indexOf("."));
4316
+ if (slug === "") {
4317
+ throw new InstanceInferenceError(
4318
+ `no instance given, and the config target "${resolved.name}" points at the bare hosted domain`
4319
+ );
4320
+ }
4321
+ return { slug, targetName: resolved.name };
4322
+ }
4323
+
4324
+ // src/cloud/api.ts
4325
+ function isRecord(value) {
4326
+ return typeof value === "object" && value !== null;
4327
+ }
4328
+ var ControlPlaneError = class extends Error {
4329
+ constructor(status2, code, message2) {
4330
+ super(message2);
4331
+ this.status = status2;
4332
+ this.code = code;
4333
+ this.name = "ControlPlaneError";
4334
+ }
4335
+ };
4336
+ function isTransientStatus(status2) {
4337
+ return status2 >= 500 || status2 === 429;
4338
+ }
4339
+ function isNetworkError(error) {
4340
+ return error instanceof TypeError;
4341
+ }
4342
+ var NON_JSON_DETAIL = "the control plane returned a body that is not JSON; check that MALLOYYO_API_URL points at the control plane and not at something in front of it";
4343
+ var DEFAULT_MAX_ATTEMPTS = 3;
4344
+ var RETRY_BASE_DELAY_MS = 500;
4345
+ var NOT_JSON = Symbol("not-json");
4346
+ function parseBody(text) {
4347
+ if (text === "") return void 0;
4348
+ try {
4349
+ return JSON.parse(text);
4350
+ } catch {
4351
+ return NOT_JSON;
4352
+ }
4353
+ }
4354
+ function createControlPlaneClient(options) {
4355
+ const doFetch = options.fetch ?? apiFetch;
4356
+ const base = options.apiUrl.replace(/\/+$/, "");
4357
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve4) => setTimeout(resolve4, ms)));
4358
+ const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
4359
+ async function attempt(method, path12, extra) {
4360
+ const token = await options.getAccessToken();
4361
+ const headers = {
4362
+ authorization: `Bearer ${token}`,
4363
+ accept: "application/json"
4364
+ };
4365
+ if (extra?.idempotencyKey !== void 0) headers["idempotency-key"] = extra.idempotencyKey;
4366
+ if (extra?.body !== void 0) headers["content-type"] = "application/json";
4367
+ const response = await doFetch(`${base}${path12}`, {
4368
+ method,
4369
+ headers,
4370
+ ...extra?.body === void 0 ? {} : { body: JSON.stringify(extra.body) }
4371
+ });
4372
+ const parsed = parseBody(await response.text());
4373
+ if (!response.ok) {
4374
+ const body = parsed === NOT_JSON ? void 0 : parsed;
4375
+ const code = isRecord(body) && typeof body.error === "string" ? body.error : "error";
4376
+ const detail = isRecord(body) && typeof body.message === "string" ? body.message : parsed === NOT_JSON ? NON_JSON_DETAIL : code;
4377
+ throw new ControlPlaneError(response.status, code, detail);
4378
+ }
4379
+ if (parsed === NOT_JSON) {
4380
+ throw new ControlPlaneError(response.status, "invalid_response", NON_JSON_DETAIL);
4381
+ }
4382
+ return parsed;
4383
+ }
4384
+ async function request(method, path12, extra) {
4385
+ for (let attemptNumber = 1; ; attemptNumber += 1) {
4386
+ try {
4387
+ return await attempt(method, path12, extra);
4388
+ } catch (error) {
4389
+ const transient = error instanceof ControlPlaneError ? isTransientStatus(error.status) : isNetworkError(error);
4390
+ if (!transient || attemptNumber >= maxAttempts) throw error;
4391
+ }
4392
+ await sleep(RETRY_BASE_DELAY_MS * attemptNumber);
4393
+ }
4394
+ }
4395
+ function expectRecord(value) {
4396
+ if (!isRecord(value)) {
4397
+ throw new Error("the control plane returned a response that was not a JSON object");
4398
+ }
4399
+ return value;
4400
+ }
4401
+ function expectField(body, field) {
4402
+ const value = body[field];
4403
+ if (!isRecord(value) && !Array.isArray(value)) {
4404
+ throw new Error(`the control plane's response carried no \`${field}\``);
4405
+ }
4406
+ return value;
4407
+ }
4408
+ return {
4409
+ async createInstance({ slug, idempotencyKey }) {
4410
+ const body = expectRecord(
4411
+ await request("POST", "/v1/instances", { idempotencyKey, body: { slug } })
4412
+ );
4413
+ return {
4414
+ instance: expectField(body, "instance"),
4415
+ operation: expectField(body, "operation")
4416
+ };
4417
+ },
4418
+ async getInstance(id) {
4419
+ const body = expectRecord(await request("GET", `/v1/instances/${encodeURIComponent(id)}`));
4420
+ return expectField(body, "instance");
4421
+ },
4422
+ async listInstances() {
4423
+ const body = expectRecord(await request("GET", "/v1/instances"));
4424
+ return expectField(body, "instances");
4425
+ },
4426
+ async deleteInstance({ id, idempotencyKey }) {
4427
+ const body = expectRecord(
4428
+ await request("DELETE", `/v1/instances/${encodeURIComponent(id)}`, { idempotencyKey })
4429
+ );
4430
+ return {
4431
+ instance: expectField(body, "instance"),
4432
+ // Documented as nullable on this endpoint alone; see `ControlPlaneClient`.
4433
+ operation: body.operation === null ? null : expectField(body, "operation")
4434
+ };
4435
+ },
4436
+ async setInstanceSecrets({ id, values, idempotencyKey }) {
4437
+ const body = expectRecord(
4438
+ await request("POST", `/v1/instances/${encodeURIComponent(id)}/secrets`, {
4439
+ idempotencyKey,
4440
+ body: { secrets: values }
4441
+ })
4442
+ );
4443
+ return {
4444
+ instance: expectField(body, "instance"),
4445
+ operation: expectField(body, "operation"),
4446
+ secrets: expectField(body, "secrets")
4447
+ };
4448
+ },
4449
+ async unsetInstanceSecrets({ id, names, idempotencyKey }) {
4450
+ const body = expectRecord(
4451
+ await request("DELETE", `/v1/instances/${encodeURIComponent(id)}/secrets`, {
4452
+ idempotencyKey,
4453
+ body: { names }
4454
+ })
4455
+ );
4456
+ return {
4457
+ instance: expectField(body, "instance"),
4458
+ operation: expectField(body, "operation"),
4459
+ secrets: expectField(body, "secrets")
4460
+ };
4461
+ },
4462
+ async listInstanceSecrets(id) {
4463
+ const body = expectRecord(
4464
+ await request("GET", `/v1/instances/${encodeURIComponent(id)}/secrets`)
4465
+ );
4466
+ return {
4467
+ instance: expectField(body, "instance"),
4468
+ secrets: expectField(body, "secrets")
4469
+ };
4470
+ },
4471
+ async getOperation(id) {
4472
+ const body = expectRecord(await request("GET", `/v1/operations/${encodeURIComponent(id)}`));
4473
+ return expectField(body, "operation");
4474
+ }
4475
+ };
4476
+ }
4477
+
4478
+ // src/cloud/commands.ts
4479
+ import { randomUUID } from "node:crypto";
4480
+ var STEP_LABELS = {
4481
+ create_sign_in: "Setting up sign-in",
4482
+ seat_administrator: "Adding you as administrator",
4483
+ register_hostname: "Authorizing your address for sign-in",
4484
+ create_database: "Creating your database",
4485
+ reserve_instance: "Reserving your instance",
4486
+ store_credentials: "Storing credentials",
4487
+ start_instance: "Starting your instance",
4488
+ start_standby: "Preparing standby capacity",
4489
+ wait_for_startup: "Waiting for it to come up",
4490
+ setup_check: "Checking it responds",
4491
+ record_success: "Finishing up",
4492
+ // The secrets roll. The standby first — it is updated while stopped, so it costs
4493
+ // nothing — then the one actually serving, which is the brief interruption.
4494
+ update_standby: "Updating standby capacity",
4495
+ update_instance: "Applying to your instance"
4496
+ };
4497
+ var COMMAND_SCOPES = {
4498
+ create: ["instances:create", "instances:read"],
4499
+ status: ["instances:read"],
4500
+ list: ["instances:read"],
4501
+ delete: ["instances:delete", "instances:read"],
4502
+ // `secrets:write` is deliberately not an `instances:*` scope: it can replace the
4503
+ // credentials a tenant queries its own warehouse with, which is well outside the blast
4504
+ // radius the instance-management scopes are bounded to. `instances:read` comes along for
4505
+ // the same reason `create` carries it — this command polls the operation it opens and
4506
+ // re-reads the instance, and the control plane gates both GETs on read.
4507
+ "secrets set": ["secrets:write", "instances:read"],
4508
+ // Removal is a write to the same configuration, so it is the same scope — there is
4509
+ // still no read scope, and nothing here for one to read.
4510
+ "secrets unset": ["secrets:write", "instances:read"],
4511
+ // Listing serves names and digests, which the write scope already learns from every
4512
+ // set and unset response — there is no value anywhere on this path to gate. No
4513
+ // `instances:read`: nothing is polled and nothing re-read.
4514
+ "secrets list": ["secrets:write"]
4515
+ };
4516
+ var DEFAULT_POLL_INTERVAL_MS = 2e3;
4517
+ var DEFAULT_MAX_OPERATION_WAIT_MS = 10 * 60 * 1e3;
4518
+ function instanceUrl(instance) {
4519
+ return instance.hostname === null ? null : `https://${instance.hostname}`;
4520
+ }
4521
+ function printInstance(ctx, instance) {
4522
+ const url6 = instanceUrl(instance);
4523
+ ctx.out(` ${instance.slug} (${instance.id})`);
4524
+ ctx.out(` lifecycle: ${instance.lifecycle}`);
4525
+ if (url6 !== null) ctx.out(` url: ${url6}`);
4526
+ }
4527
+ function formatDuration(ms) {
4528
+ const seconds = Math.max(0, Math.round(ms / 1e3));
4529
+ const minutes = Math.floor(seconds / 60);
4530
+ return minutes === 0 ? `${seconds}s` : `${minutes}m ${seconds % 60}s`;
4531
+ }
4532
+ async function waitForOperation(ctx, operation) {
4533
+ const sleep = ctx.sleep ?? ((ms) => new Promise((resolve4) => setTimeout(resolve4, ms)));
4534
+ const now = ctx.now ?? Date.now;
4535
+ const interval = ctx.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
4536
+ const maxWait = ctx.maxOperationWaitMs ?? DEFAULT_MAX_OPERATION_WAIT_MS;
4537
+ const startedAt = now();
4538
+ const deadline = startedAt + maxWait;
4539
+ const reported = /* @__PURE__ */ new Set();
4540
+ const report = (current2) => {
4541
+ if (ctx.json) return;
4542
+ for (const step of current2.steps ?? []) {
4543
+ if (reported.has(step.key)) continue;
4544
+ reported.add(step.key);
4545
+ const label = STEP_LABELS[step.key];
4546
+ if (label !== void 0) ctx.out(` \u2713 ${label}`);
4547
+ }
4548
+ };
4549
+ let current = operation;
4550
+ report(current);
4551
+ while (current.status === "running") {
4552
+ if (now() >= deadline) {
4553
+ current = await ctx.client.getOperation(current.id);
4554
+ report(current);
4555
+ break;
4556
+ }
4557
+ await sleep(interval);
4558
+ current = await ctx.client.getOperation(current.id);
4559
+ report(current);
4560
+ }
4561
+ return { operation: current, waitedMs: now() - startedAt };
4562
+ }
4563
+ async function reportOperation(ctx, operation, instanceId, verb) {
4564
+ if (operation === null) {
4565
+ const instance = await ctx.client.getInstance(instanceId);
4566
+ if (ctx.json) {
4567
+ ctx.out(JSON.stringify({ operation: null, instance }, null, 2));
4568
+ } else {
4569
+ ctx.out(`${verb} succeeded: the instance was already ${instance.lifecycle}.`);
4570
+ printInstance(ctx, instance);
4571
+ }
4572
+ return 0;
4573
+ }
4574
+ const { operation: final, waitedMs } = await waitForOperation(ctx, operation);
4575
+ if (final.status === "succeeded") {
4576
+ const instance = await ctx.client.getInstance(instanceId);
4577
+ if (ctx.json) {
4578
+ ctx.out(JSON.stringify({ operation: final, instance }, null, 2));
4579
+ } else {
4580
+ ctx.out(`${verb} succeeded:`);
4581
+ printInstance(ctx, instance);
4582
+ const url6 = instanceUrl(instance);
4583
+ if (verb === "create" && url6 !== null) {
4584
+ ctx.out("");
4585
+ ctx.out("Add this to malloy-config.json in your model repo:");
4586
+ ctx.out("");
4587
+ ctx.out(` "malloyyo": {`);
4588
+ ctx.out(` "targets": {`);
4589
+ ctx.out(` "${instance.slug}": { "url": "${url6}", "dataset": "<dataset>" }`);
4590
+ ctx.out(` }`);
4591
+ ctx.out(` }`);
4592
+ ctx.out("");
4593
+ ctx.out(`Sign in at ${url6}`);
4594
+ ctx.out(`Then, from that repo: malloyyo login && malloyyo publish`);
4595
+ }
4596
+ }
4597
+ return 0;
4598
+ }
4599
+ if (final.status === "failed") {
4600
+ if (ctx.json) {
4601
+ ctx.out(JSON.stringify({ operation: final }, null, 2));
4602
+ } else {
4603
+ ctx.out(`${verb} failed: ${final.error ?? "unknown error"}`);
4604
+ }
4605
+ return 1;
4606
+ }
4607
+ if (ctx.json) {
4608
+ ctx.out(JSON.stringify({ operation: final }, null, 2));
4609
+ } else {
4610
+ ctx.out(
4611
+ `Stopped waiting after ${formatDuration(waitedMs)} \u2014 this is the CLI giving up, not the ${verb} failing.`
4612
+ );
4613
+ ctx.out(
4614
+ `The ${verb} is still running server-side (operation ${final.id}). Check it with \`malloyyo cloud instance status\`.`
4615
+ );
4616
+ }
4617
+ return 0;
4618
+ }
4619
+ async function createCommand(ctx, args) {
4620
+ const idempotencyKey = (ctx.newIdempotencyKey ?? randomUUID)();
4621
+ const { instance, operation } = await ctx.client.createInstance({
4622
+ slug: args.slug,
4623
+ idempotencyKey
4624
+ });
4625
+ if (!ctx.json) ctx.out(`creating instance "${args.slug}" (operation ${operation.id})\u2026`);
4626
+ return reportOperation(ctx, operation, instance.id, "create");
4627
+ }
4628
+ var DEFAULT_BUSY_POLL_INTERVAL_MS = 5e3;
4629
+ var DEFAULT_MAX_BUSY_WAIT_MS = 5 * 60 * 1e3;
4630
+ var InvalidSecretArgumentError = class extends Error {
4631
+ constructor(message2) {
4632
+ super(message2);
4633
+ this.name = "InvalidSecretArgumentError";
4634
+ }
4635
+ };
4636
+ function parseSecretArguments(args) {
4637
+ if (args.length === 0) {
4638
+ throw new InvalidSecretArgumentError("give at least one NAME or NAME=value");
4639
+ }
4640
+ const parsed = [];
4641
+ const seen = /* @__PURE__ */ new Set();
4642
+ for (const [index2, argument] of args.entries()) {
4643
+ const separator = argument.indexOf("=");
4644
+ if (separator === 0) {
4645
+ throw new InvalidSecretArgumentError(`argument ${index2 + 1} has no name before its "="`);
4646
+ }
4647
+ const name = separator === -1 ? argument : argument.slice(0, separator);
4648
+ if (name === "") throw new InvalidSecretArgumentError(`argument ${index2 + 1} is empty`);
4649
+ if (seen.has(name)) throw new InvalidSecretArgumentError(`"${name}" was given more than once`);
4650
+ seen.add(name);
4651
+ parsed.push(separator === -1 ? { name } : { name, value: argument.slice(separator + 1) });
4652
+ }
4653
+ return parsed;
4654
+ }
4655
+ function parseSecretLines(text) {
4656
+ const values = {};
4657
+ for (const [index2, rawLine] of text.split("\n").entries()) {
4658
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
4659
+ if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
4660
+ const separator = line.indexOf("=");
4661
+ if (separator <= 0) {
4662
+ throw new InvalidSecretArgumentError(`line ${index2 + 1} is not a NAME=value pair`);
4663
+ }
4664
+ const name = line.slice(0, separator).trim();
4665
+ if (name in values) {
4666
+ throw new InvalidSecretArgumentError(`"${name}" appears on more than one line`);
4667
+ }
4668
+ values[name] = line.slice(separator + 1);
4669
+ }
4670
+ if (Object.keys(values).length === 0) {
4671
+ throw new InvalidSecretArgumentError("stdin carried no NAME=value pairs");
4672
+ }
4673
+ return values;
4674
+ }
4675
+ function isInstanceBusy(error) {
4676
+ return error instanceof ControlPlaneError && error.code === "instance_busy";
4677
+ }
4678
+ function rejectInstanceAsSecretName(id, parsed) {
4679
+ const looksLikeTheInstance = parsed.some(
4680
+ (argument) => argument.value === void 0 && argument.name === id
4681
+ );
4682
+ if (looksLikeTheInstance) {
4683
+ throw new InvalidSecretArgumentError(
4684
+ `"${id}" is the instance, not a secret name \u2014 it is no longer a positional argument.
4685
+ Run this inside the model repo that publishes to it, or pass it as -i ${id}.`
4686
+ );
4687
+ }
4688
+ }
4689
+ async function resolveSecretValues(ctx, args) {
4690
+ if (args.stdin === true) {
4691
+ if (args.assignments.length > 0) {
4692
+ throw new InvalidSecretArgumentError("--stdin takes the pairs on stdin, not as arguments");
4693
+ }
4694
+ if (ctx.readStdin === void 0) {
4695
+ throw new InvalidSecretArgumentError("--stdin is not available here");
4696
+ }
4697
+ return parseSecretLines(await ctx.readStdin());
4698
+ }
4699
+ const parsed = parseSecretArguments(args.assignments);
4700
+ rejectInstanceAsSecretName(args.id, parsed);
4701
+ const values = {};
4702
+ for (const argument of parsed) {
4703
+ if (argument.value !== void 0) {
4704
+ values[argument.name] = argument.value;
4705
+ continue;
4706
+ }
4707
+ if (ctx.promptSecret === void 0) {
4708
+ throw new InvalidSecretArgumentError(
4709
+ `no value given for "${argument.name}", and there is no terminal to prompt on; pass it with --stdin instead`
4710
+ );
4711
+ }
4712
+ const value = await ctx.promptSecret(argument.name);
4713
+ if (value === "") {
4714
+ throw new InvalidSecretArgumentError(`no value entered for "${argument.name}"`);
4715
+ }
4716
+ values[argument.name] = value;
4717
+ }
4718
+ return values;
4719
+ }
4720
+ async function secretsSetCommand(ctx, args) {
4721
+ const values = await resolveSecretValues(ctx, args);
4722
+ const names = Object.keys(values);
4723
+ const idempotencyKey = (ctx.newIdempotencyKey ?? randomUUID)();
4724
+ const sleep = ctx.sleep ?? ((ms) => new Promise((resolve4) => setTimeout(resolve4, ms)));
4725
+ const now = ctx.now ?? Date.now;
4726
+ const deadline = now() + (ctx.maxBusyWaitMs ?? DEFAULT_MAX_BUSY_WAIT_MS);
4727
+ let result;
4728
+ let announcedWait = false;
4729
+ for (; ; ) {
4730
+ try {
4731
+ result = await ctx.client.setInstanceSecrets({ id: args.id, values, idempotencyKey });
4732
+ break;
4733
+ } catch (error) {
4734
+ if (!isInstanceBusy(error) || now() >= deadline) throw error;
4735
+ if (!ctx.json && !announcedWait) {
4736
+ ctx.out("another operation is running on this instance, waiting\u2026");
4737
+ announcedWait = true;
4738
+ }
4739
+ }
4740
+ await sleep(ctx.busyPollIntervalMs ?? DEFAULT_BUSY_POLL_INTERVAL_MS);
4741
+ }
4742
+ if (!ctx.json) {
4743
+ ctx.out(`setting ${names.join(", ")} on "${result.instance.slug}"\u2026`);
4744
+ }
4745
+ return reportOperation(ctx, result.operation, result.instance.id, "secrets set");
4746
+ }
4747
+ function parseUnsetNames(raw) {
4748
+ if (raw.length === 0) {
4749
+ throw new InvalidSecretArgumentError("give at least one secret name to unset");
4750
+ }
4751
+ const seen = /* @__PURE__ */ new Set();
4752
+ for (const name of raw) {
4753
+ if (name.includes("=")) {
4754
+ throw new InvalidSecretArgumentError(
4755
+ `unset takes names, not NAME=value pairs (got "${name.slice(0, name.indexOf("="))}=\u2026")`
4756
+ );
4757
+ }
4758
+ if (seen.has(name)) {
4759
+ throw new InvalidSecretArgumentError(`"${name}" appears more than once`);
4760
+ }
4761
+ seen.add(name);
4762
+ }
4763
+ return raw;
4764
+ }
4765
+ async function secretsUnsetCommand(ctx, args) {
4766
+ const names = parseUnsetNames(args.names);
4767
+ rejectInstanceAsSecretName(
4768
+ args.id,
4769
+ names.map((name) => ({ name }))
4770
+ );
4771
+ const idempotencyKey = (ctx.newIdempotencyKey ?? randomUUID)();
4772
+ const sleep = ctx.sleep ?? ((ms) => new Promise((resolve4) => setTimeout(resolve4, ms)));
4773
+ const now = ctx.now ?? Date.now;
4774
+ const deadline = now() + (ctx.maxBusyWaitMs ?? DEFAULT_MAX_BUSY_WAIT_MS);
4775
+ let result;
4776
+ let announcedWait = false;
4777
+ for (; ; ) {
4778
+ try {
4779
+ result = await ctx.client.unsetInstanceSecrets({ id: args.id, names, idempotencyKey });
4780
+ break;
4781
+ } catch (error) {
4782
+ if (!isInstanceBusy(error) || now() >= deadline) throw error;
4783
+ if (!ctx.json && !announcedWait) {
4784
+ ctx.out("another operation is running on this instance, waiting\u2026");
4785
+ announcedWait = true;
4786
+ }
4787
+ }
4788
+ await sleep(ctx.busyPollIntervalMs ?? DEFAULT_BUSY_POLL_INTERVAL_MS);
4789
+ }
4790
+ if (!ctx.json) {
4791
+ const wasRemoved = result.secrets.filter((secret) => secret.removed).map((s) => s.name);
4792
+ const wasAbsent = result.secrets.filter((secret) => !secret.removed).map((s) => s.name);
4793
+ if (wasRemoved.length > 0) {
4794
+ ctx.out(`removing ${wasRemoved.join(", ")} from "${result.instance.slug}"\u2026`);
4795
+ }
4796
+ for (const name of wasAbsent) {
4797
+ ctx.out(` ${name} was not set`);
4798
+ }
4799
+ }
4800
+ return reportOperation(ctx, result.operation, result.instance.id, "secrets unset");
4801
+ }
4802
+ async function secretsListCommand(ctx, args) {
4803
+ const { instance, secrets } = await ctx.client.listInstanceSecrets(args.id);
4804
+ if (ctx.json) {
4805
+ ctx.out(JSON.stringify({ instance, secrets }, null, 2));
4806
+ return 0;
4807
+ }
4808
+ if (secrets.length === 0) {
4809
+ ctx.out(`no secrets set on "${instance.slug}".`);
4810
+ return 0;
4811
+ }
4812
+ const width = Math.max(...secrets.map((secret) => secret.name.length));
4813
+ for (const secret of secrets) {
4814
+ ctx.out(` ${secret.name.padEnd(width)} ${secret.digest ?? ""}`.trimEnd());
4815
+ }
4816
+ return 0;
4817
+ }
4818
+ async function statusCommand(ctx, args) {
4819
+ const instance = await ctx.client.getInstance(args.id);
4820
+ if (ctx.json) {
4821
+ ctx.out(JSON.stringify(instance, null, 2));
4822
+ } else {
4823
+ printInstance(ctx, instance);
4824
+ }
4825
+ return 0;
4826
+ }
4827
+ async function listCommand(ctx) {
4828
+ const instances = await ctx.client.listInstances();
4829
+ if (ctx.json) {
4830
+ ctx.out(JSON.stringify(instances, null, 2));
4831
+ return 0;
4832
+ }
4833
+ if (instances.length === 0) {
4834
+ ctx.out("no instances.");
4835
+ return 0;
4836
+ }
4837
+ ctx.out(`${instances.length} instance${instances.length === 1 ? "" : "s"}:`);
4838
+ for (const instance of instances) printInstance(ctx, instance);
4839
+ return 0;
4840
+ }
4841
+ async function deleteCommand(ctx, args) {
4842
+ const idempotencyKey = (ctx.newIdempotencyKey ?? randomUUID)();
4843
+ const { instance, operation } = await ctx.client.deleteInstance({
4844
+ id: args.id,
4845
+ idempotencyKey
4846
+ });
4847
+ if (!ctx.json && operation !== null) {
4848
+ ctx.out(`deleting instance "${instance.slug}" (operation ${operation.id})\u2026`);
4849
+ }
4850
+ return reportOperation(ctx, operation, instance.id, "delete");
4851
+ }
4852
+
4853
+ // src/cloud/config.ts
4854
+ var DEFAULT_API_URL = "https://api.malloyyo.com";
4855
+ var MissingCloudConfigError = class extends Error {
4856
+ constructor(names) {
4857
+ super(
4858
+ `missing required configuration: ${names.join(", ")}.
4859
+ Set them as environment variables. Your credential comes from Malloyyo when your account is created.`
4860
+ );
4861
+ this.names = names;
4862
+ this.name = "MissingCloudConfigError";
4863
+ }
4864
+ };
4865
+ var ENV_NAMES = {
4866
+ apiUrl: "MALLOYYO_API_URL",
4867
+ clientId: "MALLOYYO_CLIENT_ID",
4868
+ clientSecret: "MALLOYYO_CLIENT_SECRET"
4869
+ };
4870
+ function loadCloudConfig(env) {
4871
+ const read = (key) => {
4872
+ const raw = env[ENV_NAMES[key]];
4873
+ return raw === void 0 || raw.trim() === "" ? void 0 : raw.trim();
4874
+ };
4875
+ const values = {
4876
+ apiUrl: read("apiUrl") ?? DEFAULT_API_URL,
4877
+ clientId: read("clientId"),
4878
+ clientSecret: read("clientSecret")
4879
+ };
4880
+ const missing = Object.keys(values).filter((key) => values[key] === void 0).map((key) => ENV_NAMES[key]);
4881
+ if (missing.length > 0) throw new MissingCloudConfigError(missing);
4882
+ return values;
4883
+ }
4884
+
4885
+ // src/cloud/token-source.ts
4886
+ var DEFAULT_REFRESH_SKEW_SECONDS = 60;
4887
+ function createCredentialExchange(config) {
4888
+ const base = config.apiUrl.replace(/\/+$/, "");
4889
+ return async (scopes) => {
4890
+ const body = new URLSearchParams({
4891
+ grant_type: "client_credentials",
4892
+ client_id: config.clientId,
4893
+ client_secret: config.clientSecret
4894
+ });
4895
+ if (scopes.length > 0) body.set("scope", scopes.join(" "));
4896
+ const response = await apiFetch(`${base}/v1/token`, {
4897
+ method: "POST",
4898
+ headers: {
4899
+ "content-type": "application/x-www-form-urlencoded",
4900
+ accept: "application/json"
4901
+ },
4902
+ body: body.toString()
4903
+ });
4904
+ const text = await response.text();
4905
+ let parsed;
4906
+ try {
4907
+ parsed = text === "" ? void 0 : JSON.parse(text);
4908
+ } catch {
4909
+ parsed = void 0;
4910
+ }
4911
+ const fields = typeof parsed === "object" && parsed !== null ? parsed : {};
4912
+ if (!response.ok) {
4913
+ const code = typeof fields.error === "string" ? fields.error : "error";
4914
+ throw new ControlPlaneError(
4915
+ response.status,
4916
+ code,
4917
+ // The server deliberately says nothing about *why* a credential was rejected, so
4918
+ // the actionable sentence has to come from here — it is the side that knows which
4919
+ // two environment variables the values came from.
4920
+ code === "invalid_client" ? "your Malloyyo credentials were rejected. Check MALLOYYO_CLIENT_ID and MALLOYYO_CLIENT_SECRET, and that your account is still active." : typeof fields.message === "string" ? fields.message : `could not get an access token (${code})`
4921
+ );
4922
+ }
4923
+ if (typeof fields.access_token !== "string" || typeof fields.expires_in !== "number") {
4924
+ throw new ControlPlaneError(
4925
+ response.status,
4926
+ "invalid_response",
4927
+ "the control plane returned no access token; check that MALLOYYO_API_URL points at it"
4928
+ );
4929
+ }
4930
+ return { accessToken: fields.access_token, expiresIn: fields.expires_in };
4931
+ };
4932
+ }
4933
+ function createCachedTokenSource(exchange, scopes, options = {}) {
4934
+ const now = options.now ?? Date.now;
4935
+ const skewMs = (options.refreshSkewSeconds ?? DEFAULT_REFRESH_SKEW_SECONDS) * 1e3;
4936
+ let cached = null;
4937
+ let inFlight = null;
4938
+ async function fetchToken() {
4939
+ const grant = await exchange(scopes);
4940
+ cached = {
4941
+ accessToken: grant.accessToken,
4942
+ renewAtMs: now() + grant.expiresIn * 1e3 - skewMs
4943
+ };
4944
+ return grant.accessToken;
4945
+ }
4946
+ return {
4947
+ async getAccessToken() {
4948
+ if (cached !== null && now() < cached.renewAtMs) return cached.accessToken;
4949
+ inFlight ??= fetchToken().finally(() => {
4950
+ inFlight = null;
4951
+ });
4952
+ return inFlight;
4953
+ }
4954
+ };
4955
+ }
4956
+ function createCloudTokenSource(config, scopes) {
4957
+ return createCachedTokenSource(createCredentialExchange(config), scopes);
4958
+ }
4959
+
4960
+ // src/cloud/run.ts
4961
+ function defaultClientFactory(config, scopes) {
4962
+ const tokenSource2 = createCloudTokenSource(config, scopes);
4963
+ return createControlPlaneClient({
4964
+ apiUrl: config.apiUrl,
4965
+ getAccessToken: () => tokenSource2.getAccessToken()
4966
+ });
4967
+ }
4968
+ async function runCloud(invocation, deps) {
4969
+ let config;
4970
+ try {
4971
+ config = loadCloudConfig(deps.env);
4972
+ } catch (error) {
4973
+ if (error instanceof MissingCloudConfigError) {
4974
+ deps.err(error.message);
4975
+ return 2;
4976
+ }
4977
+ throw error;
4978
+ }
4979
+ const makeClient = deps.makeClient ?? defaultClientFactory;
4980
+ let ctx;
4981
+ try {
4982
+ ctx = {
4983
+ client: makeClient(config, COMMAND_SCOPES[invocation.command]),
4984
+ out: deps.out,
4985
+ json: deps.json === true,
4986
+ // `promptSecret` is absent when stdin is not a terminal, and that absence is what
4987
+ // turns a bare NAME under a pipe into a clear "use --stdin" error instead of a
4988
+ // command that hangs on input that is never coming.
4989
+ ...deps.readStdin === void 0 ? {} : { readStdin: deps.readStdin },
4990
+ ...deps.promptSecret === void 0 ? {} : { promptSecret: deps.promptSecret }
4991
+ };
4992
+ } catch (error) {
4993
+ deps.err(
4994
+ `could not build a control-plane client from the environment: ${error instanceof Error ? error.message : "unknown error"}`
4995
+ );
4996
+ return 2;
4997
+ }
4998
+ try {
4999
+ switch (invocation.command) {
5000
+ case "create":
5001
+ return await createCommand(ctx, { slug: invocation.slug });
5002
+ case "status":
5003
+ return await statusCommand(ctx, { id: invocation.id });
5004
+ case "list":
5005
+ return await listCommand(ctx);
5006
+ case "delete":
5007
+ return await deleteCommand(ctx, { id: invocation.id });
5008
+ case "secrets set":
5009
+ return await secretsSetCommand(ctx, {
5010
+ id: invocation.id,
5011
+ assignments: invocation.assignments,
5012
+ stdin: invocation.stdin
5013
+ });
5014
+ case "secrets unset":
5015
+ return await secretsUnsetCommand(ctx, { id: invocation.id, names: invocation.names });
5016
+ case "secrets list":
5017
+ return await secretsListCommand(ctx, { id: invocation.id });
5018
+ }
5019
+ } catch (error) {
5020
+ if (error instanceof InvalidSecretArgumentError) {
5021
+ deps.err(error.message);
5022
+ return 2;
5023
+ }
5024
+ if (error instanceof ControlPlaneError) {
5025
+ deps.err(`error (${error.status}): ${error.message}`);
5026
+ return 1;
5027
+ }
5028
+ deps.err(error instanceof Error ? error.message : "an unexpected error occurred");
5029
+ return 1;
5030
+ }
5031
+ }
5032
+
5033
+ // src/cloud/index.ts
5034
+ async function readStdin2() {
5035
+ process.stdin.setEncoding("utf8");
5036
+ let text = "";
5037
+ for await (const chunk of process.stdin) text += chunk;
5038
+ return text;
5039
+ }
5040
+ async function promptSecret(name) {
5041
+ const { default: password } = await import("@inquirer/password");
5042
+ try {
5043
+ return await password({ message: `Value for ${name} (hidden):` }, { output: process.stderr });
5044
+ } catch (error) {
5045
+ if (error instanceof Error && error.name === "ExitPromptError") {
5046
+ throw new Error(`entering ${name} was cancelled`);
5047
+ }
5048
+ throw error;
5049
+ }
5050
+ }
5051
+ function resolveOrInferInstance(given) {
5052
+ if (given !== void 0) return given;
5053
+ try {
5054
+ const inferred = inferInstance(resolve2("."));
5055
+ process.stderr.write(
5056
+ `using instance ${inferred.slug} (from malloy-config.json target "${inferred.targetName}")
5057
+ `
5058
+ );
5059
+ return inferred.slug;
5060
+ } catch (error) {
5061
+ if (error instanceof InstanceInferenceError) {
5062
+ process.stderr.write(`${error.message}
5063
+ `);
5064
+ process.exitCode = 2;
5065
+ return null;
5066
+ }
5067
+ throw error;
5068
+ }
5069
+ }
5070
+ async function dispatch(invocation, json) {
5071
+ process.exitCode = await runCloud(invocation, {
5072
+ env: process.env,
5073
+ out: (line) => process.stdout.write(`${line}
5074
+ `),
5075
+ err: (line) => process.stderr.write(`${line}
5076
+ `),
5077
+ json,
5078
+ readStdin: readStdin2,
5079
+ // Only when there is a terminal to prompt on. Its absence is what lets a bare `NAME`
5080
+ // under a pipe fail with "use --stdin" instead of hanging forever on input that is never
5081
+ // coming.
5082
+ ...process.stdin.isTTY === true ? { promptSecret } : {}
5083
+ });
5084
+ }
5085
+ var SECRETS_SET_HELP = `Secrets are the values your Malloy models resolve warehouse connections from, as
5086
+ { "env": "NAME" } in malloy-config.json. Several in one command are applied together.
5087
+ The command returns once they are live; your instance restarts briefly. Values are
5088
+ stored only by the instance host and are never readable back through this CLI.
5089
+
5090
+ Run inside your model repo and the instance is the one its malloy-config.json
5091
+ publishes to; -i names a different one explicitly.
5092
+
5093
+ A value typed as NAME=value goes into your shell history and is visible to other
5094
+ processes while the command runs. Two ways to avoid that:
5095
+
5096
+ malloyyo cloud secrets set PG_HOST=db.example.com PG_USER=app PG_PASSWORD
5097
+ a bare NAME is prompted for, with the input hidden
5098
+
5099
+ op read op://vault/pg/password | malloyyo cloud secrets set --stdin
5100
+ --stdin reads NAME=value lines: a file, a CI variable, a password manager`;
5101
+ function registerCloudCommands(program2) {
5102
+ const cloud = program2.command("cloud").description("manage Malloyyo-hosted instances").addHelpText(
5103
+ "after",
5104
+ "\nCredentials come from MALLOYYO_CLIENT_ID and MALLOYYO_CLIENT_SECRET.\n"
5105
+ );
5106
+ const instance = cloud.command("instance").description("create and manage hosted instances");
5107
+ instance.command("create").argument("<slug>", "the name your instance is reached at: <slug>.malloyyo.com").option("--json", "print the result as JSON instead of progress lines").description("provision a new hosted instance and wait for it to come up").action(async (slug, opts) => {
5108
+ await dispatch({ command: "create", slug }, opts.json === true);
5109
+ });
5110
+ instance.command("status").argument("[instance]", "instance name (the one in its URL) or ID; inferred from malloy-config.json when omitted").option("--json", "print the instance as JSON").description("show an instance's lifecycle, URL, and observed state").action(async (instance2, opts) => {
5111
+ const id = resolveOrInferInstance(instance2);
5112
+ if (id === null) return;
5113
+ await dispatch({ command: "status", id }, opts.json === true);
5114
+ });
5115
+ instance.command("list").option("--json", "print the instances as JSON").description("list your hosted instances").action(async (opts) => {
5116
+ await dispatch({ command: "list" }, opts.json === true);
5117
+ });
5118
+ instance.command("delete").argument("<instance>", "instance name (the one in its URL) or ID").option("--json", "print the result as JSON instead of progress lines").description("delete an instance (reversible until it is destroyed)").action(async (instance2, opts) => {
5119
+ await dispatch({ command: "delete", id: instance2 }, opts.json === true);
5120
+ });
5121
+ const secrets = cloud.command("secrets").description("manage an instance's warehouse secrets");
5122
+ const instanceOption = [
5123
+ "-i, --instance <instance>",
5124
+ "instance name (the one in its URL) or ID; otherwise inferred from malloy-config.json"
5125
+ ];
5126
+ secrets.command("set").argument("[assignments...]", "NAME=value pairs, or a bare NAME to be prompted for").option(...instanceOption).option("--stdin", "read NAME=value lines from stdin instead of the command line").option("--json", "print the result as JSON instead of progress lines").description("set warehouse secrets on an instance and wait until they are live").addHelpText("after", `
5127
+ ${SECRETS_SET_HELP}
5128
+ `).action(
5129
+ async (assignments, opts) => {
5130
+ const id = resolveOrInferInstance(opts.instance);
5131
+ if (id === null) return;
5132
+ await dispatch(
5133
+ { command: "secrets set", id, assignments, stdin: opts.stdin === true },
5134
+ opts.json === true
5135
+ );
5136
+ }
5137
+ );
5138
+ secrets.command("unset").argument("<names...>", "secret names to remove \u2014 names only, no values").option(...instanceOption).option("--json", "print the result as JSON instead of progress lines").description("remove warehouse secrets from an instance and wait until the change is live").action(async (names, opts) => {
5139
+ const id = resolveOrInferInstance(opts.instance);
5140
+ if (id === null) return;
5141
+ await dispatch({ command: "secrets unset", id, names }, opts.json === true);
5142
+ });
5143
+ secrets.command("list").option(...instanceOption).option("--json", "print names and digests as JSON").description("list the secrets set on an instance \u2014 names and digests, never values").action(async (opts) => {
5144
+ const id = resolveOrInferInstance(opts.instance);
5145
+ if (id === null) return;
5146
+ await dispatch({ command: "secrets list", id }, opts.json === true);
5147
+ });
5148
+ }
4206
5149
 
4207
5150
  // src/index.ts
4208
5151
  function shortSha(sha) {
4209
5152
  return sha ? sha.slice(0, 7) : "";
4210
5153
  }
5154
+ function authHint(status2, t, source) {
5155
+ const login2 = `malloyyo login ${t.name}`;
5156
+ if (status2 === 403) {
5157
+ return `
5158
+ The token is valid, but that account isn't an admin on ${t.url} \u2014
5159
+ publishing is admin-only. Ask an admin there to grant access.`;
5160
+ }
5161
+ switch (source) {
5162
+ case "flag":
5163
+ return `
5164
+ That token came from --token. Drop the flag and run: ${login2}`;
5165
+ case "env":
5166
+ return `
5167
+ That token came from $${t.tokenEnv}. Re-issue it, or unset it and run: ${login2}`;
5168
+ default:
5169
+ return `
5170
+ Your saved login for ${t.url} is expired or revoked.
5171
+ Run: ${login2}`;
5172
+ }
5173
+ }
5174
+ function failureHint(out, t) {
5175
+ switch (out.kind) {
5176
+ case "missing-import":
5177
+ return `
5178
+ A file the model imports wasn't in the upload. Publish from the directory
5179
+ that holds index.malloy, and check the import path's spelling/case.`;
5180
+ case "connection":
5181
+ if (out.missingEnv?.length) {
5182
+ const vars = out.missingEnv.map((v) => `$${v}`).join(", ");
5183
+ return `
5184
+ malloy-config.json references ${vars}, which ${out.missingEnv.length > 1 ? "are" : "is"} NOT set on ${t.url}.
5185
+ Secrets don't travel with the model \u2014 set them in that deployment's environment
5186
+ (Vercel: Settings \u2192 Environment Variables), then publish again.`;
5187
+ }
5188
+ return `
5189
+ The server couldn't open the connection the model uses. Check the
5190
+ \`connections\` block in malloy-config.json, and that ${t.url} can reach it.`;
5191
+ case "persist":
5192
+ return `
5193
+ The model itself is fine \u2014 this failed writing to the server's database.
5194
+ Retry; if it repeats, the message above is the database's own.`;
5195
+ default:
5196
+ return "";
5197
+ }
5198
+ }
5199
+ function requestFailed(what, res, out, t, source) {
5200
+ const detail = out.error ?? `${res.status} ${res.statusText}`;
5201
+ const hint = res.status === 401 || res.status === 403 ? authHint(res.status, t, source) : failureHint(out, t);
5202
+ return new Error(`${what} failed: ${detail}${hint}`);
5203
+ }
4211
5204
  async function publish(target, dir, opts) {
4212
- const root = resolve2(dir);
5205
+ const root = resolve3(dir);
4213
5206
  const t = resolveTarget(root, target);
5207
+ const source = tokenSource(t, { tokenFlag: opts.token });
4214
5208
  const bearer = await getAccessToken(t, { tokenFlag: opts.token });
4215
5209
  const { files, config } = gatherDirectory(root);
4216
5210
  if (files.length === 0) {
@@ -4223,40 +5217,48 @@ async function publish(target, dir, opts) {
4223
5217
  printLintReport(report);
4224
5218
  }
4225
5219
  if (!report.ok) {
4226
- throw new Error("dashboard lint failed \u2014 fix the above, or pass --skip-lint");
5220
+ throw new Error(
5221
+ "dashboard lint failed \u2014 fix the above, or pass --skip-lint" + missingEnvHint(missingEnvRefs(config), "this shell")
5222
+ );
4227
5223
  }
4228
5224
  }
4229
5225
  const git = gitInfo(root);
4230
5226
  const dashboards = await gatherDashboards(root);
4231
5227
  const body = { files, config, git, dashboards };
4232
5228
  const provenance = git.sha ? `${git.branch}@${shortSha(git.sha)}${git.dirty ? " (dirty)" : ""}` : "(no git)";
4233
- console.log(`\u2192 ${t.url} dataset=${t.dataset}`);
5229
+ console.log(`\u2192 ${t.url} dataset=${t.dataset}${opts.createDataset ? " (create if missing)" : ""}`);
4234
5230
  console.log(` ${files.length} file(s) ${provenance}`);
4235
5231
  if (opts.dryRun) {
4236
5232
  console.log("dry run \u2014 not sending");
4237
5233
  return;
4238
5234
  }
4239
- const res = await fetch(`${t.url}/api/datasets/${t.dataset}/model/push`, {
5235
+ const push = `${t.url}/api/datasets/${t.dataset}/model/push${opts.createDataset ? "?create=1" : ""}`;
5236
+ const res = await apiFetch(push, {
4240
5237
  method: "POST",
4241
5238
  headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` },
4242
5239
  body: JSON.stringify(body)
4243
5240
  });
4244
5241
  const out = await res.json().catch(() => ({}));
4245
5242
  if (!res.ok || !out.ok) {
4246
- throw new Error(`publish failed: ${out.error ?? `${res.status} ${res.statusText}`}`);
5243
+ throw requestFailed("publish", res, out, t, source);
5244
+ }
5245
+ if (out.created) {
5246
+ console.log(`\u2713 created dataset ${out.dataset ?? t.dataset} (private) \u2014 ${t.url}/datasets/${out.dataset ?? t.dataset}`);
4247
5247
  }
4248
5248
  console.log(
4249
5249
  `\u2713 published version ${out.version} \u2014 ${out.sources?.length ?? 0} source(s)` + (dashboards.length ? `, ${dashboards.length} dashboard(s)` : "")
4250
5250
  );
4251
5251
  }
4252
5252
  async function status(target, opts) {
4253
- const t = resolveTarget(resolve2("."), target);
5253
+ const t = resolveTarget(resolve3("."), target);
5254
+ const source = tokenSource(t, { tokenFlag: opts.token });
4254
5255
  const bearer = await getAccessToken(t, { tokenFlag: opts.token });
4255
- const res = await fetch(`${t.url}/api/datasets/${t.dataset}/model/status`, {
5256
+ const res = await apiFetch(`${t.url}/api/datasets/${t.dataset}/model/status`, {
4256
5257
  headers: { authorization: `Bearer ${bearer}` }
4257
5258
  });
4258
5259
  if (!res.ok) {
4259
- throw new Error(`status failed: ${res.status} ${res.statusText}`);
5260
+ const body = await res.json().catch(() => ({}));
5261
+ throw requestFailed("status", res, body, t, source);
4260
5262
  }
4261
5263
  const s = await res.json();
4262
5264
  const git = s.git;
@@ -4265,29 +5267,40 @@ async function status(target, opts) {
4265
5267
  console.log(` ${s.compileError ? `\u2717 ${s.compileError}` : `\u2713 compiled ${s.compiledAt ?? ""}`}`);
4266
5268
  }
4267
5269
  async function loginCmd(target) {
4268
- const inst = resolveInstance(resolve2("."), target);
5270
+ const inst = resolveInstance(resolve3("."), target);
4269
5271
  await login(inst.url);
4270
5272
  console.log(`\u2713 logged in to ${inst.name} (${inst.url})`);
4271
5273
  }
4272
5274
  async function logoutCmd(target) {
4273
- const inst = resolveInstance(resolve2("."), target);
5275
+ const inst = resolveInstance(resolve3("."), target);
4274
5276
  console.log(clearCreds(inst.url) ? `\u2713 logged out of ${inst.url}` : `not logged in to ${inst.url}`);
4275
5277
  }
4276
5278
  var program = new Command();
4277
5279
  program.name("malloyyo").description("Publish Malloy models to a Malloyyo instance").version(version);
4278
5280
  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
5281
  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);
5282
+ program.command("publish").argument(
5283
+ "[target]",
5284
+ "named target from the `malloyyo` config block; optional when the repo defines one"
5285
+ ).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
5286
  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));
5287
+ const root = resolve3(dir);
5288
+ const report = await lintDashboards(root);
4283
5289
  if (report.dashboards.length === 0) {
4284
5290
  console.log("no dashboards to lint");
4285
5291
  return;
4286
5292
  }
4287
5293
  printLintReport(report);
4288
- if (!report.ok) process.exit(1);
5294
+ if (!report.ok) {
5295
+ const hint = missingEnvHint(missingEnvRefs(gatherDirectory(root).config), "this shell");
5296
+ if (hint) console.error(hint.replace(/^\n/, ""));
5297
+ process.exit(1);
5298
+ }
4289
5299
  });
4290
- 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);
5300
+ program.command("status").argument(
5301
+ "[target]",
5302
+ "named target from the `malloyyo` config block; optional when the repo defines one"
5303
+ ).option("--token <token>", "bearer token (overrides login/env)").description("show what's live on the target: version, commit, compile state").action(status);
4291
5304
  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(
4292
5305
  "run a local stdio MCP server over the Malloy model in the current directory. --develop for authoring, --explore (default) to preview the web experience"
4293
5306
  ).action(async (opts) => {
@@ -4351,6 +5364,7 @@ program.command("dashboard").argument("<action>", "action to run (dev | bundle)"
4351
5364
  throw new Error(`unknown dashboard action '${action}' (expected: dev | bundle)`);
4352
5365
  }
4353
5366
  );
5367
+ registerCloudCommands(program);
4354
5368
  program.parseAsync().catch((err) => {
4355
5369
  console.error(err instanceof Error ? err.message : String(err));
4356
5370
  process.exit(1);