@bonnard/cli 0.1.1 → 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 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.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bon": "./dist/bin/bon.mjs"
@@ -14,11 +14,15 @@
14
14
  "test": "vitest run"
15
15
  },
16
16
  "dependencies": {
17
+ "@inquirer/prompts": "^7.0.0",
18
+ "@toon-format/toon": "^2.1.0",
17
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",
18
23
  "picocolors": "^1.0.0"
19
24
  },
20
25
  "devDependencies": {
21
- "@bonnard/core": "workspace:*",
22
26
  "tsdown": "^0.20.1",
23
27
  "vitest": "^2.0.0"
24
28
  },