@malloydata/malloyyo 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +791 -276
- package/package.json +2 -2
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 } from "node:path";
|
|
5
|
+
import { resolve as resolve2 } from "node:path";
|
|
6
6
|
|
|
7
7
|
// src/config.ts
|
|
8
8
|
import { readFileSync, existsSync } from "node:fs";
|
|
@@ -38,8 +38,8 @@ function resolveTarget(dir, name) {
|
|
|
38
38
|
}
|
|
39
39
|
function resolveInstance(dir, arg) {
|
|
40
40
|
if (arg && /^https?:\/\//i.test(arg)) {
|
|
41
|
-
const
|
|
42
|
-
return { name:
|
|
41
|
+
const url4 = normalizeUrl(arg);
|
|
42
|
+
return { name: url4, url: url4 };
|
|
43
43
|
}
|
|
44
44
|
const targets = readTargetMap(dir);
|
|
45
45
|
const entries = Object.entries(targets);
|
|
@@ -84,6 +84,29 @@ function gatherDirectory(dir) {
|
|
|
84
84
|
const config = existsSync2(configPath) ? readFileSync2(configPath, "utf8") : void 0;
|
|
85
85
|
return { files, config };
|
|
86
86
|
}
|
|
87
|
+
function listDashboardDirs(dir) {
|
|
88
|
+
const base = join2(dir, "dashboards");
|
|
89
|
+
if (!existsSync2(base)) return [];
|
|
90
|
+
return readdirSync(base).filter((name) => {
|
|
91
|
+
const d = join2(base, name);
|
|
92
|
+
return statSync(d).isDirectory() && existsSync2(join2(d, "manifest.json"));
|
|
93
|
+
}).sort();
|
|
94
|
+
}
|
|
95
|
+
function gatherDashboards(dir) {
|
|
96
|
+
const base = join2(dir, "dashboards");
|
|
97
|
+
return listDashboardDirs(dir).map((name) => {
|
|
98
|
+
const raw = readFileSync2(join2(base, name, "manifest.json"), "utf8");
|
|
99
|
+
let manifest;
|
|
100
|
+
try {
|
|
101
|
+
manifest = JSON.parse(raw);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
throw new Error(`dashboards/${name}/manifest.json: invalid JSON (${e.message})`);
|
|
104
|
+
}
|
|
105
|
+
const tsxPath = join2(base, name, "Dashboard.tsx");
|
|
106
|
+
if (!existsSync2(tsxPath)) throw new Error(`dashboards/${name}: missing Dashboard.tsx`);
|
|
107
|
+
return { name, manifest, source: readFileSync2(tsxPath, "utf8") };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
87
110
|
function gitInfo(dir) {
|
|
88
111
|
const git = (args) => execFileSync("git", args, {
|
|
89
112
|
cwd: dir,
|
|
@@ -111,213 +134,15 @@ function gitInfo(dir) {
|
|
|
111
134
|
}
|
|
112
135
|
}
|
|
113
136
|
|
|
114
|
-
// src/
|
|
115
|
-
import
|
|
116
|
-
import
|
|
117
|
-
import
|
|
118
|
-
|
|
119
|
-
// src/store.ts
|
|
120
|
-
import { homedir } from "node:os";
|
|
121
|
-
import { dirname, join as join3 } from "node:path";
|
|
122
|
-
import { mkdirSync, readFileSync as readFileSync3, writeFileSync, existsSync as existsSync3, chmodSync } from "node:fs";
|
|
123
|
-
function credsPath() {
|
|
124
|
-
const base = process.env.XDG_CONFIG_HOME || join3(homedir(), ".config");
|
|
125
|
-
return join3(base, "malloyyo", "credentials.json");
|
|
126
|
-
}
|
|
127
|
-
function readAll() {
|
|
128
|
-
const p = credsPath();
|
|
129
|
-
if (!existsSync3(p)) return {};
|
|
130
|
-
try {
|
|
131
|
-
return JSON.parse(readFileSync3(p, "utf8"));
|
|
132
|
-
} catch {
|
|
133
|
-
return {};
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
function loadCreds(url3) {
|
|
137
|
-
return readAll()[url3];
|
|
138
|
-
}
|
|
139
|
-
function saveCreds(url3, creds) {
|
|
140
|
-
const p = credsPath();
|
|
141
|
-
mkdirSync(dirname(p), { recursive: true });
|
|
142
|
-
const all = readAll();
|
|
143
|
-
all[url3] = creds;
|
|
144
|
-
writeFileSync(p, JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
145
|
-
try {
|
|
146
|
-
chmodSync(p, 384);
|
|
147
|
-
} catch {
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
function clearCreds(url3) {
|
|
151
|
-
const all = readAll();
|
|
152
|
-
if (!(url3 in all)) return false;
|
|
153
|
-
delete all[url3];
|
|
154
|
-
writeFileSync(credsPath(), JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
155
|
-
return true;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// src/oauth.ts
|
|
159
|
-
var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
160
|
-
async function discover(baseUrl) {
|
|
161
|
-
const res = await fetch(`${baseUrl}/api/oauth/discovery/authorization-server`);
|
|
162
|
-
if (!res.ok) throw new Error(`OAuth discovery failed at ${baseUrl}: ${res.status} ${res.statusText}`);
|
|
163
|
-
return await res.json();
|
|
164
|
-
}
|
|
165
|
-
function pkce() {
|
|
166
|
-
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
167
|
-
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
168
|
-
return { verifier, challenge };
|
|
169
|
-
}
|
|
170
|
-
async function registerClient(registrationEndpoint, redirectUri) {
|
|
171
|
-
const res = await fetch(registrationEndpoint, {
|
|
172
|
-
method: "POST",
|
|
173
|
-
headers: { "content-type": "application/json" },
|
|
174
|
-
body: JSON.stringify({
|
|
175
|
-
client_name: "malloyyo CLI",
|
|
176
|
-
redirect_uris: [redirectUri],
|
|
177
|
-
token_endpoint_auth_method: "none",
|
|
178
|
-
grant_types: ["authorization_code", "refresh_token"],
|
|
179
|
-
response_types: ["code"],
|
|
180
|
-
scope: "mcp"
|
|
181
|
-
})
|
|
182
|
-
});
|
|
183
|
-
if (!res.ok) throw new Error(`client registration failed: ${res.status} ${await res.text()}`);
|
|
184
|
-
return (await res.json()).client_id;
|
|
185
|
-
}
|
|
186
|
-
function openBrowser(url3) {
|
|
187
|
-
const [cmd, args] = process.platform === "darwin" ? ["open", [url3]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url3]] : ["xdg-open", [url3]];
|
|
188
|
-
try {
|
|
189
|
-
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
190
|
-
} catch {
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
function awaitRedirect(state) {
|
|
194
|
-
return new Promise((resolveServer) => {
|
|
195
|
-
let resolveCode;
|
|
196
|
-
let rejectCode;
|
|
197
|
-
const code = new Promise((res, rej) => {
|
|
198
|
-
resolveCode = res;
|
|
199
|
-
rejectCode = rej;
|
|
200
|
-
});
|
|
201
|
-
const timer = setTimeout(() => rejectCode(new Error("timed out waiting for browser sign-in")), LOGIN_TIMEOUT_MS);
|
|
202
|
-
const server = http.createServer((req, res) => {
|
|
203
|
-
const u = new URL(req.url ?? "/", "http://localhost");
|
|
204
|
-
if (u.pathname !== "/callback") {
|
|
205
|
-
res.writeHead(404).end();
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
const err = u.searchParams.get("error");
|
|
209
|
-
const got = u.searchParams.get("code");
|
|
210
|
-
const ok = !err && !!got && u.searchParams.get("state") === state;
|
|
211
|
-
res.writeHead(ok ? 200 : 400, { "content-type": "text/html" });
|
|
212
|
-
res.end(
|
|
213
|
-
`<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>${ok ? "\u2713 Signed in to malloyyo" : "Sign-in failed"}</h2><p>${ok ? "You can close this tab and return to the terminal." : err ?? "state mismatch"}</p></body>`
|
|
214
|
-
);
|
|
215
|
-
clearTimeout(timer);
|
|
216
|
-
if (ok) resolveCode(got);
|
|
217
|
-
else rejectCode(new Error(err ?? "state mismatch or missing code"));
|
|
218
|
-
});
|
|
219
|
-
server.listen(0, "127.0.0.1", () => {
|
|
220
|
-
const port = server.address().port;
|
|
221
|
-
resolveServer({ port, code, close: () => server.close() });
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
async function login(baseUrl) {
|
|
226
|
-
const ep = await discover(baseUrl);
|
|
227
|
-
const { verifier, challenge } = pkce();
|
|
228
|
-
const state = crypto.randomBytes(16).toString("base64url");
|
|
229
|
-
const { port, code, close } = await awaitRedirect(state);
|
|
230
|
-
try {
|
|
231
|
-
const redirectUri = `http://localhost:${port}/callback`;
|
|
232
|
-
const clientId = await registerClient(ep.registration_endpoint, redirectUri);
|
|
233
|
-
const authUrl = new URL(ep.authorization_endpoint);
|
|
234
|
-
authUrl.search = new URLSearchParams({
|
|
235
|
-
response_type: "code",
|
|
236
|
-
client_id: clientId,
|
|
237
|
-
redirect_uri: redirectUri,
|
|
238
|
-
code_challenge: challenge,
|
|
239
|
-
code_challenge_method: "S256",
|
|
240
|
-
scope: "mcp",
|
|
241
|
-
state
|
|
242
|
-
}).toString();
|
|
243
|
-
console.log("Opening your browser to sign in\u2026");
|
|
244
|
-
console.log(`If it doesn't open, visit:
|
|
245
|
-
${authUrl.toString()}
|
|
246
|
-
`);
|
|
247
|
-
openBrowser(authUrl.toString());
|
|
248
|
-
const authCode = await code;
|
|
249
|
-
const res = await fetch(ep.token_endpoint, {
|
|
250
|
-
method: "POST",
|
|
251
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
252
|
-
body: new URLSearchParams({
|
|
253
|
-
grant_type: "authorization_code",
|
|
254
|
-
code: authCode,
|
|
255
|
-
redirect_uri: redirectUri,
|
|
256
|
-
client_id: clientId,
|
|
257
|
-
code_verifier: verifier
|
|
258
|
-
})
|
|
259
|
-
});
|
|
260
|
-
if (!res.ok) throw new Error(`token exchange failed: ${res.status} ${await res.text()}`);
|
|
261
|
-
const grant = await res.json();
|
|
262
|
-
const creds = {
|
|
263
|
-
clientId,
|
|
264
|
-
accessToken: grant.access_token,
|
|
265
|
-
refreshToken: grant.refresh_token,
|
|
266
|
-
expiresAt: Date.now() + (grant.expires_in ?? 86400) * 1e3
|
|
267
|
-
};
|
|
268
|
-
saveCreds(baseUrl, creds);
|
|
269
|
-
return creds;
|
|
270
|
-
} finally {
|
|
271
|
-
close();
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
async function refresh(baseUrl, creds) {
|
|
275
|
-
const ep = await discover(baseUrl);
|
|
276
|
-
const res = await fetch(ep.token_endpoint, {
|
|
277
|
-
method: "POST",
|
|
278
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
279
|
-
body: new URLSearchParams({
|
|
280
|
-
grant_type: "refresh_token",
|
|
281
|
-
refresh_token: creds.refreshToken,
|
|
282
|
-
client_id: creds.clientId
|
|
283
|
-
})
|
|
284
|
-
});
|
|
285
|
-
if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
|
|
286
|
-
const grant = await res.json();
|
|
287
|
-
const updated = {
|
|
288
|
-
clientId: creds.clientId,
|
|
289
|
-
accessToken: grant.access_token,
|
|
290
|
-
refreshToken: grant.refresh_token,
|
|
291
|
-
expiresAt: Date.now() + (grant.expires_in ?? 86400) * 1e3
|
|
292
|
-
};
|
|
293
|
-
saveCreds(baseUrl, updated);
|
|
294
|
-
return updated;
|
|
295
|
-
}
|
|
296
|
-
async function getAccessToken(target, opts) {
|
|
297
|
-
if (opts.tokenFlag) return opts.tokenFlag;
|
|
298
|
-
if (target.tokenEnv && process.env[target.tokenEnv]) return process.env[target.tokenEnv];
|
|
299
|
-
let creds = loadCreds(target.url);
|
|
300
|
-
if (!creds) {
|
|
301
|
-
throw new Error(`Not authenticated for ${target.url}.
|
|
302
|
-
Run: malloyyo login ${target.name}`);
|
|
303
|
-
}
|
|
304
|
-
if (creds.expiresAt - Date.now() < 6e4) {
|
|
305
|
-
try {
|
|
306
|
-
creds = await refresh(target.url, creds);
|
|
307
|
-
} catch {
|
|
308
|
-
throw new Error(`Session expired for ${target.url}.
|
|
309
|
-
Run: malloyyo login ${target.name}`);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return creds.accessToken;
|
|
313
|
-
}
|
|
137
|
+
// src/lint.ts
|
|
138
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
139
|
+
import { join as join3, resolve } from "node:path";
|
|
140
|
+
import * as esbuild from "esbuild";
|
|
314
141
|
|
|
315
|
-
// src/
|
|
142
|
+
// src/host.ts
|
|
316
143
|
import fs from "node:fs";
|
|
317
144
|
import path2 from "node:path";
|
|
318
145
|
import url2 from "node:url";
|
|
319
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
320
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
321
146
|
import {
|
|
322
147
|
MalloyConfig,
|
|
323
148
|
Runtime,
|
|
@@ -753,30 +578,30 @@ function walkFields(fields, structDefFields, depth, ctx, anon) {
|
|
|
753
578
|
const ef = f;
|
|
754
579
|
const cls = classifyJoinTarget(ef, ctx.knownSources);
|
|
755
580
|
const inlineMode = ctx.opts.expand === "inline";
|
|
756
|
-
const
|
|
757
|
-
applyDocs(
|
|
758
|
-
if (cls.kind === "ref")
|
|
581
|
+
const join5 = { name: f.name, relationship: joinRel(ef) };
|
|
582
|
+
applyDocs(join5, f.annotations);
|
|
583
|
+
if (cls.kind === "ref") join5.source_ref = cls.name;
|
|
759
584
|
else if (cls.kind === "anon" && !inlineMode) {
|
|
760
|
-
|
|
585
|
+
join5.anon_src_index = allocAnon(ef, cls.refId, depth + 1, ctx, anon);
|
|
761
586
|
}
|
|
762
|
-
if (needsQuote(f.name))
|
|
763
|
-
if (annotations.length > 0)
|
|
764
|
-
if (loc)
|
|
587
|
+
if (needsQuote(f.name)) join5.must_quote = true;
|
|
588
|
+
if (annotations.length > 0) join5.annotations = annotations;
|
|
589
|
+
if (loc) join5.location = loc;
|
|
765
590
|
const synthetic = isScalarArray(ef) || isRepeatedRecord(ef) || isAnonymousRecord(ef);
|
|
766
|
-
if (isScalarArray(ef))
|
|
767
|
-
else if (isRepeatedRecord(ef))
|
|
768
|
-
else if (isAnonymousRecord(ef))
|
|
591
|
+
if (isScalarArray(ef)) join5.column_shape = "scalar_array";
|
|
592
|
+
else if (isRepeatedRecord(ef)) join5.column_shape = "record_array";
|
|
593
|
+
else if (isAnonymousRecord(ef)) join5.column_shape = "record";
|
|
769
594
|
if (!synthetic && mLoc) {
|
|
770
595
|
const body = sliceSource(ctx.readSource(mLoc.url), mLoc);
|
|
771
|
-
if (body)
|
|
596
|
+
if (body) join5.body = body;
|
|
772
597
|
}
|
|
773
598
|
const shouldInline = inlineMode || cls.kind === "own";
|
|
774
599
|
if (shouldInline && depth < MAX_JOIN_DEPTH) {
|
|
775
600
|
const childStructFields = ef.structDef.fields ?? [];
|
|
776
601
|
const sub = walkFields(ef.allFields, childStructFields, depth + 1, ctx, anon);
|
|
777
|
-
|
|
602
|
+
join5.fields = stripScalarArrayValue(ef, sub);
|
|
778
603
|
}
|
|
779
|
-
groups.joins.push(
|
|
604
|
+
groups.joins.push(join5);
|
|
780
605
|
continue;
|
|
781
606
|
}
|
|
782
607
|
if (f.isQueryField()) {
|
|
@@ -1180,6 +1005,67 @@ async function executeMaterialized(query, opts, loadProblems, decorate = (p) =>
|
|
|
1180
1005
|
return { ok: false, problems: [...loadProblems, errorProblem(e, uri)] };
|
|
1181
1006
|
}
|
|
1182
1007
|
}
|
|
1008
|
+
async function run(runtime, entry, opts = {}) {
|
|
1009
|
+
let materializer;
|
|
1010
|
+
let modelQueries;
|
|
1011
|
+
let loadProblems;
|
|
1012
|
+
try {
|
|
1013
|
+
materializer = runtime.loadModel(entry);
|
|
1014
|
+
const model = await materializer.getModel();
|
|
1015
|
+
modelQueries = { named: [...model.queries().named], unnamed: model.queries().unnamed };
|
|
1016
|
+
loadProblems = mapProblems(model.problems);
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
if (e instanceof MalloyError2) {
|
|
1019
|
+
return { ok: false, problems: mapProblems(e.problems) };
|
|
1020
|
+
}
|
|
1021
|
+
return { ok: false, problems: [errorProblem(e, entry.href)] };
|
|
1022
|
+
}
|
|
1023
|
+
let query;
|
|
1024
|
+
if (opts.name !== void 0) {
|
|
1025
|
+
if (!modelQueries.named.includes(opts.name)) {
|
|
1026
|
+
return {
|
|
1027
|
+
ok: false,
|
|
1028
|
+
problems: [
|
|
1029
|
+
codeProblem(
|
|
1030
|
+
"selector-not-found",
|
|
1031
|
+
`No query named '${opts.name}'. Available: ` + JSON.stringify({ queries: modelQueries.named, runs: modelQueries.unnamed }),
|
|
1032
|
+
entry.href
|
|
1033
|
+
)
|
|
1034
|
+
]
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
query = materializer.loadQueryByName(opts.name);
|
|
1038
|
+
} else if (typeof opts.index === "number") {
|
|
1039
|
+
if (opts.index < 0 || opts.index >= modelQueries.unnamed) {
|
|
1040
|
+
return {
|
|
1041
|
+
ok: false,
|
|
1042
|
+
problems: [
|
|
1043
|
+
codeProblem(
|
|
1044
|
+
"selector-out-of-range",
|
|
1045
|
+
`Index ${opts.index} out of range; the model has ${modelQueries.unnamed} run: statement(s).`,
|
|
1046
|
+
entry.href
|
|
1047
|
+
)
|
|
1048
|
+
]
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
query = materializer.loadQueryByIndex(opts.index);
|
|
1052
|
+
} else {
|
|
1053
|
+
if (modelQueries.unnamed === 0) {
|
|
1054
|
+
return {
|
|
1055
|
+
ok: false,
|
|
1056
|
+
problems: [
|
|
1057
|
+
codeProblem(
|
|
1058
|
+
"no-run",
|
|
1059
|
+
"The source has no run: statement. Specify a named query via `name`, or add a run: to the source.",
|
|
1060
|
+
entry.href
|
|
1061
|
+
)
|
|
1062
|
+
]
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
query = materializer.loadFinalQuery();
|
|
1066
|
+
}
|
|
1067
|
+
return executeMaterialized(query, opts, loadProblems, (p) => p, entry.href);
|
|
1068
|
+
}
|
|
1183
1069
|
async function queryGivens(q) {
|
|
1184
1070
|
try {
|
|
1185
1071
|
const pq = await q.getPreparedQuery();
|
|
@@ -1693,36 +1579,383 @@ function exploreSurface(host, opts = {}) {
|
|
|
1693
1579
|
};
|
|
1694
1580
|
}
|
|
1695
1581
|
|
|
1696
|
-
//
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
if (typeof malloy_text === "string" && malloy_text.length > 0) {
|
|
1708
|
-
content.push({ type: "text", text: malloy_text });
|
|
1709
|
-
}
|
|
1710
|
-
return { content, structuredContent: { ...rest } };
|
|
1582
|
+
// src/host.ts
|
|
1583
|
+
var ENTRY = "index.malloy";
|
|
1584
|
+
function fsReader() {
|
|
1585
|
+
return {
|
|
1586
|
+
readURL: async (u) => {
|
|
1587
|
+
if (u.protocol !== "file:") {
|
|
1588
|
+
throw new Error(`unsupported URL scheme for import: ${u.href}`);
|
|
1589
|
+
}
|
|
1590
|
+
return fs.promises.readFile(u, "utf8");
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1711
1593
|
}
|
|
1712
|
-
function
|
|
1713
|
-
|
|
1594
|
+
async function loadConfig(rootUrl, reader) {
|
|
1595
|
+
const discovered = await discoverConfig(rootUrl, rootUrl, reader).catch(() => null);
|
|
1596
|
+
return discovered ?? new MalloyConfig({ includeDefaultConnections: true }, {
|
|
1597
|
+
rootDirectory: rootUrl.toString()
|
|
1598
|
+
});
|
|
1714
1599
|
}
|
|
1715
|
-
function
|
|
1716
|
-
|
|
1717
|
-
const
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1600
|
+
async function makeRunner(root) {
|
|
1601
|
+
await import("@malloydata/malloy-connections");
|
|
1602
|
+
const abs = path2.resolve(root);
|
|
1603
|
+
const rootUrl = url2.pathToFileURL(abs + path2.sep);
|
|
1604
|
+
async function lease(fn) {
|
|
1605
|
+
const reader = fsReader();
|
|
1606
|
+
const config = await loadConfig(rootUrl, reader);
|
|
1607
|
+
const { reader: prepared, entry } = prepareSource(reader, { url: path2.join(abs, ENTRY) });
|
|
1608
|
+
const runtime = new Runtime({ config, urlReader: prepared });
|
|
1609
|
+
try {
|
|
1610
|
+
return await fn(runtime, entry);
|
|
1611
|
+
} finally {
|
|
1612
|
+
await config.shutdown("idle").catch(() => {
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
return {
|
|
1617
|
+
root: abs,
|
|
1618
|
+
entryExists: () => fs.existsSync(path2.join(abs, ENTRY)),
|
|
1619
|
+
run(queryName, givens) {
|
|
1620
|
+
return lease(
|
|
1621
|
+
(runtime, entry) => run(runtime, entry, { name: queryName, givens, stableResult: true, rowLimit: 5e3 })
|
|
1622
|
+
);
|
|
1623
|
+
},
|
|
1624
|
+
validate(queryName, givens) {
|
|
1625
|
+
return lease(async (runtime, entry) => {
|
|
1626
|
+
try {
|
|
1627
|
+
const mm = runtime.loadModel(entry);
|
|
1628
|
+
const model = await mm.getModel();
|
|
1629
|
+
const named = [...model.queries().named];
|
|
1630
|
+
if (!named.includes(queryName)) {
|
|
1631
|
+
return { ok: false, error: `no query named '${queryName}' (model has: ${named.join(", ") || "none"})` };
|
|
1632
|
+
}
|
|
1633
|
+
const q = mm.loadQueryByName(queryName);
|
|
1634
|
+
const has = givens && Object.keys(givens).length > 0;
|
|
1635
|
+
await q.getSQL(has ? { givens } : void 0);
|
|
1636
|
+
return { ok: true };
|
|
1637
|
+
} catch (e) {
|
|
1638
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// src/lint.ts
|
|
1646
|
+
async function lintDashboards(root) {
|
|
1647
|
+
const abs = resolve(root);
|
|
1648
|
+
const names = listDashboardDirs(abs);
|
|
1649
|
+
const dashboards = [];
|
|
1650
|
+
if (names.length === 0) return { ok: true, dashboards };
|
|
1651
|
+
const runner = await makeRunner(abs);
|
|
1652
|
+
if (!runner.entryExists()) {
|
|
1653
|
+
return { ok: false, dashboards: [{ name: "(model)", errors: [`no index.malloy at ${abs}`] }] };
|
|
1654
|
+
}
|
|
1655
|
+
for (const name of names) {
|
|
1656
|
+
const errors = [];
|
|
1657
|
+
const dir = join3(abs, "dashboards", name);
|
|
1658
|
+
let manifest = null;
|
|
1659
|
+
try {
|
|
1660
|
+
manifest = JSON.parse(readFileSync3(join3(dir, "manifest.json"), "utf8"));
|
|
1661
|
+
} catch (e) {
|
|
1662
|
+
errors.push(`manifest.json: invalid JSON (${e.message})`);
|
|
1663
|
+
}
|
|
1664
|
+
let query;
|
|
1665
|
+
const givenValues = {};
|
|
1666
|
+
if (manifest) {
|
|
1667
|
+
if (typeof manifest.title !== "string") errors.push(`manifest: "title" must be a string`);
|
|
1668
|
+
if (typeof manifest.query !== "string") errors.push(`manifest: "query" must be a string`);
|
|
1669
|
+
else query = manifest.query;
|
|
1670
|
+
const givens = manifest.givens;
|
|
1671
|
+
if (!Array.isArray(givens)) {
|
|
1672
|
+
errors.push(`manifest: "givens" must be an array`);
|
|
1673
|
+
} else {
|
|
1674
|
+
for (const g of givens) {
|
|
1675
|
+
if (typeof g?.name !== "string") {
|
|
1676
|
+
errors.push(`manifest: every given needs a string "name"`);
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
if (g.type !== "string" && g.type !== "number") {
|
|
1680
|
+
errors.push(`given "${g.name}": "type" must be "string" or "number"`);
|
|
1681
|
+
}
|
|
1682
|
+
if (g.default !== void 0) givenValues[g.name] = g.default;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
const tsxPath = join3(dir, "Dashboard.tsx");
|
|
1687
|
+
if (!existsSync3(tsxPath)) {
|
|
1688
|
+
errors.push(`missing Dashboard.tsx`);
|
|
1689
|
+
} else {
|
|
1690
|
+
try {
|
|
1691
|
+
await esbuild.transform(readFileSync3(tsxPath, "utf8"), { loader: "tsx", jsx: "automatic" });
|
|
1692
|
+
} catch (e) {
|
|
1693
|
+
const msg = e.errors?.map((x) => x.text).join("; ") ?? String(e);
|
|
1694
|
+
errors.push(`Dashboard.tsx: ${msg}`);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
if (query) {
|
|
1698
|
+
const v = await runner.validate(query, givenValues);
|
|
1699
|
+
if (!v.ok) errors.push(v.error);
|
|
1700
|
+
}
|
|
1701
|
+
dashboards.push({ name, errors });
|
|
1702
|
+
}
|
|
1703
|
+
return { ok: dashboards.every((d) => d.errors.length === 0), dashboards };
|
|
1704
|
+
}
|
|
1705
|
+
function printLintReport(report) {
|
|
1706
|
+
for (const d of report.dashboards) {
|
|
1707
|
+
if (d.errors.length === 0) {
|
|
1708
|
+
console.log(` \u2713 ${d.name}`);
|
|
1709
|
+
} else {
|
|
1710
|
+
console.log(` \u2717 ${d.name}`);
|
|
1711
|
+
for (const e of d.errors) console.log(` ${e}`);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// src/oauth.ts
|
|
1717
|
+
import http from "node:http";
|
|
1718
|
+
import crypto from "node:crypto";
|
|
1719
|
+
import { spawn } from "node:child_process";
|
|
1720
|
+
|
|
1721
|
+
// src/store.ts
|
|
1722
|
+
import { homedir } from "node:os";
|
|
1723
|
+
import { dirname, join as join4 } from "node:path";
|
|
1724
|
+
import { mkdirSync, readFileSync as readFileSync4, writeFileSync, existsSync as existsSync4, chmodSync } from "node:fs";
|
|
1725
|
+
function credsPath() {
|
|
1726
|
+
const base = process.env.XDG_CONFIG_HOME || join4(homedir(), ".config");
|
|
1727
|
+
return join4(base, "malloyyo", "credentials.json");
|
|
1728
|
+
}
|
|
1729
|
+
function readAll() {
|
|
1730
|
+
const p = credsPath();
|
|
1731
|
+
if (!existsSync4(p)) return {};
|
|
1732
|
+
try {
|
|
1733
|
+
return JSON.parse(readFileSync4(p, "utf8"));
|
|
1734
|
+
} catch {
|
|
1735
|
+
return {};
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
function loadCreds(url4) {
|
|
1739
|
+
return readAll()[url4];
|
|
1740
|
+
}
|
|
1741
|
+
function saveCreds(url4, creds) {
|
|
1742
|
+
const p = credsPath();
|
|
1743
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
1744
|
+
const all = readAll();
|
|
1745
|
+
all[url4] = creds;
|
|
1746
|
+
writeFileSync(p, JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
1747
|
+
try {
|
|
1748
|
+
chmodSync(p, 384);
|
|
1749
|
+
} catch {
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
function clearCreds(url4) {
|
|
1753
|
+
const all = readAll();
|
|
1754
|
+
if (!(url4 in all)) return false;
|
|
1755
|
+
delete all[url4];
|
|
1756
|
+
writeFileSync(credsPath(), JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
1757
|
+
return true;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// src/oauth.ts
|
|
1761
|
+
var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
1762
|
+
async function discover(baseUrl) {
|
|
1763
|
+
const res = await fetch(`${baseUrl}/api/oauth/discovery/authorization-server`);
|
|
1764
|
+
if (!res.ok) throw new Error(`OAuth discovery failed at ${baseUrl}: ${res.status} ${res.statusText}`);
|
|
1765
|
+
return await res.json();
|
|
1766
|
+
}
|
|
1767
|
+
function pkce() {
|
|
1768
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
1769
|
+
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
1770
|
+
return { verifier, challenge };
|
|
1771
|
+
}
|
|
1772
|
+
async function registerClient(registrationEndpoint, redirectUri) {
|
|
1773
|
+
const res = await fetch(registrationEndpoint, {
|
|
1774
|
+
method: "POST",
|
|
1775
|
+
headers: { "content-type": "application/json" },
|
|
1776
|
+
body: JSON.stringify({
|
|
1777
|
+
client_name: "malloyyo CLI",
|
|
1778
|
+
redirect_uris: [redirectUri],
|
|
1779
|
+
token_endpoint_auth_method: "none",
|
|
1780
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
1781
|
+
response_types: ["code"],
|
|
1782
|
+
scope: "mcp"
|
|
1783
|
+
})
|
|
1784
|
+
});
|
|
1785
|
+
if (!res.ok) throw new Error(`client registration failed: ${res.status} ${await res.text()}`);
|
|
1786
|
+
return (await res.json()).client_id;
|
|
1787
|
+
}
|
|
1788
|
+
function openBrowser(url4) {
|
|
1789
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url4]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url4]] : ["xdg-open", [url4]];
|
|
1790
|
+
try {
|
|
1791
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
1792
|
+
} catch {
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
function awaitRedirect(state) {
|
|
1796
|
+
return new Promise((resolveServer) => {
|
|
1797
|
+
let resolveCode;
|
|
1798
|
+
let rejectCode;
|
|
1799
|
+
const code = new Promise((res, rej) => {
|
|
1800
|
+
resolveCode = res;
|
|
1801
|
+
rejectCode = rej;
|
|
1802
|
+
});
|
|
1803
|
+
const timer = setTimeout(() => rejectCode(new Error("timed out waiting for browser sign-in")), LOGIN_TIMEOUT_MS);
|
|
1804
|
+
const server = http.createServer((req, res) => {
|
|
1805
|
+
const u = new URL(req.url ?? "/", "http://localhost");
|
|
1806
|
+
if (u.pathname !== "/callback") {
|
|
1807
|
+
res.writeHead(404).end();
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
const err = u.searchParams.get("error");
|
|
1811
|
+
const got = u.searchParams.get("code");
|
|
1812
|
+
const ok = !err && !!got && u.searchParams.get("state") === state;
|
|
1813
|
+
res.writeHead(ok ? 200 : 400, { "content-type": "text/html" });
|
|
1814
|
+
res.end(
|
|
1815
|
+
`<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>${ok ? "\u2713 Signed in to malloyyo" : "Sign-in failed"}</h2><p>${ok ? "You can close this tab and return to the terminal." : err ?? "state mismatch"}</p></body>`
|
|
1816
|
+
);
|
|
1817
|
+
clearTimeout(timer);
|
|
1818
|
+
if (ok) resolveCode(got);
|
|
1819
|
+
else rejectCode(new Error(err ?? "state mismatch or missing code"));
|
|
1820
|
+
});
|
|
1821
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1822
|
+
const port = server.address().port;
|
|
1823
|
+
resolveServer({ port, code, close: () => server.close() });
|
|
1824
|
+
});
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
async function login(baseUrl) {
|
|
1828
|
+
const ep = await discover(baseUrl);
|
|
1829
|
+
const { verifier, challenge } = pkce();
|
|
1830
|
+
const state = crypto.randomBytes(16).toString("base64url");
|
|
1831
|
+
const { port, code, close } = await awaitRedirect(state);
|
|
1832
|
+
try {
|
|
1833
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
1834
|
+
const clientId = await registerClient(ep.registration_endpoint, redirectUri);
|
|
1835
|
+
const authUrl = new URL(ep.authorization_endpoint);
|
|
1836
|
+
authUrl.search = new URLSearchParams({
|
|
1837
|
+
response_type: "code",
|
|
1838
|
+
client_id: clientId,
|
|
1839
|
+
redirect_uri: redirectUri,
|
|
1840
|
+
code_challenge: challenge,
|
|
1841
|
+
code_challenge_method: "S256",
|
|
1842
|
+
scope: "mcp",
|
|
1843
|
+
state
|
|
1844
|
+
}).toString();
|
|
1845
|
+
console.log("Opening your browser to sign in\u2026");
|
|
1846
|
+
console.log(`If it doesn't open, visit:
|
|
1847
|
+
${authUrl.toString()}
|
|
1848
|
+
`);
|
|
1849
|
+
openBrowser(authUrl.toString());
|
|
1850
|
+
const authCode = await code;
|
|
1851
|
+
const res = await fetch(ep.token_endpoint, {
|
|
1852
|
+
method: "POST",
|
|
1853
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1854
|
+
body: new URLSearchParams({
|
|
1855
|
+
grant_type: "authorization_code",
|
|
1856
|
+
code: authCode,
|
|
1857
|
+
redirect_uri: redirectUri,
|
|
1858
|
+
client_id: clientId,
|
|
1859
|
+
code_verifier: verifier
|
|
1860
|
+
})
|
|
1861
|
+
});
|
|
1862
|
+
if (!res.ok) throw new Error(`token exchange failed: ${res.status} ${await res.text()}`);
|
|
1863
|
+
const grant = await res.json();
|
|
1864
|
+
const creds = {
|
|
1865
|
+
clientId,
|
|
1866
|
+
accessToken: grant.access_token,
|
|
1867
|
+
refreshToken: grant.refresh_token,
|
|
1868
|
+
expiresAt: Date.now() + (grant.expires_in ?? 86400) * 1e3
|
|
1869
|
+
};
|
|
1870
|
+
saveCreds(baseUrl, creds);
|
|
1871
|
+
return creds;
|
|
1872
|
+
} finally {
|
|
1873
|
+
close();
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
async function refresh(baseUrl, creds) {
|
|
1877
|
+
const ep = await discover(baseUrl);
|
|
1878
|
+
const res = await fetch(ep.token_endpoint, {
|
|
1879
|
+
method: "POST",
|
|
1880
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1881
|
+
body: new URLSearchParams({
|
|
1882
|
+
grant_type: "refresh_token",
|
|
1883
|
+
refresh_token: creds.refreshToken,
|
|
1884
|
+
client_id: creds.clientId
|
|
1885
|
+
})
|
|
1886
|
+
});
|
|
1887
|
+
if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
|
|
1888
|
+
const grant = await res.json();
|
|
1889
|
+
const updated = {
|
|
1890
|
+
clientId: creds.clientId,
|
|
1891
|
+
accessToken: grant.access_token,
|
|
1892
|
+
refreshToken: grant.refresh_token,
|
|
1893
|
+
expiresAt: Date.now() + (grant.expires_in ?? 86400) * 1e3
|
|
1894
|
+
};
|
|
1895
|
+
saveCreds(baseUrl, updated);
|
|
1896
|
+
return updated;
|
|
1897
|
+
}
|
|
1898
|
+
async function getAccessToken(target, opts) {
|
|
1899
|
+
if (opts.tokenFlag) return opts.tokenFlag;
|
|
1900
|
+
if (target.tokenEnv && process.env[target.tokenEnv]) return process.env[target.tokenEnv];
|
|
1901
|
+
let creds = loadCreds(target.url);
|
|
1902
|
+
if (!creds) {
|
|
1903
|
+
throw new Error(`Not authenticated for ${target.url}.
|
|
1904
|
+
Run: malloyyo login ${target.name}`);
|
|
1905
|
+
}
|
|
1906
|
+
if (creds.expiresAt - Date.now() < 6e4) {
|
|
1907
|
+
try {
|
|
1908
|
+
creds = await refresh(target.url, creds);
|
|
1909
|
+
} catch {
|
|
1910
|
+
throw new Error(`Session expired for ${target.url}.
|
|
1911
|
+
Run: malloyyo login ${target.name}`);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
return creds.accessToken;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// src/mcp.ts
|
|
1918
|
+
import fs2 from "node:fs";
|
|
1919
|
+
import path3 from "node:path";
|
|
1920
|
+
import url3 from "node:url";
|
|
1921
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1922
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1923
|
+
import {
|
|
1924
|
+
MalloyConfig as MalloyConfig2,
|
|
1925
|
+
Runtime as Runtime2,
|
|
1926
|
+
discoverConfig as discoverConfig2
|
|
1927
|
+
} from "@malloydata/malloy";
|
|
1928
|
+
|
|
1929
|
+
// ../mcp-engine/dist/mcp-sdk.js
|
|
1930
|
+
import {
|
|
1931
|
+
CallToolRequestSchema,
|
|
1932
|
+
ListToolsRequestSchema
|
|
1933
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
1934
|
+
var HOST_ONLY2 = "host_only";
|
|
1935
|
+
function toContent(result) {
|
|
1936
|
+
const { malloy_text, [HOST_ONLY2]: _hostOnly, ...rest } = result;
|
|
1937
|
+
const content = [
|
|
1938
|
+
{ type: "text", text: JSON.stringify(rest, null, 2) }
|
|
1939
|
+
];
|
|
1940
|
+
if (typeof malloy_text === "string" && malloy_text.length > 0) {
|
|
1941
|
+
content.push({ type: "text", text: malloy_text });
|
|
1942
|
+
}
|
|
1943
|
+
return { content, structuredContent: { ...rest } };
|
|
1944
|
+
}
|
|
1945
|
+
function lowLevel(server) {
|
|
1946
|
+
return "server" in server ? server.server : server;
|
|
1947
|
+
}
|
|
1948
|
+
function attachSurface(server, surface, opts = {}) {
|
|
1949
|
+
const s = lowLevel(server);
|
|
1950
|
+
const byName = new Map(surface.tools.map((t) => [t.name, t]));
|
|
1951
|
+
s.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1952
|
+
tools: surface.tools.map((t) => ({
|
|
1953
|
+
name: t.name,
|
|
1954
|
+
title: t.title,
|
|
1955
|
+
description: t.description,
|
|
1956
|
+
inputSchema: t.inputSchema
|
|
1957
|
+
}))
|
|
1958
|
+
}));
|
|
1726
1959
|
s.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
1727
1960
|
const tool = byName.get(req.params.name);
|
|
1728
1961
|
if (!tool) {
|
|
@@ -1760,18 +1993,78 @@ function attachSurface(server, surface, opts = {}) {
|
|
|
1760
1993
|
}
|
|
1761
1994
|
}
|
|
1762
1995
|
|
|
1996
|
+
// src/dashboard-guidance.ts
|
|
1997
|
+
var DASHBOARD_GUIDANCE = `
|
|
1998
|
+
|
|
1999
|
+
# Authoring dashboards
|
|
2000
|
+
|
|
2001
|
+
You can build a **dashboard** for this model: a small React view that renders one
|
|
2002
|
+
or more of the model's queries, with filter controls. Store it in the repo under
|
|
2003
|
+
\`./dashboards/<name>/\`. Preview it with \`malloyyo dashboard dev\`.
|
|
2004
|
+
|
|
2005
|
+
## Before you write anything
|
|
2006
|
+
1. Call \`describe_source\` to see the model's **named queries** and its
|
|
2007
|
+
**givens** (the declared filter inputs, e.g. STATE, DECADE, with their types).
|
|
2008
|
+
A dashboard may ONLY run named queries the model exposes, driven by givens \u2014
|
|
2009
|
+
never invent Malloy in the dashboard.
|
|
2010
|
+
2. If the query or givens you need don't exist yet, add them to the \`.malloy\`
|
|
2011
|
+
model first (a top-level \`query:\` that references \`$GIVEN\` in its filters),
|
|
2012
|
+
then re-check with \`describe_source\`.
|
|
2013
|
+
|
|
2014
|
+
## Files to create
|
|
2015
|
+
\`./dashboards/<name>/manifest.json\`
|
|
2016
|
+
\`\`\`json
|
|
2017
|
+
{
|
|
2018
|
+
"title": "Human title",
|
|
2019
|
+
"query": "<a named query from the model>",
|
|
2020
|
+
"givens": [
|
|
2021
|
+
{ "name": "STATE", "label": "State", "type": "string", "control": "select",
|
|
2022
|
+
"options": ["CA","NY","TX"], "default": "CA" },
|
|
2023
|
+
{ "name": "DECADE", "label": "Decade", "type": "number", "control": "select",
|
|
2024
|
+
"options": [1980,1990], "default": 1980 }
|
|
2025
|
+
]
|
|
2026
|
+
}
|
|
2027
|
+
\`\`\`
|
|
2028
|
+
Given \`name\`s must match the model's given names exactly; \`type\` must match.
|
|
2029
|
+
|
|
2030
|
+
\`./dashboards/<name>/Dashboard.tsx\` \u2014 a default-exported React component. It
|
|
2031
|
+
receives everything as props from the host runtime; it must NOT import data
|
|
2032
|
+
libraries, fetch, or hold credentials:
|
|
2033
|
+
\`\`\`tsx
|
|
2034
|
+
export default function Dashboard({ manifest, givens, setGiven, Panel }) {
|
|
2035
|
+
// givens : current filter values, e.g. { STATE: "CA", DECADE: 1980 }
|
|
2036
|
+
// setGiven : (name, value) => void \u2014 change a filter, the Panel re-runs
|
|
2037
|
+
// Panel : <Panel givens={givens} /> runs manifest.query with those givens
|
|
2038
|
+
// and renders the result with Malloy's renderer
|
|
2039
|
+
// Lay out the controls + Panel however you like \u2014 this is your React.
|
|
2040
|
+
}
|
|
2041
|
+
\`\`\`
|
|
2042
|
+
|
|
2043
|
+
## Rules
|
|
2044
|
+
- Only React is available to the dashboard (plus the injected \`Panel\`). No other
|
|
2045
|
+
imports, no network, no arbitrary Malloy \u2014 the runtime sandboxes it.
|
|
2046
|
+
- Interactivity is done by changing **givens** (which drive the query's filters),
|
|
2047
|
+
not by rewriting queries.
|
|
2048
|
+
- The dashboard runs against the SAME model you're exploring, so what you preview
|
|
2049
|
+
is what the model actually returns.
|
|
2050
|
+
|
|
2051
|
+
## Preview
|
|
2052
|
+
From the model repo: \`malloyyo dashboard dev\` \u2192 open the printed URL. Editing
|
|
2053
|
+
\`Dashboard.tsx\` and reloading rebuilds it.
|
|
2054
|
+
`;
|
|
2055
|
+
|
|
1763
2056
|
// src/mcp.ts
|
|
1764
|
-
var
|
|
2057
|
+
var ENTRY2 = "index.malloy";
|
|
1765
2058
|
function defaultConfig(rootUrl) {
|
|
1766
|
-
return new
|
|
2059
|
+
return new MalloyConfig2({ includeDefaultConnections: true }, {
|
|
1767
2060
|
rootDirectory: rootUrl.toString()
|
|
1768
2061
|
});
|
|
1769
2062
|
}
|
|
1770
|
-
async function
|
|
1771
|
-
const rootUrl =
|
|
2063
|
+
async function loadConfig2(root, reader) {
|
|
2064
|
+
const rootUrl = url3.pathToFileURL(root + path3.sep);
|
|
1772
2065
|
let discovered;
|
|
1773
2066
|
try {
|
|
1774
|
-
discovered = await
|
|
2067
|
+
discovered = await discoverConfig2(rootUrl, rootUrl, reader);
|
|
1775
2068
|
} catch (e) {
|
|
1776
2069
|
return {
|
|
1777
2070
|
config: defaultConfig(rootUrl),
|
|
@@ -1784,19 +2077,19 @@ async function loadConfig(root, reader) {
|
|
|
1784
2077
|
}
|
|
1785
2078
|
return { config: defaultConfig(rootUrl), problems: [] };
|
|
1786
2079
|
}
|
|
1787
|
-
function
|
|
2080
|
+
function fsReader2() {
|
|
1788
2081
|
return {
|
|
1789
2082
|
readURL: async (u) => {
|
|
1790
2083
|
if (u.protocol !== "file:") {
|
|
1791
2084
|
throw new Error(`unsupported URL scheme for import: ${u.href}`);
|
|
1792
2085
|
}
|
|
1793
|
-
return
|
|
2086
|
+
return fs2.promises.readFile(u, "utf8");
|
|
1794
2087
|
}
|
|
1795
2088
|
};
|
|
1796
2089
|
}
|
|
1797
2090
|
function resolveUnderRoot(root, p) {
|
|
1798
|
-
const abs = p.includes("://") ?
|
|
1799
|
-
if (abs !== root && !abs.startsWith(root +
|
|
2091
|
+
const abs = p.includes("://") ? path3.resolve(decodeURIComponent(new URL(p).pathname)) : path3.resolve(root, p);
|
|
2092
|
+
if (abs !== root && !abs.startsWith(root + path3.sep)) {
|
|
1800
2093
|
throw new Error(`path is outside the project root: ${p}`);
|
|
1801
2094
|
}
|
|
1802
2095
|
return abs;
|
|
@@ -1805,7 +2098,7 @@ function makeConfigSource(root) {
|
|
|
1805
2098
|
let cached;
|
|
1806
2099
|
const signature = () => ["malloy-config.json", "malloy-config-local.json"].map((name) => {
|
|
1807
2100
|
try {
|
|
1808
|
-
const st =
|
|
2101
|
+
const st = fs2.statSync(path3.join(root, name));
|
|
1809
2102
|
return `${name}:${st.mtimeMs}:${st.size}`;
|
|
1810
2103
|
} catch {
|
|
1811
2104
|
return `${name}:absent`;
|
|
@@ -1814,7 +2107,7 @@ function makeConfigSource(root) {
|
|
|
1814
2107
|
return async () => {
|
|
1815
2108
|
const sig = signature();
|
|
1816
2109
|
if (cached?.sig !== sig) {
|
|
1817
|
-
cached = { sig, loaded: await
|
|
2110
|
+
cached = { sig, loaded: await loadConfig2(root, fsReader2()) };
|
|
1818
2111
|
}
|
|
1819
2112
|
return cached.loaded;
|
|
1820
2113
|
};
|
|
@@ -1825,10 +2118,10 @@ function makeWithRuntime(root, currentConfig) {
|
|
|
1825
2118
|
return gateConfigProblems(problems, async () => {
|
|
1826
2119
|
const resolved = "url" in input ? { url: resolveUnderRoot(root, input.url) } : {
|
|
1827
2120
|
source: input.source,
|
|
1828
|
-
baseUrl: input.baseUrl ? resolveUnderRoot(root, input.baseUrl) : root +
|
|
2121
|
+
baseUrl: input.baseUrl ? resolveUnderRoot(root, input.baseUrl) : root + path3.sep
|
|
1829
2122
|
};
|
|
1830
|
-
const { reader, entry, readSource } = prepareSource(
|
|
1831
|
-
const runtime = new
|
|
2123
|
+
const { reader, entry, readSource } = prepareSource(fsReader2(), resolved);
|
|
2124
|
+
const runtime = new Runtime2({ config, urlReader: reader });
|
|
1832
2125
|
try {
|
|
1833
2126
|
return await fn({ runtime, entry, readSource });
|
|
1834
2127
|
} finally {
|
|
@@ -1839,17 +2132,17 @@ function makeWithRuntime(root, currentConfig) {
|
|
|
1839
2132
|
}
|
|
1840
2133
|
function makeExploreHost(root, currentConfig) {
|
|
1841
2134
|
const withRuntime = makeWithRuntime(root, currentConfig);
|
|
1842
|
-
const published = (ref) => ref ===
|
|
2135
|
+
const published = (ref) => ref === ENTRY2 && fs2.existsSync(path3.join(root, ENTRY2));
|
|
1843
2136
|
return {
|
|
1844
2137
|
withModel: (ref, fn) => {
|
|
1845
2138
|
if (!published(ref)) throw new Error(`no published model '${ref}'`);
|
|
1846
|
-
return withRuntime({ url:
|
|
2139
|
+
return withRuntime({ url: ENTRY2 }, fn);
|
|
1847
2140
|
},
|
|
1848
2141
|
list: async () => {
|
|
1849
|
-
if (!published(
|
|
1850
|
-
const entry = await withRuntime({ url:
|
|
2142
|
+
if (!published(ENTRY2)) return { entries: [] };
|
|
2143
|
+
const entry = await withRuntime({ url: ENTRY2 }, async (m) => {
|
|
1851
2144
|
const compiled = await compile(m.runtime, m.entry, { exportedOnly: true });
|
|
1852
|
-
return compiled.ok && compiled.model ? modelCatalogEntry(
|
|
2145
|
+
return compiled.ok && compiled.model ? modelCatalogEntry(ENTRY2, compiled.model) : { model_ref: ENTRY2 };
|
|
1853
2146
|
});
|
|
1854
2147
|
return { entries: [entry] };
|
|
1855
2148
|
}
|
|
@@ -1857,14 +2150,14 @@ function makeExploreHost(root, currentConfig) {
|
|
|
1857
2150
|
}
|
|
1858
2151
|
async function serveMcp(opts) {
|
|
1859
2152
|
await import("@malloydata/malloy-connections");
|
|
1860
|
-
const root =
|
|
2153
|
+
const root = path3.resolve(opts.root ?? process.cwd());
|
|
1861
2154
|
const currentConfig = makeConfigSource(root);
|
|
1862
2155
|
const surface = exploreSurface(makeExploreHost(root, currentConfig));
|
|
1863
2156
|
const instanceName = process.env.INSTANCE_NAME || "Malloyyo";
|
|
1864
2157
|
const server = new McpServer(
|
|
1865
2158
|
{ name: "malloyyo-explore", version: opts.version },
|
|
1866
2159
|
{
|
|
1867
|
-
instructions: renderInstructions(surface.instructions, instanceName),
|
|
2160
|
+
instructions: renderInstructions(surface.instructions, instanceName) + DASHBOARD_GUIDANCE,
|
|
1868
2161
|
capabilities: { tools: {}, prompts: {}, resources: {} }
|
|
1869
2162
|
}
|
|
1870
2163
|
);
|
|
@@ -1877,23 +2170,230 @@ async function serveMcp(opts) {
|
|
|
1877
2170
|
});
|
|
1878
2171
|
}
|
|
1879
2172
|
|
|
2173
|
+
// src/dashboard.ts
|
|
2174
|
+
import http2 from "node:http";
|
|
2175
|
+
import fs3 from "node:fs";
|
|
2176
|
+
import path4 from "node:path";
|
|
2177
|
+
import { fileURLToPath } from "node:url";
|
|
2178
|
+
import { createRequire } from "node:module";
|
|
2179
|
+
import * as esbuild2 from "esbuild";
|
|
2180
|
+
var require2 = createRequire(import.meta.url);
|
|
2181
|
+
var HOST_LIBS = [
|
|
2182
|
+
"react",
|
|
2183
|
+
"react-dom",
|
|
2184
|
+
"react-dom/client",
|
|
2185
|
+
"react/jsx-runtime",
|
|
2186
|
+
"react/jsx-dev-runtime",
|
|
2187
|
+
"@malloydata/render"
|
|
2188
|
+
];
|
|
2189
|
+
var HOST_ALIAS = {};
|
|
2190
|
+
for (const spec of HOST_LIBS) {
|
|
2191
|
+
try {
|
|
2192
|
+
HOST_ALIAS[spec] = require2.resolve(spec);
|
|
2193
|
+
} catch {
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
function resolveFrameEntry() {
|
|
2197
|
+
const candidates = [
|
|
2198
|
+
new URL("./frame-entry.tsx", import.meta.url),
|
|
2199
|
+
// dev: src/dashboard.ts
|
|
2200
|
+
new URL("../src/frame-entry.tsx", import.meta.url)
|
|
2201
|
+
// built: dist/index.js
|
|
2202
|
+
].map((u) => fileURLToPath(u));
|
|
2203
|
+
const found = candidates.find((c) => fs3.existsSync(c));
|
|
2204
|
+
if (!found) {
|
|
2205
|
+
throw new Error(
|
|
2206
|
+
"frame-entry.tsx not found \u2014 `dashboard dev` currently needs the CLI source checkout (looked in ./ and ../src). See docs/repo-artifacts.md packaging note."
|
|
2207
|
+
);
|
|
2208
|
+
}
|
|
2209
|
+
return found;
|
|
2210
|
+
}
|
|
2211
|
+
function discoverDashboards(root) {
|
|
2212
|
+
const base = path4.join(root, "dashboards");
|
|
2213
|
+
if (!fs3.existsSync(base)) return [];
|
|
2214
|
+
const out = [];
|
|
2215
|
+
for (const name of fs3.readdirSync(base)) {
|
|
2216
|
+
const dir = path4.join(base, name);
|
|
2217
|
+
const mf = path4.join(dir, "manifest.json");
|
|
2218
|
+
if (!fs3.statSync(dir).isDirectory() || !fs3.existsSync(mf)) continue;
|
|
2219
|
+
try {
|
|
2220
|
+
out.push({ name, dir, manifest: JSON.parse(fs3.readFileSync(mf, "utf8")) });
|
|
2221
|
+
} catch (e) {
|
|
2222
|
+
console.error(` ! skipping ${name}: bad manifest.json (${e.message})`);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
2226
|
+
}
|
|
2227
|
+
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2228
|
+
function makeBundler() {
|
|
2229
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2230
|
+
const frameEntry = resolveFrameEntry();
|
|
2231
|
+
return async function bundle(dash) {
|
|
2232
|
+
const dashboardFile = path4.join(dash.dir, "Dashboard.tsx");
|
|
2233
|
+
const mtimeMs = fs3.statSync(dashboardFile).mtimeMs + fs3.statSync(frameEntry).mtimeMs;
|
|
2234
|
+
const hit = cache.get(dash.name);
|
|
2235
|
+
if (hit && hit.mtimeMs === mtimeMs) return hit.js;
|
|
2236
|
+
const result = await esbuild2.build({
|
|
2237
|
+
entryPoints: [frameEntry],
|
|
2238
|
+
bundle: true,
|
|
2239
|
+
format: "iife",
|
|
2240
|
+
platform: "browser",
|
|
2241
|
+
jsx: "automatic",
|
|
2242
|
+
write: false,
|
|
2243
|
+
logLevel: "silent",
|
|
2244
|
+
loader: { ".css": "empty" },
|
|
2245
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
2246
|
+
plugins: [
|
|
2247
|
+
{
|
|
2248
|
+
name: "virtual-dashboard",
|
|
2249
|
+
setup(b) {
|
|
2250
|
+
b.onResolve({ filter: /^virtual:dashboard$/ }, () => ({ path: dashboardFile }));
|
|
2251
|
+
b.onResolve(
|
|
2252
|
+
{ filter: /^(react($|\/)|react-dom($|\/)|@malloydata\/render$)/ },
|
|
2253
|
+
(args) => HOST_ALIAS[args.path] ? { path: HOST_ALIAS[args.path] } : void 0
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
]
|
|
2258
|
+
});
|
|
2259
|
+
const js = result.outputFiles[0].text;
|
|
2260
|
+
cache.set(dash.name, { mtimeMs, js });
|
|
2261
|
+
return js;
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
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"></head><body style="margin:0">${body}</body></html>`;
|
|
2265
|
+
function parentShell(dash, frameBase, all) {
|
|
2266
|
+
const d = JSON.stringify(dash.name);
|
|
2267
|
+
const fb = JSON.stringify(frameBase);
|
|
2268
|
+
const nav = all.length > 1 ? `<nav style="display:flex;gap:4px;align-items:center;padding:8px 12px;background:#f6f7f9;border-bottom:1px solid #e2e4e8;font:13px system-ui,sans-serif"><span style="color:#888;margin-right:8px">Dashboards</span>` + all.map((x) => {
|
|
2269
|
+
const on = x.name === dash.name;
|
|
2270
|
+
return `<a href="/?d=${encodeURIComponent(x.name)}" style="padding:4px 10px;border-radius:6px;text-decoration:none;${on ? "background:#1a1a1a;color:#fff" : "color:#333"}">${esc(x.manifest.title || x.name)}</a>`;
|
|
2271
|
+
}).join("") + `</nav>` : "";
|
|
2272
|
+
return html(
|
|
2273
|
+
`<div style="display:flex;flex-direction:column;height:100vh">` + nav + `<iframe id="f" sandbox="allow-scripts allow-same-origin" src="${frameBase}/frame?d=${encodeURIComponent(dash.name)}" style="border:0;flex:1;width:100%"></iframe></div><script>
|
|
2274
|
+
const f=document.getElementById('f');
|
|
2275
|
+
window.addEventListener('message',async(e)=>{
|
|
2276
|
+
if(e.source!==f.contentWindow||e.origin!==${fb})return;
|
|
2277
|
+
const m=e.data; if(!m||m.type!=='run')return;
|
|
2278
|
+
let out;
|
|
2279
|
+
try{
|
|
2280
|
+
const res=await fetch('/api/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
2281
|
+
body:JSON.stringify({d:${d},query:m.query,givens:m.givens})});
|
|
2282
|
+
out=await res.json();
|
|
2283
|
+
}catch(err){ out={ok:false,problems:[{message:String(err)}]}; }
|
|
2284
|
+
f.contentWindow.postMessage({type:'result',id:m.id,...out},${fb});
|
|
2285
|
+
});
|
|
2286
|
+
</script>`,
|
|
2287
|
+
dash.manifest.title
|
|
2288
|
+
);
|
|
2289
|
+
}
|
|
2290
|
+
function frameDoc(dash) {
|
|
2291
|
+
return html(
|
|
2292
|
+
`<div id="root"></div><script>window.__MANIFEST__=${JSON.stringify(dash.manifest)}</script><script src="/bundle.js?d=${encodeURIComponent(dash.name)}"></script>`,
|
|
2293
|
+
dash.manifest.title
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
async function readBody(req) {
|
|
2297
|
+
const chunks = [];
|
|
2298
|
+
for await (const c of req) chunks.push(c);
|
|
2299
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
2300
|
+
}
|
|
2301
|
+
async function serveDashboard(opts) {
|
|
2302
|
+
await import("@malloydata/malloy-connections");
|
|
2303
|
+
const root = path4.resolve(opts.root ?? process.cwd());
|
|
2304
|
+
const port = opts.port ?? 4173;
|
|
2305
|
+
const framePort = port + 1;
|
|
2306
|
+
const frameBase = `http://localhost:${framePort}`;
|
|
2307
|
+
const dashboards = discoverDashboards(root);
|
|
2308
|
+
if (dashboards.length === 0) {
|
|
2309
|
+
throw new Error(`No dashboards found under ${path4.join(root, "dashboards")}/`);
|
|
2310
|
+
}
|
|
2311
|
+
const byName = new Map(dashboards.map((d) => [d.name, d]));
|
|
2312
|
+
const runner = await makeRunner(root);
|
|
2313
|
+
if (!runner.entryExists()) {
|
|
2314
|
+
throw new Error(`No index.malloy at ${root} \u2014 run this from a Malloy model repo.`);
|
|
2315
|
+
}
|
|
2316
|
+
const bundle = makeBundler();
|
|
2317
|
+
const pick = (url4) => byName.get(url4.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
|
|
2318
|
+
const handler = async (req, res) => {
|
|
2319
|
+
const onFramePort = (req.socket.localPort ?? port) === framePort;
|
|
2320
|
+
const url4 = new URL(req.url ?? "/", `http://localhost:${onFramePort ? framePort : port}`);
|
|
2321
|
+
const send = (code, type, body, extra = {}) => {
|
|
2322
|
+
res.writeHead(code, { "content-type": type, ...extra });
|
|
2323
|
+
res.end(body);
|
|
2324
|
+
};
|
|
2325
|
+
try {
|
|
2326
|
+
if (onFramePort) {
|
|
2327
|
+
if (url4.pathname === "/frame") {
|
|
2328
|
+
return send(200, "text/html; charset=utf-8", frameDoc(pick(url4)));
|
|
2329
|
+
}
|
|
2330
|
+
if (url4.pathname === "/bundle.js") {
|
|
2331
|
+
return send(200, "application/javascript; charset=utf-8", await bundle(pick(url4)));
|
|
2332
|
+
}
|
|
2333
|
+
return send(404, "text/plain", "not found");
|
|
2334
|
+
}
|
|
2335
|
+
if (url4.pathname === "/") {
|
|
2336
|
+
return send(200, "text/html; charset=utf-8", parentShell(pick(url4), frameBase, dashboards));
|
|
2337
|
+
}
|
|
2338
|
+
if (url4.pathname === "/api/run" && req.method === "POST") {
|
|
2339
|
+
const { d, query, givens } = JSON.parse(await readBody(req));
|
|
2340
|
+
const dash = byName.get(d);
|
|
2341
|
+
if (!dash) return send(404, "application/json", JSON.stringify({ ok: false, problems: [{ message: `no dashboard '${d}'` }] }));
|
|
2342
|
+
if (query !== dash.manifest.query) {
|
|
2343
|
+
return send(403, "application/json", JSON.stringify({ ok: false, problems: [{ message: `query '${query}' is not declared by ${d}` }] }));
|
|
2344
|
+
}
|
|
2345
|
+
const out = await runner.run(query, givens ?? {});
|
|
2346
|
+
return send(200, "application/json", JSON.stringify(out));
|
|
2347
|
+
}
|
|
2348
|
+
send(404, "text/plain", "not found");
|
|
2349
|
+
} catch (e) {
|
|
2350
|
+
send(500, "application/json", JSON.stringify({ ok: false, problems: [{ message: e.message }] }));
|
|
2351
|
+
}
|
|
2352
|
+
};
|
|
2353
|
+
const shellServer = http2.createServer(handler);
|
|
2354
|
+
const frameServer = http2.createServer(handler);
|
|
2355
|
+
await new Promise((r) => shellServer.listen(port, r));
|
|
2356
|
+
await new Promise((r) => frameServer.listen(framePort, r));
|
|
2357
|
+
console.error(`
|
|
2358
|
+
malloyyo dashboard dev \u2014 model: ${root}`);
|
|
2359
|
+
console.error(` http://localhost:${port}/ (artifact origin: ${frameBase})`);
|
|
2360
|
+
for (const d of dashboards) {
|
|
2361
|
+
console.error(` \u2022 ${d.name} \u2192 http://localhost:${port}/?d=${d.name}`);
|
|
2362
|
+
}
|
|
2363
|
+
console.error(` Ctrl-C to stop.
|
|
2364
|
+
`);
|
|
2365
|
+
await new Promise(() => {
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
|
|
1880
2369
|
// package.json
|
|
1881
|
-
var version = "0.2.
|
|
2370
|
+
var version = "0.2.9";
|
|
1882
2371
|
|
|
1883
2372
|
// src/index.ts
|
|
1884
2373
|
function shortSha(sha) {
|
|
1885
2374
|
return sha ? sha.slice(0, 7) : "";
|
|
1886
2375
|
}
|
|
1887
2376
|
async function publish(target, dir, opts) {
|
|
1888
|
-
const root =
|
|
2377
|
+
const root = resolve2(dir);
|
|
1889
2378
|
const t = resolveTarget(root, target);
|
|
1890
2379
|
const bearer = await getAccessToken(t, { tokenFlag: opts.token });
|
|
1891
2380
|
const { files, config } = gatherDirectory(root);
|
|
1892
2381
|
if (files.length === 0) {
|
|
1893
2382
|
throw new Error(`No .malloy files found under ${root}`);
|
|
1894
2383
|
}
|
|
2384
|
+
if (!opts.skipLint) {
|
|
2385
|
+
const report = await lintDashboards(root);
|
|
2386
|
+
if (report.dashboards.length > 0) {
|
|
2387
|
+
console.log("dashboards:");
|
|
2388
|
+
printLintReport(report);
|
|
2389
|
+
}
|
|
2390
|
+
if (!report.ok) {
|
|
2391
|
+
throw new Error("dashboard lint failed \u2014 fix the above, or pass --skip-lint");
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
1895
2394
|
const git = gitInfo(root);
|
|
1896
|
-
const
|
|
2395
|
+
const dashboards = gatherDashboards(root);
|
|
2396
|
+
const body = { files, config, git, dashboards };
|
|
1897
2397
|
const provenance = git.sha ? `${git.branch}@${shortSha(git.sha)}${git.dirty ? " (dirty)" : ""}` : "(no git)";
|
|
1898
2398
|
console.log(`\u2192 ${t.url} dataset=${t.dataset}`);
|
|
1899
2399
|
console.log(` ${files.length} file(s) ${provenance}`);
|
|
@@ -1910,10 +2410,12 @@ async function publish(target, dir, opts) {
|
|
|
1910
2410
|
if (!res.ok || !out.ok) {
|
|
1911
2411
|
throw new Error(`publish failed: ${out.error ?? `${res.status} ${res.statusText}`}`);
|
|
1912
2412
|
}
|
|
1913
|
-
console.log(
|
|
2413
|
+
console.log(
|
|
2414
|
+
`\u2713 published version ${out.version} \u2014 ${out.sources?.length ?? 0} source(s)` + (dashboards.length ? `, ${dashboards.length} dashboard(s)` : "")
|
|
2415
|
+
);
|
|
1914
2416
|
}
|
|
1915
2417
|
async function status(target, opts) {
|
|
1916
|
-
const t = resolveTarget(
|
|
2418
|
+
const t = resolveTarget(resolve2("."), target);
|
|
1917
2419
|
const bearer = await getAccessToken(t, { tokenFlag: opts.token });
|
|
1918
2420
|
const res = await fetch(`${t.url}/api/datasets/${t.dataset}/model/status`, {
|
|
1919
2421
|
headers: { authorization: `Bearer ${bearer}` }
|
|
@@ -1928,25 +2430,38 @@ async function status(target, opts) {
|
|
|
1928
2430
|
console.log(` ${s.compileError ? `\u2717 ${s.compileError}` : `\u2713 compiled ${s.compiledAt ?? ""}`}`);
|
|
1929
2431
|
}
|
|
1930
2432
|
async function loginCmd(target) {
|
|
1931
|
-
const inst = resolveInstance(
|
|
2433
|
+
const inst = resolveInstance(resolve2("."), target);
|
|
1932
2434
|
await login(inst.url);
|
|
1933
2435
|
console.log(`\u2713 logged in to ${inst.name} (${inst.url})`);
|
|
1934
2436
|
}
|
|
1935
2437
|
async function logoutCmd(target) {
|
|
1936
|
-
const inst = resolveInstance(
|
|
2438
|
+
const inst = resolveInstance(resolve2("."), target);
|
|
1937
2439
|
console.log(clearCreds(inst.url) ? `\u2713 logged out of ${inst.url}` : `not logged in to ${inst.url}`);
|
|
1938
2440
|
}
|
|
1939
2441
|
var program = new Command();
|
|
1940
2442
|
program.name("malloyyo").description("Publish Malloy models to a Malloyyo instance").version(version);
|
|
1941
2443
|
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);
|
|
1942
2444
|
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);
|
|
1943
|
-
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").description('push the Malloy model in <dir> (default ".") to <target>').action(publish);
|
|
2445
|
+
program.command("publish").argument("<target>", "named target from the `malloyyo` config block").argument("[dir]", "directory to publish", ".").option("--token <token>", "bearer token (overrides login/env)").option("--dry-run", "gather and report what would be sent, but don't POST").option("--skip-lint", "skip the pre-publish dashboard lint").description('push the Malloy model in <dir> (default ".") to <target>').action(publish);
|
|
2446
|
+
program.command("lint").argument("[dir]", "directory to lint", ".").description("validate ./dashboards against the model (manifest, query, givens, Dashboard.tsx)").action(async (dir) => {
|
|
2447
|
+
const report = await lintDashboards(resolve2(dir));
|
|
2448
|
+
if (report.dashboards.length === 0) {
|
|
2449
|
+
console.log("no dashboards to lint");
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
printLintReport(report);
|
|
2453
|
+
if (!report.ok) process.exit(1);
|
|
2454
|
+
});
|
|
1944
2455
|
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);
|
|
1945
2456
|
program.command("mcp").option("-C, --root <dir>", "project root (default: current directory)").description(
|
|
1946
2457
|
"run a local stdio MCP server (the explore / test-window surface) over the Malloy model in the current directory"
|
|
1947
2458
|
).action(async (opts) => {
|
|
1948
2459
|
await serveMcp({ root: opts.root, version });
|
|
1949
2460
|
});
|
|
2461
|
+
program.command("dashboard").argument("<action>", "action to run (currently: dev)").option("-C, --root <dir>", "project root (default: current directory)").option("-p, --port <port>", "port to serve on", "4173").description("preview dashboard artifacts in ./dashboards against the local Malloy model").action(async (action, opts) => {
|
|
2462
|
+
if (action !== "dev") throw new Error(`unknown dashboard action '${action}' (expected: dev)`);
|
|
2463
|
+
await serveDashboard({ root: opts.root, port: Number(opts.port) });
|
|
2464
|
+
});
|
|
1950
2465
|
program.parseAsync().catch((err) => {
|
|
1951
2466
|
console.error(err instanceof Error ? err.message : String(err));
|
|
1952
2467
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloydata/malloyyo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
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
|
"prebuild": "npm --prefix ../mcp-engine run build",
|
|
26
|
-
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --external:commander --external:@malloydata/* --external:@modelcontextprotocol/* --outfile=dist/index.js",
|
|
26
|
+
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --external:commander --external:esbuild --external:@malloydata/* --external:@modelcontextprotocol/* --outfile=dist/index.js",
|
|
27
27
|
"dev": "tsx src/index.ts",
|
|
28
28
|
"typecheck": "tsc --noEmit",
|
|
29
29
|
"pretest": "npm run build",
|