backpack-viewer 0.6.0 → 0.7.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 (51) hide show
  1. package/bin/serve.js +159 -396
  2. package/dist/app/assets/index-D-H7agBH.js +12 -0
  3. package/dist/app/assets/index-DE73ngo-.css +1 -0
  4. package/dist/app/assets/index-DFW3OKgJ.js +6 -0
  5. package/dist/app/index.html +2 -2
  6. package/dist/bridge.d.ts +22 -0
  7. package/dist/bridge.js +41 -0
  8. package/dist/config.js +10 -0
  9. package/dist/copy-prompt.d.ts +17 -0
  10. package/dist/copy-prompt.js +81 -0
  11. package/dist/default-config.json +4 -0
  12. package/dist/dom-utils.d.ts +46 -0
  13. package/dist/dom-utils.js +57 -0
  14. package/dist/empty-state.js +63 -31
  15. package/dist/extensions/api.d.ts +15 -0
  16. package/dist/extensions/api.js +185 -0
  17. package/dist/extensions/chat/backpack-extension.json +23 -0
  18. package/dist/extensions/chat/src/index.js +32 -0
  19. package/dist/extensions/chat/src/panel.js +306 -0
  20. package/dist/extensions/chat/src/providers/anthropic.js +158 -0
  21. package/dist/extensions/chat/src/providers/types.js +15 -0
  22. package/dist/extensions/chat/src/tools.js +281 -0
  23. package/dist/extensions/chat/style.css +147 -0
  24. package/dist/extensions/event-bus.d.ts +12 -0
  25. package/dist/extensions/event-bus.js +30 -0
  26. package/dist/extensions/loader.d.ts +32 -0
  27. package/dist/extensions/loader.js +71 -0
  28. package/dist/extensions/manifest.d.ts +54 -0
  29. package/dist/extensions/manifest.js +116 -0
  30. package/dist/extensions/panel-mount.d.ts +26 -0
  31. package/dist/extensions/panel-mount.js +377 -0
  32. package/dist/extensions/taskbar.d.ts +29 -0
  33. package/dist/extensions/taskbar.js +64 -0
  34. package/dist/extensions/types.d.ts +182 -0
  35. package/dist/extensions/types.js +8 -0
  36. package/dist/info-panel.d.ts +2 -1
  37. package/dist/info-panel.js +78 -87
  38. package/dist/main.js +189 -29
  39. package/dist/search.js +1 -1
  40. package/dist/server-api-routes.d.ts +56 -0
  41. package/dist/server-api-routes.js +442 -0
  42. package/dist/server-extensions.d.ts +126 -0
  43. package/dist/server-extensions.js +272 -0
  44. package/dist/server-viewer-state.d.ts +18 -0
  45. package/dist/server-viewer-state.js +33 -0
  46. package/dist/sidebar.js +19 -7
  47. package/dist/style.css +356 -74
  48. package/dist/tools-pane.js +31 -14
  49. package/package.json +4 -3
  50. package/dist/app/assets/index-B3z5bBGl.css +0 -1
  51. package/dist/app/assets/index-BROJmzot.js +0 -35
