@polderlabs/bizar 4.9.0 → 5.0.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 (183) hide show
  1. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js +701 -0
  2. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
  3. package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
  4. package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
  5. package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
  6. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
  7. package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DD-FZrTC.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.package-lock.json +6 -0
  19. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  20. package/bizar-dash/package-lock.json +6 -0
  21. package/bizar-dash/skills/eval/SKILL.md +237 -0
  22. package/bizar-dash/src/server/api.mjs +35 -0
  23. package/bizar-dash/src/server/auth.mjs +155 -1
  24. package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
  25. package/bizar-dash/src/server/eval-store.mjs +226 -0
  26. package/bizar-dash/src/server/eval.mjs +347 -0
  27. package/bizar-dash/src/server/memory-store.mjs +46 -0
  28. package/bizar-dash/src/server/ocr.mjs +55 -0
  29. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  30. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  31. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  32. package/bizar-dash/src/server/providers-store.mjs +11 -4
  33. package/bizar-dash/src/server/routes/_shared.mjs +2 -2
  34. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  35. package/bizar-dash/src/server/routes/doctor.mjs +71 -0
  36. package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
  37. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +3 -0
  39. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  40. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
  41. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  42. package/bizar-dash/src/server/routes/schedules.mjs +55 -0
  43. package/bizar-dash/src/server/routes/users.mjs +84 -0
  44. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  45. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  46. package/bizar-dash/src/server/serve-info.mjs +172 -0
  47. package/bizar-dash/src/server/voice-store.mjs +202 -0
  48. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  49. package/bizar-dash/src/server/workspaces.mjs +626 -0
  50. package/bizar-dash/src/web/App.tsx +48 -11
  51. package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
  52. package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
  53. package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
  54. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  55. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
  56. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  57. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  58. package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
  59. package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
  60. package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
  61. package/bizar-dash/src/web/components/Topbar.tsx +11 -0
  62. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  63. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  64. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  65. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
  66. package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
  67. package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
  68. package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
  69. package/bizar-dash/src/web/lib/types.ts +134 -0
  70. package/bizar-dash/src/web/styles/chat.css +8 -5
  71. package/bizar-dash/src/web/styles/main.css +77 -2
  72. package/bizar-dash/src/web/styles/memory.css +82 -0
  73. package/bizar-dash/src/web/styles/settings.css +265 -0
  74. package/bizar-dash/src/web/views/Chat.tsx +15 -1
  75. package/bizar-dash/src/web/views/Doctor.tsx +317 -0
  76. package/bizar-dash/src/web/views/Memory.tsx +17 -2
  77. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
  78. package/bizar-dash/src/web/views/Schedules.tsx +94 -12
  79. package/bizar-dash/src/web/views/Settings.tsx +78 -45
  80. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
  82. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  83. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  84. package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
  85. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
  86. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
  87. package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
  88. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  89. package/bizar-dash/tests/autosave.test.tsx +276 -0
  90. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
  91. package/bizar-dash/tests/chat-composer.test.tsx +140 -0
  92. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  93. package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
  94. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  95. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  96. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  97. package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
  98. package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
  99. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  100. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  101. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  102. package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
  103. package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
  104. package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
  105. package/bizar-dash/tests/minimax-models.test.mjs +123 -0
  106. package/bizar-dash/tests/ocr.test.mjs +87 -0
  107. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
  108. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  109. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  110. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  111. package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
  112. package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
  113. package/bizar-dash/tests/settings-layout.test.tsx +129 -0
  114. package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
  115. package/bizar-dash/tests/settings-nav.test.tsx +126 -0
  116. package/bizar-dash/tests/users.test.mjs +108 -0
  117. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  118. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  119. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  120. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  121. package/cli/bin.mjs +72 -2
  122. package/cli/commands/clip.mjs +146 -0
  123. package/cli/commands/deploy/cloudflare.mjs +250 -0
  124. package/cli/commands/deploy/docker.mjs +221 -0
  125. package/cli/commands/deploy/fly.mjs +161 -0
  126. package/cli/commands/deploy/vercel.mjs +225 -0
  127. package/cli/commands/deploy.mjs +240 -0
  128. package/cli/commands/eval.mjs +378 -0
  129. package/cli/commands/marketplace.mjs +64 -0
  130. package/cli/commands/minimax.mjs +5 -2
  131. package/cli/commands/ocr.mjs +165 -0
  132. package/cli/commands/plugin.mjs +358 -0
  133. package/cli/commands/voice.mjs +211 -0
  134. package/cli/commands/workspace.mjs +247 -0
  135. package/cli/tests/minimax-cli.test.mjs +79 -0
  136. package/config/agents/frigg.md +1 -1
  137. package/config/agents/heimdall.md +1 -1
  138. package/config/agents/mimir.md +1 -1
  139. package/config/agents/quick.md +1 -1
  140. package/config/agents/semble-search.md +1 -1
  141. package/config/agents/vor.md +1 -1
  142. package/config/opencode.json +28 -21
  143. package/config/opencode.json.template +8 -1
  144. package/package.json +12 -8
  145. package/plugins/bizar/index.ts +77 -0
  146. package/plugins/bizar/src/compaction.d.mts +48 -0
  147. package/plugins/bizar/src/compaction.mjs +192 -0
  148. package/plugins/bizar/tests/compaction.test.ts +264 -0
  149. package/templates/deploy/cloudflare/README.md +32 -0
  150. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  151. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  152. package/templates/deploy/docker/.env.template +16 -0
  153. package/templates/deploy/docker/README.md +58 -0
  154. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  155. package/templates/deploy/fly/README.md +35 -0
  156. package/templates/deploy/fly/fly.toml.template +28 -0
  157. package/templates/deploy/vercel/README.md +29 -0
  158. package/templates/deploy/vercel/api-index.template.js +18 -0
  159. package/templates/deploy/vercel/vercel.json.template +16 -0
  160. package/templates/eval-fixtures/README.md +58 -0
  161. package/templates/eval-fixtures/code-search-basic.json +28 -0
  162. package/templates/eval-fixtures/latency-bounds.json +16 -0
  163. package/templates/eval-fixtures/regression-suite.json +79 -0
  164. package/templates/eval-fixtures/response-format.json +30 -0
  165. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  166. package/templates/plugin-template/README.md +121 -0
  167. package/templates/plugin-template/index.js +66 -0
  168. package/templates/plugin-template/plugin.json +42 -0
  169. package/templates/plugin-template/tests/plugin.test.js +83 -0
  170. package/templates/schedules/daily-backup.json +12 -0
  171. package/templates/schedules/daily-cleanup.json +12 -0
  172. package/templates/schedules/hourly-health-check.json +12 -0
  173. package/templates/schedules/webhook-on-push.json +13 -0
  174. package/templates/schedules/weekly-digest.json +13 -0
  175. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
  176. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
  177. package/bizar-dash/dist/assets/main-DfmIfOUS.css +0 -1
  178. package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
  179. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
  180. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
  181. package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
  182. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
  183. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
