unbound-cli 0.5.0 → 0.6.0

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.
@@ -0,0 +1,531 @@
1
+ /**
2
+ * `unbound chat` — interactive session or one-shot wrapper around
3
+ * POST /api/v1/query_builder/chat/. Conversation history lives in memory
4
+ * only; nothing is written to disk. In one-shot mode (-m), `--json` writes
5
+ * the response to stdout and `-o <path>` writes it to a file.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const readline = require('readline/promises');
12
+
13
+ const api = require('../api');
14
+ const output = require('../output');
15
+ const { ensureLoggedIn } = require('../auth');
16
+ const chart = require('../chartRender');
17
+ const { sanitizeForTerminal } = chart;
18
+
19
+ const ENDPOINT = '/api/v1/query_builder/chat/';
20
+ const HISTORY_CAP = 20;
21
+ const RECENT_RESULTS_CAP = 5;
22
+ const MAX_MESSAGE_LEN = 4000;
23
+ const MAX_TURN_CONTENT_LEN = 8000;
24
+ const ALLOWED_ROLES = new Set(['user', 'assistant']);
25
+
26
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
27
+ const dim = (s) => useColor ? `\x1b[2m${s}\x1b[0m` : s;
28
+ const bold = (s) => useColor ? `\x1b[1m${s}\x1b[0m` : s;
29
+ const cyan = (s) => useColor ? `\x1b[36m${s}\x1b[0m` : s;
30
+
31
+ function nowIso() {
32
+ return new Date().toISOString();
33
+ }
34
+
35
+ // Expand a leading `~/` to the user's home directory. Does NOT expand `~user/`
36
+ // (uncommon, not worth parsing). Returns an absolute path.
37
+ function expandPath(p) {
38
+ if (typeof p !== 'string') return p;
39
+ if (p === '~') return os.homedir();
40
+ if (p.startsWith('~/') || p.startsWith('~\\')) {
41
+ return path.join(os.homedir(), p.slice(2));
42
+ }
43
+ return path.resolve(p);
44
+ }
45
+
46
+ // Write `data` to `target` without following symlinks and without overwriting
47
+ // an existing file. Creates the file with mode 0o600 so org analytics don't
48
+ // leak on multi-user hosts. Returns the resolved absolute path on success.
49
+ function safeWriteFile(target, data) {
50
+ if (target === undefined || target === null || target === '') {
51
+ throw new Error('Output path is empty.');
52
+ }
53
+ if (typeof target !== 'string') {
54
+ throw new Error('Output path must be a string.');
55
+ }
56
+ if (target.includes('\0')) {
57
+ throw new Error('Output path contains an invalid character (NUL).');
58
+ }
59
+ const resolved = expandPath(target);
60
+ try {
61
+ const stat = fs.lstatSync(resolved);
62
+ if (stat.isDirectory()) {
63
+ throw new Error(`Refusing to write: '${resolved}' is a directory.`);
64
+ }
65
+ throw new Error(`Refusing to overwrite existing file: '${resolved}'. Choose a new path or remove it first.`);
66
+ } catch (err) {
67
+ if (err.code !== 'ENOENT') throw err;
68
+ }
69
+ const fd = fs.openSync(
70
+ resolved,
71
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
72
+ 0o600,
73
+ );
74
+ try {
75
+ fs.writeFileSync(fd, data);
76
+ } finally {
77
+ fs.closeSync(fd);
78
+ }
79
+ return resolved;
80
+ }
81
+
82
+ function buildBody(message, history, recent) {
83
+ return {
84
+ message,
85
+ conversation_history: history.slice(-HISTORY_CAP),
86
+ recent_results: recent.slice(-RECENT_RESULTS_CAP),
87
+ };
88
+ }
89
+
90
+ function pushRecent(recent, query, result) {
91
+ recent.push({
92
+ query,
93
+ view_spec: result.view_spec,
94
+ result_summary: {
95
+ row_count: result.row_count,
96
+ chart_type: result.chart_meta?.chart_type,
97
+ },
98
+ });
99
+ while (recent.length > RECENT_RESULTS_CAP) recent.shift();
100
+ }
101
+
102
+ function pushHistory(history, turn) {
103
+ history.push(turn);
104
+ while (history.length > HISTORY_CAP) history.shift();
105
+ }
106
+
107
+ // Turn text/field from a backend response is untrusted — normalize it before
108
+ // pushing into conversation_history (which we echo on the wire next turn and
109
+ // may print inside /json). Reject unknown roles to prevent a hostile backend
110
+ // from injecting fake `system` turns that amplify prompt-injection attacks.
111
+ function normalizeAssistantTurn(turn) {
112
+ if (!turn || typeof turn !== 'object') return null;
113
+ const role = typeof turn.role === 'string' ? turn.role.toLowerCase() : '';
114
+ if (!ALLOWED_ROLES.has(role)) return null;
115
+ const content = typeof turn.content === 'string' ? turn.content.slice(0, MAX_TURN_CONTENT_LEN) : '';
116
+ const timestamp = typeof turn.timestamp === 'string' ? turn.timestamp : nowIso();
117
+ return { role, content, timestamp };
118
+ }
119
+
120
+ // Map an error from a chat-endpoint call into a human-readable hint. Keep
121
+ // backend-supplied strings out of the shell line so copy-pastes stay safe.
122
+ function friendlyChatError(err) {
123
+ if (err?.statusCode === 401) return 'Not authenticated. Run `unbound login` and try again.';
124
+ if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound whoami` to check yours.';
125
+ if (err?.statusCode === 429) return 'Rate limited. Please wait a moment and try again.';
126
+ return sanitizeForTerminal(err?.message || 'Request failed.');
127
+ }
128
+
129
+ function renderSuggestionsForHuman(suggestions) {
130
+ if (!suggestions?.length) return;
131
+ console.log('');
132
+ output.info('Suggested follow-ups (rerun `unbound chat -m "<your question>"` with one of these):');
133
+ suggestions.forEach((s, i) => {
134
+ const label = sanitizeForTerminal(s.label || `Suggestion ${i + 1}`);
135
+ const query = sanitizeForTerminal(s.query || '');
136
+ const desc = sanitizeForTerminal(s.description || '');
137
+ console.log(` ${i + 1}. ${bold(label)}`);
138
+ if (query) console.log(` ${query}`);
139
+ if (desc) console.log(` ${dim(desc)}`);
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Print the assistant message + (if a chart) a one-line summary and a chart
145
+ * body. Suggestions are NOT printed here — caller decides (REPL picker vs
146
+ * one-shot numbered list).
147
+ */
148
+ function renderResponse(resp) {
149
+ const intent = resp?.intent;
150
+ const message = sanitizeForTerminal(resp?.message || '');
151
+
152
+ if (intent === 'invalid') {
153
+ output.error(message || 'The request could not be processed.');
154
+ return;
155
+ }
156
+
157
+ if (intent === 'conversational' || !resp.result) {
158
+ if (message) console.log(cyan('\u25cf ') + message);
159
+ return;
160
+ }
161
+
162
+ if (message) console.log(cyan('\u25cf ') + message);
163
+ const result = resp.result;
164
+ const summary = sanitizeForTerminal(chart.summarizeChart(result.chart_meta, result.row_count));
165
+ console.log(dim(' ' + summary));
166
+
167
+ const { columns, rows } = chart.reconstructRows(result.chart_option, result.chart_meta);
168
+ if (!rows.length) {
169
+ console.log(dim(' (no rows to render)'));
170
+ return;
171
+ }
172
+
173
+ console.log('');
174
+ const cap = chart.MAX_RENDER_ROWS;
175
+ const renderRows = rows.slice(0, cap);
176
+ const chartType = result.chart_meta?.chart_type;
177
+ const cols = process.stdout.columns || 80;
178
+ const tableCols = columns.map((k) => ({ key: k, header: k }));
179
+
180
+ if (cols < 40) {
181
+ output.table(renderRows, tableCols);
182
+ } else if (chartType === 'bar' && columns.length >= 2) {
183
+ const xKey = columns[0];
184
+ const yKeys = columns.slice(1);
185
+ const body = yKeys.length === 1
186
+ ? chart.renderBarChart(renderRows, xKey, yKeys[0])
187
+ : chart.renderGroupedBarChart(renderRows, xKey, yKeys);
188
+ if (body) console.log(body); else output.table(renderRows, tableCols);
189
+ } else if (chartType === 'line' && columns.length >= 2) {
190
+ const yKey = columns[1];
191
+ const series = renderRows.map((r) => Number(r[yKey] ?? 0));
192
+ const spark = chart.renderSparkline(series);
193
+ if (spark) console.log(spark);
194
+ output.table(renderRows, tableCols);
195
+ } else {
196
+ output.table(renderRows, tableCols);
197
+ }
198
+
199
+ if (rows.length > cap) {
200
+ console.log(dim(` \u2026 ${rows.length - cap} more row${rows.length - cap === 1 ? '' : 's'} hidden. Use --json or -o for full data.`));
201
+ }
202
+ }
203
+
204
+ async function runOneShot({ message, json, outputPath }) {
205
+ const body = buildBody(message, [], []);
206
+
207
+ if (json || outputPath) {
208
+ let resp;
209
+ try {
210
+ resp = await api.post(ENDPOINT, { body });
211
+ } catch (err) {
212
+ output.error(friendlyChatError(err));
213
+ process.exitCode = 1;
214
+ return;
215
+ }
216
+ const serialized = JSON.stringify(resp, null, 2) + '\n';
217
+ // Write to stdout first (when requested) so a downstream `-o` write failure
218
+ // doesn't eat the response when both flags are combined.
219
+ if (json) {
220
+ process.stdout.write(serialized);
221
+ }
222
+ if (outputPath) {
223
+ try {
224
+ const resolved = safeWriteFile(outputPath, serialized);
225
+ output.success(`Wrote ${resolved}`);
226
+ } catch (err) {
227
+ output.error(sanitizeForTerminal(err.message));
228
+ process.exitCode = 1;
229
+ return;
230
+ }
231
+ }
232
+ if (resp?.intent === 'invalid') process.exitCode = 1;
233
+ return;
234
+ }
235
+
236
+ const spin = output.spinner('Thinking...');
237
+ let resp;
238
+ try {
239
+ resp = await api.post(ENDPOINT, { body });
240
+ spin.stop();
241
+ } catch (err) {
242
+ spin.fail(friendlyChatError(err));
243
+ process.exitCode = 1;
244
+ return;
245
+ }
246
+
247
+ renderResponse(resp);
248
+ renderSuggestionsForHuman(resp?.suggestions);
249
+ if (resp?.intent === 'invalid') process.exitCode = 1;
250
+ }
251
+
252
+ function printBanner() {
253
+ console.log(bold('unbound chat') + dim(' — ask questions about your usage data'));
254
+ console.log(dim('Type a question, or /help for commands. /exit (or Ctrl+C) to quit.'));
255
+ console.log('');
256
+ }
257
+
258
+ function printReplHelp() {
259
+ const lines = [
260
+ ' /help show this help',
261
+ ' /json print the last result as JSON to stdout',
262
+ ' /export <path> write the last result to a new JSON file',
263
+ ' (refuses to overwrite; supports ~/ expansion; mode 0600)',
264
+ ' /clear reset the conversation in memory',
265
+ ' /exit quit (Ctrl+C also works)',
266
+ ];
267
+ console.log(lines.join('\n'));
268
+ }
269
+
270
+ function handleSlash(line, state) {
271
+ const trimmed = line.trim();
272
+ const spaceIdx = trimmed.search(/\s/);
273
+ const cmd = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
274
+ const argStr = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim();
275
+ switch (cmd) {
276
+ case '/exit':
277
+ case '/quit':
278
+ return 'exit';
279
+ case '/help':
280
+ printReplHelp();
281
+ return 'continue';
282
+ case '/clear':
283
+ state.history.length = 0;
284
+ state.recent.length = 0;
285
+ state.lastResult = null;
286
+ output.success('Conversation cleared.');
287
+ return 'continue';
288
+ case '/json':
289
+ if (state.lastResult) console.log(JSON.stringify(state.lastResult, null, 2));
290
+ else console.log(dim('(no result yet)'));
291
+ return 'continue';
292
+ case '/export': {
293
+ if (!state.lastResult) { output.error('No result to export yet. Ask a question first.'); return 'continue'; }
294
+ if (!argStr) { output.error('Usage: /export <path>'); return 'continue'; }
295
+ try {
296
+ const resolved = safeWriteFile(argStr, JSON.stringify(state.lastResult, null, 2) + '\n');
297
+ output.success(`Wrote ${resolved}`);
298
+ } catch (err) {
299
+ output.error(sanitizeForTerminal(err.message));
300
+ }
301
+ return 'continue';
302
+ }
303
+ default:
304
+ output.warn(`Unknown command: ${sanitizeForTerminal(cmd)}. Type /help for the list.`);
305
+ return 'continue';
306
+ }
307
+ }
308
+
309
+ async function runRepl({ noSuggestions }) {
310
+ const state = { history: [], recent: [], lastResult: null };
311
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
312
+ let closed = false;
313
+ const setClosed = () => { closed = true; };
314
+ rl.on('SIGINT', () => { closed = true; rl.close(); });
315
+ rl.on('close', setClosed);
316
+
317
+ printBanner();
318
+ let pending = null;
319
+
320
+ while (!closed) {
321
+ let msg;
322
+ if (pending) {
323
+ msg = pending;
324
+ pending = null;
325
+ console.log(`${bold('chat>')} ${dim('(picked) ') + sanitizeForTerminal(msg)}`);
326
+ } else {
327
+ try {
328
+ msg = await rl.question(`${bold('chat>')} `);
329
+ } catch {
330
+ break;
331
+ }
332
+ }
333
+ if (closed) break;
334
+ msg = (msg || '').trim();
335
+ if (!msg) continue;
336
+ if (msg.length > MAX_MESSAGE_LEN) {
337
+ output.warn(`Message truncated to ${MAX_MESSAGE_LEN} characters.`);
338
+ msg = msg.slice(0, MAX_MESSAGE_LEN);
339
+ }
340
+
341
+ if (msg.startsWith('/')) {
342
+ const action = handleSlash(msg, state);
343
+ if (action === 'exit') break;
344
+ continue;
345
+ }
346
+
347
+ // Build the request body BEFORE pushing the current turn into history —
348
+ // otherwise `message` and the last entry of `conversation_history` would
349
+ // both contain the same user text, making the backend process it twice.
350
+ const body = buildBody(msg, state.history, state.recent);
351
+
352
+ const spin = output.spinner('Thinking...');
353
+ let resp;
354
+ try {
355
+ resp = await api.post(ENDPOINT, { body });
356
+ spin.stop();
357
+ } catch (err) {
358
+ spin.fail(friendlyChatError(err));
359
+ if (err?.statusCode === 401 || err?.statusCode === 403) break;
360
+ continue;
361
+ }
362
+
363
+ // Record the user turn only after a successful round-trip so history stays
364
+ // consistent with the server view.
365
+ pushHistory(state.history, { role: 'user', content: msg, timestamp: nowIso() });
366
+
367
+ const assistantTurn = normalizeAssistantTurn(resp?.turn);
368
+ if (assistantTurn) pushHistory(state.history, assistantTurn);
369
+ if (resp?.result) {
370
+ pushRecent(state.recent, msg, resp.result);
371
+ state.lastResult = resp.result;
372
+ }
373
+
374
+ renderResponse(resp);
375
+
376
+ const suggestions = Array.isArray(resp?.suggestions) ? resp.suggestions : [];
377
+ if (suggestions.length && !noSuggestions && process.stdin.isTTY && process.stdout.isTTY) {
378
+ const pickOptions = [
379
+ ...suggestions.map((s, i) => ({
380
+ label: sanitizeForTerminal(s.label || `Suggestion ${i + 1}`)
381
+ + (s.description ? ' ' + dim('— ' + sanitizeForTerminal(s.description)) : ''),
382
+ value: sanitizeForTerminal(s.query || ''),
383
+ })).filter((o) => o.value),
384
+ { label: dim('(type your own)'), value: null },
385
+ { label: dim('(exit)'), value: '__exit__' },
386
+ ];
387
+ let pick;
388
+ try {
389
+ rl.pause();
390
+ pick = await output.select('Follow up?', pickOptions);
391
+ } catch {
392
+ pick = null;
393
+ } finally {
394
+ rl.resume();
395
+ }
396
+ if (pick === '__exit__') break;
397
+ if (pick) pending = pick;
398
+ } else if (suggestions.length && (!process.stdin.isTTY || !process.stdout.isTTY)) {
399
+ renderSuggestionsForHuman(suggestions);
400
+ }
401
+ }
402
+
403
+ rl.close();
404
+ console.log('');
405
+ }
406
+
407
+ function register(program) {
408
+ program
409
+ .command('chat')
410
+ .description('Chat with your usage data. Opens an interactive session by default; use -m for one-shot.')
411
+ .option('-m, --message <text>', 'Ask a single question and exit (no interactive session).')
412
+ .option('--json', 'Write the raw JSON response to stdout. Use with -m.')
413
+ .option('-o, --output <path>', 'Write the raw JSON response to a file. Refuses to overwrite. Use with -m.')
414
+ .option('--no-suggestions', 'Skip the follow-up picker after each turn (interactive session only).')
415
+ .addHelpText('after', `
416
+ EXAMPLES
417
+ Start an interactive session:
418
+ $ unbound chat
419
+
420
+ Ask a single question and render a chart in the terminal:
421
+ $ unbound chat -m "show cost by provider for the last 30 days"
422
+
423
+ Write the raw JSON response to stdout (pipe to jq, a script, or another tool):
424
+ $ unbound chat -m "cost by provider last 30 days" --json
425
+ $ unbound chat -m "cost by provider last 30 days" --json | jq '.intent'
426
+ $ unbound chat -m "cost by provider last 30 days" --json | jq '.result.chart_option'
427
+
428
+ Write the raw JSON response to a file:
429
+ $ unbound chat -m "cost by provider last 30 days" -o cost.json
430
+
431
+ Combine --json and -o to pipe and archive at the same time:
432
+ $ unbound chat -m "cost by provider last 30 days" --json -o cost.json | jq '.intent'
433
+
434
+ OUTPUT FORMAT (--json and -o)
435
+ { message, intent, result?, suggestions?, turn }
436
+
437
+ intent: "sql_visualise" | "conversational" | "invalid"
438
+ sql_visualise — a chart was generated; 'result' is populated.
439
+ conversational — the model wants clarification; pick a suggestions[i].query and re-run.
440
+ invalid — the prompt could not be answered; exit code is 1.
441
+
442
+ result (only when intent == "sql_visualise"):
443
+ columns string[] left-to-right column names
444
+ row_count number total rows in the answer
445
+ chart_meta { chart_type, x, y } chart_type is one of:
446
+ "bar" | "line" | "donut" | "pie"
447
+ | "kpi" | "scatter" | "table"
448
+ chart_option ECharts config with the data embedded.
449
+
450
+ suggestions (may be present with any intent):
451
+ [{ label, query, description }]
452
+ Each entry's 'query' is the exact text for a follow-up '-m "<query>"' call.
453
+
454
+ turn: { role, content, timestamp } — the assistant's turn record.
455
+
456
+ DATA EXTRACTION (rows are embedded in chart_option; no separate rows field)
457
+ bar, line chart_option.xAxis.data (category labels, ordered)
458
+ chart_option.series[i].data (values, parallel to xAxis.data)
459
+ donut, pie chart_option.series[0].data ([{name, value}, ...])
460
+ scatter chart_option.series[0].data ([[x, y], ...])
461
+ or chart_option.dataset.source
462
+ table chart_option.dataset.source (first row is the header)
463
+ kpi chart_option.series[0].data[0] (a single number; no axes)
464
+
465
+ EXIT CODES
466
+ 0 success
467
+ 1 intent=="invalid", auth/role/network failure, or a write error
468
+
469
+ INTERACTIVE SESSION
470
+ $ unbound chat start the session
471
+ $ unbound chat --no-suggestions skip the follow-up picker
472
+
473
+ Commands (type inside the session):
474
+ /help show these commands
475
+ /json print the last result as JSON
476
+ /export <path> write the last result to a file (refuses to overwrite;
477
+ supports ~/ expansion; file mode is 0600)
478
+ /clear reset the conversation in memory
479
+ /exit quit (Ctrl+C also works)
480
+
481
+ NOTES
482
+ * Requires Admin or Manager role in your organization. Run 'unbound whoami' to check.
483
+ * Conversation history is held in memory only; nothing is ever written to disk.
484
+ * Each -m invocation sends no history; keep each prompt self-contained.
485
+ * -o writes a new file with mode 0600 and refuses to overwrite existing files.
486
+ `)
487
+ .action(async (opts) => {
488
+ try {
489
+ await ensureLoggedIn();
490
+ } catch (err) {
491
+ if (!err.displayed) output.error(sanitizeForTerminal(err.message));
492
+ process.exitCode = 1;
493
+ return;
494
+ }
495
+
496
+ const oneShotFlagsGiven = opts.message !== undefined || !!opts.json || !!opts.output;
497
+ if (oneShotFlagsGiven) {
498
+ const trimmed = typeof opts.message === 'string' ? opts.message.trim() : '';
499
+ if (!trimmed) {
500
+ output.error('`-m/--message` requires a non-empty message. For interactive chat, run `unbound chat` with no flags.');
501
+ process.exitCode = 1;
502
+ return;
503
+ }
504
+ if (trimmed.length > MAX_MESSAGE_LEN) {
505
+ output.error(`Message too long (max ${MAX_MESSAGE_LEN} characters).`);
506
+ process.exitCode = 1;
507
+ return;
508
+ }
509
+ try {
510
+ await runOneShot({ message: trimmed, json: !!opts.json, outputPath: opts.output });
511
+ } catch (err) {
512
+ output.error(friendlyChatError(err));
513
+ process.exitCode = 1;
514
+ }
515
+ return;
516
+ }
517
+
518
+ try {
519
+ await runRepl({ noSuggestions: opts.suggestions === false });
520
+ } catch (err) {
521
+ output.error(friendlyChatError(err));
522
+ process.exitCode = 1;
523
+ }
524
+ });
525
+ }
526
+
527
+ module.exports = {
528
+ register,
529
+ // Exposed for unit tests:
530
+ _internals: { safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError },
531
+ };
@@ -27,6 +27,8 @@ function register(program) {
27
27
  .requiredOption('--api-key <key>', 'User API key (for tool setup and login)')
28
28
  .requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
29
29
  .option('--domain <url>', 'Backend URL for discovery', DEFAULT_DOMAIN)
30
+ .addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
31
+ .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
30
32
  .addHelpText('after', `
31
33
  Runs the full onboarding flow for an end user:
32
34
  1. Logs in with --api-key and stores credentials.
@@ -54,7 +56,7 @@ Examples:
54
56
 
55
57
  console.log('');
56
58
  output.info('Step 1/2: Installing tool bundle');
57
- const ok = await runSetupAllBundle(apiKey);
59
+ const ok = await runSetupAllBundle(apiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
58
60
  if (!ok) return;
59
61
  setupSucceeded = true;
60
62
 
@@ -87,7 +89,8 @@ Examples:
87
89
  .requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
88
90
  .requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
89
91
  .option('--domain <url>', 'Backend URL for discovery', DEFAULT_DOMAIN)
90
- .addOption(new Option('--url <url>', 'Override backend URL for setup scripts').hideHelp())
92
+ .addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
93
+ .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
91
94
  .addHelpText('after', `
92
95
  Runs the full MDM onboarding flow for device enrollment:
93
96
  1. Installs the MDM tool bundle: ${MDM_ALL_TOOLS.join(', ')}.
@@ -108,7 +111,7 @@ Examples:
108
111
 
109
112
  console.log('');
110
113
  output.info('Step 1/2: Installing MDM tool bundle');
111
- const ok = await runMdmSetupAllBundle(opts.adminApiKey, { url: opts.url });
114
+ const ok = await runMdmSetupAllBundle(opts.adminApiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
112
115
  if (!ok) return;
113
116
  setupSucceeded = true;
114
117