@malloydata/malloyyo 0.2.31 → 0.2.33

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
@@ -96,3 +96,63 @@ your `malloyyo login` session. So interactively you just `login` once; in CI you
96
96
  var and never touch the browser.
97
97
 
98
98
  See `docs/model-publishing-design.md` in the repo for the full design.
99
+
100
+ ## Malloyyo-hosted instances (`malloyyo cloud`)
101
+
102
+ For instances Malloyyo runs for you. Everything above works the same on one — a hosted
103
+ instance is a URL you `login` to and `publish` at — and these commands are how you get one
104
+ and configure it.
105
+
106
+ ```bash
107
+ malloyyo cloud instance create acme # provision acme.malloyyo.com, wait for it to come up
108
+ malloyyo cloud instance list
109
+ malloyyo cloud instance status acme
110
+ malloyyo cloud instance delete acme # reversible until it is destroyed
111
+ ```
112
+
113
+ Name the instance the way you already know it — `acme`, the name in its URL and the one you
114
+ `malloyyo login` to. Its ID works too, and is the one to use for an instance that has been
115
+ fully destroyed, since its name is free for someone else to take.
116
+
117
+ `create` prints each provisioning step as it finishes and ends with the URL to sign in at,
118
+ which is the same URL you then `malloyyo login`. It provisions real infrastructure, so it
119
+ takes minutes; if the command stops waiting, the work continues and `instance status` picks
120
+ it up. Every command takes `--json` for a parseable answer instead of progress lines.
121
+
122
+ ### Warehouse secrets
123
+
124
+ The credentials your Malloy models resolve connections from — the values behind
125
+ `{ "env": "NAME" }` in `malloy-config.json`. Several in one command are applied together, and
126
+ the command returns once they are live (your instance restarts briefly).
127
+
128
+ ```bash
129
+ malloyyo cloud secrets set acme PG_HOST=db.example.com PG_USER=app PG_PASSWORD
130
+ op read op://vault/pg/password | malloyyo cloud secrets set acme --stdin
131
+ ```
132
+
133
+ A value typed as `NAME=value` lands in your shell history and is visible in `ps` while the
134
+ command runs, so there are two ways not to type one: a **bare `NAME`** is prompted for with
135
+ the input hidden, and **`--stdin`** reads `NAME=value` lines from a file, a CI variable, or a
136
+ password manager. `NAME=value` stays for the parts that are not secrets — a host, a port, a
137
+ user. Values are write-only: nothing in this CLI, and no endpoint behind it, reads one back.
138
+
139
+ ### Credentials
140
+
141
+ `malloyyo cloud` authenticates with a machine credential Malloyyo issues when your account is
142
+ created, read from the environment:
143
+
144
+ ```bash
145
+ export MALLOYYO_CLIENT_ID=...
146
+ export MALLOYYO_CLIENT_SECRET=...
147
+ ```
148
+
149
+ That is the whole of it — there is nothing else to configure.
150
+
151
+ It is separate from `malloyyo login`, which authenticates *you* to one instance. This one
152
+ identifies your account to Malloyyo, and each command trades it for a short-lived access
153
+ token carrying only the permissions that command needs: a `list` cannot create, and only
154
+ `secrets set` can write secrets. The trade happens against Malloyyo's own API, so the CLI
155
+ talks to nothing else. Your secret is held in memory for that request and is never logged,
156
+ printed, or written to disk.
157
+
158
+ `MALLOYYO_API_URL` overrides the built-in API address; you should not need to set it.
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)";
@@ -2682,10 +2704,30 @@ function clearCreds(url6) {
2682
2704
  return true;
2683
2705
  }
2684
2706
 
2707
+ // package.json
2708
+ var version = "0.2.33";
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
+
2685
2727
  // src/oauth.ts
2686
2728
  var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
2687
2729
  async function discover(baseUrl) {
2688
- const res = await fetch(`${baseUrl}/api/oauth/discovery/authorization-server`);
2730
+ const res = await apiFetch(`${baseUrl}/api/oauth/discovery/authorization-server`);
2689
2731
  if (!res.ok) throw new Error(`OAuth discovery failed at ${baseUrl}: ${res.status} ${res.statusText}`);
2690
2732
  return await res.json();
2691
2733
  }
@@ -2695,7 +2737,7 @@ function pkce() {
2695
2737
  return { verifier, challenge };
2696
2738
  }