@@ -0,0 +1,455 @@
1
+ /**
2
+ * tests/plugins-store.test.mjs
3
+ *
4
+ * Tests for src/server/plugins/store.mjs:
5
+ * - listInstalled is empty before any install
6
+ * - installPlugin downloads, verifies checksum, extracts, registers
7
+ * - installPlugin refuses to reinstall without force
8
+ * - installPlugin rejects a checksum mismatch
9
+ * - installPlugin rejects a tarball with no plugin.json
10
+ * - uninstallPlugin removes disk + installed.json
11
+ * - updatePlugin preserves config across the version bump
12
+ * - invokePlugin runs an installed plugin in the sandbox
13
+ * - replaceConfig persists a new config
14
+ *
15
+ * Strategy: stand up a fake HTTP server that serves both a registry
16
+ * JSON and a tarball on demand. Build the tarball in-memory using
17
+ * node:tar's `tar.c` (create + gzip). Point the client at the server
18
+ * with a fetch override + BIZAR_PLUGIN_HOME tmpdir.
19
+ */
20
+ import { test, describe, before, after, beforeEach } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import http from 'node:http';
23
+ import {
24
+ mkdirSync,
25
+ rmSync,
26
+ writeFileSync,
27
+ readFileSync,
28
+ existsSync,
29
+ statSync,
30
+ } from 'node:fs';
31
+ import { join, resolve, dirname } from 'node:path';
32
+ import { tmpdir } from 'node:os';
33
+ import { createHash } from 'node:crypto';
34
+ import { createGzip } from 'node:zlib';
35
+ import { pack as tarPack } from 'tar-stream';
36
+ import { fileURLToPath } from 'node:url';
37
+
38
+ const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..');
39
+
40
+ // ── Sandbox HOME + plugin home BEFORE importing the store ────────────────
41
+ // The store reads BIZAR_PLUGIN_HOME at import time, so set it first.
42
+ const SANDBOX_HOME = join(tmpdir(), `bizar-store-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
43
+ process.env.HOME = SANDBOX_HOME;
44
+ process.env.BIZAR_PLUGIN_HOME = join(SANDBOX_HOME, 'plugins');
45
+ mkdirSync(SANDBOX_HOME, { recursive: true });
46
+ mkdirSync(process.env.BIZAR_PLUGIN_HOME, { recursive: true });
47
+
48
+ const STORE = await import(resolve(REPO, 'src/server/plugins/store.mjs'));
49
+ const REG = await import(resolve(REPO, 'src/server/plugins/registry.mjs'));
50
+
51
+ function sha256OfBuffer(buf) {
52
+ return 'sha256:' + createHash('sha256').update(buf).digest('hex');
53
+ }
54
+
55
+ /**
56
+ * Build a gzip-compressed tarball in memory. The contents are a
57
+ * synthetic plugin: { plugin.json, index.js }.
58
+ *
59
+ * @param {object} opts
60
+ * @param {string} opts.id
61
+ * @param {string} opts.version
62
+ * @param {string} [opts.mainSource] source code for index.js
63
+ * @param {string[]} [opts.permissions]
64
+ * @param {string} [opts.wrapper] 'flat' (default) | 'topdir' (one top-level dir)
65
+ */
66
+ async function buildTarball(opts) {
67
+ const { id, version, mainSource, permissions = [], wrapper = 'flat' } = opts;
68
+ const tmpDir = join(SANDBOX_HOME, 'tarball-src', id);
69
+ rmSync(tmpDir, { recursive: true, force: true });
70
+ mkdirSync(tmpDir, { recursive: true });
71
+
72
+ const manifest = {
73
+ id,
74
+ name: `Test ${id}`,
75
+ version,
76
+ description: `Synthetic test plugin ${id}@${version}`,
77
+ permissions,
78
+ main: 'index.js',
79
+ exports: [
80
+ { name: 'whoami', args: [], returns: 'string' },
81
+ { name: 'echo', args: [{ name: 'msg', type: 'string' }], returns: 'string' },
82
+ { name: 'init', args: [], returns: 'void' },
83
+ ],
84
+ };
85
+ const indexSrc = mainSource || `
86
+ module.exports = {
87
+ async whoami() { return '${id}@${version}'; },
88
+ async echo(msg) { return 'echo:' + msg; },
89
+ async init() { /* no-op */ },
90
+ };
91
+ `;
92
+ writeFileSync(join(tmpDir, 'plugin.json'), JSON.stringify(manifest, null, 2));
93
+ writeFileSync(join(tmpDir, 'index.js'), indexSrc);
94
+
95
+ // Build the tarball as a Buffer. tar-stream's `pack()` is a
96
+ // Transform stream: we collect bytes by piping through a PassThrough
97
+ // (or directly collecting chunks after attach a data listener).
98
+ // The trick is to attach the 'data' listener BEFORE finalize()
99
+ // so the stream can flow.
100
+ const tarBytes = await new Promise((res, rej) => {
101
+ const packer = tarPack();
102
+ const chunks = [];
103
+ packer.on('data', (c) => chunks.push(c));
104
+ packer.on('end', () => res(Buffer.concat(chunks)));
105
+ packer.on('error', rej);
106
+ const pluginJsonBuf = readFileSync(join(tmpDir, 'plugin.json'));
107
+ const indexBuf = readFileSync(join(tmpDir, 'index.js'));
108
+ if (wrapper === 'topdir') {
109
+ packer.entry({ name: `${id}/plugin.json`, size: pluginJsonBuf.length }, pluginJsonBuf);
110
+ packer.entry({ name: `${id}/index.js`, size: indexBuf.length }, indexBuf);
111
+ } else {
112
+ packer.entry({ name: 'plugin.json', size: pluginJsonBuf.length }, pluginJsonBuf);
113
+ packer.entry({ name: 'index.js', size: indexBuf.length }, indexBuf);
114
+ }
115
+ packer.finalize();
116
+ });
117
+
118
+ // Gzip-compress.
119
+ return new Promise((res, rej) => {
120
+ const gzChunks = [];
121
+ const gz = createGzip();
122
+ gz.on('data', (c) => gzChunks.push(c));
123
+ gz.on('end', () => res(Buffer.concat(gzChunks)));
124
+ gz.on('error', rej);
125
+ gz.end(tarBytes);
126
+ });
127
+ }
128
+
129
+ // ── Fake registry server ──────────────────────────────────────────────────
130
+
131
+ let server;
132
+ let serverUrl;
133
+ let servedTarballs = {}; // id -> { buf, version }
134
+ let servedRegistry = { version: 1, updatedAt: 'x', plugins: [] };
135
+
136
+ function rebuildRegistry() {
137
+ servedRegistry = {
138
+ version: 1,
139
+ updatedAt: new Date().toISOString(),
140
+ plugins: Object.entries(servedTarballs).map(([id, { buf, version }]) => ({
141
+ id,
142
+ name: `Test ${id}`,
143
+ version,
144
+ description: '...',
145
+ tarball: `${serverUrl}/tarball/${id}`,
146
+ checksum: sha256OfBuffer(buf),
147
+ permissions: [],
148
+ })),
149
+ };
150
+ }
151
+
152
+ async function startServer() {
153
+ return new Promise((r) => {
154
+ const s = http.createServer((req, res) => {
155
+ if (req.url === '/registry.json') {
156
+ res.writeHead(200, { 'Content-Type': 'application/json' });
157
+ res.end(JSON.stringify(servedRegistry));
158
+ return;
159
+ }
160
+ const m = /^\/tarball\/([a-z0-9-]+)$/.exec(req.url);
161
+ if (m) {
162
+ const id = m[1];
163
+ const entry = servedTarballs[id];
164
+ if (!entry) {
165
+ res.writeHead(404);
166
+ res.end();
167
+ return;
168
+ }
169
+ res.writeHead(200, { 'Content-Type': 'application/gzip' });
170
+ res.end(entry.buf);
171
+ return;
172
+ }
173
+ res.writeHead(404);
174
+ res.end();
175
+ });
176
+ s.listen(0, '127.0.0.1', () => {
177
+ const { port } = s.address();
178
+ r({
179
+ url: `http://127.0.0.1:${port}`,
180
+ close: () => new Promise((rr) => s.close(rr)),
181
+ });
182
+ });
183
+ });
184
+ }
185
+
186
+ before(async () => {
187
+ server = await startServer();
188
+ serverUrl = server.url;
189
+ });
190
+
191
+ after(async () => {
192
+ if (server) await server.close();
193
+ rmSync(SANDBOX_HOME, { recursive: true, force: true });
194
+ });
195
+
196
+ beforeEach(() => {
197
+ // Wipe installed state between tests so each starts clean.
198
+ rmSync(process.env.BIZAR_PLUGIN_HOME, { recursive: true, force: true });
199
+ mkdirSync(process.env.BIZAR_PLUGIN_HOME, { recursive: true });
200
+ servedTarballs = {};
201
+ REG.__resetCache();
202
+ });
203
+
204
+ // ── listInstalled / readInstalled ────────────────────────────────────────
205
+
206
+ describe('listInstalled', () => {
207
+ test('returns [] before any install', () => {
208
+ assert.deepEqual(STORE.listInstalled(), []);
209
+ });
210
+
211
+ test('reads installed.json even if empty', () => {
212
+ writeFileSync(
213
+ join(process.env.BIZAR_PLUGIN_HOME, 'installed.json'),
214
+ JSON.stringify({ plugins: [] }),
215
+ );
216
+ assert.deepEqual(STORE.listInstalled(), []);
217
+ });
218
+
219
+ test('returns [] for a malformed installed.json', () => {
220
+ writeFileSync(
221
+ join(process.env.BIZAR_PLUGIN_HOME, 'installed.json'),
222
+ 'not json',
223
+ );
224
+ assert.deepEqual(STORE.listInstalled(), []);
225
+ });
226
+ });
227
+
228
+ // ── installPlugin happy path ─────────────────────────────────────────────
229
+
230
+ /**
231
+ * Convenience: pass the fake registry URL through to installPlugin
232
+ * so every call below doesn't need to spell it out.
233
+ */
234
+ const registryUrl = () => `${serverUrl}/registry.json`;
235
+
236
+ describe('installPlugin', () => {
237
+ test('installs a plugin from the registry (flat tarball)', async () => {
238
+ servedTarballs['hello'] = { buf: await buildTarball({ id: 'hello', version: '1.0.0' }), version: '1.0.0' };
239
+ rebuildRegistry();
240
+
241
+ const r = await STORE.installPlugin('hello', { url: registryUrl() });
242
+ assert.equal(r.id, 'hello');
243
+ assert.equal(r.version, '1.0.0');
244
+ assert.ok(r.installedAt);
245
+ assert.ok(r.path);
246
+ assert.ok(existsSync(join(r.path, 'plugin.json')));
247
+ assert.ok(existsSync(join(r.path, 'index.js')));
248
+
249
+ // installed.json records the plugin.
250
+ const listed = STORE.listInstalled();
251
+ assert.equal(listed.length, 1);
252
+ assert.equal(listed[0].id, 'hello');
253
+ });
254
+
255
+ test('installs a plugin from the registry (top-level dir wrapper)', async () => {
256
+ servedTarballs['wrapped'] = {
257
+ buf: await buildTarball({ id: 'wrapped', version: '1.0.0', wrapper: 'topdir' }),
258
+ version: '1.0.0',
259
+ };
260
+ rebuildRegistry();
261
+
262
+ const r = await STORE.installPlugin('wrapped', { url: registryUrl() });
263
+ assert.ok(existsSync(join(r.path, 'plugin.json')));
264
+ });
265
+
266
+ test('refuses to reinstall without force', async () => {
267
+ servedTarballs['dup'] = { buf: await buildTarball({ id: 'dup', version: '1.0.0' }), version: '1.0.0' };
268
+ rebuildRegistry();
269
+ await STORE.installPlugin('dup', { url: registryUrl() });
270
+ await assert.rejects(
271
+ () => STORE.installPlugin('dup', { url: registryUrl() }),
272
+ (err) => err.code === 'already_installed',
273
+ );
274
+ });
275
+
276
+ test('force=true reinstalls', async () => {
277
+ servedTarballs['forced'] = { buf: await buildTarball({ id: 'forced', version: '1.0.0' }), version: '1.0.0' };
278
+ rebuildRegistry();
279
+ await STORE.installPlugin('forced', { url: registryUrl() });
280
+ const r2 = await STORE.installPlugin('forced', { force: true, url: registryUrl() });
281
+ assert.equal(r2.id, 'forced');
282
+ });
283
+
284
+ test('rejects a checksum mismatch', async () => {
285
+ const buf = await buildTarball({ id: 'evil', version: '1.0.0' });
286
+ servedTarballs['evil'] = { buf, version: '1.0.0' };
287
+ // Build the registry with a WRONG checksum.
288
+ servedRegistry = {
289
+ version: 1,
290
+ updatedAt: new Date().toISOString(),
291
+ plugins: [{
292
+ id: 'evil', name: 'Evil', version: '1.0.0',
293
+ tarball: `${serverUrl}/tarball/evil`,
294
+ checksum: 'sha256:' + '0'.repeat(64),
295
+ permissions: [],
296
+ }],
297
+ };
298
+ REG.__resetCache();
299
+ await assert.rejects(
300
+ () => STORE.installPlugin('evil', { url: registryUrl() }),
301
+ (err) => err.code === 'checksum_mismatch',
302
+ );
303
+ // No plugin should be on disk after a failed install.
304
+ assert.equal(existsSync(join(process.env.BIZAR_PLUGIN_HOME, 'evil')), false);
305
+ });
306
+
307
+ test('rejects a tarball with no plugin.json', async () => {
308
+ // Build a tarball that only contains a junk file.
309
+ const tmpDir = join(SANDBOX_HOME, 'no-manifest');
310
+ rmSync(tmpDir, { recursive: true, force: true });
311
+ mkdirSync(tmpDir, { recursive: true });
312
+ writeFileSync(join(tmpDir, 'README.md'), 'no manifest here');
313
+ const tarBytes = await new Promise((res, rej) => {
314
+ const packer = tarPack();
315
+ const chunks = [];
316
+ packer.on('data', (c) => chunks.push(c));
317
+ packer.on('end', () => res(Buffer.concat(chunks)));
318
+ packer.on('error', rej);
319
+ const readmeBuf = readFileSync(join(tmpDir, 'README.md'));
320
+ packer.entry({ name: 'README.md', size: readmeBuf.length }, readmeBuf);
321
+ packer.finalize();
322
+ });
323
+ const gzBuf = await new Promise((res, rej) => {
324
+ const gz = createGzip();
325
+ const out = [];
326
+ gz.on('data', (c) => out.push(c));
327
+ gz.on('end', () => res(Buffer.concat(out)));
328
+ gz.on('error', rej);
329
+ gz.end(tarBytes);
330
+ });
331
+ servedTarballs['noman'] = { buf: gzBuf, version: '1.0.0' };
332
+ rebuildRegistry();
333
+
334
+ await assert.rejects(
335
+ () => STORE.installPlugin('noman', { url: registryUrl() }),
336
+ /contains no plugin\.json/,
337
+ );
338
+ });
339
+
340
+ test('throws not_found for an unknown id', async () => {
341
+ rebuildRegistry();
342
+ await assert.rejects(
343
+ () => STORE.installPlugin('never', { url: registryUrl() }),
344
+ (err) => err.code === 'not_found',
345
+ );
346
+ });
347
+ });
348
+
349
+ // ── uninstallPlugin ──────────────────────────────────────────────────────
350
+
351
+ describe('uninstallPlugin', () => {
352
+ test('removes disk + installed.json entry', async () => {
353
+ servedTarballs['bye'] = { buf: await buildTarball({ id: 'bye', version: '1.0.0' }), version: '1.0.0' };
354
+ rebuildRegistry();
355
+ await STORE.installPlugin('bye', { url: registryUrl() });
356
+ const dir = join(process.env.BIZAR_PLUGIN_HOME, 'bye');
357
+ assert.ok(existsSync(dir));
358
+
359
+ const ok = STORE.uninstallPlugin('bye');
360
+ assert.equal(ok, true);
361
+ assert.equal(existsSync(dir), false);
362
+ assert.equal(STORE.listInstalled().length, 0);
363
+ });
364
+
365
+ test('returns false for a not-installed id', () => {
366
+ assert.equal(STORE.uninstallPlugin('never'), false);
367
+ });
368
+ });
369
+
370
+ // ── updatePlugin ─────────────────────────────────────────────────────────
371
+
372
+ describe('updatePlugin', () => {
373
+ test('upgrades to the latest registry version, preserving config', async () => {
374
+ servedTarballs['updatable'] = { buf: await buildTarball({ id: 'updatable', version: '1.0.0' }), version: '1.0.0' };
375
+ rebuildRegistry();
376
+ await STORE.installPlugin('updatable', { url: registryUrl() });
377
+ STORE.replaceConfig('updatable', { apiKey: 'sk-test-123', enabled: true });
378
+
379
+ // Bump the registry to 2.0.0 with a fresh tarball.
380
+ servedTarballs['updatable'] = { buf: await buildTarball({ id: 'updatable', version: '2.0.0' }), version: '2.0.0' };
381
+ rebuildRegistry();
382
+ // Reset the in-memory cache so the next installPlugin() actually
383
+ // re-fetches and sees the new checksum.
384
+ REG.__resetCache();
385
+
386
+ const r = await STORE.updatePlugin('updatable', { url: registryUrl() });
387
+ assert.equal(r.from, '1.0.0');
388
+ assert.equal(r.to, '2.0.0');
389
+ // Config survived the update.
390
+ const installed = STORE.getInstalled('updatable');
391
+ assert.equal(installed.config.apiKey, 'sk-test-123');
392
+ assert.equal(installed.config.enabled, true);
393
+ });
394
+
395
+ test('throws not_installed for a missing plugin', async () => {
396
+ await assert.rejects(
397
+ () => STORE.updatePlugin('never', { url: registryUrl() }),
398
+ (err) => err.code === 'not_installed',
399
+ );
400
+ });
401
+ });
402
+
403
+ // ── invokePlugin ─────────────────────────────────────────────────────────
404
+
405
+ describe('invokePlugin', () => {
406
+ test('runs a method on the installed plugin', async () => {
407
+ servedTarballs['runner'] = { buf: await buildTarball({ id: 'runner', version: '1.0.0' }), version: '1.0.0' };
408
+ rebuildRegistry();
409
+ await STORE.installPlugin('runner', { url: registryUrl() });
410
+
411
+ const r = await STORE.invokePlugin('runner', 'whoami', []);
412
+ assert.equal(r.ok, true);
413
+ assert.equal(r.result, 'runner@1.0.0');
414
+
415
+ const r2 = await STORE.invokePlugin('runner', 'echo', ['hi']);
416
+ assert.equal(r2.ok, true);
417
+ assert.equal(r2.result, 'echo:hi');
418
+ });
419
+
420
+ test('returns not_installed for a missing plugin', async () => {
421
+ const r = await STORE.invokePlugin('never', 'echo', []);
422
+ assert.equal(r.ok, false);
423
+ assert.equal(r.code, 'not_installed');
424
+ });
425
+
426
+ test('returns no_such_method for an unknown method', async () => {
427
+ servedTarballs['runs2'] = { buf: await buildTarball({ id: 'runs2', version: '1.0.0' }), version: '1.0.0' };
428
+ rebuildRegistry();
429
+ await STORE.installPlugin('runs2', { url: registryUrl() });
430
+ const r = await STORE.invokePlugin('runs2', 'imaginary', []);
431
+ assert.equal(r.ok, false);
432
+ assert.equal(r.code, 'no_such_method');
433
+ });
434
+ });
435
+
436
+ // ── replaceConfig ────────────────────────────────────────────────────────
437
+
438
+ describe('replaceConfig', () => {
439
+ test('replaces the entire config object', async () => {
440
+ servedTarballs['cfg'] = { buf: await buildTarball({ id: 'cfg', version: '1.0.0' }), version: '1.0.0' };
441
+ rebuildRegistry();
442
+ await STORE.installPlugin('cfg', { url: registryUrl() });
443
+ STORE.replaceConfig('cfg', { apiKey: 'a', region: 'us-east-1' });
444
+ const installed = STORE.getInstalled('cfg');
445
+ assert.equal(installed.config.apiKey, 'a');
446
+ assert.equal(installed.config.region, 'us-east-1');
447
+ });
448
+
449
+ test('rejects non-object config', () => {
450
+ assert.throws(
451
+ () => STORE.replaceConfig('cfg', 'not an object'),
452
+ /must be a plain object/,
453
+ );
454
+ });
455
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * tests/routes-doctor.test.mjs
3
+ *
4
+ * v6.0.0 — Tests for the Doctor REST surface:
5
+ *
6
+ * GET /api/doctor — full snapshot
7
+ * GET /api/doctor/health — rolled-up status + groups
8
+ * POST /api/doctor/check — single-check dispatch
9
+ *
10
+ * We stand up a minimal express server backed by the real
11
+ * `routes/doctor.mjs` factory. The store reads `~/.config/bizar/`
12
+ * on the host, so the tests must not require a pristine install —
13
+ * any I/O failure path is handled by the store (returns [] / 'warn')
14
+ * and the test assertions stay loose enough to pass on dev machines
15
+ * AND on CI with no bizar installed.
16
+ */
17
+ import { describe, it, before, after } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import express from 'express';
20
+
21
+ const DOCTOR_ROUTES = await import('../src/server/routes/doctor.mjs');
22
+
23
+ let server;
24
+ let baseUrl;
25
+
26
+ before(async () => {
27
+ const router = DOCTOR_ROUTES.createDoctorRouter();
28
+ const app = express();
29
+ app.use(express.json({ limit: '1mb' }));
30
+ app.use('/api/doctor', router);
31
+ await new Promise((resolve) => {
32
+ server = app.listen(0, () => {
33
+ const addr = server.address();
34
+ baseUrl = `http://127.0.0.1:${addr.port}`;
35
+ resolve();
36
+ });
37
+ });
38
+ });
39
+
40
+ after(() => {
41
+ try { server?.close?.(); } catch { /* ignore */ }
42
+ });
43
+
44
+ // ── GET /api/doctor ────────────────────────────────────────────────────────
45
+
46
+ describe('GET /api/doctor', () => {
47
+ it('returns 200 + a snapshot with the documented top-level keys', async () => {
48
+ const r = await fetch(`${baseUrl}/api/doctor/`);
49
+ assert.strictEqual(r.status, 200);
50
+ const body = await r.json();
51
+ for (const key of [
52
+ 'timestamp', 'bizarVersion', 'nodeVersion', 'platform', 'arch',
53
+ 'uptime', 'memory', 'disk', 'services', 'counts',
54
+ 'recentErrors', 'configHealth', 'opencode', 'checks', 'health',
55
+ ]) {
56
+ assert.ok(key in body, `expected ${key} in snapshot`);
57
+ }
58
+ assert.strictEqual(body.nodeVersion, process.version);
59
+ assert.ok(['ok', 'warn', 'fail'].includes(body.health.status));
60
+ });
61
+
62
+ it('counts include all expected numeric fields', async () => {
63
+ const r = await fetch(`${baseUrl}/api/doctor/`);
64
+ const body = await r.json();
65
+ for (const key of [
66
+ 'tasks', 'schedules', 'mods', 'providers', 'mcps', 'agents',
67
+ 'projects', 'workspaces', 'voiceNotes', 'evalRuns', 'backups',
68
+ ]) {
69
+ assert.strictEqual(typeof body.counts[key], 'number', `counts.${key}`);
70
+ }
71
+ });
72
+
73
+ it('health.issues is an array', async () => {
74
+ const r = await fetch(`${baseUrl}/api/doctor/`);
75
+ const body = await r.json();
76
+ assert.ok(Array.isArray(body.health.issues));
77
+ });
78
+ });
79
+
80
+ // ── GET /api/doctor/health ─────────────────────────────────────────────────
81
+
82
+ describe('GET /api/doctor/health', () => {
83
+ it('returns 200 + rolled-up health with groups', async () => {
84
+ const r = await fetch(`${baseUrl}/api/doctor/health`);
85
+ assert.strictEqual(r.status, 200);
86
+ const body = await r.json();
87
+ assert.strictEqual(typeof body.ts, 'string');
88
+ assert.ok(['ok', 'warn', 'fail'].includes(body.status));
89
+ assert.ok(Array.isArray(body.issues));
90
+ assert.ok(body.groups);
91
+ assert.ok(Array.isArray(body.groups.system));
92
+ assert.ok(Array.isArray(body.groups.config));
93
+ assert.ok(Array.isArray(body.groups.services));
94
+ });
95
+
96
+ it('derives status from issues', async () => {
97
+ const r = await fetch(`${baseUrl}/api/doctor/health`);
98
+ const body = await r.json();
99
+ const has = (s) => body.issues.some((i) => i.status === s);
100
+ if (has('fail')) assert.strictEqual(body.status, 'fail');
101
+ else if (has('warn')) assert.strictEqual(body.status, 'warn');
102
+ else assert.strictEqual(body.status, 'ok');
103
+ });
104
+ });
105
+
106
+ // ── POST /api/doctor/check ─────────────────────────────────────────────────
107
+
108
+ describe('POST /api/doctor/check', () => {
109
+ it('returns 200 + the check for a known name', async () => {
110
+ const r = await fetch(`${baseUrl}/api/doctor/check`, {
111
+ method: 'POST',
112
+ headers: { 'content-type': 'application/json' },
113
+ body: JSON.stringify({ checkName: 'node' }),
114
+ });
115
+ assert.strictEqual(r.status, 200);
116
+ const body = await r.json();
117
+ assert.strictEqual(body.name, 'node');
118
+ assert.ok(['ok', 'warn', 'fail'].includes(body.status));
119
+ assert.strictEqual(typeof body.message, 'string');
120
+ });
121
+
122
+ it('returns 404 for an unknown check name', async () => {
123
+ const r = await fetch(`${baseUrl}/api/doctor/check`, {
124
+ method: 'POST',
125
+ headers: { 'content-type': 'application/json' },
126
+ body: JSON.stringify({ checkName: 'definitely-not-a-real-check' }),
127
+ });
128
+ assert.strictEqual(r.status, 404);
129
+ const body = await r.json();
130
+ assert.strictEqual(body.status, 'fail');
131
+ assert.strictEqual(body.message, 'unknown check');
132
+ assert.match(body.error, /Available: /);
133
+ });
134
+
135
+ it('returns 400 when checkName is missing', async () => {
136
+ const r = await fetch(`${baseUrl}/api/doctor/check`, {
137
+ method: 'POST',
138
+ headers: { 'content-type': 'application/json' },
139
+ body: JSON.stringify({}),
140
+ });
141
+ assert.strictEqual(r.status, 400);
142
+ const body = await r.json();
143
+ assert.strictEqual(body.status, 'fail');
144
+ assert.match(body.message, /checkName required/);
145
+ });
146
+
147
+ it('accepts multiple known check names', async () => {
148
+ for (const checkName of ['memory', 'opencode-config', 'dashboard']) {
149
+ const r = await fetch(`${baseUrl}/api/doctor/check`, {
150
+ method: 'POST',
151
+ headers: { 'content-type': 'application/json' },
152
+ body: JSON.stringify({ checkName }),
153
+ });
154
+ assert.strictEqual(r.status, 200);
155
+ const body = await r.json();
156
+ assert.strictEqual(body.name, checkName);
157
+ }
158
+ });
159
+ });