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.
- package/.github/workflows/test.yml +25 -0
- package/LOCAL_DEV.md +45 -0
- package/package.json +2 -2
- package/src/chartRender.js +284 -0
- package/src/commands/chat.js +531 -0
- package/src/commands/onboard.js +6 -3
- package/src/commands/setup.js +38 -20
- package/src/index.js +9 -0
- package/test/chart-render.test.js +205 -0
- package/test/chat-internals.test.js +144 -0
package/src/commands/setup.js
CHANGED
|
@@ -90,8 +90,11 @@ function buildSetupCommand(scriptPath, args) {
|
|
|
90
90
|
/**
|
|
91
91
|
* Runs a Python setup script from the setup repo with inherited stdio (live output).
|
|
92
92
|
*/
|
|
93
|
-
function runSetupScript(scriptPath, apiKey, { clear = false } = {}) {
|
|
94
|
-
|
|
93
|
+
function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl } = {}) {
|
|
94
|
+
let args = `--api-key ${shellEscape(apiKey)}`;
|
|
95
|
+
if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
|
|
96
|
+
if (frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
|
|
97
|
+
if (clear) args += ' --clear';
|
|
95
98
|
console.log('');
|
|
96
99
|
try {
|
|
97
100
|
execSync(buildSetupCommand(scriptPath, args), { stdio: 'inherit' });
|
|
@@ -177,6 +180,8 @@ function register(program) {
|
|
|
177
180
|
.option('--subscription', 'Use subscription mode for Claude Code / Codex (hooks only)')
|
|
178
181
|
.option('--gateway', 'Use gateway mode for Claude Code / Codex (Unbound as AI provider)')
|
|
179
182
|
.option('--all', 'Set up the default bundle: Cursor, Claude Code (hooks), Codex (hooks)')
|
|
183
|
+
.addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
|
|
184
|
+
.addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
|
|
180
185
|
.addHelpText('after', `
|
|
181
186
|
Available tools:
|
|
182
187
|
cursor Cursor IDE
|
|
@@ -252,8 +257,11 @@ automatically to authenticate before proceeding.
|
|
|
252
257
|
const selectedTools = SETUP_TOOLS.filter(t => selected.includes(t.value));
|
|
253
258
|
console.log('');
|
|
254
259
|
|
|
260
|
+
let interactiveArgs = `--api-key ${shellEscape(apiKey)}`;
|
|
261
|
+
if (opts.backendUrl) interactiveArgs += ` --backend-url ${shellEscape(opts.backendUrl)}`;
|
|
262
|
+
if (opts.frontendUrl) interactiveArgs += ` --domain ${shellEscape(opts.frontendUrl)}`;
|
|
255
263
|
const ok = await runBatch(selectedTools, (tool) =>
|
|
256
|
-
runScriptPiped(tool.script,
|
|
264
|
+
runScriptPiped(tool.script, interactiveArgs)
|
|
257
265
|
);
|
|
258
266
|
if (!ok) return;
|
|
259
267
|
|
|
@@ -318,13 +326,13 @@ automatically to authenticate before proceeding.
|
|
|
318
326
|
const toolName = tools[0];
|
|
319
327
|
|
|
320
328
|
if (SETUP_TOOL_MAP[toolName]) {
|
|
321
|
-
runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear });
|
|
329
|
+
runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
322
330
|
} else if (MODE_TOOLS[toolName]) {
|
|
323
331
|
const mode = MODE_TOOLS[toolName];
|
|
324
332
|
if (opts.clear) {
|
|
325
333
|
// Clear both modes
|
|
326
|
-
runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true });
|
|
327
|
-
runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true });
|
|
334
|
+
runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
335
|
+
runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
328
336
|
} else {
|
|
329
337
|
let useSubscription = opts.subscription;
|
|
330
338
|
if (!opts.subscription && !opts.gateway) {
|
|
@@ -332,7 +340,7 @@ automatically to authenticate before proceeding.
|
|
|
332
340
|
useSubscription = choice === 'subscription';
|
|
333
341
|
}
|
|
334
342
|
const resolved = useSubscription ? mode.subscription : mode.gateway;
|
|
335
|
-
runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, {});
|
|
343
|
+
runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
336
344
|
}
|
|
337
345
|
} else if (INSTRUCTION_TOOLS[toolName]) {
|
|
338
346
|
output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
|
|
@@ -370,7 +378,10 @@ automatically to authenticate before proceeding.
|
|
|
370
378
|
// Run automated tools with spinners
|
|
371
379
|
if (resolvedScripts.length > 0) {
|
|
372
380
|
console.log('');
|
|
373
|
-
|
|
381
|
+
let args = `--api-key ${shellEscape(apiKey)}`;
|
|
382
|
+
if (opts.backendUrl) args += ` --backend-url ${shellEscape(opts.backendUrl)}`;
|
|
383
|
+
if (opts.frontendUrl) args += ` --domain ${shellEscape(opts.frontendUrl)}`;
|
|
384
|
+
if (opts.clear) args += ' --clear';
|
|
374
385
|
const ok = await runBatch(resolvedScripts, (tool) =>
|
|
375
386
|
runScriptPiped(tool.script, args)
|
|
376
387
|
, { clear: opts.clear });
|
|
@@ -409,7 +420,6 @@ automatically to authenticate before proceeding.
|
|
|
409
420
|
.requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
|
|
410
421
|
.option('--clear', 'Remove Unbound configuration for the specified tools')
|
|
411
422
|
.option('--all', 'Set up all available tools')
|
|
412
|
-
.addOption(new Option('--url <url>', 'Override backend URL').hideHelp())
|
|
413
423
|
.addHelpText('after', `
|
|
414
424
|
Available tools:
|
|
415
425
|
cursor Cursor IDE
|
|
@@ -429,18 +439,22 @@ Examples:
|
|
|
429
439
|
$ sudo unbound setup mdm --admin-api-key KEY --all
|
|
430
440
|
$ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
|
|
431
441
|
`)
|
|
432
|
-
.action(async (tools, opts) => {
|
|
442
|
+
.action(async (tools, opts, command) => {
|
|
433
443
|
try {
|
|
434
444
|
checkRoot();
|
|
445
|
+
// --all and --clear are defined on both this command and the parent `setup` command;
|
|
446
|
+
// --backend-url and --frontend-url are defined only on the parent `setup` command.
|
|
447
|
+
// Use optsWithGlobals() so all four work regardless of position relative to `mdm`.
|
|
448
|
+
const globalOpts = command.optsWithGlobals();
|
|
435
449
|
|
|
436
|
-
if (
|
|
450
|
+
if (globalOpts.all && tools.length > 0) {
|
|
437
451
|
output.error('Cannot combine --all with specific tool names. Use one or the other.');
|
|
438
452
|
process.exitCode = 1;
|
|
439
453
|
return;
|
|
440
454
|
}
|
|
441
455
|
|
|
442
456
|
let toolNames;
|
|
443
|
-
if (
|
|
457
|
+
if (globalOpts.all) {
|
|
444
458
|
toolNames = MDM_ALL_TOOLS;
|
|
445
459
|
} else if (tools.length > 0) {
|
|
446
460
|
toolNames = tools;
|
|
@@ -476,20 +490,21 @@ Examples:
|
|
|
476
490
|
|
|
477
491
|
const mdmArgs = (tool) => {
|
|
478
492
|
let args = `--api-key ${shellEscape(opts.adminApiKey)}`;
|
|
479
|
-
if (
|
|
480
|
-
if (
|
|
493
|
+
if (globalOpts.backendUrl) args += ` --backend-url ${shellEscape(globalOpts.backendUrl)}`;
|
|
494
|
+
if (globalOpts.frontendUrl) args += ` --domain ${shellEscape(globalOpts.frontendUrl)}`;
|
|
495
|
+
if (globalOpts.clear) args += ' --clear';
|
|
481
496
|
return args;
|
|
482
497
|
};
|
|
483
498
|
|
|
484
499
|
const ok = await runBatch(
|
|
485
500
|
resolvedTools,
|
|
486
501
|
(tool) => runScriptPiped(tool.script, mdmArgs(tool)),
|
|
487
|
-
{ clear:
|
|
502
|
+
{ clear: globalOpts.clear }
|
|
488
503
|
);
|
|
489
504
|
if (!ok) return;
|
|
490
505
|
|
|
491
506
|
console.log('');
|
|
492
|
-
output.success(
|
|
507
|
+
output.success(globalOpts.clear ? 'All tools cleared' : 'All tools configured');
|
|
493
508
|
} catch (err) {
|
|
494
509
|
output.error(err.message);
|
|
495
510
|
process.exitCode = 1;
|
|
@@ -502,9 +517,11 @@ Examples:
|
|
|
502
517
|
* Assumes the caller has already ensured the user is logged in.
|
|
503
518
|
* Returns true on success, false on failure.
|
|
504
519
|
*/
|
|
505
|
-
async function runSetupAllBundle(apiKey) {
|
|
520
|
+
async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl } = {}) {
|
|
506
521
|
const resolvedTools = ALL_TOOLS.map(name => ({ name, ...SETUP_TOOL_MAP[name] }));
|
|
507
|
-
|
|
522
|
+
let args = `--api-key ${shellEscape(apiKey)}`;
|
|
523
|
+
if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
|
|
524
|
+
if (frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
|
|
508
525
|
return runBatch(resolvedTools, (tool) => runScriptPiped(tool.script, args));
|
|
509
526
|
}
|
|
510
527
|
|
|
@@ -513,10 +530,11 @@ async function runSetupAllBundle(apiKey) {
|
|
|
513
530
|
* Caller must ensure the process is running as root.
|
|
514
531
|
* Returns true on success, false on failure.
|
|
515
532
|
*/
|
|
516
|
-
async function runMdmSetupAllBundle(adminApiKey, {
|
|
533
|
+
async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl } = {}) {
|
|
517
534
|
const resolvedTools = MDM_ALL_TOOLS.map(name => ({ name, ...MDM_TOOLS[name] }));
|
|
518
535
|
let args = `--api-key ${shellEscape(adminApiKey)}`;
|
|
519
|
-
if (
|
|
536
|
+
if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
|
|
537
|
+
if (frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
|
|
520
538
|
return runBatch(resolvedTools, (tool) => runScriptPiped(tool.script, args));
|
|
521
539
|
}
|
|
522
540
|
|
package/src/index.js
CHANGED
|
@@ -115,6 +115,14 @@ TOOL CONNECTIONS
|
|
|
115
115
|
$ unbound tools connect <type> Connect a new tool
|
|
116
116
|
$ unbound tools approved List approved tool types
|
|
117
117
|
|
|
118
|
+
CHAT (Admin/Manager)
|
|
119
|
+
$ unbound chat Interactive REPL (multi-turn, in-memory history)
|
|
120
|
+
$ unbound chat -m "show cost by provider for the last 30 days"
|
|
121
|
+
One-shot: render a chart in the terminal
|
|
122
|
+
$ unbound chat -m "..." --json One-shot: write the raw JSON response to stdout
|
|
123
|
+
$ unbound chat -m "..." -o report.json One-shot: write the raw JSON response to a file
|
|
124
|
+
See "unbound chat --help" for the response shape and REPL slash commands.
|
|
125
|
+
|
|
118
126
|
CONFIGURATION
|
|
119
127
|
$ unbound config show Show all settings
|
|
120
128
|
|
|
@@ -138,6 +146,7 @@ require('./commands/tools').register(program);
|
|
|
138
146
|
require('./commands/setup').register(program);
|
|
139
147
|
require('./commands/discover').register(program);
|
|
140
148
|
require('./commands/onboard').register(program);
|
|
149
|
+
require('./commands/chat').register(program);
|
|
141
150
|
|
|
142
151
|
// config command for managing CLI settings
|
|
143
152
|
const configCmd = program
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const chart = require('../src/chartRender');
|
|
5
|
+
|
|
6
|
+
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
7
|
+
|
|
8
|
+
test('reconstructRows: bar chart with single series', () => {
|
|
9
|
+
const chart_option = {
|
|
10
|
+
xAxis: { type: 'category', data: ['A', 'B', 'C'] },
|
|
11
|
+
yAxis: { type: 'value' },
|
|
12
|
+
series: [{ name: 'cost', type: 'bar', data: [10, 20, 30] }],
|
|
13
|
+
};
|
|
14
|
+
const chart_meta = { chart_type: 'bar', x: 'department', y: ['cost'] };
|
|
15
|
+
const { columns, rows } = chart.reconstructRows(chart_option, chart_meta);
|
|
16
|
+
assert.deepEqual(columns, ['department', 'cost']);
|
|
17
|
+
assert.deepEqual(rows, [
|
|
18
|
+
{ department: 'A', cost: 10 },
|
|
19
|
+
{ department: 'B', cost: 20 },
|
|
20
|
+
{ department: 'C', cost: 30 },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('reconstructRows: multi-series bar chart', () => {
|
|
25
|
+
const chart_option = {
|
|
26
|
+
xAxis: { data: ['A', 'B'] },
|
|
27
|
+
series: [
|
|
28
|
+
{ name: 'opus', data: [10, 20] },
|
|
29
|
+
{ name: 'sonnet', data: [5, 15] },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
const chart_meta = { chart_type: 'bar', x: 'department', y: ['opus', 'sonnet'] };
|
|
33
|
+
const { columns, rows } = chart.reconstructRows(chart_option, chart_meta);
|
|
34
|
+
assert.deepEqual(columns, ['department', 'opus', 'sonnet']);
|
|
35
|
+
assert.deepEqual(rows, [
|
|
36
|
+
{ department: 'A', opus: 10, sonnet: 5 },
|
|
37
|
+
{ department: 'B', opus: 20, sonnet: 15 },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('reconstructRows: donut/pie with {name,value} data', () => {
|
|
42
|
+
const chart_option = {
|
|
43
|
+
series: [{
|
|
44
|
+
type: 'pie',
|
|
45
|
+
data: [
|
|
46
|
+
{ name: 'Engineering', value: 100 },
|
|
47
|
+
{ name: 'Sales', value: 50 },
|
|
48
|
+
],
|
|
49
|
+
}],
|
|
50
|
+
};
|
|
51
|
+
const chart_meta = { chart_type: 'donut', x: 'department', y: ['cost'] };
|
|
52
|
+
const { columns, rows } = chart.reconstructRows(chart_option, chart_meta);
|
|
53
|
+
assert.deepEqual(columns, ['department', 'cost']);
|
|
54
|
+
assert.deepEqual(rows, [
|
|
55
|
+
{ department: 'Engineering', cost: 100 },
|
|
56
|
+
{ department: 'Sales', cost: 50 },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('reconstructRows: dataset.source with header row (array of arrays)', () => {
|
|
61
|
+
const chart_option = {
|
|
62
|
+
dataset: {
|
|
63
|
+
source: [
|
|
64
|
+
['provider', 'cost'],
|
|
65
|
+
['anthropic', 1234],
|
|
66
|
+
['openai', 567],
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const { columns, rows } = chart.reconstructRows(chart_option, { chart_type: 'table' });
|
|
71
|
+
assert.deepEqual(columns, ['provider', 'cost']);
|
|
72
|
+
assert.deepEqual(rows, [
|
|
73
|
+
{ provider: 'anthropic', cost: 1234 },
|
|
74
|
+
{ provider: 'openai', cost: 567 },
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('reconstructRows: dataset.source as array of objects', () => {
|
|
79
|
+
const chart_option = {
|
|
80
|
+
dataset: {
|
|
81
|
+
source: [
|
|
82
|
+
{ provider: 'anthropic', cost: 1234 },
|
|
83
|
+
{ provider: 'openai', cost: 567 },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const { columns, rows } = chart.reconstructRows(chart_option, { chart_type: 'table' });
|
|
88
|
+
assert.deepEqual(columns, ['provider', 'cost']);
|
|
89
|
+
assert.equal(rows.length, 2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('reconstructRows: missing chart_option returns empty', () => {
|
|
93
|
+
assert.deepEqual(chart.reconstructRows(null, null), { columns: [], rows: [] });
|
|
94
|
+
assert.deepEqual(chart.reconstructRows(undefined, {}), { columns: [], rows: [] });
|
|
95
|
+
assert.deepEqual(chart.reconstructRows({}, {}), { columns: [], rows: [] });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('reconstructRows: strips ANSI / control chars from backend-provided labels', () => {
|
|
99
|
+
const chart_option = {
|
|
100
|
+
xAxis: { data: ['\x1b]0;pwned\x07Anthropic', 'Open\nAI'] },
|
|
101
|
+
series: [{ name: 'cost', data: [100, 200] }],
|
|
102
|
+
};
|
|
103
|
+
const { rows, columns } = chart.reconstructRows(chart_option, { chart_type: 'bar', x: 'provider', y: ['cost'] });
|
|
104
|
+
assert.deepEqual(columns, ['provider', 'cost']);
|
|
105
|
+
assert.equal(rows[0].provider, 'Anthropic');
|
|
106
|
+
assert.equal(rows[1].provider, 'Open AI');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('reconstructRows: dataset.source array-of-objects is capped at MAX_RECONSTRUCT_ROWS', () => {
|
|
110
|
+
const source = Array.from({ length: chart.MAX_RECONSTRUCT_ROWS + 10 }, (_, i) => ({ k: i, v: i * 2 }));
|
|
111
|
+
const { rows } = chart.reconstructRows({ dataset: { source } }, { chart_type: 'table' });
|
|
112
|
+
assert.equal(rows.length, chart.MAX_RECONSTRUCT_ROWS);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('reconstructRows: caps allocation at MAX_RECONSTRUCT_ROWS', () => {
|
|
116
|
+
const huge = Array.from({ length: chart.MAX_RECONSTRUCT_ROWS + 5000 }, (_, i) => `row${i}`);
|
|
117
|
+
const hugeVals = huge.map((_, i) => i);
|
|
118
|
+
const chart_option = {
|
|
119
|
+
xAxis: { data: huge },
|
|
120
|
+
series: [{ name: 'v', data: hugeVals }],
|
|
121
|
+
};
|
|
122
|
+
const { rows } = chart.reconstructRows(chart_option, { chart_type: 'bar', x: 'p', y: ['v'] });
|
|
123
|
+
assert.equal(rows.length, chart.MAX_RECONSTRUCT_ROWS);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('summarizeChart: bar with single y', () => {
|
|
127
|
+
const s = stripAnsi(chart.summarizeChart({ chart_type: 'bar', x: 'provider', y: ['cost'] }, 4));
|
|
128
|
+
assert.equal(s, 'Bar chart, x=provider, y=cost, 4 rows');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('summarizeChart: handles singular row + multi y', () => {
|
|
132
|
+
const s = stripAnsi(chart.summarizeChart({ chart_type: 'line', x: 'date', y: ['cost', 'tokens'] }, 1));
|
|
133
|
+
assert.equal(s, 'Line chart, x=date, y=cost+tokens, 1 row');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('summarizeChart: missing meta degrades gracefully', () => {
|
|
137
|
+
assert.equal(chart.summarizeChart({}, 0), 'Unknown chart, 0 rows');
|
|
138
|
+
assert.equal(chart.summarizeChart(null), 'Unknown chart');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('renderBarChart: lines respect requested width', () => {
|
|
142
|
+
const rows = [
|
|
143
|
+
{ dept: 'Engineering', cost: 30000 },
|
|
144
|
+
{ dept: 'Sales', cost: 10000 },
|
|
145
|
+
{ dept: 'Marketing', cost: 5000 },
|
|
146
|
+
];
|
|
147
|
+
const out = chart.renderBarChart(rows, 'dept', 'cost', { width: 80 });
|
|
148
|
+
assert.ok(out.length > 0);
|
|
149
|
+
for (const line of out.split('\n')) {
|
|
150
|
+
assert.ok(stripAnsi(line).length <= 80, `line too long: ${stripAnsi(line)}`);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('renderBarChart: empty rows returns empty string', () => {
|
|
155
|
+
assert.equal(chart.renderBarChart([], 'x', 'y'), '');
|
|
156
|
+
assert.equal(chart.renderBarChart(null, 'x', 'y'), '');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('renderBarChart: scales the largest value to the full bar area', () => {
|
|
160
|
+
const rows = [{ x: 'A', y: 100 }, { x: 'B', y: 50 }];
|
|
161
|
+
const out = stripAnsi(chart.renderBarChart(rows, 'x', 'y', { width: 60 }));
|
|
162
|
+
const lines = out.split('\n');
|
|
163
|
+
const blocksA = (lines[0].match(/\u2588/g) || []).length;
|
|
164
|
+
const blocksB = (lines[1].match(/\u2588/g) || []).length;
|
|
165
|
+
assert.ok(blocksA > blocksB, 'larger value should produce more blocks');
|
|
166
|
+
assert.equal(blocksB, Math.max(1, Math.round(blocksA * 50 / 100)));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('renderBarChart: all-zero values do not crash and produce empty bars', () => {
|
|
170
|
+
const rows = [{ x: 'A', y: 0 }, { x: 'B', y: 0 }];
|
|
171
|
+
const out = stripAnsi(chart.renderBarChart(rows, 'x', 'y', { width: 80 }));
|
|
172
|
+
assert.ok(out.length > 0);
|
|
173
|
+
assert.equal((out.match(/\u2588/g) || []).length, 0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('renderGroupedBarChart: emits a legend and one row per series per label', () => {
|
|
177
|
+
const rows = [
|
|
178
|
+
{ dept: 'Eng', opus: 100, sonnet: 50 },
|
|
179
|
+
{ dept: 'Sales', opus: 20, sonnet: 80 },
|
|
180
|
+
];
|
|
181
|
+
const out = stripAnsi(chart.renderGroupedBarChart(rows, 'dept', ['opus', 'sonnet'], { width: 80 }));
|
|
182
|
+
assert.match(out, /Legend:/);
|
|
183
|
+
assert.match(out, /opus/);
|
|
184
|
+
assert.match(out, /sonnet/);
|
|
185
|
+
assert.match(out, /Eng/);
|
|
186
|
+
assert.match(out, /Sales/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('renderSparkline: returns a single-line braille bar between min and max', () => {
|
|
190
|
+
const out = stripAnsi(chart.renderSparkline([1, 2, 3, 4, 5, 4, 3], { width: 20 }));
|
|
191
|
+
assert.ok(out.includes('1'));
|
|
192
|
+
assert.ok(out.includes('5'));
|
|
193
|
+
assert.equal(out.split('\n').length, 1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('renderSparkline: too few points returns empty string', () => {
|
|
197
|
+
assert.equal(chart.renderSparkline([1]), '');
|
|
198
|
+
assert.equal(chart.renderSparkline([]), '');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('formatNumber: integer thousands separator, decimal capped', () => {
|
|
202
|
+
assert.equal(chart.formatNumber(1234567), '1,234,567');
|
|
203
|
+
assert.equal(chart.formatNumber(12.3456), '12.35');
|
|
204
|
+
assert.equal(chart.formatNumber(0), '0');
|
|
205
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const chat = require('../src/commands/chat');
|
|
8
|
+
const { sanitizeForTerminal } = require('../src/chartRender');
|
|
9
|
+
|
|
10
|
+
const { safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError } = chat._internals;
|
|
11
|
+
|
|
12
|
+
function tmpFile(name) {
|
|
13
|
+
return path.join(os.tmpdir(), `unbound-cli-test-${process.pid}-${Date.now()}-${name}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('sanitizeForTerminal: strips CSI, OSC, clipboard, title, hyperlink sequences', () => {
|
|
17
|
+
assert.equal(sanitizeForTerminal('\x1b[31mred\x1b[0m'), 'red');
|
|
18
|
+
assert.equal(sanitizeForTerminal('\x1b]0;PWNED\x07hi'), 'hi');
|
|
19
|
+
assert.equal(sanitizeForTerminal('\x1b]52;c;Y3VybHxzaA==\x07ok'), 'ok');
|
|
20
|
+
assert.equal(sanitizeForTerminal('\x1b]8;;https://evil/\x07click\x1b]8;;\x07'), 'click');
|
|
21
|
+
assert.equal(sanitizeForTerminal('a\x1bPdata\x1b\\b'), 'ab');
|
|
22
|
+
assert.equal(sanitizeForTerminal('plain text'), 'plain text');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('sanitizeForTerminal: strips 8-bit C1 control bytes', () => {
|
|
26
|
+
// \x9b = 8-bit CSI, \x9d = 8-bit OSC, \x90 = 8-bit DCS. Stripping the
|
|
27
|
+
// trigger byte neutralizes the sequence; residual parameter text is
|
|
28
|
+
// harmless without the preceding control byte.
|
|
29
|
+
const a = sanitizeForTerminal('\x9b31mhello\x9b0m');
|
|
30
|
+
assert.ok(!/[\x80-\x9f]/.test(a), 'should not contain any C1 bytes');
|
|
31
|
+
assert.ok(a.includes('hello'));
|
|
32
|
+
const b = sanitizeForTerminal('a\x9dtitle\x07b');
|
|
33
|
+
assert.ok(!/[\x80-\x9f]/.test(b));
|
|
34
|
+
for (let code = 0x80; code <= 0x9f; code++) {
|
|
35
|
+
assert.equal(sanitizeForTerminal(String.fromCharCode(code)), '');
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('sanitizeForTerminal: removes control chars but preserves tab; collapses newlines', () => {
|
|
40
|
+
assert.equal(sanitizeForTerminal('a\x00b\x07c\x7fd'), 'abcd');
|
|
41
|
+
assert.equal(sanitizeForTerminal('line1\nline2\r\nline3'), 'line1 line2 line3');
|
|
42
|
+
assert.equal(sanitizeForTerminal('tab\there'), 'tab\there');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('sanitizeForTerminal: handles non-string inputs safely', () => {
|
|
46
|
+
assert.equal(sanitizeForTerminal(null), '');
|
|
47
|
+
assert.equal(sanitizeForTerminal(undefined), '');
|
|
48
|
+
assert.equal(sanitizeForTerminal(42), '42');
|
|
49
|
+
assert.equal(sanitizeForTerminal({ a: 1 }), '[object Object]');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('expandPath: expands leading ~/ to homedir, passes through absolute paths', () => {
|
|
53
|
+
assert.equal(expandPath('~'), os.homedir());
|
|
54
|
+
assert.equal(expandPath('~/foo/bar'), path.join(os.homedir(), 'foo/bar'));
|
|
55
|
+
assert.equal(expandPath('/etc/passwd'), '/etc/passwd');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('safeWriteFile: happy path writes a new file with mode 0600', () => {
|
|
59
|
+
const target = tmpFile('happy.json');
|
|
60
|
+
try {
|
|
61
|
+
const resolved = safeWriteFile(target, '{"ok":true}\n');
|
|
62
|
+
assert.equal(resolved, path.resolve(target));
|
|
63
|
+
assert.equal(fs.readFileSync(target, 'utf8'), '{"ok":true}\n');
|
|
64
|
+
const stat = fs.statSync(target);
|
|
65
|
+
assert.equal(stat.mode & 0o777, 0o600);
|
|
66
|
+
} finally {
|
|
67
|
+
try { fs.unlinkSync(target); } catch { /* ignore */ }
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('safeWriteFile: refuses to overwrite existing file', () => {
|
|
72
|
+
const target = tmpFile('noclobber.json');
|
|
73
|
+
try {
|
|
74
|
+
fs.writeFileSync(target, 'original');
|
|
75
|
+
assert.throws(() => safeWriteFile(target, 'hijacked'), /Refusing to overwrite/);
|
|
76
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'original');
|
|
77
|
+
} finally {
|
|
78
|
+
try { fs.unlinkSync(target); } catch { /* ignore */ }
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('safeWriteFile: refuses to follow a symlink', () => {
|
|
83
|
+
const realTarget = tmpFile('real.txt');
|
|
84
|
+
const linkTarget = tmpFile('link.json');
|
|
85
|
+
try {
|
|
86
|
+
fs.writeFileSync(realTarget, 'original');
|
|
87
|
+
try {
|
|
88
|
+
fs.symlinkSync(realTarget, linkTarget);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err.code === 'EPERM') return; // symlink creation not permitted; skip
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
assert.throws(() => safeWriteFile(linkTarget, 'hijacked'));
|
|
94
|
+
assert.equal(fs.readFileSync(realTarget, 'utf8'), 'original');
|
|
95
|
+
} finally {
|
|
96
|
+
try { fs.unlinkSync(linkTarget); } catch { /* ignore */ }
|
|
97
|
+
try { fs.unlinkSync(realTarget); } catch { /* ignore */ }
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('safeWriteFile: rejects empty, non-string, NUL-containing paths', () => {
|
|
102
|
+
assert.throws(() => safeWriteFile('', 'x'), /empty/i);
|
|
103
|
+
assert.throws(() => safeWriteFile(null, 'x'), /empty/i);
|
|
104
|
+
assert.throws(() => safeWriteFile(42, 'x'), /string/i);
|
|
105
|
+
assert.throws(() => safeWriteFile('foo\0bar', 'x'), /NUL/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('safeWriteFile: rejects writing to a directory', () => {
|
|
109
|
+
assert.throws(() => safeWriteFile(os.tmpdir(), 'x'), /directory/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('normalizeAssistantTurn: accepts user/assistant roles, rejects others', () => {
|
|
113
|
+
assert.deepEqual(
|
|
114
|
+
normalizeAssistantTurn({ role: 'assistant', content: 'hi', timestamp: 't' }),
|
|
115
|
+
{ role: 'assistant', content: 'hi', timestamp: 't' },
|
|
116
|
+
);
|
|
117
|
+
assert.deepEqual(
|
|
118
|
+
normalizeAssistantTurn({ role: 'USER', content: 'hi', timestamp: 't' }),
|
|
119
|
+
{ role: 'user', content: 'hi', timestamp: 't' },
|
|
120
|
+
);
|
|
121
|
+
assert.equal(normalizeAssistantTurn({ role: 'system', content: 'pwn' }), null);
|
|
122
|
+
assert.equal(normalizeAssistantTurn({ role: 'tool', content: 'x' }), null);
|
|
123
|
+
assert.equal(normalizeAssistantTurn(null), null);
|
|
124
|
+
assert.equal(normalizeAssistantTurn({}), null);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('normalizeAssistantTurn: caps content length', () => {
|
|
128
|
+
const big = 'x'.repeat(20000);
|
|
129
|
+
const turn = normalizeAssistantTurn({ role: 'assistant', content: big });
|
|
130
|
+
assert.ok(turn.content.length <= 8000);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('friendlyChatError: maps status codes to role-specific guidance', () => {
|
|
134
|
+
assert.match(friendlyChatError({ statusCode: 403 }), /Admin or Manager/);
|
|
135
|
+
assert.match(friendlyChatError({ statusCode: 401 }), /login/);
|
|
136
|
+
assert.match(friendlyChatError({ statusCode: 429 }), /Rate limited/);
|
|
137
|
+
assert.equal(friendlyChatError({ statusCode: 500, message: 'boom' }), 'boom');
|
|
138
|
+
assert.equal(friendlyChatError({ message: 'no status' }), 'no status');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('friendlyChatError: sanitizes ANSI from backend error messages', () => {
|
|
142
|
+
const msg = friendlyChatError({ statusCode: 500, message: '\x1b]0;title\x07boom\x1b[31m' });
|
|
143
|
+
assert.equal(msg, 'boom');
|
|
144
|
+
});
|