@malloydata/malloyyo 0.2.31 → 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/README.md +60 -0
- package/dist/index.js +945 -30
- package/dist/shared/html.ts +25 -0
- package/package.json +3 -2
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
|
|
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.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
|
+
|
|
2685
2727
|
// src/oauth.ts
|
|
2686
2728
|
var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
2687
2729
|
async function discover(baseUrl) {
|
|
2688
|
-
const res = await
|
|
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
|
|
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
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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__=${
|
|
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 =
|
|
3279
|
-
const fb =
|
|
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__=${
|
|
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((
|
|
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
|
-
|
|
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__ = ${
|
|
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__ = ${
|
|
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__ = ${
|
|
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((
|
|
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
|
-
|
|
4279
|
+
resolve4();
|
|
4233
4280
|
});
|
|
4234
|
-
child.on("exit", () =>
|
|
4281
|
+
child.on("exit", () => resolve4());
|
|
4235
4282
|
});
|
|
4236
4283
|
fs10.rmSync(tmpDir, { recursive: true, force: true });
|
|
4237
4284
|
}
|
|
4238
4285
|
|
|
4239
|
-
//
|
|
4240
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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.
|
|
3
|
+
"version": "0.2.32",
|
|
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,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@duckdb/duckdb-wasm": "1.33.1-dev45.0",
|
|
36
|
+
"@inquirer/password": "^5.1.1",
|
|
36
37
|
"@malloydata/db-duckdb": "^0.0.430",
|
|
37
38
|
"@malloydata/malloy": "^0.0.430",
|
|
38
39
|
"@malloydata/malloy-connections": "^0.0.430",
|