package/bin/serve.js CHANGED
@@ -83,17 +83,24 @@ async function getCachedVersionCheck(currentVersion) {
83
83
 
84
84
  if (hasDistBuild) {
85
85
  // --- Production: static file server + API (zero native deps) ---
86
- const {
87
- JsonFileBackend,
88
- dataDir,
89
- RemoteRegistry,
90
- listBackpacks,
91
- getActiveBackpack,
92
- setActiveBackpack,
93
- registerBackpack,
94
- unregisterBackpack,
95
- } = await import("backpack-ontology");
86
+ const { JsonFileBackend, RemoteRegistry, getActiveBackpack } = await import(
87
+ "backpack-ontology"
88
+ );
96
89
  const { loadViewerConfig } = await import("../dist/config.js");
90
+ const { writeViewerState, readViewerState } = await import(
91
+ "../dist/server-viewer-state.js"
92
+ );
93
+ const {
94
+ loadExtensions: loadServerExtensions,
95
+ findExtension,
96
+ publicExtensionInfo,
97
+ proxyExtensionFetch,
98
+ readExtensionSettings,
99
+ writeExtensionSetting,
100
+ deleteExtensionSetting,
101
+ resolveExtensionFile,
102
+ } = await import("../dist/server-extensions.js");
103
+ const { handleApiRequest } = await import("../dist/server-api-routes.js");
97
104
 
98
105
  // Load our own version from package.json for the stale-version check
99
106
  const pkgJson = JSON.parse(
@@ -105,15 +112,17 @@ if (hasDistBuild) {
105
112
  // precedence for quick overrides. Default is 127.0.0.1 loopback —
106
113
  // the viewer must never bind to all interfaces by default because
107
114
  // its API exposes read/write access to the user's learning graphs.
108
- const viewerConfigForServer = loadViewerConfig();
109
- const configuredHost = viewerConfigForServer?.server?.host ?? "127.0.0.1";
110
- const configuredPort = viewerConfigForServer?.server?.port ?? 5173;
115
+ const viewerConfig = loadViewerConfig();
116
+ const configuredHost = viewerConfig?.server?.host ?? "127.0.0.1";
117
+ const configuredPort = viewerConfig?.server?.port ?? 5173;
111
118
  const bindHost = process.env.BACKPACK_VIEWER_HOST ?? configuredHost;
112
119
  const port = parseInt(process.env.PORT || String(configuredPort), 10);
113
120
 
114
121
  // Storage points at the active backpack. Wrapped in a mutable
115
122
  // holder so a `/api/backpacks/switch` POST can swap it out in place
116
- // without restarting the whole server.
123
+ // without restarting the whole server. The holder is shared with
124
+ // the API route handler so a swap inside one request is visible to
125
+ // the next request without re-plumbing.
117
126
  async function makeBackend() {
118
127
  const entry = await getActiveBackpack();
119
128
  const backend = new JsonFileBackend(undefined, {
@@ -122,10 +131,36 @@ if (hasDistBuild) {
122
131
  await backend.initialize();
123
132
  return { backend, entry };
124
133
  }
125
- let { backend: storage, entry: activeEntry } = await makeBackend();
134
+ const initial = await makeBackend();
135
+ const storageHolder = { current: initial.backend, activeEntry: initial.entry };
136
+
126
137
  const remoteRegistry = new RemoteRegistry();
127
138
  await remoteRegistry.initialize();
128
- const viewerConfig = loadViewerConfig();
139
+
140
+ // First-party extensions are bundled at dist/extensions/<name>/.
141
+ // External extensions come from the user's viewer config.
142
+ const firstPartyExtensionsDir = path.resolve(distDir, "..", "extensions");
143
+ const extConfig = viewerConfig.extensions ?? {};
144
+ const userExternalExtensions = Array.isArray(extConfig.external)
145
+ ? extConfig.external.filter((e) => e && typeof e.name === "string" && typeof e.path === "string")
146
+ : [];
147
+ const disabledFirstParty = new Set(
148
+ Array.isArray(extConfig.disabled) ? extConfig.disabled : [],
149
+ );
150
+ const loadedExtensions = loadServerExtensions(
151
+ firstPartyExtensionsDir,
152
+ userExternalExtensions,
153
+ disabledFirstParty,
154
+ );
155
+
156
+ // Context object passed to the shared API route handler.
157
+ const apiContext = {
158
+ storage: storageHolder,
159
+ remoteRegistry,
160
+ viewerConfig,
161
+ makeBackend,
162
+ versionCheck: () => getCachedVersionCheck(currentVersion),
163
+ };
129
164
 
130
165
  const MIME_TYPES = {
131
166
  ".html": "text/html",
@@ -139,6 +174,10 @@ if (hasDistBuild) {
139
174
 
140
175
  // Strict CSP — style-src 'self' means no inline styles allowed.
141
176
  // Keep it that way; see CLAUDE.md for the rule.
177
+ //
178
+ // connect-src stays at 'self' — extensions never talk to external
179
+ // origins directly. They go through the per-extension proxy below
180
+ // which forwards server-side using env-var-injected secrets.
142
181
  const CSP = [
143
182
  "default-src 'self'",
144
183
  "script-src 'self'",
@@ -158,143 +197,17 @@ if (hasDistBuild) {
158
197
 
159
198
  const url = req.url?.replace(/\?.*$/, "") || "/";
160
199
 
161
- // --- API routes ---
162
- if (url === "/api/config") {
163
- res.writeHead(200, { "Content-Type": "application/json" });
164
- res.end(JSON.stringify(viewerConfig));
165
- return;
166
- }
167
-
168
- if (url === "/api/version-check" && req.method === "GET") {
169
- try {
170
- const result = await getCachedVersionCheck(currentVersion);
171
- res.writeHead(200, { "Content-Type": "application/json" });
172
- res.end(JSON.stringify(result));
173
- } catch {
174
- res.writeHead(200, { "Content-Type": "application/json" });
175
- res.end(
176
- JSON.stringify({ current: currentVersion, latest: null, stale: false }),
177
- );
178
- }
179
- return;
180
- }
181
-
182
- // --- Remote graph routes (read-only) ---
183
- if (url === "/api/remotes" && req.method === "GET") {
184
- try {
185
- const remotes = await remoteRegistry.list();
186
- const summaries = await Promise.all(
187
- remotes.map(async (r) => {
188
- try {
189
- const data = await remoteRegistry.loadCached(r.name);
190
- return {
191
- name: r.name,
192
- url: r.url,
193
- source: r.source,
194
- addedAt: r.addedAt,
195
- lastFetched: r.lastFetched,
196
- pinned: r.pinned,
197
- sizeBytes: r.sizeBytes,
198
- nodeCount: data.nodes.length,
199
- edgeCount: data.edges.length,
200
- };
201
- } catch {
202
- return {
203
- name: r.name,
204
- url: r.url,
205
- source: r.source,
206
- addedAt: r.addedAt,
207
- lastFetched: r.lastFetched,
208
- pinned: r.pinned,
209
- sizeBytes: r.sizeBytes,
210
- nodeCount: 0,
211
- edgeCount: 0,
212
- };
213
- }
214
- }),
215
- );
216
- res.writeHead(200, { "Content-Type": "application/json" });
217
- res.end(JSON.stringify(summaries));
218
- } catch (err) {
219
- res.writeHead(500, { "Content-Type": "application/json" });
220
- res.end(JSON.stringify({ error: err.message }));
221
- }
222
- return;
223
- }
224
-
225
- const remoteItemMatch = url.match(/^\/api\/remotes\/(.+)$/);
226
- if (remoteItemMatch && req.method === "GET") {
227
- const remoteName = decodeURIComponent(remoteItemMatch[1]);
228
- try {
229
- const data = await remoteRegistry.loadCached(remoteName);
230
- res.writeHead(200, { "Content-Type": "application/json" });
231
- res.end(JSON.stringify(data));
232
- } catch (err) {
233
- res.writeHead(404, { "Content-Type": "application/json" });
234
- res.end(JSON.stringify({ error: err.message }));
235
- }
236
- return;
237
- }
238
-
239
- // --- Branch routes ---
240
- const branchSwitchMatch = url.match(/^\/api\/graphs\/(.+)\/branches\/switch$/);
241
- if (branchSwitchMatch && req.method === "POST") {
242
- const graphName = decodeURIComponent(branchSwitchMatch[1]);
243
- let body = "";
244
- req.on("data", (chunk) => { body += chunk.toString(); });
245
- req.on("end", async () => {
246
- try {
247
- const { name: branchName } = JSON.parse(body);
248
- await storage.switchBranch(graphName, branchName);
249
- res.writeHead(200, { "Content-Type": "application/json" });
250
- res.end(JSON.stringify({ ok: true }));
251
- } catch (err) {
252
- res.writeHead(400, { "Content-Type": "application/json" });
253
- res.end(JSON.stringify({ error: err.message }));
254
- }
255
- });
256
- return;
257
- }
258
-
259
- const deleteBranchMatch = url.match(/^\/api\/graphs\/(.+)\/branches\/(.+)$/);
260
- if (deleteBranchMatch && req.method === "DELETE") {
261
- const graphName = decodeURIComponent(deleteBranchMatch[1]);
262
- const branchName = decodeURIComponent(deleteBranchMatch[2]);
263
- try {
264
- await storage.deleteBranch(graphName, branchName);
265
- res.writeHead(200, { "Content-Type": "application/json" });
266
- res.end(JSON.stringify({ ok: true }));
267
- } catch (err) {
268
- res.writeHead(400, { "Content-Type": "application/json" });
269
- res.end(JSON.stringify({ error: err.message }));
270
- }
271
- return;
272
- }
273
-
274
- const branchMatch = url.match(/^\/api\/graphs\/(.+)\/branches$/);
275
- if (branchMatch && req.method === "GET") {
276
- const graphName = decodeURIComponent(branchMatch[1]);
277
- try {
278
- const branches = await storage.listBranches(graphName);
279
- res.writeHead(200, { "Content-Type": "application/json" });
280
- res.end(JSON.stringify(branches));
281
- } catch (err) {
282
- res.writeHead(500, { "Content-Type": "application/json" });
283
- res.end(JSON.stringify({ error: err.message }));
284
- }
285
- return;
286
- }
287
-
288
- if (branchMatch && req.method === "POST") {
289
- const graphName = decodeURIComponent(branchMatch[1]);
200
+ // --- Viewer state bridge ---
201
+ // Logic lives in src/server-viewer-state.ts so dev (Vite plugin)
202
+ // and prod share it. Only the HTTP wiring is here.
203
+ if (url === "/api/viewer-state" && req.method === "PUT") {
290
204
  let body = "";
291
205
  req.on("data", (chunk) => { body += chunk.toString(); });
292
206
  req.on("end", async () => {
293
207
  try {
294
- const { name: branchName, from } = JSON.parse(body);
295
- await storage.createBranch(graphName, branchName, from);
208
+ await writeViewerState(body);
296
209
  res.writeHead(200, { "Content-Type": "application/json" });
297
- res.end(JSON.stringify({ ok: true }));
210
+ res.end('{"ok":true}');
298
211
  } catch (err) {
299
212
  res.writeHead(400, { "Content-Type": "application/json" });
300
213
  res.end(JSON.stringify({ error: err.message }));
@@ -303,171 +216,103 @@ if (hasDistBuild) {
303
216
  return;
304
217
  }
305
218
 
306
- // --- Snapshot routes ---
307
- const snapshotMatch = url.match(/^\/api\/graphs\/(.+)\/snapshots$/);
308
- if (snapshotMatch && req.method === "GET") {
309
- const graphName = decodeURIComponent(snapshotMatch[1]);
219
+ if (url === "/api/viewer-state" && req.method === "GET") {
310
220
  try {
311
- const snapshots = await storage.listSnapshots(graphName);
221
+ const data = await readViewerState();
312
222
  res.writeHead(200, { "Content-Type": "application/json" });
313
- res.end(JSON.stringify(snapshots));
314
- } catch (err) {
315
- res.writeHead(500, { "Content-Type": "application/json" });
316
- res.end(JSON.stringify({ error: err.message }));
223
+ res.end(data);
224
+ } catch {
225
+ res.writeHead(404, { "Content-Type": "application/json" });
226
+ res.end('{"error":"no viewer state"}');
317
227
  }
318
228
  return;
319
229
  }
320
230
 
321
- if (snapshotMatch && req.method === "POST") {
322
- const graphName = decodeURIComponent(snapshotMatch[1]);
323
- let body = "";
324
- req.on("data", (chunk) => { body += chunk.toString(); });
325
- req.on("end", async () => {
326
- try {
327
- const { label } = JSON.parse(body);
328
- await storage.createSnapshot(graphName, label);
329
- res.writeHead(200, { "Content-Type": "application/json" });
330
- res.end(JSON.stringify({ ok: true }));
331
- } catch (err) {
332
- res.writeHead(400, { "Content-Type": "application/json" });
333
- res.end(JSON.stringify({ error: err.message }));
334
- }
335
- });
336
- return;
337
- }
338
-
339
- // --- Rollback route ---
340
- const rollbackMatch = url.match(/^\/api\/graphs\/(.+)\/rollback$/);
341
- if (rollbackMatch && req.method === "POST") {
342
- const graphName = decodeURIComponent(rollbackMatch[1]);
343
- let body = "";
344
- req.on("data", (chunk) => { body += chunk.toString(); });
345
- req.on("end", async () => {
346
- try {
347
- const { version } = JSON.parse(body);
348
- await storage.rollback(graphName, version);
349
- res.writeHead(200, { "Content-Type": "application/json" });
350
- res.end(JSON.stringify({ ok: true }));
351
- } catch (err) {
352
- res.writeHead(400, { "Content-Type": "application/json" });
353
- res.end(JSON.stringify({ error: err.message }));
354
- }
355
- });
356
- return;
357
- }
231
+ // --- Extension system ---
232
+ // Generic per-extension endpoints. Logic lives in
233
+ // src/server-extensions.ts so dev and prod share it.
358
234
 
359
- // --- Diff route ---
360
- const diffMatch = url.match(/^\/api\/graphs\/(.+)\/diff\/(\d+)$/);
361
- if (diffMatch && req.method === "GET") {
362
- const graphName = decodeURIComponent(diffMatch[1]);
363
- const version = parseInt(diffMatch[2], 10);
364
- try {
365
- const current = await storage.loadOntology(graphName);
366
- const snapshot = await storage.loadSnapshot(graphName, version);
367
- const currentNodeIds = new Set(current.nodes.map(n => n.id));
368
- const snapshotNodeIds = new Set(snapshot.nodes.map(n => n.id));
369
- const currentEdgeIds = new Set(current.edges.map(e => e.id));
370
- const snapshotEdgeIds = new Set(snapshot.edges.map(e => e.id));
371
- const diff = {
372
- nodesAdded: current.nodes.filter(n => !snapshotNodeIds.has(n.id)).length,
373
- nodesRemoved: snapshot.nodes.filter(n => !currentNodeIds.has(n.id)).length,
374
- edgesAdded: current.edges.filter(e => !snapshotEdgeIds.has(e.id)).length,
375
- edgesRemoved: snapshot.edges.filter(e => !currentEdgeIds.has(e.id)).length,
376
- };
377
- res.writeHead(200, { "Content-Type": "application/json" });
378
- res.end(JSON.stringify(diff));
379
- } catch (err) {
380
- res.writeHead(500, { "Content-Type": "application/json" });
381
- res.end(JSON.stringify({ error: err.message }));
382
- }
235
+ if (url === "/api/extensions" && req.method === "GET") {
236
+ res.writeHead(200, { "Content-Type": "application/json" });
237
+ res.end(JSON.stringify(loadedExtensions.map(publicExtensionInfo)));
383
238
  return;
384
239
  }
385
240
 
386
- // --- Snippet routes ---
387
- const snippetItemMatch = url.match(/^\/api\/graphs\/(.+)\/snippets\/(.+)$/);
388
- if (snippetItemMatch && req.method === "GET") {
389
- const graphName = decodeURIComponent(snippetItemMatch[1]);
390
- const snippetId = decodeURIComponent(snippetItemMatch[2]);
391
- try {
392
- const snippet = await storage.loadSnippet(graphName, snippetId);
393
- res.writeHead(200, { "Content-Type": "application/json" });
394
- res.end(JSON.stringify(snippet));
395
- } catch {
241
+ const extFileMatch = url.match(/^\/extensions\/([^/]+)\/(.+)$/);
242
+ if (extFileMatch && req.method === "GET") {
243
+ const extName = decodeURIComponent(extFileMatch[1]);
244
+ const subPath = decodeURIComponent(extFileMatch[2]);
245
+ const filePath = resolveExtensionFile(loadedExtensions, extName, subPath);
246
+ if (!filePath) {
396
247
  res.writeHead(404, { "Content-Type": "application/json" });
397
- res.end(JSON.stringify({ error: "Snippet not found" }));
398
- }
399
- return;
400
- }
401
-
402
- if (snippetItemMatch && req.method === "DELETE") {
403
- const graphName = decodeURIComponent(snippetItemMatch[1]);
404
- const snippetId = decodeURIComponent(snippetItemMatch[2]);
405
- try {
406
- await storage.deleteSnippet(graphName, snippetId);
407
- res.writeHead(200, { "Content-Type": "application/json" });
408
- res.end(JSON.stringify({ ok: true }));
409
- } catch (err) {
410
- res.writeHead(400, { "Content-Type": "application/json" });
411
- res.end(JSON.stringify({ error: err.message }));
248
+ res.end('{"error":"extension file not found"}');
249
+ return;
412
250
  }
413
- return;
414
- }
415
-
416
- const snippetMatch = url.match(/^\/api\/graphs\/(.+)\/snippets$/);
417
- if (snippetMatch && req.method === "GET") {
418
- const graphName = decodeURIComponent(snippetMatch[1]);
419
251
  try {
420
- const snippets = await storage.listSnippets(graphName);
421
- res.writeHead(200, { "Content-Type": "application/json" });
422
- res.end(JSON.stringify(snippets));
252
+ const data = fs.readFileSync(filePath);
253
+ const ext = path.extname(filePath);
254
+ const mime = MIME_TYPES[ext] || "application/octet-stream";
255
+ res.writeHead(200, { "Content-Type": mime });
256
+ res.end(data);
423
257
  } catch {
424
- res.writeHead(200, { "Content-Type": "application/json" });
425
- res.end("[]");
258
+ res.writeHead(404);
259
+ res.end("Not found");
426
260
  }
427
261
  return;
428
262
  }
429
263
 
430
- if (snippetMatch && req.method === "POST") {
431
- const graphName = decodeURIComponent(snippetMatch[1]);
264
+ const extFetchMatch = url.match(/^\/api\/extensions\/([^/]+)\/fetch$/);
265
+ if (extFetchMatch && req.method === "POST") {
266
+ const extName = decodeURIComponent(extFetchMatch[1]);
267
+ const ext = findExtension(loadedExtensions, extName);
268
+ if (!ext) {
269
+ res.writeHead(404, { "Content-Type": "application/json" });
270
+ res.end(JSON.stringify({ error: `unknown extension: ${extName}` }));
271
+ return;
272
+ }
432
273
  let body = "";
433
274
  req.on("data", (chunk) => { body += chunk.toString(); });
434
275
  req.on("end", async () => {
435
276
  try {
436
- const { label, description, nodeIds, edgeIds } = JSON.parse(body);
437
- const id = await storage.saveSnippet(graphName, { label, description, nodeIds, edgeIds: edgeIds ?? [] });
438
- res.writeHead(200, { "Content-Type": "application/json" });
439
- res.end(JSON.stringify({ ok: true, id }));
277
+ const result = await proxyExtensionFetch(ext, body);
278
+ if (result.errorJson || !result.upstreamBody) {
279
+ res.writeHead(result.status, { "Content-Type": "application/json" });
280
+ res.end(result.errorJson ?? JSON.stringify({ error: "proxy failed" }));
281
+ return;
282
+ }
283
+ const upstreamCT =
284
+ result.upstreamHeaders?.get("content-type") ?? "application/octet-stream";
285
+ res.writeHead(result.status, {
286
+ "Content-Type": upstreamCT,
287
+ "Cache-Control": "no-cache",
288
+ Connection: "keep-alive",
289
+ });
290
+ const reader = result.upstreamBody.getReader();
291
+ while (true) {
292
+ const { done, value } = await reader.read();
293
+ if (done) break;
294
+ res.write(value);
295
+ }
296
+ res.end();
440
297
  } catch (err) {
441
- res.writeHead(400, { "Content-Type": "application/json" });
442
- res.end(JSON.stringify({ error: err.message }));
298
+ if (!res.headersSent) {
299
+ res.writeHead(500, { "Content-Type": "application/json" });
300
+ res.end(JSON.stringify({ error: err.message }));
301
+ } else {
302
+ res.end();
303
+ }
443
304
  }
444
305
  });
445
306
  return;
446
307
  }
447
308
 
448
- // --- Backpacks (meta: list, active, switch) ---
449
- if (url === "/api/backpacks" && req.method === "GET") {
450
- try {
451
- const list = await listBackpacks();
452
- const active = await getActiveBackpack();
453
- res.writeHead(200, { "Content-Type": "application/json" });
454
- res.end(
455
- JSON.stringify(
456
- list.map((b) => ({ ...b, active: b.name === active.name })),
457
- ),
458
- );
459
- } catch (err) {
460
- res.writeHead(500, { "Content-Type": "application/json" });
461
- res.end(JSON.stringify({ error: err.message }));
462
- }
463
- return;
464
- }
465
-
466
- if (url === "/api/backpacks/active" && req.method === "GET") {
309
+ const extSettingsAllMatch = url.match(/^\/api\/extensions\/([^/]+)\/settings$/);
310
+ if (extSettingsAllMatch && req.method === "GET") {
311
+ const extName = decodeURIComponent(extSettingsAllMatch[1]);
467
312
  try {
468
- const active = await getActiveBackpack();
313
+ const all = await readExtensionSettings(extName);
469
314
  res.writeHead(200, { "Content-Type": "application/json" });
470
- res.end(JSON.stringify(active));
315
+ res.end(JSON.stringify(all));
471
316
  } catch (err) {
472
317
  res.writeHead(500, { "Content-Type": "application/json" });
473
318
  res.end(JSON.stringify({ error: err.message }));
@@ -475,41 +320,34 @@ if (hasDistBuild) {
475
320
  return;
476
321
  }
477
322
 
478
- if (url === "/api/backpacks/switch" && req.method === "POST") {
479
- let body = "";
480
- req.on("data", (chunk) => { body += chunk.toString(); });
481
- req.on("end", async () => {
323
+ const extSettingsKeyMatch = url.match(/^\/api\/extensions\/([^/]+)\/settings\/([^/]+)$/);
324
+ if (extSettingsKeyMatch && (req.method === "PUT" || req.method === "DELETE")) {
325
+ const extName = decodeURIComponent(extSettingsKeyMatch[1]);
326
+ const key = decodeURIComponent(extSettingsKeyMatch[2]);
327
+ if (req.method === "DELETE") {
482
328
  try {
483
- const { name } = JSON.parse(body);
484
- await setActiveBackpack(name);
485
- const swapped = await makeBackend();
486
- storage = swapped.backend;
487
- activeEntry = swapped.entry;
329
+ await deleteExtensionSetting(extName, key);
488
330
  res.writeHead(200, { "Content-Type": "application/json" });
489
- res.end(JSON.stringify({ ok: true, active: activeEntry }));
331
+ res.end('{"ok":true}');
490
332
  } catch (err) {
491
- res.writeHead(400, { "Content-Type": "application/json" });
333
+ res.writeHead(500, { "Content-Type": "application/json" });
492
334
  res.end(JSON.stringify({ error: err.message }));
493
335
  }
494
- });
495
- return;
496
- }
497
-
498
- if (url === "/api/backpacks" && req.method === "POST") {
336
+ return;
337
+ }
499
338
  let body = "";
500
339
  req.on("data", (chunk) => { body += chunk.toString(); });
501
340
  req.on("end", async () => {
502
341
  try {
503
- const { name, path: p, activate } = JSON.parse(body);
504
- const entry = await registerBackpack(name, p);
505
- if (activate) {
506
- await setActiveBackpack(name);
507
- const swapped = await makeBackend();
508
- storage = swapped.backend;
509
- activeEntry = swapped.entry;
342
+ const parsed = JSON.parse(body);
343
+ if (!("value" in parsed)) {
344
+ res.writeHead(400, { "Content-Type": "application/json" });
345
+ res.end('{"error":"body must include {value}"}');
346
+ return;
510
347
  }
348
+ await writeExtensionSetting(extName, key, parsed.value);
511
349
  res.writeHead(200, { "Content-Type": "application/json" });
512
- res.end(JSON.stringify({ ok: true, entry }));
350
+ res.end('{"ok":true}');
513
351
  } catch (err) {
514
352
  res.writeHead(400, { "Content-Type": "application/json" });
515
353
  res.end(JSON.stringify({ error: err.message }));
@@ -518,93 +356,11 @@ if (hasDistBuild) {
518
356
  return;
519
357
  }
520
358
 
521
- const backpackDeleteMatch = url.match(/^\/api\/backpacks\/(.+)$/);
522
- if (backpackDeleteMatch && req.method === "DELETE") {
523
- const name = decodeURIComponent(backpackDeleteMatch[1]);
524
- try {
525
- await unregisterBackpack(name);
526
- // If we just removed the active one, the registry switched us;
527
- // rebuild the backend to match.
528
- if (activeEntry && activeEntry.name === name) {
529
- const swapped = await makeBackend();
530
- storage = swapped.backend;
531
- activeEntry = swapped.entry;
532
- }
533
- res.writeHead(200, { "Content-Type": "application/json" });
534
- res.end(JSON.stringify({ ok: true }));
535
- } catch (err) {
536
- res.writeHead(400, { "Content-Type": "application/json" });
537
- res.end(JSON.stringify({ error: err.message }));
538
- }
539
- return;
540
- }
541
-
542
- // --- Lock heartbeat ---
543
- if (url === "/api/locks" && req.method === "GET") {
544
- // Batch endpoint: returns { graphName: lockInfo|null } for all graphs.
545
- // One request instead of N on every sidebar refresh.
546
- try {
547
- const summaries = await storage.listOntologies();
548
- const result = {};
549
- if (typeof storage.readLock === "function") {
550
- await Promise.all(
551
- summaries.map(async (s) => {
552
- try {
553
- result[s.name] = await storage.readLock(s.name);
554
- } catch {
555
- result[s.name] = null;
556
- }
557
- }),
558
- );
559
- }
560
- res.writeHead(200, { "Content-Type": "application/json" });
561
- res.end(JSON.stringify(result));
562
- } catch {
563
- res.writeHead(200, { "Content-Type": "application/json" });
564
- res.end("{}");
565
- }
566
- return;
567
- }
568
-
569
- const lockMatch = url.match(/^\/api\/graphs\/(.+)\/lock$/);
570
- if (lockMatch && req.method === "GET") {
571
- const graphName = decodeURIComponent(lockMatch[1]);
572
- try {
573
- const lock =
574
- typeof storage.readLock === "function"
575
- ? await storage.readLock(graphName)
576
- : null;
577
- res.writeHead(200, { "Content-Type": "application/json" });
578
- res.end(JSON.stringify(lock));
579
- } catch {
580
- res.writeHead(200, { "Content-Type": "application/json" });
581
- res.end("null");
582
- }
583
- return;
584
- }
585
-
586
- if (url === "/api/ontologies") {
587
- try {
588
- const summaries = await storage.listOntologies();
589
- res.writeHead(200, { "Content-Type": "application/json" });
590
- res.end(JSON.stringify(summaries));
591
- } catch {
592
- res.writeHead(200, { "Content-Type": "application/json" });
593
- res.end("[]");
594
- }
595
- return;
596
- }
597
-
598
- if (url.startsWith("/api/ontologies/")) {
599
- const name = decodeURIComponent(url.replace("/api/ontologies/", ""));
600
- try {
601
- const data = await storage.loadOntology(name);
602
- res.writeHead(200, { "Content-Type": "application/json" });
603
- res.end(JSON.stringify(data));
604
- } catch {
605
- res.writeHead(404, { "Content-Type": "application/json" });
606
- res.end(JSON.stringify({ error: "Ontology not found" }));
607
- }
359
+ // --- Shared API routes ---
360
+ // All the other API endpoints (config, version-check, ontologies,
361
+ // backpacks, branches, snapshots, snippets, locks, remotes, etc.)
362
+ // live in src/server-api-routes.ts so dev and prod share them.
363
+ if (await handleApiRequest(req, res, apiContext)) {
608
364
  return;
609
365
  }
610
366
 
@@ -638,6 +394,13 @@ if (hasDistBuild) {
638
394
  server.listen(port, bindHost, () => {
639
395
  const displayHost = bindHost === "0.0.0.0" || bindHost === "::" ? "localhost" : bindHost;
640
396
  console.log(` Backpack Viewer v${currentVersion} running at http://${displayHost}:${port}/`);
397
+ if (loadedExtensions.length > 0) {
398
+ console.log(
399
+ ` ${loadedExtensions.length} extension(s) loaded: ${loadedExtensions.map((e) => e.manifest.name).join(", ")}`,
400
+ );
401
+ } else {
402
+ console.log(" No extensions loaded");
403
+ }
641
404
  const isLoopback =
642
405
  bindHost === "127.0.0.1" ||
643
406
  bindHost === "localhost" ||
@@ -654,7 +417,7 @@ if (hasDistBuild) {
654
417
  fetchLatestVersion("backpack-viewer").then((latest) => {
655
418
  if (latest && latest !== currentVersion) {
656
419
  console.warn("");
657
- console.warn(` Backpack Viewer ${currentVersion} is out of date — latest is ${latest}`);
420
+ console.warn(` Backpack Viewer ${currentVersion} is out of date — latest is ${latest}`);
658
421
  console.warn(` To update:`);
659
422
  console.warn(` npm cache clean --force`);
660
423
  console.warn(` npx backpack-viewer@latest`);
@@ -669,7 +432,7 @@ if (hasDistBuild) {
669
432
  const server = await createServer({
670
433
  root,
671
434
  configFile: path.resolve(root, "vite.config.ts"),
672
- server: { port, open: true },
435
+ server: { port: parseInt(process.env.PORT || "5173", 10), open: true },
673
436
  });
674
437
 
675
438
  await server.listen();