@polderlabs/bizar 4.8.0 → 5.0.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.
Files changed (129) hide show
  1. package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
  2. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
  3. package/bizar-dash/dist/assets/index-DmpSFPJY.js +9 -0
  4. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
  6. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-ZAfGKENE.css} +1 -1
  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-C2gysFOZ.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-DHXXbn1A.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/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/skills/eval/SKILL.md +237 -0
  20. package/bizar-dash/src/server/api.mjs +28 -0
  21. package/bizar-dash/src/server/auth.mjs +155 -1
  22. package/bizar-dash/src/server/eval-store.mjs +226 -0
  23. package/bizar-dash/src/server/eval.mjs +347 -0
  24. package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
  25. package/bizar-dash/src/server/memory-store.mjs +121 -0
  26. package/bizar-dash/src/server/ocr.mjs +55 -0
  27. package/bizar-dash/src/server/otel.mjs +133 -0
  28. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  29. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  30. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  31. package/bizar-dash/src/server/routes/chat.mjs +246 -170
  32. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  33. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  34. package/bizar-dash/src/server/routes/memory.mjs +46 -0
  35. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  36. package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
  37. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  38. package/bizar-dash/src/server/routes/users.mjs +84 -0
  39. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  40. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  41. package/bizar-dash/src/server/server.mjs +40 -0
  42. package/bizar-dash/src/server/voice-store.mjs +202 -0
  43. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  44. package/bizar-dash/src/server/workspaces.mjs +626 -0
  45. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  46. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  47. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  48. package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
  49. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  50. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  51. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  52. package/bizar-dash/src/web/lib/search.ts +115 -0
  53. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
  54. package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
  55. package/bizar-dash/src/web/styles/memory.css +166 -1
  56. package/bizar-dash/src/web/styles/settings.css +80 -0
  57. package/bizar-dash/src/web/views/Memory.tsx +22 -2
  58. package/bizar-dash/src/web/views/Settings.tsx +99 -0
  59. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  60. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  61. package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
  62. package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
  63. package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
  64. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  65. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  66. package/bizar-dash/tests/bundle-analysis.test.mjs +73 -0
  67. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  68. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  69. package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
  70. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  71. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  72. package/bizar-dash/tests/docker-build.test.mjs +96 -0
  73. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  74. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  75. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  76. package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
  77. package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
  78. package/bizar-dash/tests/memory-graph.test.mjs +95 -0
  79. package/bizar-dash/tests/ocr.test.mjs +87 -0
  80. package/bizar-dash/tests/otel.test.mjs +188 -0
  81. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  82. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  83. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  84. package/bizar-dash/tests/users.test.mjs +108 -0
  85. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  86. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  87. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  88. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  89. package/cli/bin.mjs +72 -2
  90. package/cli/commands/clip.mjs +146 -0
  91. package/cli/commands/dash.mjs +6 -0
  92. package/cli/commands/deploy/cloudflare.mjs +250 -0
  93. package/cli/commands/deploy/docker.mjs +221 -0
  94. package/cli/commands/deploy/fly.mjs +161 -0
  95. package/cli/commands/deploy/vercel.mjs +225 -0
  96. package/cli/commands/deploy.mjs +240 -0
  97. package/cli/commands/eval.mjs +378 -0
  98. package/cli/commands/marketplace.mjs +64 -0
  99. package/cli/commands/ocr.mjs +165 -0
  100. package/cli/commands/plugin.mjs +358 -0
  101. package/cli/commands/voice.mjs +211 -0
  102. package/cli/commands/workspace.mjs +247 -0
  103. package/package.json +12 -2
  104. package/templates/deploy/cloudflare/README.md +32 -0
  105. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  106. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  107. package/templates/deploy/docker/.env.template +16 -0
  108. package/templates/deploy/docker/README.md +58 -0
  109. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  110. package/templates/deploy/fly/README.md +35 -0
  111. package/templates/deploy/fly/fly.toml.template +28 -0
  112. package/templates/deploy/vercel/README.md +29 -0
  113. package/templates/deploy/vercel/api-index.template.js +18 -0
  114. package/templates/deploy/vercel/vercel.json.template +16 -0
  115. package/templates/eval-fixtures/README.md +58 -0
  116. package/templates/eval-fixtures/code-search-basic.json +28 -0
  117. package/templates/eval-fixtures/latency-bounds.json +16 -0
  118. package/templates/eval-fixtures/regression-suite.json +79 -0
  119. package/templates/eval-fixtures/response-format.json +30 -0
  120. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  121. package/templates/plugin-template/README.md +121 -0
  122. package/templates/plugin-template/index.js +66 -0
  123. package/templates/plugin-template/plugin.json +42 -0
  124. package/templates/plugin-template/tests/plugin.test.js +83 -0
  125. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
  126. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
  127. package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
  128. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
  129. package/bizar-dash/dist/assets/mobile-Chvf9u_B.js +0 -1
