limbo-ai 1.9.3 → 1.9.5
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/cli.js +73 -30
- package/package.json +3 -2
- package/test/cli-auth.test.js +250 -0
- package/test/cli-filter.test.js +197 -0
package/cli.js
CHANGED
|
@@ -813,8 +813,39 @@ function applyOpenClawConfig(cfg) {
|
|
|
813
813
|
ok(t(cfg.language, 'configFlowDone'));
|
|
814
814
|
}
|
|
815
815
|
|
|
816
|
-
// Strip ANSI escape sequences
|
|
817
|
-
|
|
816
|
+
// Strip all ANSI/VT100 escape sequences from a string.
|
|
817
|
+
// Uses standards-based ECMA-48 byte ranges:
|
|
818
|
+
// CSI: ESC [ <param bytes 0x30-0x3F>* <intermediate bytes 0x20-0x2F>* <final byte 0x40-0x7E>
|
|
819
|
+
// Two-char ESC: ESC <0x40-0x5F> (e.g. ESC M, ESC =, ESC 7/8 save/restore cursor)
|
|
820
|
+
// OSC: ESC ] <any> BEL|ST
|
|
821
|
+
// Covers private-mode sequences like \x1b[?25l (hide cursor) that the old [0-9;]* missed.
|
|
822
|
+
const stripAnsi = (str) => str
|
|
823
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') // CSI sequences (all parameter byte combos)
|
|
824
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences (before two-char — shares \x1b] prefix)
|
|
825
|
+
.replace(/\x1b[^[\]]/g, '') // two-char ESC sequences (e.g. ESC 7/8 save/restore)
|
|
826
|
+
.replace(/\r/g, '');
|
|
827
|
+
|
|
828
|
+
// Matches OAuth/browser URLs emitted by OpenClaw during auth flows.
|
|
829
|
+
const AUTH_URL_RE = /https?:\/\/[^\s"'<>\]]+/g;
|
|
830
|
+
|
|
831
|
+
// Matches lines consisting entirely of TUI chrome: spinner glyphs, box-drawing chars, and
|
|
832
|
+
// clack/prompt decorations. Used to suppress animation frame scatter from OpenClaw TUI output.
|
|
833
|
+
const TUI_CHROME_RE = /^[\s\u2500-\u257f\u2580-\u259f\u25a0-\u25ff\u2600-\u26ff\u2190-\u21ff\u2700-\u27bf\u2800-\u28ff]*$/u;
|
|
834
|
+
|
|
835
|
+
// Pure helper: flush complete lines from a raw buffer.
|
|
836
|
+
// Normalises \r\n → \n, splits on \n, and within each segment keeps only the LAST
|
|
837
|
+
// \r-separated piece — that's the final rendered state after TUI in-place overwrites.
|
|
838
|
+
// Returns the lines to emit and the leftover incomplete segment.
|
|
839
|
+
function flushStreamLines(buf) {
|
|
840
|
+
const normalized = buf.replace(/\r\n/g, '\n');
|
|
841
|
+
const segments = normalized.split('\n');
|
|
842
|
+
const remaining = segments.pop(); // no trailing \n yet
|
|
843
|
+
const lines = segments.map((seg) => {
|
|
844
|
+
const frames = seg.split('\r');
|
|
845
|
+
return frames[frames.length - 1]; // last frame = final rendered content
|
|
846
|
+
});
|
|
847
|
+
return { lines, remaining };
|
|
848
|
+
}
|
|
818
849
|
|
|
819
850
|
// Spawn OpenClaw auth with filtered output: extract OAuth URLs, suppress branding.
|
|
820
851
|
// --tty is required so openclaw sees a TTY inside the container and runs the auth wizard.
|
|
@@ -827,21 +858,22 @@ function streamFilteredAuth(dockerArgs, onUrl = null) {
|
|
|
827
858
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
828
859
|
});
|
|
829
860
|
|
|
830
|
-
const urlRe = /https?:\/\/[^\s"'<>\]]+/g;
|
|
831
861
|
const seenUrls = new Set();
|
|
832
862
|
let buf = '';
|
|
833
863
|
|
|
834
864
|
const handleData = (data) => {
|
|
835
865
|
buf += data.toString();
|
|
836
|
-
//
|
|
837
|
-
|
|
838
|
-
|
|
866
|
+
// flushStreamLines keeps only the final \r frame per \n-terminated line,
|
|
867
|
+
// discarding all intermediate TUI animation states (character-by-character
|
|
868
|
+
// reveals, spinner frames) that would otherwise scatter across the terminal.
|
|
869
|
+
const { lines, remaining } = flushStreamLines(buf);
|
|
870
|
+
buf = remaining;
|
|
839
871
|
for (const line of lines) emitLine(line);
|
|
840
872
|
};
|
|
841
873
|
|
|
842
874
|
const emitLine = (rawLine) => {
|
|
843
875
|
const line = stripAnsi(rawLine);
|
|
844
|
-
const urls = line.match(
|
|
876
|
+
const urls = line.match(AUTH_URL_RE) || [];
|
|
845
877
|
if (urls.length > 0) {
|
|
846
878
|
for (const url of urls) {
|
|
847
879
|
if (!seenUrls.has(url)) {
|
|
@@ -852,15 +884,21 @@ function streamFilteredAuth(dockerArgs, onUrl = null) {
|
|
|
852
884
|
}
|
|
853
885
|
return; // don't double-print the line containing the URL
|
|
854
886
|
}
|
|
855
|
-
// Suppress OpenClaw branding
|
|
887
|
+
// Suppress OpenClaw branding
|
|
856
888
|
if (/openclaw/i.test(line)) return;
|
|
889
|
+
// Suppress TUI chrome: lines consisting only of spinner/decoration/box-drawing chars.
|
|
890
|
+
if (TUI_CHROME_RE.test(line)) return;
|
|
857
891
|
if (line.trim()) console.log(` ${line}`);
|
|
858
892
|
};
|
|
859
893
|
|
|
860
894
|
proc.stdout.on('data', handleData);
|
|
861
895
|
proc.stderr.on('data', handleData);
|
|
862
896
|
proc.on('close', (code) => {
|
|
863
|
-
if (buf.trim())
|
|
897
|
+
if (buf.trim()) {
|
|
898
|
+
// Append a synthetic \n to flush the remaining buffer through flushStreamLines.
|
|
899
|
+
const { lines } = flushStreamLines(buf + '\n');
|
|
900
|
+
for (const line of lines) emitLine(line);
|
|
901
|
+
}
|
|
864
902
|
resolve(code ?? 1);
|
|
865
903
|
});
|
|
866
904
|
proc.on('error', () => resolve(1));
|
|
@@ -1078,24 +1116,29 @@ ${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
|
|
|
1078
1116
|
|
|
1079
1117
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
1080
1118
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1119
|
+
if (require.main === module) {
|
|
1120
|
+
const [,, cmd = 'start'] = process.argv;
|
|
1121
|
+
|
|
1122
|
+
(async () => {
|
|
1123
|
+
switch (cmd) {
|
|
1124
|
+
case 'start':
|
|
1125
|
+
case 'install': await cmdStart(); break;
|
|
1126
|
+
case 'stop': cmdStop(); break;
|
|
1127
|
+
case 'logs': cmdLogs(); break;
|
|
1128
|
+
case 'update': cmdUpdate(); break;
|
|
1129
|
+
case 'status': cmdStatus(); break;
|
|
1130
|
+
case 'help':
|
|
1131
|
+
case '--help':
|
|
1132
|
+
case '-h': cmdHelp(); break;
|
|
1133
|
+
default:
|
|
1134
|
+
warn(t('en', 'unknownCommand', cmd));
|
|
1135
|
+
cmdHelp();
|
|
1136
|
+
process.exit(1);
|
|
1137
|
+
}
|
|
1138
|
+
})().catch((err) => {
|
|
1139
|
+
die(err.message || String(err));
|
|
1140
|
+
});
|
|
1141
|
+
} else {
|
|
1142
|
+
// Exported for unit testing — not part of the public CLI API.
|
|
1143
|
+
module.exports = { stripAnsi, AUTH_URL_RE, TUI_CHROME_RE, flushStreamLines };
|
|
1144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "limbo-ai",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.5",
|
|
4
4
|
"description": "Your personal AI memory agent — install and manage Limbo via npx",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"@clack/prompts": "^1.1.0"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
|
-
"start": "node cli.js start"
|
|
16
|
+
"start": "node cli.js start",
|
|
17
|
+
"test": "node --test test/**/*.test.js"
|
|
17
18
|
},
|
|
18
19
|
"keywords": [
|
|
19
20
|
"limbo",
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// test/cli-auth.test.js
|
|
2
|
+
// Unit tests for the streamFilteredAuth pure-logic functions exported from cli.js.
|
|
3
|
+
// Run with: node --test test/cli-auth.test.js
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const { test } = require('node:test');
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
|
|
9
|
+
const { stripAnsi, AUTH_URL_RE, TUI_CHROME_RE, flushStreamLines } = require('../cli.js');
|
|
10
|
+
|
|
11
|
+
// ─── stripAnsi ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
test('stripAnsi: strips standard CSI sequences', () => {
|
|
14
|
+
assert.equal(stripAnsi('\x1b[32mgreen\x1b[0m'), 'green');
|
|
15
|
+
assert.equal(stripAnsi('\x1b[1;31mbold red\x1b[0m'), 'bold red');
|
|
16
|
+
assert.equal(stripAnsi('\x1b[2Kclear line'), 'clear line');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('stripAnsi: strips ?-prefixed private-mode CSI sequences', () => {
|
|
20
|
+
// \x1b[?25l hide cursor, \x1b[?25h show cursor
|
|
21
|
+
assert.equal(stripAnsi('\x1b[?25lhello\x1b[?25h'), 'hello');
|
|
22
|
+
// \x1b[?2004h / \x1b[?2004l bracketed paste mode
|
|
23
|
+
assert.equal(stripAnsi('\x1b[?2004htext\x1b[?2004l'), 'text');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('stripAnsi: strips two-char ESC sequences (0x40-0x5F range)', () => {
|
|
27
|
+
// ESC M (0x4D) — reverse index (cursor up with scroll)
|
|
28
|
+
assert.equal(stripAnsi('\x1bMline'), 'line');
|
|
29
|
+
// ESC E (0x45) — next line
|
|
30
|
+
assert.equal(stripAnsi('text\x1bEafter'), 'textafter');
|
|
31
|
+
// ESC ^ (0x5E) — privacy message (PM)
|
|
32
|
+
assert.equal(stripAnsi('before\x1b^after'), 'beforeafter');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('stripAnsi: strips OSC sequences (BEL-terminated)', () => {
|
|
36
|
+
// OSC 0 ; title BEL — window title sequence
|
|
37
|
+
assert.equal(stripAnsi('\x1b]0;My Terminal Title\x07visible'), 'visible');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('stripAnsi: strips OSC sequences (ST-terminated)', () => {
|
|
41
|
+
assert.equal(stripAnsi('\x1b]0;title\x1b\\visible'), 'visible');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('stripAnsi: strips bare carriage returns', () => {
|
|
45
|
+
assert.equal(stripAnsi('line1\rline2'), 'line1line2');
|
|
46
|
+
assert.equal(stripAnsi('\r'), '');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('stripAnsi: leaves plain text untouched', () => {
|
|
50
|
+
const plain = 'Hello, world! 123 !@#';
|
|
51
|
+
assert.equal(stripAnsi(plain), plain);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('stripAnsi: handles empty string', () => {
|
|
55
|
+
assert.equal(stripAnsi(''), '');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('stripAnsi: strips mixed sequences in one pass', () => {
|
|
59
|
+
// CSI (?25l hide cursor) + CSI (32m color) + OSC (window title) + bare CR
|
|
60
|
+
const input = '\x1b[?25l\x1b[32mProcessing\x1b[0m...\x1b]0;term\x07done\r';
|
|
61
|
+
assert.equal(stripAnsi(input), 'Processing...done');
|
|
62
|
+
// CSI + two-char ESC (M) mixed with real text
|
|
63
|
+
const input2 = '\x1b[1mbold\x1b[0m\x1bMnext';
|
|
64
|
+
assert.equal(stripAnsi(input2), 'boldnext');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ─── TUI_CHROME_RE ────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
test('TUI_CHROME_RE: suppresses Braille spinner chars', () => {
|
|
70
|
+
// Common clack/openclaw spinner frames
|
|
71
|
+
for (const ch of ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']) {
|
|
72
|
+
assert.equal(TUI_CHROME_RE.test(ch), true, `expected ${ch} to match TUI_CHROME_RE`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('TUI_CHROME_RE: suppresses box-drawing chars', () => {
|
|
77
|
+
assert.equal(TUI_CHROME_RE.test('─'), true);
|
|
78
|
+
assert.equal(TUI_CHROME_RE.test('│'), true);
|
|
79
|
+
assert.equal(TUI_CHROME_RE.test('┌┐└┘'), true);
|
|
80
|
+
assert.equal(TUI_CHROME_RE.test('═══'), true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('TUI_CHROME_RE: suppresses clack decoration chars', () => {
|
|
84
|
+
assert.equal(TUI_CHROME_RE.test('◇'), true);
|
|
85
|
+
assert.equal(TUI_CHROME_RE.test('●'), true);
|
|
86
|
+
assert.equal(TUI_CHROME_RE.test('◆'), true);
|
|
87
|
+
assert.equal(TUI_CHROME_RE.test('○'), true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('TUI_CHROME_RE: suppresses whitespace-only lines', () => {
|
|
91
|
+
assert.equal(TUI_CHROME_RE.test(' '), true);
|
|
92
|
+
assert.equal(TUI_CHROME_RE.test(''), true);
|
|
93
|
+
assert.equal(TUI_CHROME_RE.test('\t'), true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('TUI_CHROME_RE: suppresses mixed chrome lines (spinner + whitespace)', () => {
|
|
97
|
+
assert.equal(TUI_CHROME_RE.test(' ⠋ '), true);
|
|
98
|
+
assert.equal(TUI_CHROME_RE.test('◇ ─ ◇'), true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('TUI_CHROME_RE: passes lines with real text content', () => {
|
|
102
|
+
assert.equal(TUI_CHROME_RE.test('Please open this URL'), false);
|
|
103
|
+
assert.equal(TUI_CHROME_RE.test('Authenticating...'), false);
|
|
104
|
+
assert.equal(TUI_CHROME_RE.test('Press Enter to continue'), false);
|
|
105
|
+
assert.equal(TUI_CHROME_RE.test('Error: invalid token'), false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('TUI_CHROME_RE: passes lines starting with decoration but containing text', () => {
|
|
109
|
+
// clack prompts often have a leading decoration glyph followed by text
|
|
110
|
+
assert.equal(TUI_CHROME_RE.test('◇ Enter your API key'), false);
|
|
111
|
+
assert.equal(TUI_CHROME_RE.test('● Model selected: claude-opus-4-6'), false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── AUTH_URL_RE (URL extraction) ─────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
test('AUTH_URL_RE: detects http OAuth URLs', () => {
|
|
117
|
+
const line = 'Open this URL to authenticate: http://localhost:3000/oauth/callback?code=abc123';
|
|
118
|
+
const matches = line.match(AUTH_URL_RE);
|
|
119
|
+
assert.ok(matches, 'expected URL match');
|
|
120
|
+
assert.equal(matches[0], 'http://localhost:3000/oauth/callback?code=abc123');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('AUTH_URL_RE: detects https OAuth URLs', () => {
|
|
124
|
+
const line = 'Visit https://auth.anthropic.com/oauth2/authorize?client_id=limbo&state=xyz to login';
|
|
125
|
+
const matches = line.match(AUTH_URL_RE);
|
|
126
|
+
assert.ok(matches, 'expected URL match');
|
|
127
|
+
assert.equal(matches[0], 'https://auth.anthropic.com/oauth2/authorize?client_id=limbo&state=xyz');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('AUTH_URL_RE: does not match plain text without URL', () => {
|
|
131
|
+
const line = 'Waiting for authentication...';
|
|
132
|
+
const matches = line.match(AUTH_URL_RE);
|
|
133
|
+
assert.equal(matches, null);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('AUTH_URL_RE: stops URL at whitespace', () => {
|
|
137
|
+
const line = 'URL: https://example.com/auth and then some text';
|
|
138
|
+
const matches = line.match(AUTH_URL_RE);
|
|
139
|
+
assert.ok(matches);
|
|
140
|
+
assert.equal(matches[0], 'https://example.com/auth');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('AUTH_URL_RE: stops URL at angle bracket', () => {
|
|
144
|
+
const line = 'Go to <https://example.com/auth>';
|
|
145
|
+
const matches = line.match(AUTH_URL_RE);
|
|
146
|
+
assert.ok(matches);
|
|
147
|
+
assert.equal(matches[0], 'https://example.com/auth');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('AUTH_URL_RE: extracts multiple URLs from a single line', () => {
|
|
151
|
+
const line = 'Primary: https://example.com/a Fallback: https://example.com/b';
|
|
152
|
+
const matches = line.match(AUTH_URL_RE);
|
|
153
|
+
assert.ok(matches);
|
|
154
|
+
assert.equal(matches.length, 2);
|
|
155
|
+
assert.equal(matches[0], 'https://example.com/a');
|
|
156
|
+
assert.equal(matches[1], 'https://example.com/b');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('AUTH_URL_RE: URL deduplication — same URL seen twice is emitted once', () => {
|
|
160
|
+
// This tests the seenUrls Set logic conceptually — we verify that running AUTH_URL_RE
|
|
161
|
+
// against the same URL twice and filtering via a Set yields a single emission.
|
|
162
|
+
const url = 'https://auth.openai.com/oauth/callback?code=abc';
|
|
163
|
+
const lines = [
|
|
164
|
+
`Open: ${url}`,
|
|
165
|
+
`Retry: ${url}`,
|
|
166
|
+
'Different: https://example.com/other',
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
const emitted = [];
|
|
170
|
+
const seenUrls = new Set();
|
|
171
|
+
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
const urls = line.match(AUTH_URL_RE) || [];
|
|
174
|
+
for (const u of urls) {
|
|
175
|
+
if (!seenUrls.has(u)) {
|
|
176
|
+
seenUrls.add(u);
|
|
177
|
+
emitted.push(u);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
assert.equal(emitted.length, 2, 'duplicate URL should only be emitted once');
|
|
183
|
+
assert.equal(emitted[0], url);
|
|
184
|
+
assert.equal(emitted[1], 'https://example.com/other');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ─── flushStreamLines (animation scatter regression) ──────────────────────────
|
|
188
|
+
|
|
189
|
+
test('flushStreamLines: emits only final \\r frame — suppresses scatter', () => {
|
|
190
|
+
// This is the exact pattern that caused the diagonal scatter seen in the screenshot:
|
|
191
|
+
// clack's character-by-character reveal writes each progressive state separated by \r.
|
|
192
|
+
// Before the fix, every frame was emitted as a separate line causing staircase output.
|
|
193
|
+
const buf = '│ Y\r│ Yo\r│ You\r│ Your URL: https://auth.example.com\n';
|
|
194
|
+
const { lines, remaining } = flushStreamLines(buf);
|
|
195
|
+
assert.equal(remaining, '');
|
|
196
|
+
assert.equal(lines.length, 1, 'only the final frame should be emitted');
|
|
197
|
+
assert.equal(lines[0], '│ Your URL: https://auth.example.com');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('flushStreamLines: handles spinner animation (pure chrome final frame)', () => {
|
|
201
|
+
// Spinner that completes and clears the line — final state is empty/chrome
|
|
202
|
+
const buf = '⠋\r⠙\r⠹\r⠸\r \n';
|
|
203
|
+
const { lines } = flushStreamLines(buf);
|
|
204
|
+
assert.equal(lines.length, 1);
|
|
205
|
+
assert.equal(lines[0], ' '); // final frame (space = cleared); TUI_CHROME_RE will suppress it
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('flushStreamLines: normalises \\r\\n to single newline', () => {
|
|
209
|
+
const buf = 'line one\r\nline two\r\n';
|
|
210
|
+
const { lines, remaining } = flushStreamLines(buf);
|
|
211
|
+
assert.equal(remaining, '');
|
|
212
|
+
assert.deepEqual(lines, ['line one', 'line two']);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('flushStreamLines: holds incomplete segment in remaining', () => {
|
|
216
|
+
const buf = 'complete line\nstill coming';
|
|
217
|
+
const { lines, remaining } = flushStreamLines(buf);
|
|
218
|
+
assert.deepEqual(lines, ['complete line']);
|
|
219
|
+
assert.equal(remaining, 'still coming');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('flushStreamLines: accumulating chunks yields same result as one chunk', () => {
|
|
223
|
+
// Simulate data arriving in two chunks mid-animation-frame
|
|
224
|
+
const chunk1 = '│ Y\r│ Yo\r';
|
|
225
|
+
const chunk2 = '│ You\r│ Your URL: https://auth.example.com\n';
|
|
226
|
+
|
|
227
|
+
// Chunk 1 alone: no complete \n-terminated line yet
|
|
228
|
+
const r1 = flushStreamLines(chunk1);
|
|
229
|
+
assert.deepEqual(r1.lines, []);
|
|
230
|
+
assert.equal(r1.remaining, '│ Y\r│ Yo\r');
|
|
231
|
+
|
|
232
|
+
// Chunk 2 appended to remaining: yields only the final frame
|
|
233
|
+
const r2 = flushStreamLines(r1.remaining + chunk2);
|
|
234
|
+
assert.equal(r2.lines.length, 1);
|
|
235
|
+
assert.equal(r2.lines[0], '│ Your URL: https://auth.example.com');
|
|
236
|
+
assert.equal(r2.remaining, '');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('flushStreamLines: multiple \\n-terminated lines processed independently', () => {
|
|
240
|
+
// Two different lines, each with animation frames
|
|
241
|
+
const buf = '⠋ Loading...\r⠙ Loading...\r Done!\nEnter code: \r\n';
|
|
242
|
+
const { lines } = flushStreamLines(buf);
|
|
243
|
+
assert.deepEqual(lines, [' Done!', 'Enter code: ']);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('flushStreamLines: empty buffer returns no lines', () => {
|
|
247
|
+
const { lines, remaining } = flushStreamLines('');
|
|
248
|
+
assert.deepEqual(lines, []);
|
|
249
|
+
assert.equal(remaining, '');
|
|
250
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the CLI auth output filter logic.
|
|
3
|
+
*
|
|
4
|
+
* Tests stripAnsi, processLine (carriage-return collapse), and the emitLine
|
|
5
|
+
* filtering decisions in streamFilteredAuth. Zero external dependencies —
|
|
6
|
+
* uses Node.js built-in test runner (node:test, node >= 18).
|
|
7
|
+
*
|
|
8
|
+
* Run: node --test test/cli-filter.test.js
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { test } = require('node:test');
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
|
|
16
|
+
// ─── Replicated filter logic (must stay in sync with cli.js) ─────────────────
|
|
17
|
+
// If these start diverging, extract to a shared module.
|
|
18
|
+
|
|
19
|
+
const stripAnsi = (str) => str
|
|
20
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') // CSI sequences (all parameter byte combos)
|
|
21
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences (before two-char — shares \x1b] prefix)
|
|
22
|
+
.replace(/\x1b[^[\]]/g, '') // two-char ESC sequences (e.g. ESC 7/8 save/restore)
|
|
23
|
+
.replace(/\r/g, '');
|
|
24
|
+
|
|
25
|
+
const urlRe = /https?:\/\/[^\s"'<>\]]+/g;
|
|
26
|
+
const tuiChrome = /^[\s\u2500-\u257f\u2580-\u259f\u25a0-\u25ff\u2600-\u26ff\u2190-\u21ff\u2700-\u27bf\u2800-\u28ff]*$/u;
|
|
27
|
+
|
|
28
|
+
/** Returns last \r-frame — the final visual state of any carriage-return animation. */
|
|
29
|
+
function processLine(raw) {
|
|
30
|
+
return raw.split('\r').pop() || '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Models the emitLine decision: 'url' | 'show' | 'suppress'.
|
|
35
|
+
* Reset urlRe.lastIndex before each call since it's a global regex.
|
|
36
|
+
*/
|
|
37
|
+
function classify(rawLine) {
|
|
38
|
+
urlRe.lastIndex = 0;
|
|
39
|
+
const line = stripAnsi(rawLine);
|
|
40
|
+
if (urlRe.test(line)) return 'url';
|
|
41
|
+
if (/openclaw/i.test(line)) return 'suppress';
|
|
42
|
+
if (tuiChrome.test(line)) return 'suppress';
|
|
43
|
+
if (!line.trim()) return 'suppress';
|
|
44
|
+
return 'show';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── stripAnsi ────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
test('stripAnsi: removes standard SGR color codes', () => {
|
|
50
|
+
assert.equal(stripAnsi('\x1b[31mRed text\x1b[0m'), 'Red text');
|
|
51
|
+
assert.equal(stripAnsi('\x1b[1;32mBold green\x1b[0m'), 'Bold green');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('stripAnsi: removes private-mode sequences (the original bug — ?25l hide cursor)', () => {
|
|
55
|
+
assert.equal(stripAnsi('\x1b[?25l'), '');
|
|
56
|
+
assert.equal(stripAnsi('\x1b[?25lhello\x1b[?25h'), 'hello');
|
|
57
|
+
assert.equal(stripAnsi('\x1b[?2004h bracketed paste mode \x1b[?2004l'), ' bracketed paste mode ');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('stripAnsi: removes cursor movement and erase sequences', () => {
|
|
61
|
+
assert.equal(stripAnsi('\x1b[5;10HY'), 'Y'); // cursor to row 5, col 10, then char
|
|
62
|
+
assert.equal(stripAnsi('\x1b[2K'), ''); // erase entire line
|
|
63
|
+
assert.equal(stripAnsi('\x1b[1A'), ''); // cursor up 1
|
|
64
|
+
assert.equal(stripAnsi('\x1b[G'), ''); // cursor to column 1
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('stripAnsi: removes two-char ESC sequences (save/restore cursor)', () => {
|
|
68
|
+
assert.equal(stripAnsi('\x1b7saved\x1b8'), 'saved');
|
|
69
|
+
assert.equal(stripAnsi('\x1bcReset'), 'Reset');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('stripAnsi: removes OSC sequences (window title)', () => {
|
|
73
|
+
assert.equal(stripAnsi('\x1b]0;My Terminal\x07normal'), 'normal');
|
|
74
|
+
assert.equal(stripAnsi('\x1b]2;Title\x1b\\text'), 'text');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('stripAnsi: strips \\r (carriage return)', () => {
|
|
78
|
+
assert.equal(stripAnsi('hello\rworld'), 'helloworld');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('stripAnsi: passes through plain text unchanged', () => {
|
|
82
|
+
assert.equal(stripAnsi('Auth complete.'), 'Auth complete.');
|
|
83
|
+
assert.equal(stripAnsi(''), '');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ─── processLine (carriage-return collapse) ───────────────────────────────────
|
|
87
|
+
|
|
88
|
+
test('processLine: returns last \\r-frame (final typewriter state)', () => {
|
|
89
|
+
// This is the core fix. Typewriter animation builds text with \r between frames.
|
|
90
|
+
const input = '│ Y\r│ Yo\r│ You\r│ You \r│ You are running in a remote environment';
|
|
91
|
+
assert.equal(processLine(input), '│ You are running in a remote environment');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('processLine: collapses spinner animation to final frame', () => {
|
|
95
|
+
assert.equal(processLine('⠋ Waiting\r⠙ Waiting\r⠹ Waiting\r⠸ Done'), '⠸ Done');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('processLine: returns line unchanged if no \\r present', () => {
|
|
99
|
+
assert.equal(processLine('Auth complete.'), 'Auth complete.');
|
|
100
|
+
assert.equal(processLine(''), '');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('processLine: handles trailing \\r (line ending with empty final frame)', () => {
|
|
104
|
+
// \r at end → final frame is empty string; processLine returns ''
|
|
105
|
+
assert.equal(processLine('clear this\r'), '');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ─── emitLine classification ──────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
test('classify: detects OAuth URLs', () => {
|
|
111
|
+
assert.equal(classify('https://auth.openai.com/oauth/authorize?foo=bar'), 'url');
|
|
112
|
+
assert.equal(classify(' → https://auth.openai.com/oauth/authorize?foo=bar '), 'url');
|
|
113
|
+
assert.equal(classify('\x1b[36mhttps://example.com/auth\x1b[0m'), 'url');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('classify: suppresses OpenClaw branding', () => {
|
|
117
|
+
assert.equal(classify('Starting OpenClaw gateway...'), 'suppress');
|
|
118
|
+
assert.equal(classify('openclaw v1.2.3'), 'suppress');
|
|
119
|
+
assert.equal(classify(' OpenClaw ready '), 'suppress');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('classify: suppresses pure TUI chrome — spinner chars', () => {
|
|
123
|
+
assert.equal(classify('⠋'), 'suppress');
|
|
124
|
+
assert.equal(classify('⠙'), 'suppress');
|
|
125
|
+
assert.equal(classify('⠹ '), 'suppress'); // spinner + whitespace
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('classify: suppresses pure TUI chrome — box-drawing and clack decorations', () => {
|
|
129
|
+
assert.equal(classify('│'), 'suppress'); // box drawing
|
|
130
|
+
assert.equal(classify('◇'), 'suppress'); // clack diamond
|
|
131
|
+
assert.equal(classify('●'), 'suppress'); // clack bullet
|
|
132
|
+
assert.equal(classify('─────'), 'suppress'); // horizontal rule
|
|
133
|
+
assert.equal(classify(' '), 'suppress'); // whitespace only
|
|
134
|
+
assert.equal(classify(''), 'suppress'); // empty
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('classify: suppresses lines with ANSI that reduce to chrome', () => {
|
|
138
|
+
// \x1b[?25l is "hide cursor" — stripping it leaves empty string
|
|
139
|
+
assert.equal(classify('\x1b[?25l'), 'suppress');
|
|
140
|
+
assert.equal(classify('\x1b[?25l◇\x1b[?25h'), 'suppress');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('classify: shows actual text prompts (the lines we want to preserve)', () => {
|
|
144
|
+
assert.equal(classify('│ You are running in a remote environment'), 'show');
|
|
145
|
+
assert.equal(classify('Auth complete. Model connected.'), 'show');
|
|
146
|
+
assert.equal(classify('If the browser did not open, paste the callback URL:'), 'show');
|
|
147
|
+
assert.equal(classify('✓ Authentication successful'), 'show');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ─── Full pipeline: typewriter animation ─────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
test('full pipeline: typewriter animation collapses to single clean line', () => {
|
|
153
|
+
// Simulate the exact failure pattern from the bug report.
|
|
154
|
+
// OpenClaw writes text character-by-character with \r between frames,
|
|
155
|
+
// terminated by \n when the message is complete.
|
|
156
|
+
const buffer = '│ Y\r│ Yo\r│ You\r│ You \r│ You are running in a remote environment\n';
|
|
157
|
+
|
|
158
|
+
// handleData splits on \n only
|
|
159
|
+
const lines = buffer.split(/\r?\n/);
|
|
160
|
+
lines.pop(); // drop trailing empty after final \n
|
|
161
|
+
|
|
162
|
+
const results = lines.map((raw) => {
|
|
163
|
+
const frame = processLine(raw);
|
|
164
|
+
return { frame, decision: classify(frame) };
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Should produce exactly one output, fully formed
|
|
168
|
+
assert.equal(results.length, 1);
|
|
169
|
+
assert.equal(results[0].frame, '│ You are running in a remote environment');
|
|
170
|
+
assert.equal(results[0].decision, 'show');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('full pipeline: spinner-only animation is suppressed after collapse', () => {
|
|
174
|
+
// Spinner that ends without a meaningful final state (pure decoration)
|
|
175
|
+
const buffer = '⠋\r⠙\r⠹\r⠸\r⠼\r\n';
|
|
176
|
+
|
|
177
|
+
const lines = buffer.split(/\r?\n/);
|
|
178
|
+
lines.pop();
|
|
179
|
+
|
|
180
|
+
const results = lines.map((raw) => {
|
|
181
|
+
const frame = processLine(raw);
|
|
182
|
+
return classify(frame);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
assert.deepEqual(results, ['suppress']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('full pipeline: URL line is extracted and not double-printed', () => {
|
|
189
|
+
const buffer = 'Open: https://auth.openai.com/oauth/authorize?response_type=code&client_id=app\n';
|
|
190
|
+
|
|
191
|
+
const lines = buffer.split(/\r?\n/);
|
|
192
|
+
lines.pop();
|
|
193
|
+
|
|
194
|
+
const [raw] = lines;
|
|
195
|
+
const frame = processLine(raw);
|
|
196
|
+
assert.equal(classify(frame), 'url');
|
|
197
|
+
});
|