@polderlabs/bizar 4.9.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.
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
- package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
- package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
- package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.js.map} +1 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
- package/bizar-dash/dist/index.html +6 -3
- package/bizar-dash/dist/mobile.html +5 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/eval/SKILL.md +237 -0
- package/bizar-dash/src/server/api.mjs +28 -0
- package/bizar-dash/src/server/auth.mjs +155 -1
- package/bizar-dash/src/server/eval-store.mjs +226 -0
- package/bizar-dash/src/server/eval.mjs +347 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/plugins/registry.mjs +363 -0
- package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
- package/bizar-dash/src/server/plugins/store.mjs +659 -0
- package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
- package/bizar-dash/src/server/routes/eval.mjs +147 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/plugins.mjs +220 -0
- package/bizar-dash/src/server/routes/users.mjs +84 -0
- package/bizar-dash/src/server/routes/voice.mjs +131 -0
- package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
- package/bizar-dash/src/server/voice-store.mjs +202 -0
- package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
- package/bizar-dash/src/server/workspaces.mjs +626 -0
- package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
- package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
- package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
- package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
- package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
- package/bizar-dash/src/web/styles/memory.css +82 -0
- package/bizar-dash/src/web/views/Memory.tsx +17 -2
- package/bizar-dash/src/web/views/Settings.tsx +3 -0
- package/bizar-dash/src/web/views/Workspace.tsx +294 -0
- package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
- package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
- package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
- package/bizar-dash/tests/clipboard.test.mjs +147 -0
- package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
- package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
- package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
- package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
- package/bizar-dash/tests/eval/report.test.mjs +284 -0
- package/bizar-dash/tests/eval/runner.test.mjs +471 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
- package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
- package/bizar-dash/tests/plugins-store.test.mjs +455 -0
- package/bizar-dash/tests/users.test.mjs +108 -0
- package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
- package/bizar-dash/tests/voice-store.test.mjs +148 -0
- package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
- package/bizar-dash/tests/workspaces.test.mjs +527 -0
- package/cli/bin.mjs +72 -2
- package/cli/commands/clip.mjs +146 -0
- package/cli/commands/deploy/cloudflare.mjs +250 -0
- package/cli/commands/deploy/docker.mjs +221 -0
- package/cli/commands/deploy/fly.mjs +161 -0
- package/cli/commands/deploy/vercel.mjs +225 -0
- package/cli/commands/deploy.mjs +240 -0
- package/cli/commands/eval.mjs +378 -0
- package/cli/commands/marketplace.mjs +64 -0
- package/cli/commands/ocr.mjs +165 -0
- package/cli/commands/plugin.mjs +358 -0
- package/cli/commands/voice.mjs +211 -0
- package/cli/commands/workspace.mjs +247 -0
- package/package.json +12 -8
- package/templates/deploy/cloudflare/README.md +32 -0
- package/templates/deploy/cloudflare/functions-index.template.js +15 -0
- package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
- package/templates/deploy/docker/.env.template +16 -0
- package/templates/deploy/docker/README.md +58 -0
- package/templates/deploy/docker/docker-compose.template.yml +23 -0
- package/templates/deploy/fly/README.md +35 -0
- package/templates/deploy/fly/fly.toml.template +28 -0
- package/templates/deploy/vercel/README.md +29 -0
- package/templates/deploy/vercel/api-index.template.js +18 -0
- package/templates/deploy/vercel/vercel.json.template +16 -0
- package/templates/eval-fixtures/README.md +58 -0
- package/templates/eval-fixtures/code-search-basic.json +28 -0
- package/templates/eval-fixtures/latency-bounds.json +16 -0
- package/templates/eval-fixtures/regression-suite.json +79 -0
- package/templates/eval-fixtures/response-format.json +30 -0
- package/templates/eval-fixtures/tool-call-correctness.json +24 -0
- package/templates/plugin-template/README.md +121 -0
- package/templates/plugin-template/index.js +66 -0
- package/templates/plugin-template/plugin.json +42 -0
- package/templates/plugin-template/tests/plugin.test.js +83 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +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
|
+
*/
|