@polderlabs/bizar 4.8.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
  2. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
  3. package/bizar-dash/dist/assets/index-DmpSFPJY.js +9 -0
  4. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
  6. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-ZAfGKENE.css} +1 -1
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-DHXXbn1A.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/skills/eval/SKILL.md +237 -0
  20. package/bizar-dash/src/server/api.mjs +28 -0
  21. package/bizar-dash/src/server/auth.mjs +155 -1
  22. package/bizar-dash/src/server/eval-store.mjs +226 -0
  23. package/bizar-dash/src/server/eval.mjs +347 -0
  24. package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
  25. package/bizar-dash/src/server/memory-store.mjs +121 -0
  26. package/bizar-dash/src/server/ocr.mjs +55 -0
  27. package/bizar-dash/src/server/otel.mjs +133 -0
  28. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  29. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  30. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  31. package/bizar-dash/src/server/routes/chat.mjs +246 -170
  32. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  33. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  34. package/bizar-dash/src/server/routes/memory.mjs +46 -0
  35. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  36. package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
  37. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  38. package/bizar-dash/src/server/routes/users.mjs +84 -0
  39. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  40. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  41. package/bizar-dash/src/server/server.mjs +40 -0
  42. package/bizar-dash/src/server/voice-store.mjs +202 -0
  43. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  44. package/bizar-dash/src/server/workspaces.mjs +626 -0
  45. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  46. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  47. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  48. package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
  49. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  50. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  51. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  52. package/bizar-dash/src/web/lib/search.ts +115 -0
  53. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
  54. package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
  55. package/bizar-dash/src/web/styles/memory.css +166 -1
  56. package/bizar-dash/src/web/styles/settings.css +80 -0
  57. package/bizar-dash/src/web/views/Memory.tsx +22 -2
  58. package/bizar-dash/src/web/views/Settings.tsx +99 -0
  59. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  60. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  61. package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
  62. package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
  63. package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
  64. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  65. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  66. package/bizar-dash/tests/bundle-analysis.test.mjs +73 -0
  67. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  68. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  69. package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
  70. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  71. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  72. package/bizar-dash/tests/docker-build.test.mjs +96 -0
  73. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  74. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  75. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  76. package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
  77. package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
  78. package/bizar-dash/tests/memory-graph.test.mjs +95 -0
  79. package/bizar-dash/tests/ocr.test.mjs +87 -0
  80. package/bizar-dash/tests/otel.test.mjs +188 -0
  81. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  82. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  83. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  84. package/bizar-dash/tests/users.test.mjs +108 -0
  85. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  86. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  87. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  88. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  89. package/cli/bin.mjs +72 -2
  90. package/cli/commands/clip.mjs +146 -0
  91. package/cli/commands/dash.mjs +6 -0
  92. package/cli/commands/deploy/cloudflare.mjs +250 -0
  93. package/cli/commands/deploy/docker.mjs +221 -0
  94. package/cli/commands/deploy/fly.mjs +161 -0
  95. package/cli/commands/deploy/vercel.mjs +225 -0
  96. package/cli/commands/deploy.mjs +240 -0
  97. package/cli/commands/eval.mjs +378 -0
  98. package/cli/commands/marketplace.mjs +64 -0
  99. package/cli/commands/ocr.mjs +165 -0
  100. package/cli/commands/plugin.mjs +358 -0
  101. package/cli/commands/voice.mjs +211 -0
  102. package/cli/commands/workspace.mjs +247 -0
  103. package/package.json +12 -2
  104. package/templates/deploy/cloudflare/README.md +32 -0
  105. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  106. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  107. package/templates/deploy/docker/.env.template +16 -0
  108. package/templates/deploy/docker/README.md +58 -0
  109. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  110. package/templates/deploy/fly/README.md +35 -0
  111. package/templates/deploy/fly/fly.toml.template +28 -0
  112. package/templates/deploy/vercel/README.md +29 -0
  113. package/templates/deploy/vercel/api-index.template.js +18 -0
  114. package/templates/deploy/vercel/vercel.json.template +16 -0
  115. package/templates/eval-fixtures/README.md +58 -0
  116. package/templates/eval-fixtures/code-search-basic.json +28 -0
  117. package/templates/eval-fixtures/latency-bounds.json +16 -0
  118. package/templates/eval-fixtures/regression-suite.json +79 -0
  119. package/templates/eval-fixtures/response-format.json +30 -0
  120. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  121. package/templates/plugin-template/README.md +121 -0
  122. package/templates/plugin-template/index.js +66 -0
  123. package/templates/plugin-template/plugin.json +42 -0
  124. package/templates/plugin-template/tests/plugin.test.js +83 -0
  125. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
  126. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
  127. package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
  128. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
  129. package/bizar-dash/dist/assets/mobile-Chvf9u_B.js +0 -1