@@ -0,0 +1,659 @@
1
+ /**
2
+ * src/server/plugins/store.mjs
3
+ *
4
+ * v5.0.0 — Plugin store: install / uninstall / list / update / invoke.
5
+ *
6
+ * Plugins live under `~/.local/share/bizar/plugins/<id>/` by default.
7
+ * Override with the `BIZAR_PLUGIN_HOME` env var (used by tests).
8
+ *
9
+ * Disk layout:
10
+ * $PLUGIN_HOME/
11
+ * installed.json # registry of every installed plugin
12
+ * <id>/
13
+ * plugin.json # the plugin's manifest (from tarball)
14
+ * index.js # the plugin's main entry (from tarball)
15
+ * ... # whatever else the tarball shipped
16
+ *
17
+ * `installed.json` shape:
18
+ * {
19
+ * "plugins": [
20
+ * {
21
+ * "id": "vercel-deploy",
22
+ * "version": "1.0.0",
23
+ * "installedAt": "2026-...",
24
+ * "updatedAt": "2026-...",
25
+ * "config": { "apiKey": "..." },
26
+ * "permissions": ["net"]
27
+ * }
28
+ * ]
29
+ * }
30
+ *
31
+ * Install flow:
32
+ * 1. Look up plugin in the registry.
33
+ * 2. Download tarball (stream to a tmp file).
34
+ * 3. Verify SHA-256 matches registry.checksum.
35
+ * 4. Extract into a staging dir.
36
+ * 5. Validate plugin.json (must declare id+name+version+main).
37
+ * 6. Atomically move into $PLUGIN_HOME/<id>/.
38
+ * 7. Register in installed.json (merging any existing config).
39
+ * 8. Smoke-test: load the plugin in the sandbox and call `init` if
40
+ * exported. Failures here don't roll back the install — they're
41
+ * logged so the operator sees them.
42
+ */
43
+ import {
44
+ existsSync,
45
+ mkdirSync,
46
+ readFileSync,
47
+ writeFileSync,
48
+ rmSync,
49
+ renameSync,
50
+ createWriteStream,
51
+ createReadStream,
52
+ readdirSync,
53
+ } from 'node:fs';
54
+ import { tmpdir } from 'node:os';
55
+ import { join, dirname, resolve as pathResolve } from 'node:path';
56
+ import { createHash, randomUUID } from 'node:crypto';
57
+ import { pipeline } from 'node:stream/promises';
58
+ import { createGzip, createGunzip } from 'node:zlib';
59
+ import { extract as tarExtract, pack as tarPack } from 'tar-stream';
60
+
61
+ import * as registry from './registry.mjs';
62
+ import { loadPlugin, readManifest, safeInvoke } from './sandbox.mjs';
63
+ import * as logger from '../logger.mjs';
64
+
65
+ /**
66
+ * Resolve the plugin home directory. Tests set BIZAR_PLUGIN_HOME
67
+ * to a tmpdir before importing this module.
68
+ */
69
+ export function getPluginHome() {
70
+ if (process.env.BIZAR_PLUGIN_HOME && process.env.BIZAR_PLUGIN_HOME.trim()) {
71
+ return process.env.BIZAR_PLUGIN_HOME.trim();
72
+ }
73
+ const home = process.env.HOME || '/tmp';
74
+ const xdgData = process.env.XDG_DATA_HOME;
75
+ const base = xdgData && xdgData.trim()
76
+ ? join(xdgData, 'bizar', 'plugins')
77
+ : join(home, '.local', 'share', 'bizar', 'plugins');
78
+ return base;
79
+ }
80
+
81
+ /** Path to installed.json (the plugin registry on this machine). */
82
+ export function getInstalledJsonPath() {
83
+ return join(getPluginHome(), 'installed.json');
84
+ }
85
+
86
+ /** Path to a specific plugin's install dir. */
87
+ export function getPluginDir(id) {
88
+ return join(getPluginHome(), id);
89
+ }
90
+
91
+ /**
92
+ * Atomic write: serialize `data` to `filePath` via a temp + rename.
93
+ * Prevents half-written JSON from corrupting installed.json if the
94
+ * process dies mid-write.
95
+ *
96
+ * @param {string} filePath
97
+ * @param {unknown} data
98
+ */
99
+ function atomicWriteJson(filePath, data) {
100
+ mkdirSync(dirname(filePath), { recursive: true });
101
+ const tmp = `${filePath}.tmp.${process.pid}.${randomUUID()}`;
102
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
103
+ renameSync(tmp, filePath);
104
+ }
105
+
106
+ /**
107
+ * Safe JSON read with fallback.
108
+ *
109
+ * @template T
110
+ * @param {string} file
111
+ * @param {T} fallback
112
+ * @returns {T}
113
+ */
114
+ function safeReadJSON(file, fallback) {
115
+ try {
116
+ if (!existsSync(file)) return fallback;
117
+ const text = readFileSync(file, 'utf8');
118
+ if (!text.trim()) return fallback;
119
+ return JSON.parse(text);
120
+ } catch {
121
+ return fallback;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Read installed.json. Returns `{ plugins: [] }` if missing/malformed.
127
+ *
128
+ * @returns {{ plugins: InstalledPlugin[] }}
129
+ */
130
+ export function readInstalled() {
131
+ return safeReadJSON(getInstalledJsonPath(), { plugins: [] });
132
+ }
133
+
134
+ /**
135
+ * Persist the entire installed.json document. Used after install /
136
+ * uninstall / config change. Merges with any existing entries so
137
+ * partial writes don't drop sibling plugins.
138
+ *
139
+ * @param {{ plugins: InstalledPlugin[] }} doc
140
+ */
141
+ function writeInstalled(doc) {
142
+ if (!doc || !Array.isArray(doc.plugins)) {
143
+ throw new Error('writeInstalled: doc.plugins must be an array');
144
+ }
145
+ atomicWriteJson(getInstalledJsonPath(), doc);
146
+ }
147
+
148
+ /**
149
+ * Find the installed entry for a given id.
150
+ *
151
+ * @param {string} id
152
+ * @returns {InstalledPlugin | null}
153
+ */
154
+ export function getInstalled(id) {
155
+ const doc = readInstalled();
156
+ return doc.plugins.find((p) => p.id === id) || null;
157
+ }
158
+
159
+ /**
160
+ * List every installed plugin.
161
+ *
162
+ * @returns {InstalledPlugin[]}
163
+ */
164
+ export function listInstalled() {
165
+ return readInstalled().plugins;
166
+ }
167
+
168
+ /**
169
+ * Download a URL to a local file path. Streams to disk so a large
170
+ * plugin tarball doesn't sit in memory. Returns the downloaded path.
171
+ *
172
+ * @param {string} url
173
+ * @param {string} dest
174
+ * @param {typeof globalThis.fetch} [fetchImpl]
175
+ */
176
+ async function downloadTo(url, dest, fetchImpl) {
177
+ const fetchFn = fetchImpl || globalThis.fetch;
178
+ const res = await fetchFn(url, {
179
+ headers: { 'User-Agent': 'bizar-plugin-installer/5.0' },
180
+ });
181
+ if (!res.ok) {
182
+ const err = new Error(
183
+ `tarball download failed: ${url} returned ${res.status} ${res.statusText}`,
184
+ );
185
+ err.code = 'download_failed';
186
+ err.status = res.status;
187
+ throw err;
188
+ }
189
+ mkdirSync(dirname(dest), { recursive: true });
190
+ await pipeline(res.body, createWriteStream(dest));
191
+ return dest;
192
+ }
193
+
194
+ /**
195
+ * Stream a file's SHA-256 hex digest. Used internally by installPlugin
196
+ * to verify the tarball; also exported so callers can compute a
197
+ * checksum independently.
198
+ *
199
+ * @param {string} filePath
200
+ * @returns {Promise<string>}
201
+ */
202
+ export function sha256File(filePath) {
203
+ return new Promise((resolveP, rejectP) => {
204
+ const hash = createHash('sha256');
205
+ const stream = createReadStream(filePath);
206
+ stream.on('data', (c) => hash.update(c));
207
+ stream.on('error', rejectP);
208
+ stream.on('end', () => resolveP(hash.digest('hex')));
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Extract a `.tar.gz` (or plain `.tar`) tarball into `cwd`.
214
+ *
215
+ * We use `tar-stream` + `node:zlib` rather than `node:tar` (which is
216
+ * experimental and missing from some Node 24 builds). `tar-stream`
217
+ * gives us per-entry control: we validate each entry path to defend
218
+ * against "Zip Slip"-style attacks (entries whose names try to escape
219
+ * `cwd` via `../`).
220
+ *
221
+ * @param {string} tarballPath
222
+ * @param {string} cwd
223
+ */
224
+ async function extractTarball(tarballPath, cwd) {
225
+ mkdirSync(cwd, { recursive: true });
226
+ const isGzip = /\.t?gz$/i.test(tarballPath);
227
+ const source = createReadStream(tarballPath);
228
+ const gunzipped = isGzip ? source.pipe(createGunzip()) : source;
229
+ const extractor = tarExtract();
230
+ // Track every entry's write Promise so we can await all of them
231
+ // before reporting success. The `'finish'` event fires as soon as
232
+ // the source stream ends, but async entry handlers (file writes)
233
+ // may still be in flight at that point.
234
+ const pending = [];
235
+ await new Promise((resolveP, rejectP) => {
236
+ extractor.on('entry', (header, stream, next) => {
237
+ const handle = (async () => {
238
+ try {
239
+ if (!header.name || header.name.includes('\0')) {
240
+ throw new Error(`tarball contains invalid entry name: ${JSON.stringify(header.name)}`);
241
+ }
242
+ const dest = pathResolve(cwd, header.name);
243
+ const rel = pathResolve(cwd, '.') + '/';
244
+ if (dest !== pathResolve(cwd, '.') && !dest.startsWith(rel)) {
245
+ throw new Error(`tarball entry escapes cwd: ${header.name}`);
246
+ }
247
+ if (header.type === 'directory') {
248
+ mkdirSync(dest, { recursive: true });
249
+ stream.resume();
250
+ await new Promise((r) => stream.on('end', r));
251
+ return;
252
+ }
253
+ mkdirSync(dirname(dest), { recursive: true });
254
+ await pipeline(stream, createWriteStream(dest));
255
+ } catch (err) {
256
+ rejectP(err);
257
+ } finally {
258
+ next();
259
+ }
260
+ })();
261
+ pending.push(handle);
262
+ });
263
+ extractor.on('finish', async () => {
264
+ try {
265
+ await Promise.all(pending);
266
+ resolveP();
267
+ } catch (err) {
268
+ rejectP(err);
269
+ }
270
+ });
271
+ extractor.on('error', rejectP);
272
+ gunzipped.on('error', rejectP);
273
+ gunzipped.pipe(extractor);
274
+ });
275
+ }
276
+
277
+ /**
278
+ * Build a gzipped tarball from a directory. Used by tests; the
279
+ * production install flow only reads tarballs, never creates them.
280
+ *
281
+ * @param {string} srcDir absolute path of the directory to archive
282
+ * @param {string} outFile where to write the .tar.gz
283
+ * @param {object} [opts]
284
+ * @param {string} [opts.prefix] strip this prefix from entry names
285
+ */
286
+ export async function _buildTarball(srcDir, outFile, opts = {}) {
287
+ // Used in tests; also handy as a developer util. Implementation
288
+ // intentionally lives here so callers don't need a separate tar
289
+ // dependency.
290
+ const { readdirSync, statSync } = await import('node:fs');
291
+ const packer = tarPack();
292
+ const gzip = createGzip();
293
+ const out = createWriteStream(outFile);
294
+ const finished = pipeline(packer, gzip, out);
295
+
296
+ async function addEntry(absPath, nameInArchive) {
297
+ const st = statSync(absPath);
298
+ if (st.isDirectory()) {
299
+ packer.entry({ name: `${nameInArchive}/` });
300
+ for (const child of readdirSync(absPath)) {
301
+ await addEntry(join(absPath, child), `${nameInArchive}/${child}`);
302
+ }
303
+ return;
304
+ }
305
+ packer.entry({ name: nameInArchive, size: st.size }, readFileSync(absPath));
306
+ }
307
+ await addEntry(srcDir, opts.prefix || '');
308
+ packer.finalize();
309
+ await finished;
310
+ return outFile;
311
+ }
312
+
313
+ /**
314
+ * Find the directory containing `plugin.json` inside an extracted
315
+ * tree. Tarballs often wrap everything in a top-level folder; we
316
+ * allow that and return the inner dir.
317
+ *
318
+ * Returns null if no plugin.json is found within 2 levels of cwd.
319
+ *
320
+ * @param {string} cwd
321
+ * @returns {string | null}
322
+ */
323
+ function findPluginManifestDir(cwd) {
324
+ // First check cwd directly.
325
+ if (existsSync(join(cwd, 'plugin.json'))) return cwd;
326
+ // Then check each top-level subdir.
327
+ let entries;
328
+ try {
329
+ entries = readdirSync(cwd, { withFileTypes: true });
330
+ } catch {
331
+ return null;
332
+ }
333
+ for (const e of entries) {
334
+ if (!e.isDirectory()) continue;
335
+ const candidate = join(cwd, e.name);
336
+ if (existsSync(join(candidate, 'plugin.json'))) return candidate;
337
+ }
338
+ return null;
339
+ }
340
+
341
+ /**
342
+ * Install a plugin from the registry.
343
+ *
344
+ * @param {string} pluginId
345
+ * @param {object} [opts]
346
+ * @param {boolean} [opts.force=false] reinstall even if already installed
347
+ * @param {string} [opts.url] override the registry URL
348
+ * @param {typeof globalThis.fetch} [opts.fetchImpl]
349
+ * @returns {Promise<InstalledPlugin>}
350
+ */
351
+ export async function installPlugin(pluginId, { force = false, url, fetchImpl } = {}) {
352
+ if (!pluginId || typeof pluginId !== 'string') {
353
+ throw new Error('installPlugin: pluginId is required');
354
+ }
355
+ const childLog = logger.child({ module: 'plugin-store', pluginId });
356
+ childLog.info('installing plugin');
357
+
358
+ // 1. Already-installed check (unless force)
359
+ const existing = getInstalled(pluginId);
360
+ if (existing && !force) {
361
+ const err = new Error(
362
+ `plugin "${pluginId}" is already installed (v${existing.version}); pass { force: true } to reinstall`,
363
+ );
364
+ err.code = 'already_installed';
365
+ err.existing = existing;
366
+ throw err;
367
+ }
368
+
369
+ // 2. Look up in the registry.
370
+ const entry = await registry.getPlugin(pluginId, { url, fetch: fetchImpl });
371
+ if (!entry) {
372
+ const err = new Error(`plugin "${pluginId}" not found in registry`);
373
+ err.code = 'not_found';
374
+ throw err;
375
+ }
376
+
377
+ // 3. Download to a unique tmp file.
378
+ const tmpTarball = join(tmpdir(), `bizar-plugin-${pluginId}-${Date.now()}-${randomUUID()}.tar.gz`);
379
+ await downloadTo(entry.tarball, tmpTarball, fetchImpl);
380
+
381
+ // 4. Verify checksum BEFORE extracting anything.
382
+ const expected = entry.checksum.replace(/^sha256:/, '');
383
+ const actual = await sha256File(tmpTarball);
384
+ if (actual !== expected) {
385
+ rmSync(tmpTarball, { force: true });
386
+ const err = new Error(
387
+ `checksum mismatch for "${pluginId}": expected ${expected}, got ${actual}`,
388
+ );
389
+ err.code = 'checksum_mismatch';
390
+ throw err;
391
+ }
392
+
393
+ // 5. Extract to a staging dir, find plugin.json, validate.
394
+ const stagingDir = join(tmpdir(), `bizar-plugin-stage-${pluginId}-${randomUUID()}`);
395
+ try {
396
+ await extractTarball(tmpTarball, stagingDir);
397
+ const manifestDir = findPluginManifestDir(stagingDir);
398
+ if (!manifestDir) {
399
+ throw new Error(
400
+ `tarball for "${pluginId}" contains no plugin.json at the root or one level deep`,
401
+ );
402
+ }
403
+ const manifestPath = join(manifestDir, 'plugin.json');
404
+ const manifest = readManifest(manifestPath);
405
+ if (manifest.id !== pluginId) {
406
+ throw new Error(
407
+ `plugin.json id mismatch: registry says "${pluginId}" but plugin.json says "${manifest.id}"`,
408
+ );
409
+ }
410
+
411
+ // 6. Move staging → real plugin dir (atomic rename when possible).
412
+ const pluginDir = getPluginDir(pluginId);
413
+ if (existsSync(pluginDir)) {
414
+ // Wipe the existing dir so we get a clean install. We keep
415
+ // installed.json's previous config separately.
416
+ rmSync(pluginDir, { recursive: true, force: true });
417
+ }
418
+ renameSync(manifestDir, pluginDir);
419
+ childLog.info('extracted plugin', { to: pluginDir, version: entry.version });
420
+
421
+ // 7. Register in installed.json (merge with previous config).
422
+ const prevConfig = (existing && existing.config) || {};
423
+ const installed = {
424
+ id: pluginId,
425
+ version: entry.version,
426
+ installedAt: (existing && existing.installedAt) || new Date().toISOString(),
427
+ updatedAt: new Date().toISOString(),
428
+ config: prevConfig,
429
+ permissions: entry.permissions || [],
430
+ path: pluginDir,
431
+ };
432
+ upsertInstalled(installed);
433
+
434
+ // 8. Smoke test: load the plugin in the sandbox.
435
+ try {
436
+ const loaded = await loadPlugin({
437
+ mainFile: pathResolve(pluginDir, manifest.main),
438
+ config: prevConfig,
439
+ permissions: entry.permissions,
440
+ pluginId,
441
+ pluginRoot: pluginDir,
442
+ fetchImpl,
443
+ });
444
+ childLog.info('plugin loaded into sandbox', {
445
+ methods: Object.keys(loaded.exports),
446
+ invalidPermissions: loaded.invalidPermissions,
447
+ });
448
+ if (typeof loaded.exports.init === 'function') {
449
+ const r = await safeInvoke(loaded, 'init', [], 10_000);
450
+ if (!r.ok) {
451
+ childLog.warn('plugin init() failed', { error: r.error, code: r.code });
452
+ }
453
+ }
454
+ } catch (err) {
455
+ childLog.warn('plugin sandbox smoke-test failed', {
456
+ error: err.message,
457
+ code: err.code,
458
+ });
459
+ }
460
+ return installed;
461
+ } finally {
462
+ // Clean up staging + tarball regardless of outcome.
463
+ rmSync(stagingDir, { recursive: true, force: true });
464
+ rmSync(tmpTarball, { force: true });
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Insert or update an installed entry.
470
+ *
471
+ * @param {InstalledPlugin} entry
472
+ */
473
+ function upsertInstalled(entry) {
474
+ const doc = readInstalled();
475
+ const idx = doc.plugins.findIndex((p) => p.id === entry.id);
476
+ if (idx === -1) doc.plugins.push(entry);
477
+ else doc.plugins[idx] = entry;
478
+ writeInstalled(doc);
479
+ }
480
+
481
+ /**
482
+ * Remove a plugin from disk + installed.json.
483
+ *
484
+ * @param {string} pluginId
485
+ * @returns {boolean} true if removed, false if wasn't installed
486
+ */
487
+ export function uninstallPlugin(pluginId) {
488
+ if (!pluginId) return false;
489
+ const doc = readInstalled();
490
+ const idx = doc.plugins.findIndex((p) => p.id === pluginId);
491
+ if (idx === -1) return false;
492
+ doc.plugins.splice(idx, 1);
493
+ writeInstalled(doc);
494
+ const dir = getPluginDir(pluginId);
495
+ rmSync(dir, { recursive: true, force: true });
496
+ logger.info('plugin uninstalled', { module: 'plugin-store', pluginId });
497
+ return true;
498
+ }
499
+
500
+ /**
501
+ * Update a plugin to the latest registry version. Equivalent to
502
+ * installPlugin(force: true) but preserves config.
503
+ *
504
+ * @param {string} pluginId
505
+ * @param {object} [opts]
506
+ * @param {typeof globalThis.fetch} [opts.fetchImpl]
507
+ * @returns {Promise<{ from: string, to: string, plugin: InstalledPlugin }>}
508
+ */
509
+ export async function updatePlugin(pluginId, opts = {}) {
510
+ const existing = getInstalled(pluginId);
511
+ if (!existing) {
512
+ const err = new Error(`plugin "${pluginId}" is not installed`);
513
+ err.code = 'not_installed';
514
+ throw err;
515
+ }
516
+ const prevVersion = existing.version;
517
+ const prevConfig = existing.config || {};
518
+ const installed = await installPlugin(pluginId, { ...opts, force: true });
519
+ // installPlugin with force: true wipes config; we restore it here.
520
+ if (Object.keys(prevConfig).length) {
521
+ installed.config = prevConfig;
522
+ upsertInstalled(installed);
523
+ }
524
+ return { from: prevVersion, to: installed.version, plugin: installed };
525
+ }
526
+
527
+ /**
528
+ * Set a config value (dot-path) on an installed plugin. Persists
529
+ * immediately so the value survives a dashboard restart.
530
+ *
531
+ * @param {string} pluginId
532
+ * @param {string} key
533
+ * @param {unknown} value
534
+ */
535
+ export function setConfig(pluginId, key, value) {
536
+ const existing = getInstalled(pluginId);
537
+ if (!existing) {
538
+ const err = new Error(`plugin "${pluginId}" is not installed`);
539
+ err.code = 'not_installed';
540
+ throw err;
541
+ }
542
+ const parts = key.split('.');
543
+ let cur = existing.config;
544
+ for (let i = 0; i < parts.length - 1; i++) {
545
+ const p = parts[i];
546
+ if (cur[p] == null || typeof cur[p] !== 'object') cur[p] = {};
547
+ cur = cur[p];
548
+ }
549
+ cur[parts[parts.length - 1]] = value;
550
+ existing.updatedAt = new Date().toISOString();
551
+ upsertInstalled(existing);
552
+ return existing;
553
+ }
554
+
555
+ /**
556
+ * Replace the entire config object on an installed plugin. Used by
557
+ * `PUT /api/plugins/:id` with `action: 'config'`. Returns the
558
+ * updated installed entry.
559
+ *
560
+ * @param {string} pluginId
561
+ * @param {Record<string, unknown>} config
562
+ */
563
+ export function replaceConfig(pluginId, config) {
564
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
565
+ const err = new Error('config must be a plain object');
566
+ err.code = 'bad_request';
567
+ throw err;
568
+ }
569
+ const existing = getInstalled(pluginId);
570
+ if (!existing) {
571
+ const err = new Error(`plugin "${pluginId}" is not installed`);
572
+ err.code = 'not_installed';
573
+ throw err;
574
+ }
575
+ existing.config = JSON.parse(JSON.stringify(config));
576
+ existing.updatedAt = new Date().toISOString();
577
+ upsertInstalled(existing);
578
+ return existing;
579
+ }
580
+
581
+ /**
582
+ * Write the entire installed.json document. Exported so route handlers
583
+ * that need to mutate the doc outside of upsertInstalled can do so
584
+ * (e.g. bulk admin operations added in future versions).
585
+ *
586
+ * @param {{ plugins: InstalledPlugin[] }} doc
587
+ */
588
+ export { writeInstalled as _writeInstalled };
589
+
590
+ /**
591
+ * Load a plugin from disk and invoke a method inside the sandbox.
592
+ *
593
+ * @param {string} pluginId
594
+ * @param {string} method
595
+ * @param {unknown[]} [args]
596
+ * @param {object} [opts]
597
+ * @param {number} [opts.timeoutMs]
598
+ * @param {typeof globalThis.fetch} [opts.fetchImpl]
599
+ * @returns {Promise<{ ok: true, result: unknown } | { ok: false, error: string, code?: string }>}
600
+ */
601
+ export async function invokePlugin(pluginId, method, args = [], opts = {}) {
602
+ const installed = getInstalled(pluginId);
603
+ if (!installed) {
604
+ return { ok: false, error: `plugin "${pluginId}" is not installed`, code: 'not_installed' };
605
+ }
606
+ const pluginDir = getPluginDir(pluginId);
607
+ if (!existsSync(join(pluginDir, 'plugin.json'))) {
608
+ return {
609
+ ok: false,
610
+ error: `plugin "${pluginId}" has no plugin.json at ${pluginDir}`,
611
+ code: 'corrupt_install',
612
+ };
613
+ }
614
+ let manifest;
615
+ try {
616
+ manifest = readManifest(join(pluginDir, 'plugin.json'));
617
+ } catch (err) {
618
+ return { ok: false, error: err.message, code: 'bad_manifest' };
619
+ }
620
+ const mainFile = pathResolve(pluginDir, manifest.main);
621
+ let loaded;
622
+ try {
623
+ loaded = await loadPlugin({
624
+ mainFile,
625
+ config: installed.config || {},
626
+ permissions: installed.permissions,
627
+ pluginId,
628
+ pluginRoot: pluginDir,
629
+ timeoutMs: opts.timeoutMs,
630
+ fetchImpl: opts.fetchImpl,
631
+ });
632
+ } catch (err) {
633
+ return { ok: false, error: err.message, code: err.code || 'load_error' };
634
+ }
635
+ return safeInvoke(loaded, method, args, opts.timeoutMs);
636
+ }
637
+
638
+ /**
639
+ * Ensure the plugin home directory exists. Idempotent.
640
+ */
641
+ export function ensurePluginHome() {
642
+ mkdirSync(getPluginHome(), { recursive: true });
643
+ }
644
+
645
+ // ---------------------------------------------------------------------------
646
+ // Type doc — not enforced at runtime, just for editor intellisense.
647
+ // ---------------------------------------------------------------------------
648
+
649
+ /**
650
+ * @typedef {{
651
+ * id: string,
652
+ * version: string,
653
+ * installedAt: string,
654
+ * updatedAt: string,
655
+ * config: Record<string, unknown>,
656
+ * permissions: string[],
657
+ * path: string
658
+ * }} InstalledPlugin
659
+ */