2697
2739
  async function registerClient(registrationEndpoint, redirectUri) {
2698
- const res = await fetch(registrationEndpoint, {
2740
+ const res = await apiFetch(registrationEndpoint, {
2699
2741
  method: "POST",
2700
2742
  headers: { "content-type": "application/json" },
2701
2743
  body: JSON.stringify({
@@ -2773,7 +2815,7 @@ async function login(baseUrl) {
2773
2815
  `);
2774
2816
  openBrowser(authUrl.toString());
2775
2817
  const authCode = await code;
2776
- const res = await fetch(ep.token_endpoint, {
2818
+ const res = await apiFetch(ep.token_endpoint, {
2777
2819
  method: "POST",
2778
2820
  headers: { "content-type": "application/x-www-form-urlencoded" },
2779
2821
  body: new URLSearchParams({
@@ -2800,7 +2842,7 @@ async function login(baseUrl) {
2800
2842
  }
2801
2843
  async function refresh(baseUrl, creds) {
2802
2844
  const ep = await discover(baseUrl);
2803
- const res = await fetch(ep.token_endpoint, {
2845
+ const res = await apiFetch(ep.token_endpoint, {
2804
2846
  method: "POST",
2805
2847
  headers: { "content-type": "application/x-www-form-urlencoded" },
2806
2848
  body: new URLSearchParams({
@@ -3072,6 +3114,11 @@ function urlStateFromSearch(search) {
3072
3114
  return s;
3073
3115
  }
3074
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
+
3075
3122
  // src/shared/nav.ts
3076
3123
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3077
3124
  var NAV_CSS = `
@@ -3252,7 +3299,7 @@ function makeInPageBundler() {
3252
3299
  return js;
3253
3300
  };
3254
3301
  }
3255
- 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>`;
3256
3303
  function navHtml2(dash, all) {
3257
3304
  return navHtml(dash.name, all, (n) => `/?d=${encodeURIComponent(n)}`);
3258
3305
  }
@@ -3269,14 +3316,14 @@ function inPageShell(dash, all, givenSpecs, initialGivens, initialUrlState, tile
3269
3316
  autorun: dash.autorun
3270
3317
  };
3271
3318
  return html(
3272
- 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>`,
3273
3320
  dash.title
3274
3321
  );
3275
3322
  }
3276
3323
  function parentShell(dash, frameBase, all, initialGivens, initialUrlState) {
3277
3324
  const givensQs = Object.entries({ ...initialGivens, ...initialUrlState }).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("");
3278
- const d = JSON.stringify(dash.name);
3279
- const fb = JSON.stringify(frameBase);
3325
+ const d = safeJson(dash.name);
3326
+ const fb = safeJson(frameBase);
3280
3327
  const nav = navHtml2(dash, all);
3281
3328
  return html(
3282
3329
  `<div style="display:flex;flex-direction:column;height:100vh">` + nav + // allow-popups(+escape-sandbox): let a # link mark open its target in a
@@ -3351,7 +3398,7 @@ function frameDoc(dash, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
3351
3398
  autorun: dash.autorun
3352
3399
  };
3353
3400
  return html(
3354
- `<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>`,
3355
3402
  dash.title
3356
3403
  );
3357
3404
  }
@@ -3550,7 +3597,7 @@ function serveStatic(dir, port) {
3550
3597
  res.writeHead(200, { "Content-Type": type, "Content-Length": st.size, "Accept-Ranges": "bytes" });
3551
3598
  fs6.createReadStream(file).pipe(res);
3552
3599
  });
3553
- return new Promise((resolve3, reject) => {
3600
+ return new Promise((resolve4, reject) => {
3554
3601
  let attempt = 0;
3555
3602
  const tryPort = (p) => {
3556
3603
  server.once("error", (err) => {
@@ -3566,7 +3613,7 @@ function serveStatic(dir, port) {
3566
3613
  (port ${port} busy \u2014 using ${p})`);
3567
3614
  console.log(`
3568
3615
  serving ${path7.basename(dir)}/ on http://localhost:${p} (ctrl-c to stop)`);
3569
- resolve3();
3616
+ resolve4();
3570
3617
  });
3571
3618
  };
3572
3619
  tryPort(port);
@@ -3699,12 +3746,12 @@ ${analyticsSnippet(analytics)}
3699
3746
  ${navFor(dash, all, cleanUrls)}
3700
3747
  <div id="root"></div>
3701
3748
  <script>
3702
- window.__DASHBOARD__ = ${JSON.stringify(info)};
3749
+ window.__DASHBOARD__ = ${safeJson(info)};
3703
3750
  // Given SPECS (label/type/default/suggest) are introspected from the model's
3704
3751
  // given: declarations at BUILD time \u2014 the runtime reads them from here to draw
3705
3752
  // controls and seed initial values. Without them there are no controls and
3706
3753
  // every given starts empty.
3707
- window.__GIVENS__ = ${JSON.stringify(givenSpecs)};
3754
+ window.__GIVENS__ = ${safeJson(givenSpecs)};
3708
3755
  // __INITIAL_GIVENS__ is NOT set here on purpose: the entry bundle sets it from
3709
3756
  // location.search using shared/givens-url, the same encoder the dev server uses.
3710
3757
  // An inline copy is what drifted last time (it stripped the dollar-sign prefix
@@ -3719,7 +3766,7 @@ window.__GIVENS__ = ${JSON.stringify(givenSpecs)};
3719
3766
  function indexPage(dashboards, title, custom, cleanUrls, analytics) {
3720
3767
  const link = (n) => cleanUrls ? `./${encodeURIComponent(n)}` : `./${encodeURIComponent(n)}.html`;
3721
3768
  const body = custom ? `<div id="root"></div>
3722
- <script>window.__DASHBOARDS__ = ${JSON.stringify(
3769
+ <script>window.__DASHBOARDS__ = ${safeJson(
3723
3770
  dashboards.map((d) => ({ name: d.name, title: d.title, description: d.description, href: link(d.name) }))
3724
3771
  )};</script>
3725
3772
  <script type="module" src="./assets/index.js"></script>` : `<main class="index"><h1>${esc3(title)}</h1><ul>` + dashboards.map(
@@ -4222,22 +4269,883 @@ async function launchCmd(mode, opts) {
4222
4269
  stdio: "inherit",
4223
4270
  cwd: root
4224
4271
  });
4225
- await new Promise((resolve3) => {
4272
+ await new Promise((resolve4) => {
4226
4273
  child.on("error", (e) => {
4227
4274
  process.stderr.write(
4228
4275
  `\u2717 could not launch \`claude\`: ${e.message}
4229
4276
  (is Claude Code installed and on PATH?)
4230
4277
  `
4231
4278
  );
4232
- resolve3();
4279
+ resolve4();
4233
4280
  });
4234
- child.on("exit", () => resolve3());
4281
+ child.on("exit", () => resolve4());
4235
4282
  });
4236
4283
  fs10.rmSync(tmpDir, { recursive: true, force: true });
4237
4284
  }
4238
4285
 
4239
- // package.json
4240
- var version = "0.2.31";
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
+ }
4241
5149
 
4242
5150
  // src/index.ts
4243
5151
  function shortSha(sha) {
@@ -4294,7 +5202,7 @@ function requestFailed(what, res, out, t, source) {
4294
5202
  return new Error(`${what} failed: ${detail}${hint}`);
4295
5203
  }
4296
5204
  async function publish(target, dir, opts) {
4297
- const root = resolve2(dir);
5205
+ const root = resolve3(dir);
4298
5206
  const t = resolveTarget(root, target);
4299
5207
  const source = tokenSource(t, { tokenFlag: opts.token });
4300
5208
  const bearer = await getAccessToken(t, { tokenFlag: opts.token });
@@ -4325,7 +5233,7 @@ async function publish(target, dir, opts) {
4325
5233
  return;
4326
5234
  }
4327
5235
  const push = `${t.url}/api/datasets/${t.dataset}/model/push${opts.createDataset ? "?create=1" : ""}`;
4328
- const res = await fetch(push, {
5236
+ const res = await apiFetch(push, {
4329
5237
  method: "POST",
4330
5238
  headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` },
4331
5239
  body: JSON.stringify(body)
@@ -4342,10 +5250,10 @@ async function publish(target, dir, opts) {
4342
5250
  );
4343
5251
  }
4344
5252
  async function status(target, opts) {
4345
- const t = resolveTarget(resolve2("."), target);
5253
+ const t = resolveTarget(resolve3("."), target);
4346
5254
  const source = tokenSource(t, { tokenFlag: opts.token });
4347
5255
  const bearer = await getAccessToken(t, { tokenFlag: opts.token });
4348
- 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`, {
4349
5257
  headers: { authorization: `Bearer ${bearer}` }
4350
5258
  });
4351
5259
  if (!res.ok) {
@@ -4359,21 +5267,24 @@ async function status(target, opts) {
4359
5267
  console.log(` ${s.compileError ? `\u2717 ${s.compileError}` : `\u2713 compiled ${s.compiledAt ?? ""}`}`);
4360
5268
  }
4361
5269
  async function loginCmd(target) {
4362
- const inst = resolveInstance(resolve2("."), target);
5270
+ const inst = resolveInstance(resolve3("."), target);
4363
5271
  await login(inst.url);
4364
5272
  console.log(`\u2713 logged in to ${inst.name} (${inst.url})`);
4365
5273
  }
4366
5274
  async function logoutCmd(target) {
4367
- const inst = resolveInstance(resolve2("."), target);
5275
+ const inst = resolveInstance(resolve3("."), target);
4368
5276
  console.log(clearCreds(inst.url) ? `\u2713 logged out of ${inst.url}` : `not logged in to ${inst.url}`);
4369
5277
  }
4370
5278
  var program = new Command();
4371
5279
  program.name("malloyyo").description("Publish Malloy models to a Malloyyo instance").version(version);
4372
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);
4373
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);
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);
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);
4375
5286
  program.command("lint").argument("[dir]", "directory to lint", ".").description("validate ./dashboards against the model (manifest, query, givens, Dashboard.tsx)").action(async (dir) => {
4376
- const root = resolve2(dir);
5287
+ const root = resolve3(dir);
4377
5288
  const report = await lintDashboards(root);
4378
5289
  if (report.dashboards.length === 0) {
4379
5290
  console.log("no dashboards to lint");
@@ -4386,7 +5297,10 @@ program.command("lint").argument("[dir]", "directory to lint", ".").description(
4386
5297
  process.exit(1);
4387
5298
  }
4388
5299
  });
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);
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);
4390
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(
4391
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"
4392
5306
  ).action(async (opts) => {
@@ -4450,6 +5364,7 @@ program.command("dashboard").argument("<action>", "action to run (dev | bundle)"
4450
5364
  throw new Error(`unknown dashboard action '${action}' (expected: dev | bundle)`);
4451
5365
  }
4452
5366
  );
5367
+ registerCloudCommands(program);
4453
5368
  program.parseAsync().catch((err) => {
4454
5369
  console.error(err instanceof Error ? err.message : String(err));
4455
5370
  process.exit(1);
@@ -0,0 +1,25 @@
1
+ // Embedding JSON in an inline <script>, in ONE place.
2
+ //
3
+ // `JSON.stringify` is NOT sufficient here and the failure is not obvious: a
4
+ // script element's content is RAW TEXT terminated by the literal `</script`,
5
+ // regardless of the element's `type`. So a value containing
6
+ // `</script><script>…` closes the block and the rest is parsed as HTML —
7
+ // script execution straight out of a dashboard title, a given label, or a URL
8
+ // parameter. JSON.stringify escapes quotes and backslashes; it does not escape
9
+ // `<`. The hosted app hit exactly this (src/lib/dashboards/frame-html.ts); the
10
+ // three CLI shells emit the same document shape, so they share the same fix.
11
+ //
12
+ // Dependency-free so the Node dev server and the emitted static site can both
13
+ // use it.
14
+
15
+ /** JSON safe to inline inside a `<script>` element. Escapes `<`/`>` so the
16
+ element cannot be terminated early, plus U+2028/U+2029 — legal raw inside a
17
+ JSON string, but literal line terminators in JS source. Every escape is
18
+ inside a string literal, so `JSON.parse` of the result is unchanged. */
19
+ export function safeJson(value: unknown): string {
20
+ return JSON.stringify(value)
21
+ .replace(/</g, "\\u003c")
22
+ .replace(/>/g, "\\u003e")
23
+ .replace(/\u2028/g, "\\u2028")
24
+ .replace(/\u2029/g, "\\u2029");
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@malloydata/malloyyo",
3
- "version": "0.2.31",
3
+ "version": "0.2.33",
4
4
  "description": "Publish Malloy models to a Malloyyo instance",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "scripts": {
25
25
  "build:engine": "cd ../mcp-engine && npm run build",
26
- "build": "npm run build:engine && esbuild src/index.ts --bundle --platform=node --format=esm --external:commander --external:esbuild --external:@malloydata/* --external:@modelcontextprotocol/* --outfile=dist/index.js && node scripts/copy-frame-src.mjs",
26
+ "build": "npm run build:engine && esbuild src/index.ts --bundle --platform=node --format=esm --external:commander --external:esbuild --external:@malloydata/* --external:@modelcontextprotocol/* --external:@inquirer/* --outfile=dist/index.js && node scripts/copy-frame-src.mjs",
27
27
  "dev": "tsx src/index.ts",
28
28
  "typecheck": "tsc --noEmit",
29
29
  "pretest": "npm run build",
@@ -33,11 +33,12 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@duckdb/duckdb-wasm": "1.33.1-dev45.0",
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",
36
+ "@inquirer/password": "^5.1.1",
37
+ "@malloydata/db-duckdb": "^0.0.431",
38
+ "@malloydata/malloy": "^0.0.431",
39
+ "@malloydata/malloy-connections": "^0.0.431",
40
+ "@malloydata/malloy-filter": "^0.0.431",
41
+ "@malloydata/render": "^0.0.431",
41
42
  "@modelcontextprotocol/sdk": "^1.29.0",
42
43
  "commander": "^12.1.0",
43
44
  "esbuild": "^0.24.0",