@polderlabs/bizar 4.4.13 → 4.5.1

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.
Files changed (118) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
  3. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  4. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
  5. package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
  6. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
  8. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
  9. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
  10. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
  11. package/bizar-dash/dist/index.html +3 -3
  12. package/bizar-dash/dist/mobile.html +2 -2
  13. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  14. package/bizar-dash/skills/bizar/SKILL.md +116 -0
  15. package/bizar-dash/skills/chat/SKILL.md +74 -0
  16. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  17. package/bizar-dash/skills/lightrag/SKILL.md +86 -0
  18. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  19. package/bizar-dash/skills/obsidian/SKILL.md +68 -0
  20. package/bizar-dash/skills/providers/SKILL.md +75 -0
  21. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  22. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  23. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  24. package/bizar-dash/skills/usage/SKILL.md +62 -0
  25. package/bizar-dash/src/server/api.mjs +18 -0
  26. package/bizar-dash/src/server/headroom.mjs +645 -0
  27. package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
  28. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  29. package/bizar-dash/src/server/memory-store.mjs +189 -0
  30. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  31. package/bizar-dash/src/server/minimax.mjs +196 -5
  32. package/bizar-dash/src/server/providers-store.mjs +956 -0
  33. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  34. package/bizar-dash/src/server/routes/config.mjs +52 -1
  35. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  36. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  37. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +668 -1
  39. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  40. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  41. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  42. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  43. package/bizar-dash/src/server/routes/update.mjs +340 -0
  44. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  45. package/bizar-dash/src/server/serve-info.mjs +135 -4
  46. package/bizar-dash/src/server/server.mjs +20 -0
  47. package/bizar-dash/src/server/skills-store.mjs +152 -262
  48. package/bizar-dash/src/web/App.tsx +120 -29
  49. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  50. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  51. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  52. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  53. package/bizar-dash/src/web/components/Topbar.tsx +2 -1
  54. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  55. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  56. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  57. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  58. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  59. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  60. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  61. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  62. package/bizar-dash/src/web/lib/api.ts +43 -0
  63. package/bizar-dash/src/web/lib/types.ts +16 -0
  64. package/bizar-dash/src/web/main.tsx +2 -0
  65. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  66. package/bizar-dash/src/web/styles/chat.css +135 -1
  67. package/bizar-dash/src/web/styles/main.css +46 -0
  68. package/bizar-dash/src/web/styles/memory.css +955 -0
  69. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  70. package/bizar-dash/src/web/styles/settings.css +418 -0
  71. package/bizar-dash/src/web/styles/skills.css +302 -0
  72. package/bizar-dash/src/web/styles/tasks.css +288 -0
  73. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  74. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  75. package/bizar-dash/src/web/views/Memory.tsx +140 -0
  76. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  77. package/bizar-dash/src/web/views/Overview.tsx +3 -0
  78. package/bizar-dash/src/web/views/Settings.tsx +36 -0
  79. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  80. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  82. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  83. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  84. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
  85. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  86. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  87. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  88. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  89. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  90. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  91. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  92. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  93. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  94. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  95. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  96. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  97. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  98. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  99. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  100. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  101. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  102. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  103. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  104. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  105. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  106. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  107. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  108. package/bizar-dash/tests/update-check.test.mjs +127 -0
  109. package/bizar-dash/tests/update-run.test.mjs +266 -0
  110. package/cli/bin.mjs +247 -1
  111. package/cli/provision.mjs +118 -4
  112. package/config/agents/_shared/SKILLS.md +109 -0
  113. package/package.json +1 -1
  114. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  115. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  116. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  117. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  118. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -0,0 +1,308 @@
