@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
@@ -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
+ };