@hoox-sh/hoox-tui 0.2.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/README.md +247 -0
- package/bin/hoox-tui.js +35 -0
- package/dist/main.js +17594 -0
- package/package.json +72 -0
- package/src/app.tsx +696 -0
- package/src/cli-bridge-test-double.ts +212 -0
- package/src/components/layout/layout.test.tsx +205 -0
- package/src/components/layout/sidebar.tsx +69 -0
- package/src/components/layout/statusbar.test.tsx +423 -0
- package/src/components/layout/statusbar.tsx +348 -0
- package/src/components/shared/animated-border.tsx +58 -0
- package/src/components/shared/chrome.test.tsx +29 -0
- package/src/components/shared/command-palette.tsx +361 -0
- package/src/components/shared/cool-brackets.tsx +75 -0
- package/src/components/shared/crash-screen.tsx +167 -0
- package/src/components/shared/error-boundary.tsx +99 -0
- package/src/components/shared/keybinding-hint.tsx +37 -0
- package/src/components/shared/panel.tsx +61 -0
- package/src/components/shared/quit-modal.tsx +65 -0
- package/src/components/shared/shared.test.tsx +538 -0
- package/src/components/shared/spinner.tsx +85 -0
- package/src/components/shared/status-dot.tsx +54 -0
- package/src/components/shared/view-header.tsx +53 -0
- package/src/components/ui/connection-toasts.test.ts +50 -0
- package/src/components/ui/connection-toasts.ts +215 -0
- package/src/components/ui/dialog.tsx +281 -0
- package/src/components/ui/select.tsx +197 -0
- package/src/components/ui/toast.tsx +185 -0
- package/src/components/views/ai-chat.test.tsx +81 -0
- package/src/components/views/ai-chat.tsx +576 -0
- package/src/components/views/config-editor/action-bar.tsx +102 -0
- package/src/components/views/config-editor/code-editor.tsx +597 -0
- package/src/components/views/config-editor/file-tree.tsx +113 -0
- package/src/components/views/config-editor/types.ts +37 -0
- package/src/components/views/config-editor.test.tsx +465 -0
- package/src/components/views/config-editor.tsx +709 -0
- package/src/components/views/dashboard/alerts-panel.tsx +154 -0
- package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
- package/src/components/views/dashboard/model-health-section.tsx +276 -0
- package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
- package/src/components/views/dashboard.test.tsx +347 -0
- package/src/components/views/dashboard.tsx +647 -0
- package/src/components/views/db-query.test.tsx +115 -0
- package/src/components/views/db-query.tsx +849 -0
- package/src/components/views/edge-topology.test.tsx +198 -0
- package/src/components/views/edge-topology.tsx +572 -0
- package/src/components/views/kv-viewer.test.tsx +241 -0
- package/src/components/views/kv-viewer.tsx +717 -0
- package/src/components/views/logs-viewer.test.tsx +273 -0
- package/src/components/views/logs-viewer.tsx +616 -0
- package/src/components/views/queue-depth.test.tsx +221 -0
- package/src/components/views/queue-depth.tsx +376 -0
- package/src/components/views/secrets-viewer.test.tsx +65 -0
- package/src/components/views/secrets-viewer.tsx +548 -0
- package/src/components/views/service-manager.test.tsx +358 -0
- package/src/components/views/service-manager.tsx +946 -0
- package/src/components/views/settings.test.tsx +648 -0
- package/src/components/views/settings.tsx +1497 -0
- package/src/components/views/setup-wizard.test.tsx +297 -0
- package/src/components/views/setup-wizard.tsx +1321 -0
- package/src/components/views/trade-monitor.test.tsx +592 -0
- package/src/components/views/trade-monitor.tsx +745 -0
- package/src/components/views/worker-detail.test.tsx +289 -0
- package/src/components/views/worker-detail.tsx +676 -0
- package/src/components/views/worker-settings.test.tsx +43 -0
- package/src/components/views/worker-settings.tsx +563 -0
- package/src/components/views/workers-overview.test.tsx +260 -0
- package/src/components/views/workers-overview.tsx +631 -0
- package/src/hooks/hooks.test.ts +72 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/renderer-ref.ts +22 -0
- package/src/hooks/use-keyboard.ts +130 -0
- package/src/hooks/use-polling.ts +73 -0
- package/src/hooks/use-service-data.ts +18 -0
- package/src/main.tsx +117 -0
- package/src/network-test-double.ts +133 -0
- package/src/opentui-augment.d.ts +45 -0
- package/src/opentui-jsx.d.ts +70 -0
- package/src/services/cli-bridge/index.ts +1662 -0
- package/src/services/cli-bridge/standalone.ts +315 -0
- package/src/services/cli-bridge/types.ts +307 -0
- package/src/services/cli-bridge.test.ts +406 -0
- package/src/services/clipboard.test.ts +57 -0
- package/src/services/clipboard.ts +180 -0
- package/src/services/dashboard-settings-loader.ts +31 -0
- package/src/services/dev-log.test.ts +168 -0
- package/src/services/dev-log.ts +194 -0
- package/src/services/hoox-path-service.test.ts +183 -0
- package/src/services/hoox-path-service.ts +185 -0
- package/src/services/tui-connection.test.ts +212 -0
- package/src/services/tui-connection.ts +236 -0
- package/src/services/tui-storage.test.ts +92 -0
- package/src/services/tui-storage.ts +86 -0
- package/src/stores/config-store.test.ts +360 -0
- package/src/stores/service-store.test.ts +1026 -0
- package/src/stores/session-restore.test.ts +134 -0
- package/src/stores/ui-store.test.ts +276 -0
- package/src/test-setup.ts +265 -0
- package/src/test-utils.ts +179 -0
- package/src/types.ts +153 -0
- package/src/utils/colors.test.ts +326 -0
- package/src/utils/formatters.test.ts +236 -0
- package/src/view-registry.test.ts +132 -0
- package/src/view-registry.tsx +368 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tests for validateReadOnlySql — the client-side read-only SQL validator.
|
|
8
|
+
*
|
|
9
|
+
* Validates the security guarantees of the db-query view:
|
|
10
|
+
* - SELECT / WITH / EXPLAIN are allowed
|
|
11
|
+
* - INSERT / UPDATE / DELETE / DROP / CREATE / ALTER / TRUNCATE / PRAGMA
|
|
12
|
+
* / ATTACH / DETACH / VACUUM / REINDEX are rejected
|
|
13
|
+
* - Multi-statement payloads (semicolons with content after) are rejected
|
|
14
|
+
* - Empty/whitespace-only input is rejected
|
|
15
|
+
* - Comment-only input is rejected
|
|
16
|
+
* - Mixed-case variants are handled case-insensitively
|
|
17
|
+
* - String literals and comments are stripped before keyword check
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect } from "bun:test";
|
|
20
|
+
import {
|
|
21
|
+
validateReadOnlySql,
|
|
22
|
+
tryParseJsonLoose,
|
|
23
|
+
sanitizeCliArgsForLog,
|
|
24
|
+
type SqlValidationResult,
|
|
25
|
+
} from "./cli-bridge";
|
|
26
|
+
|
|
27
|
+
// ─── Helper ───────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function isValid(sql: string): boolean {
|
|
30
|
+
return validateReadOnlySql(sql).readonly;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rejectReason(sql: string): string {
|
|
34
|
+
const result = validateReadOnlySql(sql);
|
|
35
|
+
return result.readonly ? "" : (result as { reason: string }).reason;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Allowed entry points ─────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe("validateReadOnlySql — allowed entry points", () => {
|
|
41
|
+
it("accepts simple SELECT", () => {
|
|
42
|
+
expect(isValid("SELECT * FROM users")).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("accepts SELECT with WHERE clause", () => {
|
|
46
|
+
expect(isValid("SELECT id, name FROM users WHERE active = 1")).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("accepts SELECT with JOIN", () => {
|
|
50
|
+
expect(
|
|
51
|
+
isValid(
|
|
52
|
+
"SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id"
|
|
53
|
+
)
|
|
54
|
+
).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("accepts SELECT with subquery", () => {
|
|
58
|
+
expect(
|
|
59
|
+
isValid(
|
|
60
|
+
"SELECT * FROM (SELECT id, name FROM users WHERE active = 1) AS active_users"
|
|
61
|
+
)
|
|
62
|
+
).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("accepts WITH ... SELECT (CTE)", () => {
|
|
66
|
+
expect(
|
|
67
|
+
isValid(
|
|
68
|
+
"WITH active_users AS (SELECT * FROM users WHERE active = 1) SELECT * FROM active_users"
|
|
69
|
+
)
|
|
70
|
+
).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("accepts EXPLAIN SELECT", () => {
|
|
74
|
+
expect(isValid("EXPLAIN SELECT * FROM users")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("accepts EXPLAIN QUERY PLAN", () => {
|
|
78
|
+
expect(isValid("EXPLAIN QUERY PLAN SELECT * FROM users")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("accepts SELECT with LIMIT", () => {
|
|
82
|
+
expect(isValid("SELECT * FROM migrations ORDER BY id DESC LIMIT 5")).toBe(
|
|
83
|
+
true
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("accepts SELECT from sqlite_master", () => {
|
|
88
|
+
expect(
|
|
89
|
+
isValid(
|
|
90
|
+
"SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name"
|
|
91
|
+
)
|
|
92
|
+
).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ─── Forbidden keywords ────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe("validateReadOnlySql — forbidden keywords", () => {
|
|
99
|
+
// INSERT
|
|
100
|
+
it("rejects INSERT", () => {
|
|
101
|
+
expect(isValid("INSERT INTO users (name) VALUES ('test')")).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
it("rejects INSERT as first token (uppercase)", () => {
|
|
104
|
+
expect(isValid("INSERT INTO users VALUES (1)")).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
it("rejects insert (lowercase)", () => {
|
|
107
|
+
expect(isValid("insert into users values (1)")).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// UPDATE
|
|
111
|
+
it("rejects UPDATE", () => {
|
|
112
|
+
expect(isValid("UPDATE users SET name = 'test' WHERE id = 1")).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
it("rejects update (lowercase)", () => {
|
|
115
|
+
expect(isValid("update users set name = 'test' where id = 1")).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// DELETE
|
|
119
|
+
it("rejects DELETE", () => {
|
|
120
|
+
expect(isValid("DELETE FROM users WHERE id = 1")).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
it("rejects delete (lowercase)", () => {
|
|
123
|
+
expect(isValid("delete from users where id = 1")).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// REPLACE
|
|
127
|
+
it("rejects REPLACE", () => {
|
|
128
|
+
expect(isValid("REPLACE INTO users (id, name) VALUES (1, 'test')")).toBe(
|
|
129
|
+
false
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// DROP
|
|
134
|
+
it("rejects DROP TABLE", () => {
|
|
135
|
+
expect(isValid("DROP TABLE users")).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
it("rejects DROP INDEX", () => {
|
|
138
|
+
expect(isValid("DROP INDEX idx_users")).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// CREATE
|
|
142
|
+
it("rejects CREATE TABLE", () => {
|
|
143
|
+
expect(isValid("CREATE TABLE test (id INTEGER PRIMARY KEY)")).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
it("rejects CREATE INDEX", () => {
|
|
146
|
+
expect(isValid("CREATE INDEX idx ON users(name)")).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
it("rejects CREATE VIEW", () => {
|
|
149
|
+
expect(isValid("CREATE VIEW v AS SELECT * FROM users")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ALTER
|
|
153
|
+
it("rejects ALTER TABLE", () => {
|
|
154
|
+
expect(isValid("ALTER TABLE users ADD COLUMN email TEXT")).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// TRUNCATE
|
|
158
|
+
it("rejects TRUNCATE", () => {
|
|
159
|
+
expect(isValid("TRUNCATE TABLE users")).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// PRAGMA
|
|
163
|
+
it("rejects PRAGMA", () => {
|
|
164
|
+
expect(isValid("PRAGMA journal_mode=WAL")).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
it("rejects pragma (lowercase)", () => {
|
|
167
|
+
expect(isValid("pragma journal_mode")).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ATTACH
|
|
171
|
+
it("rejects ATTACH", () => {
|
|
172
|
+
expect(isValid("ATTACH DATABASE '/tmp/test.db' AS test")).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// DETACH
|
|
176
|
+
it("rejects DETACH", () => {
|
|
177
|
+
expect(isValid("DETACH DATABASE test")).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// VACUUM
|
|
181
|
+
it("rejects VACUUM", () => {
|
|
182
|
+
expect(isValid("VACUUM")).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// REINDEX
|
|
186
|
+
it("rejects REINDEX", () => {
|
|
187
|
+
expect(isValid("REINDEX")).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Forbidden keyword mixed into a valid-looking SELECT (injection attempt)
|
|
191
|
+
it("rejects SELECT with DROP in string literal", () => {
|
|
192
|
+
// The validator strips string literals before checking, so embedded
|
|
193
|
+
// keywords in string literals should not trigger rejection.
|
|
194
|
+
// However, the whole-statement check should still reject if the
|
|
195
|
+
// keyword appears outside strings/comments.
|
|
196
|
+
expect(isValid("SELECT 'DROP TABLE users'")).toBe(true); // keyword is inside string
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("rejects SELECT with forbidden keyword after string literal", () => {
|
|
200
|
+
// Keyword outside string → must be rejected
|
|
201
|
+
expect(isValid("SELECT 'hello' FROM users; DROP TABLE users")).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ─── Comment stripping ────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
describe("validateReadOnlySql — comment stripping", () => {
|
|
208
|
+
it("accepts SELECT with trailing line comment", () => {
|
|
209
|
+
expect(isValid("SELECT * FROM users -- this is a comment")).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("accepts SELECT with inline line comment", () => {
|
|
213
|
+
expect(isValid("SELECT * FROM users -- comment\nWHERE id = 1")).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("accepts SELECT with block comment", () => {
|
|
217
|
+
expect(isValid("SELECT /* comment */ * FROM users")).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("rejects INSERT hidden in block comment prefix", () => {
|
|
221
|
+
// The first significant token is read from the stripped+string-stripped
|
|
222
|
+
// input. Block comments are stripped first, so "INSERT /* ... */ INTO"
|
|
223
|
+
// should still be caught.
|
|
224
|
+
// Actual behaviour: "INSERT" is the first token after block comment
|
|
225
|
+
// stripping → rejected. This tests the defence-in-depth property.
|
|
226
|
+
expect(isValid("INSERT /* comment */ INTO users VALUES (1)")).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("rejects comment-only input", () => {
|
|
230
|
+
expect(isValid("-- just a comment")).toBe(false);
|
|
231
|
+
expect(isValid("/* block comment */")).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ─── String literal stripping ──────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
describe("validateReadOnlySql — string literal stripping", () => {
|
|
238
|
+
it("accepts SELECT with string literal containing keyword", () => {
|
|
239
|
+
// Keywords inside single-quoted strings should be stripped and not
|
|
240
|
+
// cause false positives.
|
|
241
|
+
expect(isValid("SELECT 'INSERT' FROM users")).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("accepts SELECT with double-quoted identifier containing keyword", () => {
|
|
245
|
+
expect(isValid('SELECT "DROP" FROM users')).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("rejects keyword that appears after string literal end", () => {
|
|
249
|
+
expect(isValid("SELECT 'x' FROM users; DROP TABLE users")).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ─── Multi-statement rejection ─────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
describe("validateReadOnlySql — multi-statement rejection", () => {
|
|
256
|
+
it("rejects two SELECTs with semicolon", () => {
|
|
257
|
+
expect(isValid("SELECT 1; SELECT 2")).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("rejects SELECT then DROP with semicolon", () => {
|
|
261
|
+
expect(isValid("SELECT * FROM users; DROP TABLE users")).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("rejects SELECT with trailing semicolon (allowed)", () => {
|
|
265
|
+
expect(isValid("SELECT * FROM users;")).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("rejects SELECT with semicolon and trailing whitespace (allowed)", () => {
|
|
269
|
+
expect(isValid("SELECT * FROM users; ")).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("rejects SELECT with semicolon and extra statement after", () => {
|
|
273
|
+
expect(isValid("SELECT * FROM users; SELECT 2")).toBe(false);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("rejects multiple semicolons", () => {
|
|
277
|
+
expect(isValid("SELECT 1;; SELECT 2")).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ─── Empty / whitespace input ─────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
describe("validateReadOnlySql — empty and whitespace input", () => {
|
|
284
|
+
it("rejects empty string", () => {
|
|
285
|
+
expect(isValid("")).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("rejects whitespace-only string", () => {
|
|
289
|
+
expect(isValid(" ")).toBe(false);
|
|
290
|
+
expect(isValid("\t\n")).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("rejects newlines only", () => {
|
|
294
|
+
expect(isValid("\n\n")).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ─── Result shape ─────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
describe("validateReadOnlySql — result shape", () => {
|
|
301
|
+
it("returns {readonly: true} for valid SQL", () => {
|
|
302
|
+
const result = validateReadOnlySql("SELECT * FROM users");
|
|
303
|
+
expect(result.readonly).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("returns {readonly: false, reason: string} for invalid SQL", () => {
|
|
307
|
+
const result = validateReadOnlySql("DROP TABLE users");
|
|
308
|
+
expect(result.readonly).toBe(false);
|
|
309
|
+
expect(typeof (result as { reason: string }).reason).toBe("string");
|
|
310
|
+
expect((result as { reason: string }).reason.length).toBeGreaterThan(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("returns a non-empty reason for forbidden keyword", () => {
|
|
314
|
+
const result = validateReadOnlySql("INSERT INTO users VALUES (1)");
|
|
315
|
+
expect(result.readonly).toBe(false);
|
|
316
|
+
expect((result as { reason: string }).reason).toContain("INSERT");
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ─── Edge cases ───────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
describe("validateReadOnlySql — edge cases", () => {
|
|
323
|
+
it("handles SQL with leading whitespace", () => {
|
|
324
|
+
expect(isValid(" SELECT * FROM users")).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("handles SQL with leading parenthesis", () => {
|
|
328
|
+
expect(isValid("(SELECT * FROM users)")).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("handles long valid SQL without issues", () => {
|
|
332
|
+
const longSql =
|
|
333
|
+
"SELECT u.id, u.name, o.total, o.created_at " +
|
|
334
|
+
"FROM users u " +
|
|
335
|
+
"JOIN orders o ON u.id = o.user_id " +
|
|
336
|
+
"WHERE u.active = 1 AND o.total > 100 " +
|
|
337
|
+
"ORDER BY o.created_at DESC LIMIT 50";
|
|
338
|
+
expect(isValid(longSql)).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("handles unicode in SQL (emoji in string)", () => {
|
|
342
|
+
expect(isValid("SELECT '🚀' FROM users")).toBe(true);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("handles backslash in string literal", () => {
|
|
346
|
+
expect(isValid("SELECT 'C:\\Users\\test' FROM users")).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("handles escaped single quote in string", () => {
|
|
350
|
+
expect(isValid("SELECT name FROM users WHERE name = 'O''Brien'")).toBe(
|
|
351
|
+
true
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("handles doubled single quotes (SQL standard escape)", () => {
|
|
356
|
+
expect(isValid("SELECT 'it''s fine' FROM users")).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ─── Robust JSON + secret redaction helpers ───────────────────────────────────
|
|
361
|
+
|
|
362
|
+
describe("tryParseJsonLoose", () => {
|
|
363
|
+
it("parses clean JSON", () => {
|
|
364
|
+
expect(tryParseJsonLoose('{"ok":true}')).toEqual({ ok: true });
|
|
365
|
+
expect(tryParseJsonLoose("[1,2]")).toEqual([1, 2]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("parses JSON after leading noise lines", () => {
|
|
369
|
+
const noisy = 'Fetching queues…\n{\n "queues": []\n}\n';
|
|
370
|
+
expect(tryParseJsonLoose(noisy)).toEqual({ queues: [] });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("returns null on empty or garbage", () => {
|
|
374
|
+
expect(tryParseJsonLoose("")).toBeNull();
|
|
375
|
+
expect(tryParseJsonLoose("not json at all")).toBeNull();
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe("sanitizeCliArgsForLog", () => {
|
|
380
|
+
it("redacts values after set", () => {
|
|
381
|
+
const sanitized = sanitizeCliArgsForLog([
|
|
382
|
+
"config",
|
|
383
|
+
"kv",
|
|
384
|
+
"set",
|
|
385
|
+
"OPENAI_API_KEY",
|
|
386
|
+
"sk-live-super-secret",
|
|
387
|
+
]);
|
|
388
|
+
expect(sanitized).toEqual([
|
|
389
|
+
"config",
|
|
390
|
+
"kv",
|
|
391
|
+
"set",
|
|
392
|
+
"OPENAI_API_KEY",
|
|
393
|
+
"[redacted]",
|
|
394
|
+
]);
|
|
395
|
+
expect(sanitized.join(" ")).not.toContain("sk-live");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("redacts Bearer tokens in free-form args", () => {
|
|
399
|
+
const sanitized = sanitizeCliArgsForLog([
|
|
400
|
+
"deploy",
|
|
401
|
+
"Authorization: Bearer abcdefghijklmnop",
|
|
402
|
+
]);
|
|
403
|
+
expect(sanitized[1]).toContain("[redacted]");
|
|
404
|
+
expect(sanitized.join(" ")).not.toContain("abcdefghijklmnop");
|
|
405
|
+
});
|
|
406
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from "bun:test";
|
|
7
|
+
import { copyToClipboard, copyViaSystemClipboard } from "./clipboard";
|
|
8
|
+
import { messageCopiedToClipboard } from "../components/ui/toast";
|
|
9
|
+
|
|
10
|
+
describe("clipboard", () => {
|
|
11
|
+
it("treats empty text as success without throwing", async () => {
|
|
12
|
+
const result = await copyToClipboard(" ", null);
|
|
13
|
+
expect(result.ok).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("copyViaSystemClipboard returns a tool name or null", async () => {
|
|
17
|
+
const tool = await copyViaSystemClipboard("hoox-clipboard-test");
|
|
18
|
+
// CI may lack clipboard tools; null is acceptable
|
|
19
|
+
expect(tool === null || typeof tool === "string").toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("copyToClipboard falls back when no renderer is available", async () => {
|
|
23
|
+
const result = await copyToClipboard("hello from hoox tui", null);
|
|
24
|
+
// Success if system tool exists; otherwise structured failure
|
|
25
|
+
if (result.ok) {
|
|
26
|
+
expect(result.method === "osc52" || result.method === "system").toBe(
|
|
27
|
+
true
|
|
28
|
+
);
|
|
29
|
+
} else {
|
|
30
|
+
expect(result.error).toMatch(/clipboard/i);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("accepts notify option without throwing", async () => {
|
|
35
|
+
// Toast singleton may no-op without a Toaster in unit tests
|
|
36
|
+
const result = await copyToClipboard("notify me", null, { notify: true });
|
|
37
|
+
expect(typeof result.ok).toBe("boolean");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("messageCopiedToClipboard", () => {
|
|
42
|
+
it("previews short text", () => {
|
|
43
|
+
expect(messageCopiedToClipboard("hello")).toBe("Copied: “hello”");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("truncates long text", () => {
|
|
47
|
+
const long = "x".repeat(80);
|
|
48
|
+
const msg = messageCopiedToClipboard(long);
|
|
49
|
+
expect(msg.startsWith("Copied: “")).toBe(true);
|
|
50
|
+
expect(msg.endsWith("…”")).toBe(true);
|
|
51
|
+
expect(msg.length).toBeLessThan(long.length + 20);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("collapses whitespace", () => {
|
|
55
|
+
expect(messageCopiedToClipboard("a\n\nb")).toBe("Copied: “a b”");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Clipboard helpers for the Hoox TUI.
|
|
8
|
+
*
|
|
9
|
+
* Strategy (first success wins):
|
|
10
|
+
* 1. OpenTUI OSC 52 via CliRenderer.copyToClipboardOSC52 (works over SSH
|
|
11
|
+
* when the terminal allows it)
|
|
12
|
+
* 2. Platform tools: wl-copy / xclip / xsel (Linux), pbcopy (macOS),
|
|
13
|
+
* clip (Windows)
|
|
14
|
+
*
|
|
15
|
+
* Used for auto-copy on mouse text selection.
|
|
16
|
+
*/
|
|
17
|
+
import { getRendererRef } from "../hooks";
|
|
18
|
+
import {
|
|
19
|
+
toastCopiedToClipboard,
|
|
20
|
+
toastCopyFailed,
|
|
21
|
+
} from "../components/ui/toast";
|
|
22
|
+
|
|
23
|
+
/** Minimal surface we need from OpenTUI's CliRenderer (typed loosely for packages). */
|
|
24
|
+
export type ClipboardRenderer = {
|
|
25
|
+
copyToClipboardOSC52: (text: string) => boolean;
|
|
26
|
+
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
|
|
27
|
+
off: (event: string, listener: (...args: unknown[]) => void) => unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ClipboardResult =
|
|
31
|
+
| { ok: true; method: "osc52" | "system"; tool?: string }
|
|
32
|
+
| { ok: false; error: string };
|
|
33
|
+
|
|
34
|
+
const SYSTEM_CLIPBOARD_CANDIDATES: { bin: string; args: string[] }[] = [
|
|
35
|
+
{ bin: "wl-copy", args: [] },
|
|
36
|
+
{ bin: "xclip", args: ["-selection", "clipboard"] },
|
|
37
|
+
{ bin: "xsel", args: ["--clipboard", "--input"] },
|
|
38
|
+
{ bin: "pbcopy", args: [] },
|
|
39
|
+
{ bin: "clip.exe", args: [] },
|
|
40
|
+
{ bin: "clip", args: [] },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Hang-guard for clipboard utilities (X11/Wayland can block forever). */
|
|
44
|
+
const CLIPBOARD_SPAWN_TIMEOUT_MS = 2_000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Write text to the system clipboard via a known clipboard utility.
|
|
48
|
+
* Returns the tool name on success, or null if none worked.
|
|
49
|
+
* Each candidate is hard-killed after {@link CLIPBOARD_SPAWN_TIMEOUT_MS}.
|
|
50
|
+
*/
|
|
51
|
+
export async function copyViaSystemClipboard(
|
|
52
|
+
text: string
|
|
53
|
+
): Promise<string | null> {
|
|
54
|
+
for (const { bin, args } of SYSTEM_CLIPBOARD_CANDIDATES) {
|
|
55
|
+
if (!Bun.which(bin)) continue;
|
|
56
|
+
try {
|
|
57
|
+
const proc = Bun.spawn([bin, ...args], {
|
|
58
|
+
stdin: "pipe",
|
|
59
|
+
stdout: "ignore",
|
|
60
|
+
stderr: "ignore",
|
|
61
|
+
});
|
|
62
|
+
proc.stdin.write(text);
|
|
63
|
+
proc.stdin.end();
|
|
64
|
+
|
|
65
|
+
let timedOut = false;
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
timedOut = true;
|
|
68
|
+
try {
|
|
69
|
+
if (!proc.killed) proc.kill();
|
|
70
|
+
} catch {
|
|
71
|
+
/* already dead */
|
|
72
|
+
}
|
|
73
|
+
}, CLIPBOARD_SPAWN_TIMEOUT_MS);
|
|
74
|
+
|
|
75
|
+
const code = await proc.exited;
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
if (!timedOut && code === 0) return bin;
|
|
78
|
+
} catch {
|
|
79
|
+
// try next tool
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CopyToClipboardOptions {
|
|
86
|
+
/**
|
|
87
|
+
* Show a success/error toast. Default false so generic callers stay quiet;
|
|
88
|
+
* auto-copy on selection opts in.
|
|
89
|
+
*/
|
|
90
|
+
notify?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Copy text using the best available method for the current environment.
|
|
95
|
+
*
|
|
96
|
+
* @param text - Content to copy (empty / whitespace-only is a no-op success)
|
|
97
|
+
* @param renderer - Optional renderer for OSC 52; falls back to getRendererRef()
|
|
98
|
+
* @param options - Optional `{ notify: true }` for toast feedback
|
|
99
|
+
*/
|
|
100
|
+
export async function copyToClipboard(
|
|
101
|
+
text: string,
|
|
102
|
+
renderer?: ClipboardRenderer | null,
|
|
103
|
+
options?: CopyToClipboardOptions
|
|
104
|
+
): Promise<ClipboardResult> {
|
|
105
|
+
if (!text || !text.trim()) {
|
|
106
|
+
return { ok: true, method: "osc52" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const notify = options?.notify === true;
|
|
110
|
+
|
|
111
|
+
const r = (renderer ??
|
|
112
|
+
(getRendererRef() as ClipboardRenderer | null)) as ClipboardRenderer | null;
|
|
113
|
+
if (r && typeof r.copyToClipboardOSC52 === "function") {
|
|
114
|
+
try {
|
|
115
|
+
const ok = Boolean(r.copyToClipboardOSC52(text));
|
|
116
|
+
if (ok) {
|
|
117
|
+
// Still try system clipboard so local paste works even when OSC 52
|
|
118
|
+
// only reaches the remote terminal emulator.
|
|
119
|
+
void copyViaSystemClipboard(text);
|
|
120
|
+
if (notify) toastCopiedToClipboard(text);
|
|
121
|
+
return { ok: true, method: "osc52" };
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// fall through to system tools
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const tool = await copyViaSystemClipboard(text);
|
|
129
|
+
if (tool) {
|
|
130
|
+
if (notify) toastCopiedToClipboard(text);
|
|
131
|
+
return { ok: true, method: "system", tool };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const error =
|
|
135
|
+
"No clipboard backend available (OSC 52 failed; install wl-copy/xclip/pbcopy)";
|
|
136
|
+
if (notify) toastCopyFailed(error);
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
error,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wire auto-copy when the user finishes a mouse text selection.
|
|
145
|
+
* Call once after createCliRenderer + setRendererRef.
|
|
146
|
+
*
|
|
147
|
+
* OpenTUI emits `"selection"` from finishSelection() with isDragging=false.
|
|
148
|
+
* We only copy non-empty selections and ignore pure clicks (empty text).
|
|
149
|
+
*/
|
|
150
|
+
export function enableAutoCopyOnSelection(
|
|
151
|
+
renderer: ClipboardRenderer
|
|
152
|
+
): () => void {
|
|
153
|
+
let lastCopied = "";
|
|
154
|
+
let lastAt = 0;
|
|
155
|
+
|
|
156
|
+
const onSelection = (...args: unknown[]) => {
|
|
157
|
+
const selection = args[0] as
|
|
158
|
+
| {
|
|
159
|
+
isDragging?: boolean;
|
|
160
|
+
getSelectedText?: () => string;
|
|
161
|
+
}
|
|
162
|
+
| undefined;
|
|
163
|
+
if (selection?.isDragging) return;
|
|
164
|
+
const text = selection?.getSelectedText?.() ?? "";
|
|
165
|
+
if (!text.trim()) return;
|
|
166
|
+
|
|
167
|
+
// Debounce identical rapid emissions
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
if (text === lastCopied && now - lastAt < 400) return;
|
|
170
|
+
lastCopied = text;
|
|
171
|
+
lastAt = now;
|
|
172
|
+
|
|
173
|
+
void copyToClipboard(text, renderer, { notify: true });
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
renderer.on("selection", onSelection);
|
|
177
|
+
return () => {
|
|
178
|
+
renderer.off("selection", onSelection);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load worker dashboard.jsonc manifests for the TUI worker-settings view.
|
|
8
|
+
* Thin wrapper around shared filesystem discovery.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
loadDashboardManifestsFromRoot,
|
|
12
|
+
resolveHooxRuntimeRoot,
|
|
13
|
+
type WorkerDashboardManifest,
|
|
14
|
+
} from "@hoox-sh/hoox-shared";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Discover and parse all dashboard settings manifests available in the
|
|
19
|
+
* current runtime monorepo (or empty list when not found).
|
|
20
|
+
*/
|
|
21
|
+
export function loadDashboardSettingsManifests(
|
|
22
|
+
runtimeRoot?: string | null
|
|
23
|
+
): WorkerDashboardManifest[] {
|
|
24
|
+
const root =
|
|
25
|
+
runtimeRoot ??
|
|
26
|
+
resolveHooxRuntimeRoot().root ??
|
|
27
|
+
// packages/tui/src/services → monorepo root
|
|
28
|
+
join(import.meta.dir, "..", "..", "..", "..");
|
|
29
|
+
|
|
30
|
+
return loadDashboardManifestsFromRoot(root);
|
|
31
|
+
}
|