@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.
- 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-DmpSFPJY.js +9 -0
- package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
- 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-DX_Jh8Wc.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-Chvf9u_B.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/memory-lightrag.mjs +109 -0
- package/bizar-dash/src/server/memory-store.mjs +121 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/otel.mjs +133 -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/chat.mjs +246 -170
- 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/memory.mjs +46 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
- 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/server.mjs +40 -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/SettingsSearch.tsx +204 -89
- 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/lib/search.ts +115 -0
- package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
- package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
- package/bizar-dash/src/web/styles/memory.css +166 -1
- package/bizar-dash/src/web/styles/settings.css +80 -0
- package/bizar-dash/src/web/views/Memory.tsx +22 -2
- package/bizar-dash/src/web/views/Settings.tsx +99 -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/MemoryGraphLegend.tsx +29 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -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 +73 -0
- 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/settings-search.test.tsx +180 -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/docker-build.test.mjs +96 -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/lib/search-fuzzy.test.ts +149 -0
- package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
- package/bizar-dash/tests/memory-graph.test.mjs +95 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/otel.test.mjs +188 -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/dash.mjs +6 -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 -2
- 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-DHZmbnxQ.js +0 -361
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-Chvf9u_B.js +0 -1
|
@@ -706,6 +706,127 @@ export {
|
|
|
706
706
|
export const LIGHTRAG_DEFAULT_LLM = 'opencode/gpt-5-nano';
|
|
707
707
|
export const LIGHTRAG_DEFAULT_EMBEDDING = 'opencode/text-embedding-3-small';
|
|
708
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Build a wikilink link graph from all .md notes in the vault.
|
|
711
|
+
*
|
|
712
|
+
* Nodes: one per note (id = relPath, label = title or basename).
|
|
713
|
+
* Edges: one per wikilink [[Target]] found in note bodies.
|
|
714
|
+
*
|
|
715
|
+
* @param {{ projectRoot: string, vaultPath?: string|null, limit?: number }} opts
|
|
716
|
+
* @returns {{ nodes: Array<{id:string,label:string,type:string,size:number,group:string}>, edges: Array<{source:string,target:string,type:string,weight:number}> }}
|
|
717
|
+
*/
|
|
718
|
+
export function getObsidianLinkGraph({ projectRoot, vaultPath = null, limit = 200 }) {
|
|
719
|
+
const { vaultRoot } = resolveVault(projectRoot);
|
|
720
|
+
const root = vaultPath || vaultRoot;
|
|
721
|
+
const nodesMap = new Map(); // id → node
|
|
722
|
+
const edges = [];
|
|
723
|
+
|
|
724
|
+
// Collect all notes.
|
|
725
|
+
const notes = listNotesForGraph(root);
|
|
726
|
+
const sliced = notes.slice(0, limit);
|
|
727
|
+
|
|
728
|
+
for (const note of sliced) {
|
|
729
|
+
if (!nodesMap.has(note.relPath)) {
|
|
730
|
+
nodesMap.set(note.relPath, {
|
|
731
|
+
id: note.relPath,
|
|
732
|
+
label: note.frontmatter?.title || note.relPath.split('/').pop()?.replace(/\.md$/i, '') || note.relPath,
|
|
733
|
+
type: 'note',
|
|
734
|
+
size: 1,
|
|
735
|
+
group: note.relPath.split('/')[0] || 'root',
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Extract wikilinks from body.
|
|
740
|
+
const WIKILINK_RE = /\[\[([^\]\n|]+?)(?:\|[^\]\n]+?)?(?:#[^\]\n]+?)?\]\]/g;
|
|
741
|
+
const body = note.body || '';
|
|
742
|
+
let m;
|
|
743
|
+
WIKILINK_RE.lastIndex = 0;
|
|
744
|
+
while ((m = WIKILINK_RE.exec(body)) !== null) {
|
|
745
|
+
const raw = (m[1] || '').trim();
|
|
746
|
+
if (!raw) continue;
|
|
747
|
+
const target = raw.split('#')[0].trim().split('/').pop() || raw;
|
|
748
|
+
// Resolve to a note id (basename match).
|
|
749
|
+
const targetId = resolveWikilinkTarget(target, notes);
|
|
750
|
+
if (targetId && targetId !== note.relPath) {
|
|
751
|
+
edges.push({
|
|
752
|
+
source: note.relPath,
|
|
753
|
+
target: targetId,
|
|
754
|
+
type: 'links_to',
|
|
755
|
+
weight: 1,
|
|
756
|
+
});
|
|
757
|
+
// Ensure target node exists.
|
|
758
|
+
if (!nodesMap.has(targetId)) {
|
|
759
|
+
nodesMap.set(targetId, {
|
|
760
|
+
id: targetId,
|
|
761
|
+
label: target,
|
|
762
|
+
type: 'note',
|
|
763
|
+
size: 1,
|
|
764
|
+
group: targetId.split('/')[0] || 'root',
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
nodes: [...nodesMap.values()].slice(0, limit),
|
|
773
|
+
edges: edges.slice(0, limit * 3),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* List notes from a specific root path (not the project vault root).
|
|
779
|
+
* Used by getObsidianLinkGraph for scanning arbitrary vaults.
|
|
780
|
+
*/
|
|
781
|
+
function listNotesForGraph(scanRoot) {
|
|
782
|
+
if (!existsSync(scanRoot)) return [];
|
|
783
|
+
const out = [];
|
|
784
|
+
function walk(dir, prefix) {
|
|
785
|
+
let entries;
|
|
786
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
787
|
+
catch { return; }
|
|
788
|
+
for (const e of entries) {
|
|
789
|
+
if (e.name.startsWith('.')) continue;
|
|
790
|
+
const full = join(dir, e.name);
|
|
791
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
792
|
+
if (e.isDirectory()) {
|
|
793
|
+
walk(full, rel);
|
|
794
|
+
} else if (e.name.endsWith('.md')) {
|
|
795
|
+
try {
|
|
796
|
+
const raw = readFileSync(full, 'utf8');
|
|
797
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
798
|
+
out.push({ relPath: rel, frontmatter, body });
|
|
799
|
+
} catch { /* skip */ }
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
walk(scanRoot, '');
|
|
804
|
+
return out;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Resolve a wikilink target (e.g. "My Note" or "subdir/My Note") to a note id.
|
|
809
|
+
* Uses basename matching (Obsidian semantics).
|
|
810
|
+
*/
|
|
811
|
+
function resolveWikilinkTarget(targetName, notes) {
|
|
812
|
+
const normalized = targetName.replace(/\.md$/i, '').toLowerCase();
|
|
813
|
+
// Try exact relPath match first.
|
|
814
|
+
for (const n of notes) {
|
|
815
|
+
const base = n.relPath.replace(/\.md$/i, '').toLowerCase();
|
|
816
|
+
if (base === normalized || base.endsWith('/' + normalized)) {
|
|
817
|
+
return n.relPath;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// Fallback: basename-only match.
|
|
821
|
+
for (const n of notes) {
|
|
822
|
+
const base = n.relPath.split('/').pop()?.replace(/\.md$/i, '').toLowerCase();
|
|
823
|
+
if (base === normalized) {
|
|
824
|
+
return n.relPath;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
|
|
709
830
|
/**
|
|
710
831
|
* Return the effective LightRAG model defaults, applying env-var
|
|
711
832
|
* overrides on top of the built-in opencode-Zen-free defaults.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/ocr.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Tesseract.js wrapper for OCR text extraction.
|
|
5
|
+
*
|
|
6
|
+
* Provides a single `extractText(imageBuffer, options?)` function.
|
|
7
|
+
* The worker is created lazily and terminated after each call to avoid
|
|
8
|
+
* memory leaks in long-running server processes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createWorker } from 'tesseract.js';
|
|
12
|
+
|
|
13
|
+
let worker = null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extract text from an image buffer using Tesseract.js OCR.
|
|
17
|
+
*
|
|
18
|
+
* @param {Buffer} imageBuffer - Raw image data (PNG, JPEG, etc.)
|
|
19
|
+
* @param {{ lang?: string }} [options] - Recognition options.
|
|
20
|
+
* `lang` defaults to 'eng' (English).
|
|
21
|
+
* @returns {Promise<string>} - The extracted text.
|
|
22
|
+
*/
|
|
23
|
+
export async function extractText(imageBuffer, { lang = 'eng' } = {}) {
|
|
24
|
+
const w = worker || (worker = await createWorker(lang));
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const { data } = await w.recognize(imageBuffer);
|
|
28
|
+
return (data.text || '').trim();
|
|
29
|
+
} finally {
|
|
30
|
+
// Terminate and reset so a new worker picks up any config changes
|
|
31
|
+
// on the next call. This prevents the worker from accumulating state
|
|
32
|
+
// across requests in long-running servers.
|
|
33
|
+
try { await w.terminate(); } catch { /* ignore */ }
|
|
34
|
+
worker = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check whether Tesseract.js is available and can be initialised.
|
|
40
|
+
* Useful for health-check endpoints.
|
|
41
|
+
*
|
|
42
|
+
* @returns {Promise<{ ok: boolean, message: string }>}
|
|
43
|
+
*/
|
|
44
|
+
export async function checkOcrHealth() {
|
|
45
|
+
try {
|
|
46
|
+
const w = await createWorker('eng');
|
|
47
|
+
await w.terminate();
|
|
48
|
+
return { ok: true, message: 'Tesseract.js worker initialised OK' };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
message: `Tesseract.js init failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/otel.mjs
|
|
3
|
+
*
|
|
4
|
+
* v4.9.0 — OpenTelemetry distributed tracing for the Bizar dashboard.
|
|
5
|
+
*
|
|
6
|
+
* Initialises the NodeSDK with an OTLP HTTP trace exporter and exposes
|
|
7
|
+
* a single `tracer` instance for ad-hoc spans (chat.send, opencode
|
|
8
|
+
* session creation, etc). Auto-instrumentation is intentionally not
|
|
9
|
+
* enabled — the dashboard already has structured logger + Prometheus
|
|
10
|
+
* for the "what" metrics, so distributed tracing is only interesting
|
|
11
|
+
* for the multi-step flows where request correlation matters
|
|
12
|
+
* (chat SSE pump, opencode session creation).
|
|
13
|
+
*
|
|
14
|
+
* Off by default. Enable with one of:
|
|
15
|
+
* BIZAR_OTEL=1
|
|
16
|
+
* OTEL_ENABLED=1
|
|
17
|
+
* OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318/v1/traces (also opts in)
|
|
18
|
+
*
|
|
19
|
+
* `shutdownOtel()` flushes any pending spans and tears down the SDK.
|
|
20
|
+
* It is idempotent and safe to call multiple times — subsequent calls
|
|
21
|
+
* are a no-op once the SDK has been shut down.
|
|
22
|
+
*
|
|
23
|
+
* The NodeSDK constructor is wrapped in try/catch so that a malformed
|
|
24
|
+
* OTLP endpoint or missing optional binding (e.g. test runs that
|
|
25
|
+
* import this module but never call `initOtel`) does not crash the
|
|
26
|
+
* dashboard.
|
|
27
|
+
*/
|
|
28
|
+
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
29
|
+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
|
30
|
+
import { Resource } from '@opentelemetry/resources';
|
|
31
|
+
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
|
|
32
|
+
import { trace } from '@opentelemetry/api';
|
|
33
|
+
import { info as logInfo, warn as logWarn } from './logger.mjs';
|
|
34
|
+
|
|
35
|
+
const DEFAULT_OTLP_ENDPOINT = 'http://localhost:4318/v1/traces';
|
|
36
|
+
const SERVICE_NAME = 'bizar-dash';
|
|
37
|
+
const TRACER_NAME = 'bizar-dash';
|
|
38
|
+
const TRACER_VERSION = '0.1.0';
|
|
39
|
+
|
|
40
|
+
let sdk = null;
|
|
41
|
+
let shuttingDown = false;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The shared tracer used by route handlers. Bound to the global
|
|
45
|
+
* tracer provider — when OTEL is not enabled this resolves to a
|
|
46
|
+
* no-op tracer (a `startActiveSpan` call still returns a valid span
|
|
47
|
+
* object whose methods are all no-ops), so route handlers do not
|
|
48
|
+
* need a feature flag of their own.
|
|
49
|
+
*/
|
|
50
|
+
export const tracer = trace.getTracer(TRACER_NAME, TRACER_VERSION);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Initialise the NodeSDK with an OTLP HTTP trace exporter. Safe to
|
|
54
|
+
* call multiple times — the second call returns the cached SDK.
|
|
55
|
+
*
|
|
56
|
+
* `npm_package_version` is set by `npm run` contexts; in any other
|
|
57
|
+
* context we fall back to '0.0.0'. The version is informational
|
|
58
|
+
* (the OTLP collector shows it in the resource panel), so failing to
|
|
59
|
+
* read it is never a startup blocker.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} [opts]
|
|
62
|
+
* @param {string} [opts.serviceName] Override SERVICE_NAME (default 'bizar-dash')
|
|
63
|
+
* @param {string} [opts.endpoint] OTLP HTTP traces endpoint
|
|
64
|
+
* @returns {object|null} The NodeSDK instance, or null on failure
|
|
65
|
+
*/
|
|
66
|
+
export function initOtel({
|
|
67
|
+
serviceName = SERVICE_NAME,
|
|
68
|
+
endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || DEFAULT_OTLP_ENDPOINT,
|
|
69
|
+
} = {}) {
|
|
70
|
+
if (sdk) return sdk;
|
|
71
|
+
if (shuttingDown) return null;
|
|
72
|
+
try {
|
|
73
|
+
sdk = new NodeSDK({
|
|
74
|
+
resource: new Resource({
|
|
75
|
+
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
|
|
76
|
+
[SemanticResourceAttributes.SERVICE_VERSION]:
|
|
77
|
+
process.env.npm_package_version || '0.0.0',
|
|
78
|
+
}),
|
|
79
|
+
traceExporter: new OTLPTraceExporter({ url: endpoint }),
|
|
80
|
+
});
|
|
81
|
+
sdk.start();
|
|
82
|
+
logInfo('OpenTelemetry initialised', {
|
|
83
|
+
module: 'otel',
|
|
84
|
+
serviceName,
|
|
85
|
+
endpoint,
|
|
86
|
+
});
|
|
87
|
+
return sdk;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
logWarn('OpenTelemetry init failed; continuing without tracing', {
|
|
90
|
+
module: 'otel',
|
|
91
|
+
err: err?.message || String(err),
|
|
92
|
+
});
|
|
93
|
+
sdk = null;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Flush pending spans and shut down the SDK. Idempotent.
|
|
100
|
+
*
|
|
101
|
+
* Designed to be wired into the dashboard's SIGTERM/SIGINT handler
|
|
102
|
+
* so spans in flight at shutdown are not silently dropped. Tests
|
|
103
|
+
* call this between scenarios to keep registry state clean.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Promise<void>}
|
|
106
|
+
*/
|
|
107
|
+
export async function shutdownOtel() {
|
|
108
|
+
if (!sdk) return;
|
|
109
|
+
if (shuttingDown) return;
|
|
110
|
+
shuttingDown = true;
|
|
111
|
+
try {
|
|
112
|
+
await sdk.shutdown();
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logWarn('OpenTelemetry shutdown failed', {
|
|
115
|
+
module: 'otel',
|
|
116
|
+
err: err?.message || String(err),
|
|
117
|
+
});
|
|
118
|
+
} finally {
|
|
119
|
+
sdk = null;
|
|
120
|
+
shuttingDown = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Test-only: return whether the SDK is currently initialised. Lets
|
|
126
|
+
* tests assert that initOtel really did instantiate the SDK without
|
|
127
|
+
* having to introspect private state from outside.
|
|
128
|
+
*
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
export function isOtelEnabled() {
|
|
132
|
+
return sdk !== null;
|
|
133
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/plugins/registry.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Plugin marketplace registry client.
|
|
5
|
+
*
|
|
6
|
+
* The marketplace registry is a single JSON document served over HTTP
|
|
7
|
+
* (default: raw.githubusercontent.com) listing every installable plugin.
|
|
8
|
+
* We fetch + parse + validate the shape, then cache the parsed value
|
|
9
|
+
* for an hour so a dashboard boot doesn't hammer GitHub.
|
|
10
|
+
*
|
|
11
|
+
* Shape (see README for the full schema):
|
|
12
|
+
* {
|
|
13
|
+
* "version": 1,
|
|
14
|
+
* "updatedAt": "2026-...",
|
|
15
|
+
* "plugins": [
|
|
16
|
+
* {
|
|
17
|
+
* "id": "vercel-deploy",
|
|
18
|
+
* "name": "Vercel Deploy",
|
|
19
|
+
* "version": "1.0.0",
|
|
20
|
+
* "description": "...",
|
|
21
|
+
* "author": "...",
|
|
22
|
+
* "category": "deploy",
|
|
23
|
+
* "tags": ["vercel", "deploy"],
|
|
24
|
+
* "homepage": "https://...",
|
|
25
|
+
* "tarball": "https://.../vercel-deploy-1.0.0.tar.gz",
|
|
26
|
+
* "checksum": "sha256:abcdef...",
|
|
27
|
+
* "permissions": ["net", "fs:read"],
|
|
28
|
+
* "minBizarVersion": "4.9.0"
|
|
29
|
+
* }
|
|
30
|
+
* ]
|
|
31
|
+
* }
|
|
32
|
+
*/
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import { createReadStream } from 'node:fs';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Default registry URL. Override with `BIZAR_REGISTRY_URL` env var.
|
|
38
|
+
*
|
|
39
|
+
* Raw GitHub is intentional: the registry is small, static, and
|
|
40
|
+
* versioned by commit (we point to `main/registry.json`). For private
|
|
41
|
+
* deployments, point this at an internal CDN or S3 bucket.
|
|
42
|
+
*/
|
|
43
|
+
const DEFAULT_REGISTRY_URL =
|
|
44
|
+
'https://raw.githubusercontent.com/DrB0rk/bizar-plugins/main/registry.json';
|
|
45
|
+
|
|
46
|
+
/** How long a cached registry is considered fresh. */
|
|
47
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* In-memory cache shared across all callers in this process. Tests
|
|
51
|
+
* can call `__resetCache()` to force a re-fetch.
|
|
52
|
+
*/
|
|
53
|
+
let _cache = null; // { fetchedAt: number, url: string, data: RegistryShape }
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the registry URL: explicit override > env > default.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} [override]
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function getRegistryUrl(override) {
|
|
62
|
+
if (typeof override === 'string' && override.trim()) return override.trim();
|
|
63
|
+
if (process.env.BIZAR_REGISTRY_URL && process.env.BIZAR_REGISTRY_URL.trim()) {
|
|
64
|
+
return process.env.BIZAR_REGISTRY_URL.trim();
|
|
65
|
+
}
|
|
66
|
+
return DEFAULT_REGISTRY_URL;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validate the parsed registry JSON shape. Throws a structured Error
|
|
71
|
+
* (`.code = 'invalid_registry'`) on any required-field violation. We
|
|
72
|
+
* are deliberately lenient on optional fields — missing `tags`,
|
|
73
|
+
* `homepage`, etc. are fine.
|
|
74
|
+
*
|
|
75
|
+
* @param {unknown} data
|
|
76
|
+
* @returns {RegistryShape}
|
|
77
|
+
*/
|
|
78
|
+
export function validateRegistry(data) {
|
|
79
|
+
if (!data || typeof data !== 'object') {
|
|
80
|
+
const err = new Error('registry root must be an object');
|
|
81
|
+
err.code = 'invalid_registry';
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
const root = /** @type {any} */ (data);
|
|
85
|
+
if (root.version !== 1) {
|
|
86
|
+
const err = new Error(`registry.version must be 1 (got ${root.version})`);
|
|
87
|
+
err.code = 'invalid_registry';
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
if (typeof root.updatedAt !== 'string') {
|
|
91
|
+
const err = new Error('registry.updatedAt must be a string');
|
|
92
|
+
err.code = 'invalid_registry';
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
if (!Array.isArray(root.plugins)) {
|
|
96
|
+
const err = new Error('registry.plugins must be an array');
|
|
97
|
+
err.code = 'invalid_registry';
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
/** @type {string[]} */
|
|
101
|
+
const seenIds = new Set();
|
|
102
|
+
for (let i = 0; i < root.plugins.length; i++) {
|
|
103
|
+
const p = root.plugins[i];
|
|
104
|
+
if (!p || typeof p !== 'object') {
|
|
105
|
+
const err = new Error(`registry.plugins[${i}] must be an object`);
|
|
106
|
+
err.code = 'invalid_registry';
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
if (typeof p.id !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(p.id)) {
|
|
110
|
+
const err = new Error(
|
|
111
|
+
`registry.plugins[${i}].id must be a kebab-case string (got ${JSON.stringify(p.id)})`,
|
|
112
|
+
);
|
|
113
|
+
err.code = 'invalid_registry';
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
if (seenIds.has(p.id)) {
|
|
117
|
+
const err = new Error(`registry.plugins[${i}].id "${p.id}" is duplicated`);
|
|
118
|
+
err.code = 'invalid_registry';
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
seenIds.add(p.id);
|
|
122
|
+
for (const required of ['name', 'version', 'tarball', 'checksum']) {
|
|
123
|
+
if (typeof p[required] !== 'string' || !p[required]) {
|
|
124
|
+
const err = new Error(
|
|
125
|
+
`registry.plugins[${i}].${required} must be a non-empty string`,
|
|
126
|
+
);
|
|
127
|
+
err.code = 'invalid_registry';
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (typeof p.tarball !== 'string' || !/^https?:\/\//.test(p.tarball)) {
|
|
132
|
+
const err = new Error(
|
|
133
|
+
`registry.plugins[${i}].tarball must be an http(s) URL (got ${JSON.stringify(p.tarball)})`,
|
|
134
|
+
);
|
|
135
|
+
err.code = 'invalid_registry';
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
if (typeof p.checksum !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(p.checksum)) {
|
|
139
|
+
const err = new Error(
|
|
140
|
+
`registry.plugins[${i}].checksum must be sha256:<64-hex> (got ${JSON.stringify(p.checksum)})`,
|
|
141
|
+
);
|
|
142
|
+
err.code = 'invalid_registry';
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
if (!Array.isArray(p.permissions)) {
|
|
146
|
+
const err = new Error(
|
|
147
|
+
`registry.plugins[${i}].permissions must be an array`,
|
|
148
|
+
);
|
|
149
|
+
err.code = 'invalid_registry';
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return /** @type {RegistryShape} */ (root);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Fetch the registry, returning the validated parsed shape.
|
|
158
|
+
*
|
|
159
|
+
* Cache: in-memory for 1 hour per URL. A `force: true` option bypasses
|
|
160
|
+
* the cache (used by the `update` CLI subcommand).
|
|
161
|
+
*
|
|
162
|
+
* @param {object} [opts]
|
|
163
|
+
* @param {string} [opts.url] override the URL (default: env/default)
|
|
164
|
+
* @param {boolean} [opts.force] skip the cache
|
|
165
|
+
* @param {typeof globalThis.fetch} [opts.fetch] fetch override (tests)
|
|
166
|
+
* @returns {Promise<RegistryShape>}
|
|
167
|
+
*/
|
|
168
|
+
export async function fetchRegistry({ url, force = false, fetch: fetchImpl } = {}) {
|
|
169
|
+
const resolvedUrl = getRegistryUrl(url);
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
if (
|
|
172
|
+
!force &&
|
|
173
|
+
_cache &&
|
|
174
|
+
_cache.url === resolvedUrl &&
|
|
175
|
+
now - _cache.fetchedAt < CACHE_TTL_MS
|
|
176
|
+
) {
|
|
177
|
+
return _cache.data;
|
|
178
|
+
}
|
|
179
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
180
|
+
if (typeof fetchFn !== 'function') {
|
|
181
|
+
throw new Error('fetch is not available in this runtime');
|
|
182
|
+
}
|
|
183
|
+
let res;
|
|
184
|
+
try {
|
|
185
|
+
res = await fetchFn(resolvedUrl, {
|
|
186
|
+
headers: { 'User-Agent': 'bizar-registry-client/5.0' },
|
|
187
|
+
});
|
|
188
|
+
} catch (err) {
|
|
189
|
+
const wrap = new Error(
|
|
190
|
+
`registry fetch failed for ${resolvedUrl}: ${err.message}`,
|
|
191
|
+
);
|
|
192
|
+
wrap.code = 'registry_unreachable';
|
|
193
|
+
wrap.cause = err;
|
|
194
|
+
throw wrap;
|
|
195
|
+
}
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
const err = new Error(
|
|
198
|
+
`registry fetch returned ${res.status} ${res.statusText} from ${resolvedUrl}`,
|
|
199
|
+
);
|
|
200
|
+
err.code = 'registry_unreachable';
|
|
201
|
+
err.status = res.status;
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
let json;
|
|
205
|
+
try {
|
|
206
|
+
json = await res.json();
|
|
207
|
+
} catch (err) {
|
|
208
|
+
const wrap = new Error(
|
|
209
|
+
`registry at ${resolvedUrl} is not valid JSON: ${err.message}`,
|
|
210
|
+
);
|
|
211
|
+
wrap.code = 'invalid_registry';
|
|
212
|
+
wrap.cause = err;
|
|
213
|
+
throw wrap;
|
|
214
|
+
}
|
|
215
|
+
const validated = validateRegistry(json);
|
|
216
|
+
_cache = { fetchedAt: now, url: resolvedUrl, data: validated };
|
|
217
|
+
return validated;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Reset the in-memory cache. Tests use this to force a re-fetch after
|
|
222
|
+
* stubbing globalThis.fetch. Production code shouldn't need it.
|
|
223
|
+
*/
|
|
224
|
+
export function __resetCache() {
|
|
225
|
+
_cache = null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Normalize a string for fuzzy search comparison. Lowercases, strips
|
|
230
|
+
* punctuation, collapses whitespace.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} s
|
|
233
|
+
*/
|
|
234
|
+
function normalize(s) {
|
|
235
|
+
return String(s || '')
|
|
236
|
+
.toLowerCase()
|
|
237
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
238
|
+
.trim();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Does the plugin match the query? Match is by substring on name +
|
|
243
|
+
* description + id, after normalization. Empty query matches all.
|
|
244
|
+
*
|
|
245
|
+
* @param {RegistryPlugin} plugin
|
|
246
|
+
* @param {string} query
|
|
247
|
+
*/
|
|
248
|
+
function matchesQuery(plugin, query) {
|
|
249
|
+
if (!query) return true;
|
|
250
|
+
const n = normalize(query);
|
|
251
|
+
const haystack = `${plugin.id} ${plugin.name} ${plugin.description || ''} ${(plugin.tags || []).join(' ')}`;
|
|
252
|
+
const h = normalize(haystack);
|
|
253
|
+
// Substring match is enough for a v1 marketplace. v2 can swap in fuse.js.
|
|
254
|
+
return h.includes(n);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Search the registry. Returns the matching plugins in registry order.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} [query]
|
|
261
|
+
* @param {object} [opts]
|
|
262
|
+
* @param {string} [opts.category] filter by exact category
|
|
263
|
+
* @param {string} [opts.tag] filter by tag (exact)
|
|
264
|
+
* @param {string} [opts.url] registry URL override
|
|
265
|
+
* @returns {Promise<RegistryPlugin[]>}
|
|
266
|
+
*/
|
|
267
|
+
export async function searchPlugins(query = '', opts = {}) {
|
|
268
|
+
const registry = await fetchRegistry({ url: opts.url });
|
|
269
|
+
const { category, tag } = opts;
|
|
270
|
+
return registry.plugins.filter((p) => {
|
|
271
|
+
if (category && p.category !== category) return false;
|
|
272
|
+
if (tag && !(Array.isArray(p.tags) && p.tags.includes(tag))) return false;
|
|
273
|
+
if (!matchesQuery(p, query)) return false;
|
|
274
|
+
return true;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get a single plugin by id from the registry. Returns null if not found.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} id
|
|
282
|
+
* @param {object} [opts]
|
|
283
|
+
* @param {string} [opts.url]
|
|
284
|
+
* @param {typeof globalThis.fetch} [opts.fetch] override fetch (tests)
|
|
285
|
+
* @returns {Promise<RegistryPlugin | null>}
|
|
286
|
+
*/
|
|
287
|
+
export async function getPlugin(id, opts = {}) {
|
|
288
|
+
if (!id || typeof id !== 'string') return null;
|
|
289
|
+
const registry = await fetchRegistry({ url: opts.url, fetch: opts.fetch });
|
|
290
|
+
return registry.plugins.find((p) => p.id === id) || null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Stream a file from disk, compute its SHA-256, and compare against the
|
|
295
|
+
* expected `<algo>:<hex>` string. Returns true on match, false on
|
|
296
|
+
* mismatch. Throws on unsupported algorithm or file-read error.
|
|
297
|
+
*
|
|
298
|
+
* The expected format is `sha256:<64-lowercase-hex>`. Other algorithms
|
|
299
|
+
* are rejected up front rather than silently miscomputed.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} filePath
|
|
302
|
+
* @param {string} expected e.g. "sha256:abc..."
|
|
303
|
+
* @returns {Promise<boolean>}
|
|
304
|
+
*/
|
|
305
|
+
export function verifyChecksum(filePath, expected) {
|
|
306
|
+
return new Promise((resolveP, rejectP) => {
|
|
307
|
+
if (typeof expected !== 'string' || !expected.includes(':')) {
|
|
308
|
+
rejectP(new Error(`checksum must be <algo>:<hex> (got ${JSON.stringify(expected)})`));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const [algo, expectedHex] = expected.split(':', 2);
|
|
312
|
+
if (algo !== 'sha256') {
|
|
313
|
+
rejectP(new Error(`only sha256 checksums are supported (got "${algo}")`));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (!/^[a-f0-9]{64}$/.test(expectedHex)) {
|
|
317
|
+
rejectP(new Error(`sha256 checksum must be 64 lowercase hex chars`));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const hash = createHash('sha256');
|
|
321
|
+
const stream = createReadStream(filePath);
|
|
322
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
323
|
+
stream.on('error', (err) => rejectP(err));
|
|
324
|
+
stream.on('end', () => {
|
|
325
|
+
const actual = hash.digest('hex');
|
|
326
|
+
resolveP(actual === expectedHex);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @typedef {{
|
|
333
|
+
* id: string,
|
|
334
|
+
* name: string,
|
|
335
|
+
* version: string,
|
|
336
|
+
* description?: string,
|
|
337
|
+
* author?: string,
|
|
338
|
+
* category?: string,
|
|
339
|
+
* tags?: string[],
|
|
340
|
+
* homepage?: string,
|
|
341
|
+
* tarball: string,
|
|
342
|
+
* checksum: string,
|
|
343
|
+
* permissions: string[],
|
|
344
|
+
* minBizarVersion?: string
|
|
345
|
+
* }} RegistryPlugin
|
|
346
|
+
*
|
|
347
|
+
* @typedef {{
|
|
348
|
+
* version: number,
|
|
349
|
+
* updatedAt: string,
|
|
350
|
+
* plugins: RegistryPlugin[]
|
|
351
|
+
* }} RegistryShape
|
|
352
|
+
*/
|
|
353
|
+
|
|
354
|
+
// Export the symbol type aliases for downstream tooling.
|
|
355
|
+
export const __types = /** @type {{
|
|
356
|
+
RegistryPlugin: null,
|
|
357
|
+
RegistryShape: null,
|
|
358
|
+
}} */ ({});
|
|
359
|
+
|
|
360
|
+
export const __testing = {
|
|
361
|
+
CACHE_TTL_MS,
|
|
362
|
+
DEFAULT_REGISTRY_URL,
|
|
363
|
+
};
|