1
+ /**
2
+ * tests/chat-session-stream.test.mjs — v4.2.5
3
+ *
4
+ * End-to-end tests for the opencode SSE streaming endpoints. The
5
+ * opencode-session-detail router proxies the upstream
6
+ * `/event?directory=...` stream and forwards one canonical
7
+ * envelope per upstream event, filtered by `sessionID`.
8
+ *
9
+ * Tests cover three guarantees:
10
+ *
11
+ * 1. `message.updated` events for the right session are forwarded,
12
+ * and events for other sessions are dropped.
13
+ * 2. Sync envelopes (the v2 wire format) are unwrapped: the inner
14
+ * `syncEvent.type` with version suffix (`.<n>`) becomes the
15
+ * canonical event name on the dashboard-side SSE stream.
16
+ * 3. The proxy emits a heartbeat (`: keepalive`) within the
17
+ * configured SSE_HEARTBEAT_MS (default 25s — we set it to 250ms
18
+ * via env override so the test runs in well under a second).
19
+ *
20
+ * Strategy: stand up a fake opencode serve child on a random port,
21
+ * point readServeInfo() at it. The fake emits three events then
22
+ * holds the connection open long enough for the heartbeat to land.
23
+ */
24
+
25
+ import { test, before, after, beforeEach } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+ import {
28
+ mkdtempSync,
29
+ writeFileSync,
30
+ rmSync,
31
+ existsSync,
32
+ copyFileSync,
33
+ unlinkSync,
34
+ readFileSync,
35
+ } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { tmpdir } from 'node:os';
38
+ import { createServer } from 'node:http';
39
+ import express from 'express';
40
+
41
+ import { createOpencodeSessionDetailRouter } from '../src/server/routes/opencode-session-detail.mjs';
42
+
43
+ // ── fake upstream ─────────────────────────────────────────────────────────
44
+
45
+ let upstreamServer, upstreamPort;
46
+ const upstream = {
47
+ streamChunks: [],
48
+ /** When non-null, hold the SSE connection open this many ms before
49
+ * ending it. The heartbeat test uses this. */
50
+ holdMs: 0,
51
+ };
52
+
53
+ function startUpstream() {
54
+ return new Promise((resolve) => {
55
+ upstreamServer = createServer((req, res) => {
56
+ const url = new URL(req.url, 'http://127.0.0.1');
57
+ if (url.pathname === '/event') {
58
+ res.writeHead(200, {
59
+ 'Content-Type': 'text/event-stream',
60
+ 'Cache-Control': 'no-cache',
61
+ Connection: 'keep-alive',
62
+ });
63
+ for (const c of upstream.streamChunks) res.write(c);
64
+ const hold = upstream.holdMs;
65
+ setTimeout(() => res.end(), hold);
66
+ return;
67
+ }
68
+ res.writeHead(404);
69
+ res.end('not found');
70
+ });
71
+ upstreamServer.listen(0, '127.0.0.1', () => {
72
+ upstreamPort = upstreamServer.address().port;
73
+ resolve();
74
+ });
75
+ });
76
+ }
77
+
78
+ async function startDashboard() {
79
+ const app = express();
80
+ app.use(express.json({ limit: '2mb' }));
81
+ app.use('/api', createOpencodeSessionDetailRouter());
82
+ dashboardServer = createServer(app);
83
+ await new Promise((r) => dashboardServer.listen(0, '127.0.0.1', r));
84
+ dashboardBaseUrl = `http://127.0.0.1:${dashboardServer.address().port}`;
85
+ }
86
+
87
+ let dashboardServer, dashboardBaseUrl, tmpDir;
88
+ let SERVE_JSON_PATH;
89
+ let originalServeJson;
90
+
91
+ before(async () => {
92
+ // Shrink the SSE heartbeat from 25s → 250ms so the heartbeat test
93
+ // actually exercises the path within the test runner's timeout.
94
+ process.env.BIZAR_SSE_HEARTBEAT_MS = '250';
95
+ // Use a per-PID isolated serve.json path so multiple test files
96
+ // don't contaminate each other.
97
+ SERVE_JSON_PATH = `${tmpdir()}/bizar-test-serve-stream-${process.pid}-${Date.now()}.json`;
98
+ process.env.BIZAR_SERVE_JSON_PATH = SERVE_JSON_PATH;
99
+ await startUpstream();
100
+ await startDashboard();
101
+ tmpDir = mkdtempSync(join(tmpdir(), 'opencode-stream-'));
102
+ if (existsSync(SERVE_JSON_PATH)) {
103
+ originalServeJson = readFileSync(SERVE_JSON_PATH, 'utf8');
104
+ }
105
+ });
106
+
107
+ after(async () => {
108
+ if (originalServeJson !== undefined) {
109
+ writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
110
+ } else if (existsSync(SERVE_JSON_PATH)) {
111
+ rmSync(SERVE_JSON_PATH);
112
+ }
113
+ if (dashboardServer) await new Promise((r) => dashboardServer.close(r));
114
+ if (upstreamServer) await new Promise((r) => upstreamServer.close(r));
115
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
116
+ delete process.env.BIZAR_SSE_HEARTBEAT_MS;
117
+ delete process.env.BIZAR_SERVE_JSON_PATH;
118
+ });
119
+
120
+ beforeEach(() => {
121
+ upstream.streamChunks = [];
122
+ upstream.holdMs = 0;
123
+ });
124
+
125
+ function writeServeJson() {
126
+ writeFileSync(
127
+ SERVE_JSON_PATH,
128
+ JSON.stringify({
129
+ port: upstreamPort,
130
+ password: 'test-pw',
131
+ worktree: '/tmp/stream-worktree',
132
+ pid: 99999,
133
+ startedAt: Date.now(),
134
+ }),
135
+ 'utf8',
136
+ );
137
+ }
138
+
139
+ function moveServeJsonAside() {
140
+ if (!existsSync(SERVE_JSON_PATH)) return;
141
+ const tmp = join(tmpDir, `serve-aside-${Date.now()}-${process.pid}.json`);
142
+ copyFileSync(SERVE_JSON_PATH, tmp);
143
+ unlinkSync(SERVE_JSON_PATH);
144
+ }
145
+
146
+ function parseSseStream(text) {
147
+ const out = [];
148
+ let event = null, dataLines = [];
149
+ for (const line of text.split(/\r?\n/)) {
150
+ if (line === '') {
151
+ if (event !== null || dataLines.length > 0) {
152
+ out.push({ event, data: dataLines.join('\n') });
153
+ }
154
+ event = null; dataLines = [];
155
+ continue;
156
+ }
157
+ if (line.startsWith(':')) continue;
158
+ const colon = line.indexOf(':');
159
+ if (colon < 0) continue;
160
+ const field = line.slice(0, colon);
161
+ let value = line.slice(colon + 1);
162
+ if (value.startsWith(' ')) value = value.slice(1);
163
+ if (field === 'event') event = value;
164
+ else if (field === 'data') dataLines.push(value);
165
+ }
166
+ return out;
167
+ }
168
+
169
+ // ── tests ─────────────────────────────────────────────────────────────────
170
+
171
+ test('SSE forwards message.updated for the requested session, drops others', async () => {
172
+ writeServeJson();
173
+ upstream.streamChunks = [
174
+ // Direct envelope (older wire format). properties.sessionID.
175
+ 'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"our-id","messageID":"m1"}}\n\n',
176
+ // Wrong session: must be filtered.
177
+ 'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"OTHER-id","messageID":"m9"}}\n\n',
178
+ // Sync envelope (newer wire format).
179
+ 'event: sync\ndata: {"type":"sync","syncEvent":{"type":"message.updated.1","data":{"sessionID":"our-id","messageID":"m3"}}}\n\n',
180
+ ];
181
+ const res = await fetch(
182
+ `${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
183
+ );
184
+ assert.equal(res.status, 200);
185
+ assert.match(res.headers.get('content-type'), /text\/event-stream/);
186
+ assert.match(res.headers.get('cache-control'), /no-cache/);
187
+ assert.equal(res.headers.get('x-accel-buffering'), 'no');
188
+
189
+ const events = parseSseStream(await res.text());
190
+ // 1: message.updated (our-id)
191
+ // 2: message.updated from sync envelope (our-id)
192
+ // The OTHER-id message.updated is dropped.
193
+ assert.equal(events.length, 2);
194
+ assert.equal(events[0].event, 'message.updated');
195
+ const d0 = JSON.parse(events[0].data);
196
+ assert.equal(d0.sessionID, 'our-id');
197
+ assert.equal(d0.messageID, 'm1');
198
+ assert.equal(events[1].event, 'message.updated');
199
+ const d1 = JSON.parse(events[1].data);
200
+ assert.equal(d1.sessionID, 'our-id');
201
+ assert.equal(d1.messageID, 'm3');
202
+ });
203
+
204
+ test('SSE unwraps sync envelopes and strips the version suffix from the event name', async () => {
205
+ writeServeJson();
206
+ upstream.streamChunks = [
207
+ 'event: sync\ndata: {"type":"sync","syncEvent":{"type":"session.idle.1","data":{"sessionID":"our-id"}}}\n\n',
208
+ ];
209
+ const res = await fetch(
210
+ `${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
211
+ );
212
+ assert.equal(res.status, 200);
213
+ const events = parseSseStream(await res.text());
214
+ assert.equal(events.length, 1);
215
+ assert.equal(events[0].event, 'session.idle');
216
+ const d = JSON.parse(events[0].data);
217
+ assert.equal(d.sessionID, 'our-id');
218
+ });
219
+
220
+ test('SSE does NOT emit redundant chat:delta / chat:status aliases (only canonical events)', async () => {
221
+ // v4.2.5 fix: prior version emitted chat:delta/chat:status aliases
222
+ // in ADDITION to the canonical event, causing downstream listeners
223
+ // to see each idle/delta event twice. The proxy now emits one
224
+ // canonical envelope per upstream event — no aliases.
225
+ writeServeJson();
226
+ upstream.streamChunks = [
227
+ 'event: message.part.updated\ndata: {"type":"message.part.updated.1","properties":{"sessionID":"our-id","messageID":"m1","part":{"type":"text","text":"hello"}}}\n\n',
228
+ 'event: sync\ndata: {"type":"sync","syncEvent":{"type":"session.idle.1","data":{"sessionID":"our-id"}}}\n\n',
229
+ ];
230
+ const res = await fetch(
231
+ `${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
232
+ );
233
+ const events = parseSseStream(await res.text());
234
+ // Only 2 events expected — one part.updated and one session.idle.
235
+ // We do NOT want chat:delta or chat:status emitted here.
236
+ assert.equal(events.length, 2);
237
+ const evNames = events.map((e) => e.event);
238
+ assert.ok(evNames.includes('message.part.updated'));
239
+ assert.ok(evNames.includes('session.idle'));
240
+ assert.equal(evNames.filter((n) => n === 'chat:status').length, 0);
241
+ assert.equal(evNames.filter((n) => n === 'chat:delta').length, 0);
242
+ });
243
+
244
+ test('SSE emits a heartbeat (": keepalive") within BIZAR_SSE_HEARTBEAT_MS', async () => {
245
+ // Override forces a 250ms heartbeat (set in `before`). We hold
246
+ // the upstream connection open for 600ms so the heartbeat fires
247
+ // at least once after the initial chunks land.
248
+ writeServeJson();
249
+ upstream.holdMs = 600;
250
+ upstream.streamChunks = [
251
+ 'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"our-id","messageID":"m1"}}\n\n',
252
+ ];
253
+ const t0 = Date.now();
254
+ const res = await fetch(
255
+ `${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
256
+ );
257
+ const text = await res.text();
258
+ const elapsed = Date.now() - t0;
259
+ // Body should be at least one blank line-separated chunk containing
260
+ // a `: keepalive` heartbeat comment. We assert the raw body has
261
+ // `: keepalive` somewhere and the total wait was longer than the
262
+ // heartbeat interval (250ms).
263
+ assert.ok(
264
+ text.includes(': keepalive'),
265
+ `expected ": keepalive" in body — got: ${text.slice(0, 500)}`,
266
+ );
267
+ assert.ok(elapsed >= 250, `expected >= 250ms elapsed, got ${elapsed}ms`);
268
+ });
269
+
270
+ test('SSE returns the response in <50ms when upstream immediately errors (plugin offline)', async () => {
271
+ // Move serve.json aside so the proxy sees `plugin_offline`.
272
+ moveServeJsonAside();
273
+ try {
274
+ const t0 = Date.now();
275
+ const res = await fetch(
276
+ `${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
277
+ );
278
+ const elapsed = Date.now() - t0;
279
+ assert.equal(res.status, 200);
280
+ assert.match(res.headers.get('content-type'), /text\/event-stream/);
281
+ const text = await res.text();
282
+ assert.ok(text.includes('plugin_offline'));
283
+ assert.ok(elapsed < 200, `expected < 200ms, got ${elapsed}ms`);
284
+ } finally {
285
+ if (originalServeJson !== undefined) {
286
+ writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
287
+ } else {
288
+ writeServeJson();
289
+ }
290
+ }
291
+ });
292
+
293
+ test('SSE handles whitespace-only session id gracefully (no 500 crash)', async () => {
294
+ writeServeJson();
295
+ // Whitespace session id: server resolves `decodeURIComponent('%20')`
296
+ // to a single space, which is non-empty; the proxy tries to filter
297
+ // events and never matches → empty stream, but never 5xx.
298
+ const res = await fetch(
299
+ `${dashboardBaseUrl}/api/opencode-sessions/%20/stream`,
300
+ );
301
+ // The handler may return 200 (empty stream) or 503 (directory
302
+ // unknown because the worktree doesn't contain ` ` session).
303
+ // Critically it must NOT 5xx or return undefined.
304
+ assert.ok(
305
+ res.status === 200 || res.status === 503,
306
+ `unexpected ${res.status} for whitespace session id`,
307
+ );
308
+ });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * env-vars-store.test.mjs — tests for the env-vars route and loadEnvJson.
3
+ *
4
+ * Run with: node --test bizar-dash/tests/env-vars-store.test.mjs
5
+ */
6
+ import { describe, it, before, after, beforeEach } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync, chmodSync, statSync } from 'node:fs';
9
+ import { join, resolve } from 'node:path';
10
+ import { tmpdir } from 'node:os';
11
+
12
+ const REPO = resolve(import.meta.dirname, '..', '..');
13
+
14
+ // Sandbox process.env for isolation
15
+ let SANDBOX_HOME;
16
+ let ORIGINAL_HOME;
17
+ let BIZAR_DIR;
18
+ let ENV_FILE;
19
+
20
+ before(() => {
21
+ SANDBOX_HOME = mkdtempSync(join(tmpdir(), `bizar-env-vars-test-${Date.now()}-`));
22
+ ORIGINAL_HOME = process.env.HOME;
23
+ process.env.HOME = SANDBOX_HOME;
24
+ BIZAR_DIR = join(SANDBOX_HOME, '.config', 'bizar');
25
+ ENV_FILE = join(BIZAR_DIR, 'env.json');
26
+ });
27
+
28
+ after(() => {
29
+ process.env.HOME = ORIGINAL_HOME;
30
+ // Clean up any lingering BIZAR_* test vars
31
+ for (const k of Object.keys(process.env)) {
32
+ if (k.startsWith('BIZAR_TEST_')) delete process.env[k];
33
+ }
34
+ try { rmSync(SANDBOX_HOME, { recursive: true, force: true }); } catch { /* ignore */ }
35
+ });
36
+
37
+ // Helper to create a supertest-like request helper via native http
38
+ async function request(app, method, path, body) {
39
+ const http = await import('node:http');
40
+ return new Promise((resolve, reject) => {
41
+ const server = app.listen(0, '127.0.0.1', () => {
42
+ const { port } = server.address();
43
+ const bodyStr = body ? JSON.stringify(body) : '';
44
+ const opts = {
45
+ hostname: '127.0.0.1',
46
+ port,
47
+ path,
48
+ method,
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ 'Content-Length': Buffer.byteLength(bodyStr),
52
+ },
53
+ };
54
+ const req = http.request(opts, (res) => {
55
+ let data = '';
56
+ res.on('data', (chunk) => { data += chunk; });
57
+ res.on('end', () => {
58
+ server.close();
59
+ try {
60
+ const parsed = data ? JSON.parse(data) : {};
61
+ resolve({ status: res.statusCode, body: parsed, raw: data });
62
+ } catch {
63
+ resolve({ status: res.statusCode, body: data, raw: data });
64
+ }
65
+ });
66
+ });
67
+ req.on('error', (err) => { server.close(); reject(err); });
68
+ if (bodyStr) req.write(bodyStr);
69
+ req.end();
70
+ });
71
+ });
72
+ }
73
+
74
+ describe('env-vars.mjs', () => {
75
+ // Build a minimal express app with just the env-vars router
76
+ let app;
77
+ beforeEach(async () => {
78
+ // Reset module state between tests by deleting the env file
79
+ try { rmSync(ENV_FILE); } catch { /* ignore */ }
80
+ // Force-reload the module for fresh state
81
+ const express = (await import('express')).default;
82
+ const { createEnvVarsRouter, loadEnvJson, resetStore } = await import(`${REPO}/bizar-dash/src/server/routes/env-vars.mjs`);
83
+ resetStore();
84
+ app = express();
85
+ app.use(express.json());
86
+ app.use('/api', createEnvVarsRouter());
87
+ // Clear any BIZAR_TEST_ vars left from previous tests
88
+ for (const k of Object.keys(process.env)) {
89
+ if (k.startsWith('BIZAR_TEST_')) delete process.env[k];
90
+ }
91
+ app.use(createEnvVarsRouter());
92
+ });
93
+
94
+ it('GET /api/env-vars returns empty list when file does not exist', async () => {
95
+ const res = await request(app, 'GET', '/api/env-vars');
96
+ assert.equal(res.status, 200);
97
+ assert.ok(Array.isArray(res.body), 'response should be an array');
98
+ assert.equal(res.body.length, 0);
99
+ });
100
+
101
+ it('POST creates entry, GET lists it with masked value', async () => {
102
+ const create = await request(app, 'POST', '/api/env-vars', {
103
+ name: 'BIZAR_TEST_KEY',
104
+ value: 'super-secret-value-123',
105
+ });
106
+ assert.equal(create.status, 201);
107
+ assert.equal(create.body.name, 'BIZAR_TEST_KEY');
108
+ assert.ok(create.body.value.includes('*'), 'value should be masked');
109
+ assert.ok(create.body.value.endsWith('-123'), 'masked value should include last 4 chars');
110
+ assert.ok(create.body.createdAt, 'should have createdAt');
111
+
112
+ const list = await request(app, 'GET', '/api/env-vars');
113
+ assert.equal(list.status, 200);
114
+ const entry = list.body.find((e) => e.name === 'BIZAR_TEST_KEY');
115
+ assert.ok(entry, 'entry should be in list');
116
+ assert.equal(entry.value, create.body.value, 'value should be masked on list');
117
+ });
118
+
119
+ it('PUT updates entry, DELETE removes it', async () => {
120
+ await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_PUT', value: 'original' });
121
+ const update = await request(app, 'PUT', '/api/env-vars/BIZAR_TEST_PUT', { value: 'updated-value' });
122
+ assert.equal(update.status, 200);
123
+ assert.ok(update.body.value.endsWith('lue'), 'updated value should be masked');
124
+
125
+ // Verify in list
126
+ const list = await request(app, 'GET', '/api/env-vars');
127
+ const entry = list.body.find((e) => e.name === 'BIZAR_TEST_PUT');
128
+ assert.ok(entry);
129
+
130
+ // Delete
131
+ const del = await request(app, 'DELETE', '/api/env-vars/BIZAR_TEST_PUT');
132
+ assert.equal(del.status, 200);
133
+ assert.ok(del.body.ok);
134
+
135
+ // Verify gone
136
+ const listAfter = await request(app, 'GET', '/api/env-vars');
137
+ const gone = listAfter.body.find((e) => e.name === 'BIZAR_TEST_PUT');
138
+ assert.equal(gone, undefined, 'entry should be deleted');
139
+ });
140
+
141
+ it('rejects invalid name (must match /^BIZAR_[A-Z0-9_]+$/)', async () => {
142
+ const r1 = await request(app, 'POST', '/api/env-vars', { name: 'INVALID', value: 'x' });
143
+ assert.equal(r1.status, 400);
144
+ assert.ok(r1.body.error === 'invalid_name');
145
+
146
+ const r2 = await request(app, 'POST', '/api/env-vars', { name: 'bizar_lowercase', value: 'x' });
147
+ assert.equal(r2.status, 400);
148
+
149
+ const r3 = await request(app, 'POST', '/api/env-vars', { name: 'BIZAR HAS_SPACE', value: 'x' });
150
+ assert.equal(r3.status, 400);
151
+ });
152
+
153
+ it('creates env.json with mode 0600', async () => {
154
+ await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_PERM', value: 'perm-check' });
155
+ const mode = statSync(ENV_FILE).mode & 0o777;
156
+ assert.equal(mode, 0o600, `expected 0o600, got ${octal(mode)}`);
157
+ });
158
+
159
+ it('loadEnvJson sets process.env', async () => {
160
+ // Create an env var
161
+ await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_LOADENV', value: 'env-loaded-value' });
162
+
163
+ // Delete from process.env to simulate fresh start
164
+ delete process.env.BIZAR_TEST_LOADENV;
165
+
166
+ // Call loadEnvJson
167
+ const { loadEnvJson } = await import(`${REPO}/bizar-dash/src/server/routes/env-vars.mjs`);
168
+ loadEnvJson();
169
+
170
+ assert.equal(process.env.BIZAR_TEST_LOADENV, 'env-loaded-value',
171
+ 'process.env should be set after loadEnvJson');
172
+ });
173
+
174
+ it('DELETE removes from process.env', async () => {
175
+ // Create
176
+ await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_DELPROCESS', value: 'to-be-deleted' });
177
+ assert.equal(process.env.BIZAR_TEST_DELPROCESS, 'to-be-deleted');
178
+
179
+ // Delete via API
180
+ await request(app, 'DELETE', '/api/env-vars/BIZAR_TEST_DELPROCESS');
181
+
182
+ assert.equal(process.env.BIZAR_TEST_DELPROCESS, undefined,
183
+ 'process.env should be cleared after DELETE');
184
+ });
185
+
186
+ it('POST /api/env-vars/:name/test returns referenced and inProcessEnv', async () => {
187
+ // Not in process.env yet
188
+ const r1 = await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_REFS', value: 'refs-val' });
189
+ assert.equal(r1.status, 201);
190
+
191
+ const test = await request(app, 'POST', '/api/env-vars/BIZAR_TEST_REFS/test');
192
+ assert.equal(test.status, 200);
193
+ assert.ok(typeof test.body.referenced === 'boolean');
194
+ assert.ok(typeof test.body.inProcessEnv === 'boolean');
195
+ assert.equal(test.body.inProcessEnv, true, 'should be in process.env after creation');
196
+ });
197
+
198
+ it('409 on duplicate POST', async () => {
199
+ await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_DUP', value: 'first' });
200
+ const r = await request(app, 'POST', '/api/env-vars', { name: 'BIZAR_TEST_DUP', value: 'second' });
201
+ assert.equal(r.status, 409);
202
+ assert.ok(r.body.error === 'already_exists');
203
+ });
204
+
205
+ it('404 on PUT/DELETE non-existent var', async () => {
206
+ const put = await request(app, 'PUT', '/api/env-vars/BIZAR_NONEXISTENT', { value: 'x' });
207
+ assert.equal(put.status, 404);
208
+
209
+ const del = await request(app, 'DELETE', '/api/env-vars/BIZAR_NONEXISTENT');
210
+ assert.equal(del.status, 404);
211
+ });
212
+ });
213
+
214
+ function octal(n) {
215
+ return '0o' + n.toString(8);
216
+ }