@bonnard/cli 0.1.0 → 0.1.2
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/bin/bon.mjs +504 -0
- package/dist/bin/validate-Bd1D39Bj.mjs +65 -0
- package/package.json +6 -2
package/dist/bin/bon.mjs
CHANGED
|
@@ -3,6 +3,10 @@ import { program } from "commander";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import pc from "picocolors";
|
|
6
|
+
import http from "node:http";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import { encode } from "@toon-format/toon";
|
|
6
10
|
|
|
7
11
|
//#region src/commands/init.ts
|
|
8
12
|
const BON_YAML_TEMPLATE = (projectName) => `project:
|
|
@@ -31,10 +35,510 @@ async function initCommand() {
|
|
|
31
35
|
console.log(` ${pc.dim(".gitignore")} git ignore rules`);
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/lib/credentials.ts
|
|
40
|
+
const CREDENTIALS_DIR = path.join(os.homedir(), ".config", "bon");
|
|
41
|
+
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
|
|
42
|
+
function saveCredentials(credentials) {
|
|
43
|
+
fs.mkdirSync(CREDENTIALS_DIR, {
|
|
44
|
+
recursive: true,
|
|
45
|
+
mode: 448
|
|
46
|
+
});
|
|
47
|
+
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 384 });
|
|
48
|
+
}
|
|
49
|
+
function loadCredentials() {
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(CREDENTIALS_FILE, "utf-8");
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (parsed.token && parsed.email) return parsed;
|
|
54
|
+
return null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function clearCredentials() {
|
|
60
|
+
try {
|
|
61
|
+
fs.unlinkSync(CREDENTIALS_FILE);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/commands/login.ts
|
|
67
|
+
const APP_URL$1 = process.env.BON_APP_URL || "http://localhost:3000";
|
|
68
|
+
const TIMEOUT_MS = 120 * 1e3;
|
|
69
|
+
async function loginCommand() {
|
|
70
|
+
const state = crypto.randomUUID();
|
|
71
|
+
const { port, close } = await startCallbackServer(state);
|
|
72
|
+
const url = `${APP_URL$1}/auth/device?state=${state}&port=${port}`;
|
|
73
|
+
console.log(pc.dim(`Opening browser to ${url}`));
|
|
74
|
+
const open = (await import("open")).default;
|
|
75
|
+
await open(url);
|
|
76
|
+
console.log("Waiting for authentication...");
|
|
77
|
+
const timeout = setTimeout(() => {
|
|
78
|
+
close();
|
|
79
|
+
console.log(pc.red("Login timed out. Please try again."));
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}, TIMEOUT_MS);
|
|
82
|
+
const result = await waitForCallback;
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
close();
|
|
85
|
+
saveCredentials({
|
|
86
|
+
token: result.token,
|
|
87
|
+
email: result.email
|
|
88
|
+
});
|
|
89
|
+
console.log(pc.green(`Logged in as ${result.email}`));
|
|
90
|
+
}
|
|
91
|
+
let resolveCallback;
|
|
92
|
+
const waitForCallback = new Promise((resolve, reject) => {
|
|
93
|
+
resolveCallback = resolve;
|
|
94
|
+
});
|
|
95
|
+
function startCallbackServer(expectedState) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const server = http.createServer((req, res) => {
|
|
98
|
+
const url = new URL(req.url, `http://localhost`);
|
|
99
|
+
if (url.pathname !== "/callback") {
|
|
100
|
+
res.writeHead(404);
|
|
101
|
+
res.end("Not found");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const token = url.searchParams.get("token");
|
|
105
|
+
const email = url.searchParams.get("email");
|
|
106
|
+
if (url.searchParams.get("state") !== expectedState) {
|
|
107
|
+
res.writeHead(400);
|
|
108
|
+
res.end("Invalid state parameter");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!token || !email) {
|
|
112
|
+
res.writeHead(400);
|
|
113
|
+
res.end("Missing token or email");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
117
|
+
res.end(`<!DOCTYPE html>
|
|
118
|
+
<html lang="en">
|
|
119
|
+
<head>
|
|
120
|
+
<meta charset="utf-8" />
|
|
121
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
122
|
+
<title>Bonnard CLI</title>
|
|
123
|
+
<style>
|
|
124
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
125
|
+
body {
|
|
126
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
127
|
+
background: #0a0a0a;
|
|
128
|
+
color: #fafafa;
|
|
129
|
+
display: flex;
|
|
130
|
+
align-items: center;
|
|
131
|
+
justify-content: center;
|
|
132
|
+
min-height: 100vh;
|
|
133
|
+
}
|
|
134
|
+
.card {
|
|
135
|
+
text-align: center;
|
|
136
|
+
padding: 3rem;
|
|
137
|
+
max-width: 400px;
|
|
138
|
+
}
|
|
139
|
+
.check {
|
|
140
|
+
width: 48px;
|
|
141
|
+
height: 48px;
|
|
142
|
+
border-radius: 50%;
|
|
143
|
+
background: #22c55e;
|
|
144
|
+
display: inline-flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
justify-content: center;
|
|
147
|
+
margin-bottom: 1.5rem;
|
|
148
|
+
}
|
|
149
|
+
.check svg { width: 24px; height: 24px; }
|
|
150
|
+
h1 {
|
|
151
|
+
font-size: 1.25rem;
|
|
152
|
+
font-weight: 600;
|
|
153
|
+
margin-bottom: 0.5rem;
|
|
154
|
+
}
|
|
155
|
+
p {
|
|
156
|
+
color: #a1a1aa;
|
|
157
|
+
font-size: 0.875rem;
|
|
158
|
+
line-height: 1.5;
|
|
159
|
+
}
|
|
160
|
+
</style>
|
|
161
|
+
</head>
|
|
162
|
+
<body>
|
|
163
|
+
<div class="card">
|
|
164
|
+
<div class="check">
|
|
165
|
+
<svg fill="none" stroke="white" stroke-width="3" viewBox="0 0 24 24">
|
|
166
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
167
|
+
</svg>
|
|
168
|
+
</div>
|
|
169
|
+
<h1>Authentication successful</h1>
|
|
170
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
171
|
+
</div>
|
|
172
|
+
</body>
|
|
173
|
+
</html>`);
|
|
174
|
+
resolveCallback({
|
|
175
|
+
token,
|
|
176
|
+
email
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
server.on("error", reject);
|
|
180
|
+
server.listen(0, "127.0.0.1", () => {
|
|
181
|
+
const addr = server.address();
|
|
182
|
+
if (!addr || typeof addr === "string") {
|
|
183
|
+
reject(/* @__PURE__ */ new Error("Failed to start callback server"));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
resolve({
|
|
187
|
+
port: addr.port,
|
|
188
|
+
close: () => server.close()
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/commands/logout.ts
|
|
196
|
+
async function logoutCommand() {
|
|
197
|
+
clearCredentials();
|
|
198
|
+
console.log(pc.green("Logged out"));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/lib/api.ts
|
|
203
|
+
const APP_URL = process.env.BON_APP_URL || "http://localhost:3000";
|
|
204
|
+
function getToken() {
|
|
205
|
+
const creds = loadCredentials();
|
|
206
|
+
if (!creds) {
|
|
207
|
+
console.error(pc.red("Not logged in. Run `bon login` first."));
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
return creds.token;
|
|
211
|
+
}
|
|
212
|
+
async function request(method, path, body) {
|
|
213
|
+
const token = getToken();
|
|
214
|
+
const url = `${APP_URL}${path}`;
|
|
215
|
+
const res = await fetch(url, {
|
|
216
|
+
method,
|
|
217
|
+
headers: {
|
|
218
|
+
Authorization: `Bearer ${token}`,
|
|
219
|
+
"Content-Type": "application/json"
|
|
220
|
+
},
|
|
221
|
+
body: body ? JSON.stringify(body) : void 0
|
|
222
|
+
});
|
|
223
|
+
const data = await res.json();
|
|
224
|
+
if (!res.ok) {
|
|
225
|
+
const message = data.error || res.statusText;
|
|
226
|
+
throw new Error(message);
|
|
227
|
+
}
|
|
228
|
+
return data;
|
|
229
|
+
}
|
|
230
|
+
function get(path) {
|
|
231
|
+
return request("GET", path);
|
|
232
|
+
}
|
|
233
|
+
function post(path, body) {
|
|
234
|
+
return request("POST", path, body);
|
|
235
|
+
}
|
|
236
|
+
function del(path) {
|
|
237
|
+
return request("DELETE", path);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/commands/datasource/add.ts
|
|
242
|
+
async function prompts() {
|
|
243
|
+
return import("@inquirer/prompts");
|
|
244
|
+
}
|
|
245
|
+
const WAREHOUSE_TYPES = [
|
|
246
|
+
{
|
|
247
|
+
value: "snowflake",
|
|
248
|
+
label: "Snowflake",
|
|
249
|
+
configFields: [
|
|
250
|
+
{
|
|
251
|
+
name: "account",
|
|
252
|
+
message: "Account identifier (e.g. xy12345.us-east-1)",
|
|
253
|
+
required: true
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: "database",
|
|
257
|
+
message: "Database name",
|
|
258
|
+
required: true
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: "schema",
|
|
262
|
+
message: "Schema name",
|
|
263
|
+
required: true
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: "warehouse",
|
|
267
|
+
message: "Warehouse name",
|
|
268
|
+
required: true
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: "role",
|
|
272
|
+
message: "Role (optional)"
|
|
273
|
+
}
|
|
274
|
+
],
|
|
275
|
+
credentialFields: [{
|
|
276
|
+
name: "username",
|
|
277
|
+
message: "Username"
|
|
278
|
+
}, {
|
|
279
|
+
name: "password",
|
|
280
|
+
message: "Password",
|
|
281
|
+
secret: true
|
|
282
|
+
}]
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
value: "postgres",
|
|
286
|
+
label: "Postgres",
|
|
287
|
+
configFields: [
|
|
288
|
+
{
|
|
289
|
+
name: "host",
|
|
290
|
+
message: "Host",
|
|
291
|
+
required: true
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: "port",
|
|
295
|
+
message: "Port (default: 5432)"
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: "database",
|
|
299
|
+
message: "Database name",
|
|
300
|
+
required: true
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: "schema",
|
|
304
|
+
message: "Schema (default: public)"
|
|
305
|
+
}
|
|
306
|
+
],
|
|
307
|
+
credentialFields: [{
|
|
308
|
+
name: "username",
|
|
309
|
+
message: "Username"
|
|
310
|
+
}, {
|
|
311
|
+
name: "password",
|
|
312
|
+
message: "Password",
|
|
313
|
+
secret: true
|
|
314
|
+
}]
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
value: "bigquery",
|
|
318
|
+
label: "BigQuery",
|
|
319
|
+
configFields: [
|
|
320
|
+
{
|
|
321
|
+
name: "project_id",
|
|
322
|
+
message: "GCP Project ID",
|
|
323
|
+
required: true
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
name: "dataset",
|
|
327
|
+
message: "Dataset name",
|
|
328
|
+
required: true
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: "location",
|
|
332
|
+
message: "Location (e.g. US, EU)"
|
|
333
|
+
}
|
|
334
|
+
],
|
|
335
|
+
credentialFields: [{
|
|
336
|
+
name: "service_account_json",
|
|
337
|
+
message: "Service account JSON (paste or path)"
|
|
338
|
+
}]
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
value: "databricks",
|
|
342
|
+
label: "Databricks",
|
|
343
|
+
configFields: [
|
|
344
|
+
{
|
|
345
|
+
name: "hostname",
|
|
346
|
+
message: "Server hostname",
|
|
347
|
+
required: true
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
name: "http_path",
|
|
351
|
+
message: "HTTP path",
|
|
352
|
+
required: true
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
name: "catalog",
|
|
356
|
+
message: "Catalog name"
|
|
357
|
+
}
|
|
358
|
+
],
|
|
359
|
+
credentialFields: [{
|
|
360
|
+
name: "token",
|
|
361
|
+
message: "Personal access token",
|
|
362
|
+
secret: true
|
|
363
|
+
}]
|
|
364
|
+
}
|
|
365
|
+
];
|
|
366
|
+
async function datasourceAddCommand() {
|
|
367
|
+
const { input, select, password } = await prompts();
|
|
368
|
+
let name;
|
|
369
|
+
while (true) {
|
|
370
|
+
name = await input({ message: "Name for this data source:" });
|
|
371
|
+
const { dataSources } = await get("/api/datasources");
|
|
372
|
+
if (dataSources.some((ds) => ds.name === name)) {
|
|
373
|
+
console.log(pc.red(`A data source named "${name}" already exists. Choose a different name.`));
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
const warehouseType = await select({
|
|
379
|
+
message: "Warehouse type:",
|
|
380
|
+
choices: WAREHOUSE_TYPES.map((w) => ({
|
|
381
|
+
name: w.label,
|
|
382
|
+
value: w.value
|
|
383
|
+
}))
|
|
384
|
+
});
|
|
385
|
+
const wt = WAREHOUSE_TYPES.find((w) => w.value === warehouseType);
|
|
386
|
+
const config = {};
|
|
387
|
+
for (const field of wt.configFields) {
|
|
388
|
+
const value = await input({
|
|
389
|
+
message: field.message,
|
|
390
|
+
required: field.required
|
|
391
|
+
});
|
|
392
|
+
if (value) config[field.name] = value;
|
|
393
|
+
}
|
|
394
|
+
const credentials = {};
|
|
395
|
+
for (const field of wt.credentialFields) {
|
|
396
|
+
const value = field.secret ? await password({ message: field.message }) : await input({ message: field.message });
|
|
397
|
+
if (value) credentials[field.name] = value;
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const result = await post("/api/datasources", {
|
|
401
|
+
name,
|
|
402
|
+
warehouse_type: warehouseType,
|
|
403
|
+
config,
|
|
404
|
+
credentials
|
|
405
|
+
});
|
|
406
|
+
console.log(pc.green(`Data source "${result.dataSource.name}" created (${result.dataSource.id})`));
|
|
407
|
+
} catch (err) {
|
|
408
|
+
console.error(pc.red(`Failed to create data source: ${err.message}`));
|
|
409
|
+
process.exit(1);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
//#endregion
|
|
414
|
+
//#region src/commands/datasource/list.ts
|
|
415
|
+
async function datasourceListCommand() {
|
|
416
|
+
try {
|
|
417
|
+
const result = await get("/api/datasources");
|
|
418
|
+
if (result.dataSources.length === 0) {
|
|
419
|
+
console.log(pc.dim("No data sources found. Run `bon datasource add` to create one."));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
console.log(pc.bold("Data Sources\n"));
|
|
423
|
+
for (const ds of result.dataSources) {
|
|
424
|
+
const statusColor = ds.status === "active" ? pc.green : ds.status === "error" ? pc.red : pc.yellow;
|
|
425
|
+
console.log(` ${pc.bold(ds.name)}`);
|
|
426
|
+
console.log(` ID: ${pc.dim(ds.id)}`);
|
|
427
|
+
console.log(` Type: ${ds.warehouse_type}`);
|
|
428
|
+
console.log(` Status: ${statusColor(ds.status)}`);
|
|
429
|
+
console.log(` Created: ${new Date(ds.created_at).toLocaleDateString()}`);
|
|
430
|
+
console.log();
|
|
431
|
+
}
|
|
432
|
+
} catch (err) {
|
|
433
|
+
console.error(pc.red(`Failed to list data sources: ${err.message}`));
|
|
434
|
+
process.exit(1);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
//#endregion
|
|
439
|
+
//#region src/commands/datasource/test.ts
|
|
440
|
+
async function datasourceTestCommand(name) {
|
|
441
|
+
try {
|
|
442
|
+
const result = await post("/api/datasources/test", { name });
|
|
443
|
+
if (result.success) {
|
|
444
|
+
console.log(pc.green(result.message));
|
|
445
|
+
if (result.details) {
|
|
446
|
+
if (result.details.warehouse) console.log(pc.dim(` Warehouse: ${result.details.warehouse}`));
|
|
447
|
+
if (result.details.account) console.log(pc.dim(` Account: ${result.details.account}`));
|
|
448
|
+
if (result.details.latencyMs != null) console.log(pc.dim(` Latency: ${result.details.latencyMs}ms`));
|
|
449
|
+
}
|
|
450
|
+
} else console.log(pc.red(result.message));
|
|
451
|
+
} catch (err) {
|
|
452
|
+
console.error(pc.red(`Failed to test data source: ${err.message}`));
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/commands/datasource/remove.ts
|
|
459
|
+
async function datasourceRemoveCommand(name) {
|
|
460
|
+
try {
|
|
461
|
+
await del(`/api/datasources/${encodeURIComponent(name)}`);
|
|
462
|
+
console.log(pc.green(`Data source "${name}" removed.`));
|
|
463
|
+
} catch (err) {
|
|
464
|
+
console.error(pc.red(`Failed to remove data source: ${err.message}`));
|
|
465
|
+
process.exit(1);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/commands/query.ts
|
|
471
|
+
async function queryCommand(datasourceName, sql, options) {
|
|
472
|
+
const limit = options.limit ? parseInt(options.limit, 10) : 1e3;
|
|
473
|
+
const format = options.format ?? "toon";
|
|
474
|
+
try {
|
|
475
|
+
const result = await post("/api/datasources/query", {
|
|
476
|
+
name: datasourceName,
|
|
477
|
+
sql,
|
|
478
|
+
options: {
|
|
479
|
+
schema: options.schema,
|
|
480
|
+
database: options.database,
|
|
481
|
+
limit
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
if (result.error) {
|
|
485
|
+
console.error(pc.red(result.error));
|
|
486
|
+
process.exit(1);
|
|
487
|
+
}
|
|
488
|
+
if (result.rowCount === 0) {
|
|
489
|
+
console.log("No rows returned.");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (format === "json") console.log(JSON.stringify(result, null, 2));
|
|
493
|
+
else {
|
|
494
|
+
const toon = encode({ results: result.rows });
|
|
495
|
+
console.log(toon);
|
|
496
|
+
}
|
|
497
|
+
if (result.truncated) console.log(pc.dim(`(truncated to ${result.rowCount} rows)`));
|
|
498
|
+
} catch (err) {
|
|
499
|
+
console.error(pc.red(`Query failed: ${err.message}`));
|
|
500
|
+
process.exit(1);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
//#endregion
|
|
505
|
+
//#region src/commands/validate.ts
|
|
506
|
+
async function validateCommand() {
|
|
507
|
+
const cwd = process.cwd();
|
|
508
|
+
if (!fs.existsSync(path.join(cwd, "bon.yaml"))) {
|
|
509
|
+
console.log(pc.red("No bon.yaml found. Are you in a Bonnard project?"));
|
|
510
|
+
process.exit(1);
|
|
511
|
+
}
|
|
512
|
+
const { validate } = await import("./validate-Bd1D39Bj.mjs");
|
|
513
|
+
const result = await validate(cwd);
|
|
514
|
+
if (result.cubes.length === 0 && result.views.length === 0 && result.valid) {
|
|
515
|
+
console.log(pc.yellow("No model or view files found in models/ or views/."));
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (!result.valid) {
|
|
519
|
+
console.log(pc.red("Validation failed:\n"));
|
|
520
|
+
for (const err of result.errors) console.log(pc.red(` • ${err}`));
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
console.log(pc.green("Validation passed."));
|
|
524
|
+
console.log();
|
|
525
|
+
if (result.cubes.length > 0) console.log(` ${pc.dim("Cubes")} (${result.cubes.length}): ${result.cubes.join(", ")}`);
|
|
526
|
+
if (result.views.length > 0) console.log(` ${pc.dim("Views")} (${result.views.length}): ${result.views.join(", ")}`);
|
|
527
|
+
}
|
|
528
|
+
|
|
34
529
|
//#endregion
|
|
35
530
|
//#region src/bin/bon.ts
|
|
36
531
|
program.name("bon").description("Bonnard semantic layer CLI").version("0.1.0");
|
|
37
532
|
program.command("init").description("Create a new Bonnard project in the current directory").action(initCommand);
|
|
533
|
+
program.command("login").description("Authenticate with Bonnard via your browser").action(loginCommand);
|
|
534
|
+
program.command("logout").description("Remove stored credentials").action(logoutCommand);
|
|
535
|
+
const datasource = program.command("datasource").description("Manage warehouse data source connections");
|
|
536
|
+
datasource.command("add").description("Add a new data source connection").action(datasourceAddCommand);
|
|
537
|
+
datasource.command("list").description("List configured data sources").action(datasourceListCommand);
|
|
538
|
+
datasource.command("test").description("Test data source connectivity").argument("<name>", "Data source name").action(datasourceTestCommand);
|
|
539
|
+
datasource.command("remove").description("Remove a data source").argument("<name>", "Data source name").action(datasourceRemoveCommand);
|
|
540
|
+
program.command("query").description("Run a SQL query against a warehouse").argument("<datasource-name>", "Data source name").argument("<sql>", "SQL query to execute").option("--schema <schema>", "Override schema").option("--database <database>", "Override database").option("--limit <limit>", "Max rows to return", "1000").option("--format <format>", "Output format: toon or json", "toon").action(queryCommand);
|
|
541
|
+
program.command("validate").description("Validate Cube model and view YAML files").action(validateCommand);
|
|
38
542
|
program.parse();
|
|
39
543
|
|
|
40
544
|
//#endregion
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { compile } from "@cubejs-backend/schema-compiler";
|
|
4
|
+
|
|
5
|
+
//#region src/lib/validate.ts
|
|
6
|
+
function collectYamlFiles(dir, rootDir) {
|
|
7
|
+
if (!fs.existsSync(dir)) return [];
|
|
8
|
+
const results = [];
|
|
9
|
+
function walk(current) {
|
|
10
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
11
|
+
const fullPath = path.join(current, entry.name);
|
|
12
|
+
if (entry.isDirectory()) walk(fullPath);
|
|
13
|
+
else if (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml")) results.push({
|
|
14
|
+
fileName: path.relative(rootDir, fullPath),
|
|
15
|
+
content: fs.readFileSync(fullPath, "utf-8")
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
walk(dir);
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
function createModelRepository(projectPath) {
|
|
23
|
+
const modelsDir = path.join(projectPath, "models");
|
|
24
|
+
const viewsDir = path.join(projectPath, "views");
|
|
25
|
+
return {
|
|
26
|
+
localPath: () => projectPath,
|
|
27
|
+
dataSchemaFiles: () => {
|
|
28
|
+
const files = [...collectYamlFiles(modelsDir, projectPath), ...collectYamlFiles(viewsDir, projectPath)];
|
|
29
|
+
return Promise.resolve(files);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function validate(projectPath) {
|
|
34
|
+
const repo = createModelRepository(projectPath);
|
|
35
|
+
if ((await repo.dataSchemaFiles()).length === 0) return {
|
|
36
|
+
valid: true,
|
|
37
|
+
errors: [],
|
|
38
|
+
cubes: [],
|
|
39
|
+
views: []
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
const { cubeEvaluator } = await compile(repo, {});
|
|
43
|
+
const cubes = [];
|
|
44
|
+
const views = [];
|
|
45
|
+
for (const cube of cubeEvaluator.cubeNames()) if (cubeEvaluator.cubeFromPath(cube).isView) views.push(cube);
|
|
46
|
+
else cubes.push(cube);
|
|
47
|
+
return {
|
|
48
|
+
valid: true,
|
|
49
|
+
errors: [],
|
|
50
|
+
cubes,
|
|
51
|
+
views
|
|
52
|
+
};
|
|
53
|
+
} catch (err) {
|
|
54
|
+
const raw = err.messages ?? err.message ?? String(err);
|
|
55
|
+
return {
|
|
56
|
+
valid: false,
|
|
57
|
+
errors: Array.isArray(raw) ? raw : [raw],
|
|
58
|
+
cubes: [],
|
|
59
|
+
views: []
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
export { validate };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bonnard/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"bon": "./dist/bin/bon.mjs"
|
|
@@ -14,8 +14,12 @@
|
|
|
14
14
|
"test": "vitest run"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@
|
|
17
|
+
"@inquirer/prompts": "^7.0.0",
|
|
18
|
+
"@toon-format/toon": "^2.1.0",
|
|
18
19
|
"commander": "^12.0.0",
|
|
20
|
+
"open": "^11.0.0",
|
|
21
|
+
"@cubejs-backend/schema-compiler": "^1.6.7",
|
|
22
|
+
"@cubejs-backend/shared": "^1.6.7",
|
|
19
23
|
"picocolors": "^1.0.0"
|
|
20
24
|
},
|
|
21
25
|
"devDependencies": {
|