@@ -0,0 +1,527 @@
1
+ /**
2
+ * tests/workspaces.test.mjs
3
+ *
4
+ * v5.0.0 — Tests for the workspaces module.
5
+ */
6
+ import { describe, it, beforeEach, afterEach, before, after } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import {
11
+ mkdirSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ existsSync,
15
+ readFileSync,
16
+ readdirSync,
17
+ statSync,
18
+ } from 'node:fs';
19
+
20
+ // Override BIZAR_HOME to use a temp directory
21
+ const TEST_ID = `bizar-ws-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
22
+ const TEST_HOME = join(tmpdir(), TEST_ID);
23
+ const BIZAR_LOCAL = join(TEST_HOME, '.local', 'share', 'bizar');
24
+ const WORKSPACES_ROOT = join(BIZAR_LOCAL, 'workspaces');
25
+
26
+ // Store original HOME and set test HOME before importing
27
+ const originalHomedir = process.env.HOME;
28
+ process.env.HOME = TEST_HOME;
29
+
30
+ async function importWorkspaces() {
31
+ // Re-import with our mocked HOME env - use createRequire to handle cache
32
+ const modulePath = `../src/server/workspaces.mjs?ts=${Date.now()}`;
33
+ return import(modulePath);
34
+ }
35
+
36
+ describe('workspaces', () => {
37
+ let cleanupDirs = [];
38
+
39
+ beforeEach(() => {
40
+ // Create fresh temp directory
41
+ mkdirSync(BIZAR_LOCAL, { recursive: true });
42
+ cleanupDirs.push(TEST_HOME);
43
+ });
44
+
45
+ afterEach(() => {
46
+ for (const dir of cleanupDirs) {
47
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
48
+ }
49
+ cleanupDirs = [];
50
+ });
51
+
52
+ after(() => {
53
+ process.env.HOME = originalHomedir;
54
+ });
55
+
56
+ // ── Default workspace creation ───────────────────────────────────────────────
57
+
58
+ describe('ensureDefaultWorkspace', () => {
59
+ it('creates a default workspace when none exist', async () => {
60
+ const ws = await importWorkspaces();
61
+ const result = await ws.ensureDefaultWorkspace({
62
+ userId: 'usr_test123',
63
+ email: 'test@example.com',
64
+ name: 'Test User',
65
+ });
66
+
67
+ assert.ok(result.workspace);
68
+ assert.strictEqual(result.workspace.name, 'Default Workspace');
69
+ assert.strictEqual(result.workspace.ownerId, 'usr_test123');
70
+ assert.ok(result.workspace.id.startsWith('ws_'));
71
+ });
72
+
73
+ it('returns existing workspace if one already exists', async () => {
74
+ const ws = await importWorkspaces();
75
+ const first = await ws.ensureDefaultWorkspace({
76
+ userId: 'usr_test123',
77
+ email: 'test@example.com',
78
+ name: 'Test User',
79
+ });
80
+
81
+ const second = await ws.ensureDefaultWorkspace({
82
+ userId: 'usr_test456',
83
+ email: 'other@example.com',
84
+ name: 'Other User',
85
+ });
86
+
87
+ assert.strictEqual(first.workspace.id, second.workspace.id);
88
+ });
89
+
90
+ it('sets file mode 0700 on workspace directories', async () => {
91
+ const ws = await importWorkspaces();
92
+ await ws.ensureDefaultWorkspace({
93
+ userId: 'usr_test123',
94
+ email: 'test@example.com',
95
+ name: 'Test User',
96
+ });
97
+
98
+ const stat = statSync(WORKSPACES_ROOT);
99
+ assert.strictEqual(stat.mode & 0o777, 0o700);
100
+ });
101
+ });
102
+
103
+ // ── Workspace CRUD ───────────────────────────────────────────────────────────
104
+
105
+ describe('createWorkspace', () => {
106
+ it('creates a new workspace with a unique id', async () => {
107
+ const ws = await importWorkspaces();
108
+ const result = await ws.createWorkspace({
109
+ name: 'My Workspace',
110
+ ownerId: 'usr_owner',
111
+ });
112
+
113
+ assert.ok(result.id.startsWith('ws_'));
114
+ assert.strictEqual(result.name, 'My Workspace');
115
+ assert.strictEqual(result.ownerId, 'usr_owner');
116
+ assert.ok(result.createdAt);
117
+ });
118
+
119
+ it('persists the workspace to index.json', async () => {
120
+ const ws = await importWorkspaces();
121
+ const created = await ws.createWorkspace({
122
+ name: 'Persisted',
123
+ ownerId: 'usr_owner',
124
+ });
125
+
126
+ const indexFile = join(WORKSPACES_ROOT, 'index.json');
127
+ assert.ok(existsSync(indexFile));
128
+ const index = JSON.parse(readFileSync(indexFile, 'utf8'));
129
+ assert.ok(index.workspaces.some((w) => w.id === created.id));
130
+ });
131
+ });
132
+
133
+ describe('getWorkspace', () => {
134
+ it('returns workspace by id', async () => {
135
+ const ws = await importWorkspaces();
136
+ const created = await ws.createWorkspace({
137
+ name: 'Find Me',
138
+ ownerId: 'usr_owner',
139
+ });
140
+
141
+ const found = await ws.getWorkspace(created.id);
142
+ assert.ok(found);
143
+ assert.strictEqual(found.id, created.id);
144
+ assert.strictEqual(found.name, 'Find Me');
145
+ });
146
+
147
+ it('returns null for non-existent workspace', async () => {
148
+ const ws = await importWorkspaces();
149
+ const found = await ws.getWorkspace('ws_nonexistent');
150
+ assert.strictEqual(found, null);
151
+ });
152
+ });
153
+
154
+ describe('listWorkspaces', () => {
155
+ it('returns only workspaces the user is a member of', async () => {
156
+ const ws = await importWorkspaces();
157
+ const created = await ws.createWorkspace({
158
+ name: 'Member Workspace',
159
+ ownerId: 'usr_owner',
160
+ });
161
+
162
+ const list = await ws.listWorkspaces('usr_owner');
163
+ assert.ok(list.some((w) => w.id === created.id));
164
+
165
+ // User who is not a member should not see it
166
+ const listOther = await ws.listWorkspaces('usr_stranger');
167
+ assert.ok(!listOther.some((w) => w.id === created.id));
168
+ });
169
+ });
170
+
171
+ describe('deleteWorkspace', () => {
172
+ it('removes workspace from index', async () => {
173
+ const ws = await importWorkspaces();
174
+ const created = await ws.createWorkspace({
175
+ name: 'To Delete',
176
+ ownerId: 'usr_owner',
177
+ });
178
+
179
+ await ws.deleteWorkspace(created.id);
180
+ const found = await ws.getWorkspace(created.id);
181
+ assert.strictEqual(found, null);
182
+ });
183
+
184
+ it('throws 404 for non-existent workspace', async () => {
185
+ const ws = await importWorkspaces();
186
+ await assert.rejects(
187
+ ws.deleteWorkspace('ws_nonexistent'),
188
+ (err) => err.status === 404,
189
+ );
190
+ });
191
+ });
192
+
193
+ // ── Member management ────────────────────────────────────────────────────────
194
+
195
+ describe('addMember', () => {
196
+ it('adds a member with the specified role', async () => {
197
+ const ws = await importWorkspaces();
198
+ const created = await ws.createWorkspace({
199
+ name: 'Team Workspace',
200
+ ownerId: 'usr_owner',
201
+ });
202
+
203
+ await ws.addMember(created.id, 'usr_newmember', ws.ROLES.EDITOR);
204
+
205
+ const members = await ws.listMembers(created.id);
206
+ const added = members.find((m) => m.userId === 'usr_newmember');
207
+ assert.ok(added);
208
+ assert.strictEqual(added.role, 'editor');
209
+ });
210
+
211
+ it('throws on invalid role', async () => {
212
+ const ws = await importWorkspaces();
213
+ const created = await ws.createWorkspace({
214
+ name: 'Invalid Role Test',
215
+ ownerId: 'usr_owner',
216
+ });
217
+
218
+ await assert.rejects(
219
+ ws.addMember(created.id, 'usr_newmember', 'invalid_role'),
220
+ (err) => err.status === 400,
221
+ );
222
+ });
223
+
224
+ it('throws when adding duplicate member', async () => {
225
+ const ws = await importWorkspaces();
226
+ const created = await ws.createWorkspace({
227
+ name: 'Duplicate Test',
228
+ ownerId: 'usr_owner',
229
+ });
230
+
231
+ await ws.addMember(created.id, 'usr_newmember', ws.ROLES.VIEWER);
232
+ await assert.rejects(
233
+ ws.addMember(created.id, 'usr_newmember', ws.ROLES.EDITOR),
234
+ (err) => err.status === 409,
235
+ );
236
+ });
237
+ });
238
+
239
+ describe('removeMember', () => {
240
+ it('removes a member from the workspace', async () => {
241
+ const ws = await importWorkspaces();
242
+ const created = await ws.createWorkspace({
243
+ name: 'Remove Test',
244
+ ownerId: 'usr_owner',
245
+ });
246
+
247
+ await ws.addMember(created.id, 'usr_toremove', ws.ROLES.VIEWER);
248
+ await ws.removeMember(created.id, 'usr_toremove');
249
+
250
+ const members = await ws.listMembers(created.id);
251
+ assert.ok(!members.some((m) => m.userId === 'usr_toremove'));
252
+ });
253
+
254
+ it('throws 404 when removing non-member', async () => {
255
+ const ws = await importWorkspaces();
256
+ const created = await ws.createWorkspace({
257
+ name: 'Non-member Test',
258
+ ownerId: 'usr_owner',
259
+ });
260
+
261
+ await assert.rejects(
262
+ ws.removeMember(created.id, 'usr_notmember'),
263
+ (err) => err.status === 404,
264
+ );
265
+ });
266
+ });
267
+
268
+ describe('updateMemberRole', () => {
269
+ it('updates a member role', async () => {
270
+ const ws = await importWorkspaces();
271
+ const created = await ws.createWorkspace({
272
+ name: 'Role Update Test',
273
+ ownerId: 'usr_owner',
274
+ });
275
+
276
+ await ws.addMember(created.id, 'usr_member', ws.ROLES.VIEWER);
277
+ await ws.updateMemberRole(created.id, 'usr_member', ws.ROLES.EDITOR);
278
+
279
+ const members = await ws.listMembers(created.id);
280
+ const updated = members.find((m) => m.userId === 'usr_member');
281
+ assert.strictEqual(updated.role, 'editor');
282
+ });
283
+ });
284
+
285
+ // ── Invite system ────────────────────────────────────────────────────────────
286
+
287
+ describe('createInvite', () => {
288
+ it('creates an invite with token and URL', async () => {
289
+ const ws = await importWorkspaces();
290
+ const created = await ws.createWorkspace({
291
+ name: 'Invite Test',
292
+ ownerId: 'usr_owner',
293
+ });
294
+
295
+ const result = await ws.createInvite(created.id, 'guest@example.com', ws.ROLES.EDITOR, 'usr_owner');
296
+
297
+ assert.ok(result.token);
298
+ assert.ok(result.token.length > 20);
299
+ assert.ok(result.url.includes(result.token));
300
+ });
301
+
302
+ it('throws on invalid role', async () => {
303
+ const ws = await importWorkspaces();
304
+ const created = await ws.createWorkspace({
305
+ name: 'Invalid Invite Role',
306
+ ownerId: 'usr_owner',
307
+ });
308
+
309
+ await assert.rejects(
310
+ ws.createInvite(created.id, 'guest@example.com', 'invalid', 'usr_owner'),
311
+ (err) => err.status === 400,
312
+ );
313
+ });
314
+ });
315
+
316
+ describe('listInvites', () => {
317
+ it('returns non-expired invites', async () => {
318
+ const ws = await importWorkspaces();
319
+ const created = await ws.createWorkspace({
320
+ name: 'List Invites Test',
321
+ ownerId: 'usr_owner',
322
+ });
323
+
324
+ await ws.createInvite(created.id, 'a@example.com', ws.ROLES.EDITOR, 'usr_owner');
325
+ await ws.createInvite(created.id, 'b@example.com', ws.ROLES.VIEWER, 'usr_owner');
326
+
327
+ const invites = await ws.listInvites(created.id);
328
+ assert.strictEqual(invites.length, 2);
329
+ // Tokens should not be exposed in list
330
+ assert.ok(!invites[0].token);
331
+ assert.ok(!invites[0].url);
332
+ });
333
+ });
334
+
335
+ describe('acceptInvite', () => {
336
+ it('accepts invite and adds user as member', async () => {
337
+ const ws = await importWorkspaces();
338
+ const created = await ws.createWorkspace({
339
+ name: 'Accept Invite Test',
340
+ ownerId: 'usr_owner',
341
+ });
342
+
343
+ const { token } = await ws.createInvite(created.id, 'newuser@example.com', ws.ROLES.EDITOR, 'usr_owner');
344
+
345
+ const result = await ws.acceptInvite(token, 'usr_newuser', 'newuser@example.com', 'New User');
346
+
347
+ assert.ok(result.workspace);
348
+ assert.strictEqual(result.role, 'editor');
349
+
350
+ const members = await ws.listMembers(created.id);
351
+ const added = members.find((m) => m.userId === 'usr_newuser');
352
+ assert.ok(added);
353
+ assert.strictEqual(added.role, 'editor');
354
+ });
355
+
356
+ it('throws 404 for invalid token', async () => {
357
+ const ws = await importWorkspaces();
358
+ await assert.rejects(
359
+ ws.acceptInvite('invalid_token_123', 'usr_newuser', 'new@example.com', 'New User'),
360
+ (err) => err.status === 404,
361
+ );
362
+ });
363
+
364
+ it('throws 410 for expired invite', async () => {
365
+ const ws = await importWorkspaces();
366
+
367
+ // Manually create an expired invite by writing directly to the file
368
+ const created = await ws.createWorkspace({
369
+ name: 'Expired Invite Test',
370
+ ownerId: 'usr_owner',
371
+ });
372
+
373
+ const expiredInvite = {
374
+ token: 'expired_token_123',
375
+ email: 'old@example.com',
376
+ role: 'editor',
377
+ invitedBy: 'usr_owner',
378
+ expiresAt: new Date(Date.now() - 1000).toISOString(), // expired 1ms ago
379
+ };
380
+
381
+ const invitesFile = join(WORKSPACES_ROOT, created.id, 'invites.json');
382
+ mkdirSync(join(WORKSPACES_ROOT, created.id), { recursive: true });
383
+ writeFileSync(invitesFile, JSON.stringify([expiredInvite]));
384
+
385
+ await assert.rejects(
386
+ ws.acceptInvite('expired_token_123', 'usr_user', 'old@example.com', 'Old User'),
387
+ (err) => err.status === 410,
388
+ );
389
+ });
390
+ });
391
+
392
+ describe('revokeInvite', () => {
393
+ it('removes invite from workspace', async () => {
394
+ const ws = await importWorkspaces();
395
+ const created = await ws.createWorkspace({
396
+ name: 'Revoke Test',
397
+ ownerId: 'usr_owner',
398
+ });
399
+
400
+ const { token } = await ws.createInvite(created.id, 'torevoke@example.com', ws.ROLES.VIEWER, 'usr_owner');
401
+ await ws.revokeInvite(created.id, token);
402
+
403
+ const invites = await ws.listInvites(created.id);
404
+ assert.strictEqual(invites.length, 0);
405
+ });
406
+
407
+ it('throws 404 for non-existent invite', async () => {
408
+ const ws = await importWorkspaces();
409
+ const created = await ws.createWorkspace({
410
+ name: 'Non-existent Revoke Test',
411
+ ownerId: 'usr_owner',
412
+ });
413
+
414
+ await assert.rejects(
415
+ ws.revokeInvite(created.id, 'nonexistent_token'),
416
+ (err) => err.status === 404,
417
+ );
418
+ });
419
+ });
420
+
421
+ // ── Permission checks ────────────────────────────────────────────────────────
422
+
423
+ describe('checkPermission', () => {
424
+ it('admin has all permissions', async () => {
425
+ const ws = await importWorkspaces();
426
+ const created = await ws.createWorkspace({
427
+ name: 'Permission Test',
428
+ ownerId: 'usr_admin',
429
+ });
430
+
431
+ // Owner is already added as admin by createWorkspace
432
+ assert.ok(await ws.checkPermission(created.id, 'usr_admin', ws.ROLES.ADMIN));
433
+ assert.ok(await ws.checkPermission(created.id, 'usr_admin', ws.ROLES.EDITOR));
434
+ assert.ok(await ws.checkPermission(created.id, 'usr_admin', ws.ROLES.VIEWER));
435
+ });
436
+
437
+ it('editor has editor and viewer permissions', async () => {
438
+ const ws = await importWorkspaces();
439
+ const created = await ws.createWorkspace({
440
+ name: 'Editor Perms Test',
441
+ ownerId: 'usr_admin',
442
+ });
443
+
444
+ // Owner is already admin, add a separate editor
445
+ await ws.addMember(created.id, 'usr_editor', ws.ROLES.EDITOR);
446
+
447
+ assert.ok(!await ws.checkPermission(created.id, 'usr_editor', ws.ROLES.ADMIN));
448
+ assert.ok(await ws.checkPermission(created.id, 'usr_editor', ws.ROLES.EDITOR));
449
+ assert.ok(await ws.checkPermission(created.id, 'usr_editor', ws.ROLES.VIEWER));
450
+ });
451
+
452
+ it('viewer has only viewer permission', async () => {
453
+ const ws = await importWorkspaces();
454
+ const created = await ws.createWorkspace({
455
+ name: 'Viewer Perms Test',
456
+ ownerId: 'usr_admin',
457
+ });
458
+
459
+ // Owner is already admin, add a separate viewer
460
+ await ws.addMember(created.id, 'usr_viewer', ws.ROLES.VIEWER);
461
+
462
+ assert.ok(!await ws.checkPermission(created.id, 'usr_viewer', ws.ROLES.ADMIN));
463
+ assert.ok(!await ws.checkPermission(created.id, 'usr_viewer', ws.ROLES.EDITOR));
464
+ assert.ok(await ws.checkPermission(created.id, 'usr_viewer', ws.ROLES.VIEWER));
465
+ });
466
+
467
+ it('non-member has no permissions', async () => {
468
+ const ws = await importWorkspaces();
469
+ const created = await ws.createWorkspace({
470
+ name: 'Non-member Perms Test',
471
+ ownerId: 'usr_admin',
472
+ });
473
+
474
+ // Owner is already admin, check stranger has no permissions
475
+ assert.ok(!await ws.checkPermission(created.id, 'usr_stranger', ws.ROLES.VIEWER));
476
+ });
477
+ });
478
+
479
+ // ── User management ───────────────────────────────────────────────────────────
480
+
481
+ describe('getOrCreateUser', () => {
482
+ it('creates a new user', async () => {
483
+ const ws = await importWorkspaces();
484
+ const { user, created } = await ws.getOrCreateUser('newuser@example.com', 'New User');
485
+
486
+ assert.ok(created);
487
+ assert.ok(user.id.startsWith('usr_'));
488
+ assert.strictEqual(user.email, 'newuser@example.com');
489
+ assert.strictEqual(user.name, 'New User');
490
+ });
491
+
492
+ it('returns existing user without creating', async () => {
493
+ const ws = await importWorkspaces();
494
+ const first = await ws.getOrCreateUser('existing@example.com', 'Existing');
495
+ const second = await ws.getOrCreateUser('existing@example.com', 'Existing');
496
+
497
+ assert.ok(!second.created);
498
+ assert.strictEqual(first.user.id, second.user.id);
499
+ });
500
+ });
501
+
502
+ describe('updateUser', () => {
503
+ it('updates user name', async () => {
504
+ const ws = await importWorkspaces();
505
+ const { user } = await ws.getOrCreateUser('update@example.com', 'Old Name');
506
+
507
+ const updated = await ws.updateUser(user.id, { name: 'New Name' });
508
+ assert.strictEqual(updated.name, 'New Name');
509
+ });
510
+
511
+ it('updates user email', async () => {
512
+ const ws = await importWorkspaces();
513
+ const { user } = await ws.getOrCreateUser('old@example.com', 'User');
514
+
515
+ const updated = await ws.updateUser(user.id, { email: 'new@example.com' });
516
+ assert.strictEqual(updated.email, 'new@example.com');
517
+ });
518
+
519
+ it('throws 404 for non-existent user', async () => {
520
+ const ws = await importWorkspaces();
521
+ await assert.rejects(
522
+ ws.updateUser('usr_nonexistent', { name: 'Test' }),
523
+ (err) => err.status === 404,
524
+ );
525
+ });
526
+ });
527
+ });
package/cli/bin.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  * Commands:
14
14
  * install, audit, init, export, artifact, update, test-gate, service, dash,
15
15
  * memory, headroom, minimax, usage, mod, doctor, repair, dev-link, dev-unlink,
16
- * heads-up, bg, browser-harness-up, providers
16
+ * heads-up, bg, browser-harness-up, providers, deploy, plugin, marketplace
17
17
  */
18
18
  import chalk from 'chalk';
19
19
  import { existsSync, readFileSync } from 'node:fs';
@@ -110,8 +110,13 @@ function showHelp() {
110
110
  dev-unlink Remove the dev symlink and restore the deployed copy
111
111
  heads-up <subcommand> Manage pre-push / pre-release heads-ups
112
112
  bg <subcommand> Manage background agents (list/view/kill/logs)
113
+ deploy One-click deploy to Vercel, Cloudflare, Fly.io, or Docker
114
+ plugin <subcommand> Manage marketplace plugins (search/install/config/invoke)
115
+ marketplace <subcommand> Browse and install plugins from the public marketplace
113
116
  browser-harness-up Start Chromium for browser-harness (start/stop/status)
114
117
  providers detect Auto-detect provider API keys from env + opencode.json
118
+ clip <subcommand> Manage web clipper saved clips (list/delete/configure)
119
+ ocr <subcommand> OCR operations on images (list/process/configure)
115
120
 
116
121
  Examples:
117
122
  bizar install
@@ -317,7 +322,72 @@ async function main() {
317
322
  break;
318
323
  }
319
324
 
320
- // All other utility commands
325
+ case 'deploy': {
326
+ const mod = await importCommand('deploy');
327
+ if (!mod) {
328
+ console.error(chalk.red(` ✗ Could not load deploy command module`));
329
+ process.exit(EXIT_ERROR);
330
+ return;
331
+ }
332
+ dbg('loaded command module:', 'deploy');
333
+ await mod.run(cmd, cmdArgs, isHelpRequest);
334
+ dbg('command returned:', cmd);
335
+ break;
336
+ }
337
+
338
+ case 'plugin': {
339
+ const mod = await importCommand('plugin');
340
+ if (!mod) {
341
+ console.error(chalk.red(` ✗ Could not load plugin command module`));
342
+ process.exit(EXIT_ERROR);
343
+ return;
344
+ }
345
+ dbg('loaded command module:', 'plugin');
346
+ await mod.run(cmd, cmdArgs, isHelpRequest);
347
+ dbg('command returned:', cmd);
348
+ break;
349
+ }
350
+
351
+ case 'marketplace': {
352
+ const mod = await importCommand('marketplace');
353
+ if (!mod) {
354
+ console.error(chalk.red(` ✗ Could not load marketplace command module`));
355
+ process.exit(EXIT_ERROR);
356
+ return;
357
+ }
358
+ dbg('loaded command module:', 'marketplace');
359
+ await mod.run(cmd, cmdArgs, isHelpRequest);
360
+ dbg('command returned:', cmd);
361
+ break;
362
+ }
363
+
364
+ case 'clip': {
365
+ const mod = await importCommand('clip');
366
+ if (!mod) {
367
+ console.error(chalk.red(` ✗ Could not load clip command module`));
368
+ process.exit(EXIT_ERROR);
369
+ return;
370
+ }
371
+ dbg('loaded command module:', 'clip');
372
+ await mod.run(cmd, cmdArgs, isHelpRequest);
373
+ dbg('command returned:', cmd);
374
+ break;
375
+ }
376
+
377
+ case 'ocr': {
378
+ const mod = await importCommand('ocr');
379
+ if (!mod) {
380
+ console.error(chalk.red(` ✗ Could not load ocr command module`));
381
+ process.exit(EXIT_ERROR);
382
+ return;
383
+ }
384
+ dbg('loaded command module:', 'ocr');
385
+ await mod.run(cmd, cmdArgs, isHelpRequest);
386
+ dbg('command returned:', cmd);
387
+ break;
388
+ }
389
+
390
+ // All other utility commands
321
391
  case 'audit':
322
392
  case 'init':
323
393
  case 'export':