@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
|
@@ -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
|
-
|
|
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':
|