@webship.co/workspace 0.0.0-stage → 1.0.0-beta1

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 (307) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/images-svg.js +33 -0
  16. package/actions/images.js +145 -0
  17. package/actions/navigation.js +190 -0
  18. package/actions/node.js +222 -0
  19. package/actions/notes.js +55 -0
  20. package/actions/plugins.js +36 -0
  21. package/actions/project-make.js +28 -0
  22. package/actions/rag.js +202 -0
  23. package/actions/row-actions.js +262 -0
  24. package/actions/screens.js +270 -0
  25. package/actions/testing.js +121 -0
  26. package/actions/video-edit.js +54 -0
  27. package/ai-inventory.js +182 -0
  28. package/ai-items.js +202 -0
  29. package/assistant-cast.js +176 -0
  30. package/assistant-suggestions.js +91 -0
  31. package/assistant.js +484 -0
  32. package/audio-voice.js +528 -0
  33. package/auth-google.js +260 -0
  34. package/build-ai.js +52 -0
  35. package/build-order-page.js +191 -0
  36. package/build-order.js +313 -0
  37. package/builder-args.js +117 -0
  38. package/changelog.js +302 -0
  39. package/chat-threads.js +134 -0
  40. package/checks.js +105 -0
  41. package/claude-args.js +282 -0
  42. package/claude-sessions.js +129 -0
  43. package/command-author.js +117 -0
  44. package/command-block.js +20 -0
  45. package/commands/defaults.yml +159 -0
  46. package/commands-page.js +460 -0
  47. package/commands.js +568 -0
  48. package/cucumber.js +43 -0
  49. package/ddev-addons-page.js +124 -0
  50. package/ddev-addons.js +154 -0
  51. package/ddev-custom-commands.js +96 -0
  52. package/ddev-domains.js +84 -0
  53. package/ddev-import.js +93 -0
  54. package/ddev-mail-page.js +167 -0
  55. package/ddev-mail.js +110 -0
  56. package/ddev-options.js +145 -0
  57. package/ddev-snapshots.js +126 -0
  58. package/ddev.js +145 -0
  59. package/docs/DEVELOPER-GUIDE.md +224 -0
  60. package/docs/README.md +252 -0
  61. package/docs/REFERENCE.md +684 -0
  62. package/docs/USER-GUIDE.md +697 -0
  63. package/docs/screenshots/assistant/avatar-board.png +0 -0
  64. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  65. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  66. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  67. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  68. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  69. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  70. package/docs/screenshots/assistant/avatar-status.png +0 -0
  71. package/docs/screenshots/create-card/create-card.png +0 -0
  72. package/docs/screenshots/create-card/start-ai.png +0 -0
  73. package/docs/screenshots/guide-clone-address.png +0 -0
  74. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  75. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  76. package/docs/screenshots/guide-ddev-menu.png +0 -0
  77. package/docs/screenshots/guide-domains.png +0 -0
  78. package/docs/screenshots/guide-home.png +0 -0
  79. package/docs/screenshots/guide-ide-menu.png +0 -0
  80. package/docs/screenshots/guide-remove-refusal.png +0 -0
  81. package/docs/screenshots/guide-rows-worktree.png +0 -0
  82. package/docs/screenshots/guide-screens.png +0 -0
  83. package/docs/screenshots/guide-settings.png +0 -0
  84. package/docs/screenshots/guide-workspace-page.png +0 -0
  85. package/docs/screenshots/home/card-settings.png +0 -0
  86. package/docs/screenshots/home/drag-reorder.png +0 -0
  87. package/docs/screenshots/home/edit-card.png +0 -0
  88. package/docs/screenshots/images/claude-code-image.png +0 -0
  89. package/docs/screenshots/images/edit-with-ai.png +0 -0
  90. package/docs/screenshots/images/gallery.png +0 -0
  91. package/docs/screenshots/images/generate-description.png +0 -0
  92. package/docs/screenshots/images/generate-plan.png +0 -0
  93. package/docs/screenshots/images/providers.png +0 -0
  94. package/docs/screenshots/images/svg-form.png +0 -0
  95. package/docs/screenshots/images/svg-result.png +0 -0
  96. package/docs/screenshots/images/upload.png +0 -0
  97. package/docs/screenshots/new-folder/2-form.png +0 -0
  98. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  99. package/docs/screenshots/new-folder/files-folder.png +0 -0
  100. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  101. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  102. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  103. package/docs/screenshots/readme/dashboard-light.png +0 -0
  104. package/docs/screenshots/readme/dev-dark.png +0 -0
  105. package/docs/screenshots/readme/dev-light.png +0 -0
  106. package/docs/screenshots/readme/docs-dark.png +0 -0
  107. package/docs/screenshots/readme/docs-light.png +0 -0
  108. package/docs/screenshots/readme/icons/ai.svg +4 -0
  109. package/docs/screenshots/readme/icons/code.svg +4 -0
  110. package/docs/screenshots/readme/icons/docs.svg +4 -0
  111. package/docs/screenshots/readme/icons/services.svg +4 -0
  112. package/docs/screenshots/readme/icons/sites.svg +4 -0
  113. package/docs/screenshots/readme/test-report-dark.png +0 -0
  114. package/docs/screenshots/readme/test-report-light.png +0 -0
  115. package/docs/screenshots/readme/test-scenarios.png +0 -0
  116. package/docs/screenshots/readme/test-steps.png +0 -0
  117. package/docs/screenshots/readme/videos-dark.png +0 -0
  118. package/docs/screenshots/readme/videos-light.png +0 -0
  119. package/docs/screenshots/screens-menu.png +0 -0
  120. package/docs/screenshots/screens-new.png +0 -0
  121. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  122. package/docs/screenshots/settings/delete-1-row.png +0 -0
  123. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  124. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  125. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  126. package/event-log.js +59 -0
  127. package/files-browser.js +247 -0
  128. package/folders.js +121 -0
  129. package/format.js +73 -0
  130. package/git-info.js +81 -0
  131. package/github-auth.js +62 -0
  132. package/html.js +10 -0
  133. package/icons.js +83 -0
  134. package/images-ai.js +447 -0
  135. package/images-create.js +226 -0
  136. package/images-page.js +417 -0
  137. package/images-records.js +39 -0
  138. package/images-render.js +36 -0
  139. package/images-svg-core.js +214 -0
  140. package/images-svg-sanitize.js +188 -0
  141. package/images-svg.js +241 -0
  142. package/images.js +270 -0
  143. package/jobs.js +430 -0
  144. package/linger.js +21 -0
  145. package/lists.js +292 -0
  146. package/media-info.js +136 -0
  147. package/navigation-page.js +264 -0
  148. package/navigation.js +394 -0
  149. package/notes.js +0 -0
  150. package/package.json +47 -4
  151. package/palette-index.js +46 -0
  152. package/playwright.config.js +23 -0
  153. package/plugin-install.js +239 -0
  154. package/plugin-loader.js +466 -0
  155. package/plugin-manifest.js +83 -0
  156. package/plugin-settings.js +44 -0
  157. package/plugins/workspace-notes/index.js +59 -0
  158. package/plugins-page.js +103 -0
  159. package/project-make.js +241 -0
  160. package/project-name.js +210 -0
  161. package/public/assistant-avatar-moves.js +646 -0
  162. package/public/assistant-avatar-ui.js +1223 -0
  163. package/public/assistant-avatar.js +530 -0
  164. package/public/avatar/druplicon.vrm +0 -0
  165. package/public/css/base.css +260 -0
  166. package/public/css/components/ai-assistant.css +263 -0
  167. package/public/css/components/assistant-avatar.css +279 -0
  168. package/public/css/components/assistant-views.css +198 -0
  169. package/public/css/components/build-order.css +74 -0
  170. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  171. package/public/css/components/command-palette.css +61 -0
  172. package/public/css/components/commands-center.css +139 -0
  173. package/public/css/components/confirm-dialog.css +40 -0
  174. package/public/css/components/create-card.css +8 -0
  175. package/public/css/components/ddev-menu.css +23 -0
  176. package/public/css/components/deep-chat.css +17 -0
  177. package/public/css/components/density.css +11 -0
  178. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  179. package/public/css/components/folders.css +5 -0
  180. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  181. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  182. package/public/css/components/grid-and-list.css +203 -0
  183. package/public/css/components/help-page.css +36 -0
  184. package/public/css/components/hero.css +16 -0
  185. package/public/css/components/images.css +70 -0
  186. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  187. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  188. package/public/css/components/modal-fit.css +113 -0
  189. package/public/css/components/page-width.css +23 -0
  190. package/public/css/components/plugins-page.css +9 -0
  191. package/public/css/components/project-cards.css +256 -0
  192. package/public/css/components/project-rows.css +390 -0
  193. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  194. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  195. package/public/css/components/running-commands-as-toasts.css +327 -0
  196. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  197. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  198. package/public/css/components/sound-player.css +84 -0
  199. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  200. package/public/css/components/tab-modal.css +142 -0
  201. package/public/css/components/terminals.css +244 -0
  202. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  203. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  204. package/public/css/components/video-edit.css +6 -0
  205. package/public/css/components/web-ide-open-with.css +23 -0
  206. package/public/css/components/working-indicators.css +158 -0
  207. package/public/css/components/workspace-cards.css +79 -0
  208. package/public/favicon.ico +0 -0
  209. package/public/favicon.png +0 -0
  210. package/public/htmx.min.js +1 -0
  211. package/public/logo.png +0 -0
  212. package/public/media-player.js +215 -0
  213. package/public/mic.js +255 -0
  214. package/public/narration.js +110 -0
  215. package/public/palette.js +278 -0
  216. package/public/sortable.js +251 -0
  217. package/public/suggest.js +337 -0
  218. package/public/terminal.js +903 -0
  219. package/public/themes/default/theme.css +267 -0
  220. package/public/themes/ember/theme.css +180 -0
  221. package/public/themes/press/theme.css +290 -0
  222. package/public/themes/slate/theme.css +182 -0
  223. package/public/touch-icon.png +0 -0
  224. package/public/ui.js +2721 -0
  225. package/public/vendor/ace/LICENSE.txt +24 -0
  226. package/public/vendor/ace/ace.js +23 -0
  227. package/public/vendor/ace/ext-language_tools.js +8 -0
  228. package/public/vendor/ace/ext-searchbox.js +8 -0
  229. package/public/vendor/ace/mode-css.js +8 -0
  230. package/public/vendor/ace/mode-html.js +8 -0
  231. package/public/vendor/ace/mode-javascript.js +8 -0
  232. package/public/vendor/ace/mode-markdown.js +8 -0
  233. package/public/vendor/ace/mode-sh.js +8 -0
  234. package/public/vendor/ace/mode-yaml.js +8 -0
  235. package/public/vendor/ace/theme-textmate.js +8 -0
  236. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  237. package/public/vendor/ace/worker-yaml.js +1 -0
  238. package/public/vendor/deep-chat.LICENSE +21 -0
  239. package/public/vendor/deep-chat.bundle.js +1 -0
  240. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  241. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  242. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  243. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  244. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  245. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  246. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  248. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  249. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  251. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  252. package/public/vendor/tabler-LICENSE.txt +21 -0
  253. package/public/vendor/tabler-sprite.svg +1 -0
  254. package/public/vendor/three/LICENSE +21 -0
  255. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  256. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  257. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  258. package/public/vendor/three/three.core.min.js +6 -0
  259. package/public/vendor/three/three.module.min.js +6 -0
  260. package/public/vendor/three-vrm/LICENSE +22 -0
  261. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  262. package/public/vendor/uikit-icons.min.js +1 -0
  263. package/public/vendor/uikit.min.css +1 -0
  264. package/public/vendor/uikit.min.js +1 -0
  265. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  266. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  267. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  268. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  269. package/public/vendor/webship-icons/ui/play.svg +1 -0
  270. package/public/vendor/webship-icons/ui/share.svg +1 -0
  271. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  272. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  273. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  274. package/public/vendor/xterm/addon-fit.js +2 -0
  275. package/public/vendor/xterm/addon-unicode11.js +2 -0
  276. package/public/vendor/xterm/addon-web-links.js +2 -0
  277. package/public/vendor/xterm/xterm.css +218 -0
  278. package/public/vendor/xterm/xterm.js +2 -0
  279. package/rag.js +169 -0
  280. package/reader.js +132 -0
  281. package/repo-blobs.js +66 -0
  282. package/report-pdf.js +337 -0
  283. package/row-layout.js +1439 -0
  284. package/screenrc +37 -0
  285. package/screens.js +1063 -0
  286. package/scripts/code-server-start.sh +190 -0
  287. package/scripts/images-claude-finish.js +107 -0
  288. package/scripts/screen-host.sh +114 -0
  289. package/scripts/screen-runner.js +362 -0
  290. package/scripts/screen-then.js +47 -0
  291. package/scripts/update.sh +350 -0
  292. package/server.js +7923 -0
  293. package/settings-file.js +423 -0
  294. package/speech.js +297 -0
  295. package/tab-modal.js +75 -0
  296. package/test-reports.js +260 -0
  297. package/themes.js +381 -0
  298. package/transcripts.js +168 -0
  299. package/urls.js +13 -0
  300. package/validate.js +18 -0
  301. package/version-check.js +94 -0
  302. package/video-edit.js +588 -0
  303. package/waiting.js +85 -0
  304. package/workspace-card-tools.js +41 -0
  305. package/workspace-delete.js +139 -0
  306. package/workspace-types.js +85 -0
  307. package/workspaces.js +553 -0
package/rag.js ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The RAG indexes: which Milvus server holds a project's, and how the dashboard reaches it.
3
+ *
4
+ * Not the published host port — this process runs inside ddev-workspace-web, where 127.0.0.1 is
5
+ * that container. DDEV puts every project on the shared `ddev_default` network, so the answer here
6
+ * is the Milvus container's own name.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const http = require('http');
11
+ const path = require('path');
12
+
13
+ const { ROOT, CONFIG_DIR } = require('./workspaces');
14
+
15
+ /* ---------------- RAG indexes (Milvus) ---------------- */
16
+ //
17
+ // The counterpart of the graph helpers above, and deliberately the layer graphify does not cover:
18
+ // an index holds the TEXT of a project (Markdown, Twig, *.yml, hooks) as rows in Milvus, one
19
+ // collection per project — `ws_<workspace>_<project>`, the same name cmd-tools-ragify.sh writes.
20
+ //
21
+ // The database is not a file, so unlike a graph there is nothing on disk to stat: whether a project
22
+ // has an index is a question for Milvus, asked over its REST API and memoised, because
23
+ // projectRowsHtml re-renders on every job poll.
24
+
25
+ // Which Milvus instance, from the rag workspace's own settings. Read line by line rather than with
26
+ // a YAML parser, for the same reason the settings editor does: this file is written by hand and
27
+ // keeps its comments.
28
+ function milvusProjectName() {
29
+ try {
30
+ const text = fs.readFileSync(path.join(CONFIG_DIR, 'workspace.rag.settings.yml'), 'utf8');
31
+ const inMilvus = text.split('\n');
32
+ let block = false;
33
+ for (const line of inMilvus) {
34
+ if (/^milvus:\s*$/.test(line)) { block = true; continue; }
35
+ if (block && /^\S/.test(line)) break;
36
+ const m = block && line.match(/^\s{2}project:\s*"?([a-zA-Z0-9_-]+)"?\s*$/);
37
+ if (m) return m[1];
38
+ }
39
+ } catch (_) { /* not configured */ }
40
+ return 'milvus';
41
+ }
42
+
43
+ // Where the dashboard reaches Milvus. NOT the published host port: this process runs inside
44
+ // ddev-workspace-web, where 127.0.0.1 is this container and the host's firewall drops
45
+ // container-to-host traffic anyway. DDEV puts every project on the shared `ddev_default` network,
46
+ // so the Milvus container answers on its own name — same port, gRPC and REST multiplexed.
47
+ function milvusRestBase(instance) {
48
+ return `http://ddev-${instance || milvusProjectName()}-milvus-standalone:19530`;
49
+ }
50
+
51
+ // Every RAG server built in the rag workspace: a directory carrying the compose file this tooling
52
+ // writes. One shared instance is the normal case; a project can be bound to its own.
53
+ function ragInstances() {
54
+ const base = path.join(ROOT, 'rag');
55
+ let entries = [];
56
+ try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch (_) { return []; }
57
+ return entries
58
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(base, e.name, '.ddev', 'docker-compose.milvus.yaml')))
59
+ .map((e) => e.name)
60
+ .sort();
61
+ }
62
+
63
+ // Which server a project's index lives in. rag/bindings.yml holds one line per BOUND project —
64
+ // `<workspace>/<project>: <instance>` — and nothing for the rest, which use the default. The file
65
+ // is machine-local state and lives in the rag workspace rather than in the project, for the same
66
+ // reason a graph does: a repository we ship must not gain a file because somebody indexed it.
67
+ let _bindings = null;
68
+ let _bindingsAt = 0;
69
+ function ragBindings() {
70
+ const now = Date.now();
71
+ if (_bindings && now - _bindingsAt < 2000) return _bindings;
72
+ const map = new Map();
73
+ try {
74
+ const text = fs.readFileSync(path.join(ROOT, 'rag', 'bindings.yml'), 'utf8');
75
+ for (const line of text.split('\n')) {
76
+ const m = line.match(/^([a-z0-9_-]+\/[A-Za-z0-9_.-]+):\s*([A-Za-z0-9_-]+)\s*$/);
77
+ if (m) map.set(m[1], m[2]);
78
+ }
79
+ } catch (_) { /* nothing bound */ }
80
+ _bindings = map;
81
+ _bindingsAt = now;
82
+ return map;
83
+ }
84
+
85
+ function ragInstanceFor(wsKey, project) {
86
+ return ragBindings().get(`${wsKey}/${project}`) || milvusProjectName();
87
+ }
88
+
89
+ function ragWriteBinding(wsKey, project, instance) {
90
+ const file = path.join(ROOT, 'rag', 'bindings.yml');
91
+ let lines = [];
92
+ try {
93
+ lines = fs.readFileSync(file, 'utf8').split('\n');
94
+ } catch (_) {
95
+ lines = ['# Which Milvus instance holds each project\'s RAG index.',
96
+ '#',
97
+ '# Written by cmd-tools-ragify.sh --milvus <instance> and by the dashboard\'s RAG menu. A project',
98
+ '# that is not listed here uses milvus.project from core/config/workspace.rag.settings.yml.',
99
+ '#',
100
+ '# Machine-local state, like the graphs store: it describes what is built on THIS machine.', ''];
101
+ }
102
+ const key = `${wsKey}/${project}`;
103
+ const kept = lines.filter((l) => !l.startsWith(`${key}:`));
104
+ if (instance) kept.push(`${key}: ${instance}`);
105
+ fs.writeFileSync(file, `${kept.filter((l, i, a) => !(l === '' && a[i + 1] === '')).join('\n').replace(/\n+$/, '')}\n`, 'utf8');
106
+ _bindingsAt = 0;
107
+ }
108
+
109
+ // Milvus identifiers allow letters, digits and underscore only — the same folding
110
+ // rag_collection_name() does in bash and collection_ok() does in the Python worker.
111
+ function ragCollectionName(wsKey, project) {
112
+ return `ws_${wsKey}_${project}`.replace(/[^A-Za-z0-9_]/g, '_');
113
+ }
114
+
115
+ function milvusPost(pathname, body, timeoutMs = 4000, instance = null) {
116
+ return new Promise((resolve) => {
117
+ const payload = JSON.stringify(body || {});
118
+ const url = new URL(milvusRestBase(instance) + pathname);
119
+ const request = http.request({
120
+ hostname: url.hostname,
121
+ port: url.port,
122
+ path: url.pathname,
123
+ method: 'POST',
124
+ timeout: timeoutMs,
125
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
126
+ }, (response) => {
127
+ let data = '';
128
+ response.on('data', (chunk) => { data += chunk; });
129
+ response.on('end', () => {
130
+ try { resolve(JSON.parse(data)); } catch (_) { resolve(null); }
131
+ });
132
+ });
133
+ // A stopped instance must not stall a page render: every caller treats null as "no Milvus".
134
+ request.on('error', () => resolve(null));
135
+ request.on('timeout', () => { request.destroy(); resolve(null); });
136
+ request.end(payload);
137
+ });
138
+ }
139
+
140
+ // Every collection in one server, memoised for 5s and keyed by server. One request per server per
141
+ // render rather than one per project row, and a stopped Milvus costs one connection refusal, not
142
+ // one per row. Keyed, because a workspace can mix projects bound to different servers.
143
+ const _ragCache = new Map(); // instance -> { list, up, at }
144
+ const _ragInFlight = new Map();
145
+ async function ragCollections(instance) {
146
+ const key = instance || milvusProjectName();
147
+ const hit = _ragCache.get(key);
148
+ if (hit && Date.now() - hit.at < 5000) return hit;
149
+ if (!_ragInFlight.has(key)) {
150
+ _ragInFlight.set(key, milvusPost('/v2/vectordb/collections/list', {}, 4000, key).then((body) => {
151
+ const up = !!body && body.code === 0;
152
+ const entry = { list: up && Array.isArray(body.data) ? body.data : [], up, at: Date.now() };
153
+ _ragCache.set(key, entry);
154
+ _ragInFlight.delete(key);
155
+ return entry;
156
+ }));
157
+ }
158
+ return _ragInFlight.get(key);
159
+ }
160
+
161
+ function ragInvalidate() {
162
+ _ragCache.clear();
163
+ _bindingsAt = 0;
164
+ }
165
+
166
+ module.exports = {
167
+ milvusProjectName, milvusRestBase, ragInstances, ragBindings, ragInstanceFor, ragWriteBinding,
168
+ ragCollectionName, milvusPost, ragCollections, ragInvalidate,
169
+ };
package/reader.js ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The avatar's browser window: a web page, read through this server.
3
+ *
4
+ * Most pages a team needs (drupal.org, GitHub, MDN) refuse to be shown inside another page, so the
5
+ * window cannot simply frame them. GET /fragments/reader?u=<https url> fetches the page here and
6
+ * serves its main content back, cleaned: no scripts, frames, forms, styles from elsewhere or event
7
+ * handlers, every link pointing back into the reader, and a Content-Security-Policy that allows none
8
+ * of it to run anyway.
9
+ *
10
+ * The fetch happens inside the dashboard's container, which can reach the Docker network and every
11
+ * DDEV project, so an address is only fetched when it is https, a name rather than an IP, and every
12
+ * address it resolves to is public; each redirect is checked the same way. A page that answers a
13
+ * program with a bot check (drupal.org does) is said so plainly, with the link to open in a tab:
14
+ * the reader never tries to get past one.
15
+ */
16
+
17
+ const dns = require('dns').promises;
18
+ const net = require('net');
19
+ const { esc } = require('./html');
20
+
21
+ const MAX_BYTES = 1500000;
22
+
23
+ function privateIp(ip) {
24
+ if (net.isIPv4(ip)) {
25
+ const [a, b] = ip.split('.').map(Number);
26
+ return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
27
+ || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
28
+ }
29
+ const v = ip.toLowerCase();
30
+ return v === '::1' || v === '::' || v.startsWith('fc') || v.startsWith('fd') || v.startsWith('fe80') || v.startsWith('::ffff:');
31
+ }
32
+
33
+ /** Whether a URL may be fetched: https, a public name, every address it resolves to public. */
34
+ async function allowed(u, lookup = dns.lookup) {
35
+ if (u.protocol !== 'https:') return 'only https pages can be opened';
36
+ if (u.port && u.port !== '443') return 'only the standard https port';
37
+ const host = u.hostname.toLowerCase();
38
+ if (net.isIP(host) || !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(host)) return 'only website names, not addresses';
39
+ if (/(^|\.)(ddev\.site|localhost|local|internal|lan)$/.test(host)) return 'local sites open in their own tab';
40
+ try {
41
+ const addrs = await lookup(host, { all: true });
42
+ if (!addrs.length || addrs.some((a) => privateIp(a.address))) return 'that address is not a public website';
43
+ } catch (_) { return 'that site could not be found'; }
44
+ return '';
45
+ }
46
+
47
+ const isChallenge = (html) => /<title[^>]*>\s*(Client Challenge|Just a moment|Attention Required|Verifying you are human)/i
48
+ .test(String(html || '').slice(0, 20000));
49
+
50
+ const readerLink = (abs) => `/fragments/reader?u=${encodeURIComponent(abs)}`;
51
+
52
+ /** The page's main content, cleaned, with its links pointing back into the reader. */
53
+ function cleanHtml(html, base) {
54
+ let b = (html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i) || html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i) || [])[1];
55
+ if (!b) {
56
+ b = (html.match(/<body[^>]*>([\s\S]*)<\/body>/i) || [, html])[1];
57
+ b = b.replace(/<(nav|header|footer|aside)\b[\s\S]*?<\/\1\s*>/gi, '');
58
+ }
59
+ b = b.replace(/<!--[\s\S]*?-->/g, '')
60
+ .replace(/<(script|style|noscript|iframe|object|template|svg|math|canvas|video|audio|dialog)\b[\s\S]*?<\/\1\s*>/gi, '')
61
+ .replace(/<\/?(script|style|noscript|iframe|object|embed|form|input|button|select|textarea|label|link|meta|base|template|source|track|frame|frameset)\b[^>]*>/gi, '')
62
+ .replace(/\s(on\w+|style|srcset|sizes|formaction|ping|xmlns(?::\w+)?)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
63
+ .replace(/javascript:/gi, '');
64
+ b = b.replace(/<a\b[^>]*?href\s*=\s*("([^"]*)"|'([^']*)')[^>]*>/gi, (m, q, h1, h2) => {
65
+ const href = (h1 ?? h2 ?? '').replace(/&amp;/g, '&');
66
+ if (href.startsWith('#')) return `<a href="${esc(href)}">`;
67
+ let abs;
68
+ try { abs = new URL(href, base); } catch (_) { return '<a>'; }
69
+ if (abs.protocol !== 'https:' && abs.protocol !== 'http:') return '<a>';
70
+ abs.protocol = 'https:';
71
+ return `<a href="${esc(readerLink(abs.href))}" title="${esc(abs.href)}">`;
72
+ });
73
+ b = b.replace(/<img\b([^>]*?)src\s*=\s*("([^"]*)"|'([^']*)')([^>]*)>/gi, (m, pre, q, s1, s2, post) => {
74
+ let abs;
75
+ try { abs = new URL((s1 ?? s2 ?? '').replace(/&amp;/g, '&'), base); } catch (_) { return ''; }
76
+ if (abs.protocol !== 'https:') return '';
77
+ const alt = (`${pre} ${post}`.match(/\salt\s*=\s*"([^"]*)"/i) || [])[1] || '';
78
+ return `<img src="${esc(abs.href)}" alt="${esc(alt)}" loading="lazy">`;
79
+ });
80
+ return b;
81
+ }
82
+
83
+ function page(title, body) {
84
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
85
+ <title>${esc(title)}</title><style>
86
+ body { margin: 0; padding: .9rem 1.1rem 2.5rem; font: 15px/1.65 system-ui, sans-serif; color: #1d1d1f; background: #fff; }
87
+ h1, h2, h3 { color: #2b447c; line-height: 1.3; } h1 { font-size: 1.35rem; }
88
+ a { color: #0063c0; text-decoration: none; } a:hover { text-decoration: underline; }
89
+ img { max-width: 100%; height: auto; } table { border-collapse: collapse; display: block; overflow-x: auto; font-size: .9rem; }
90
+ td, th { border: 1px solid #ddd; padding: .25rem .4rem; }
91
+ pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #f5f6f8; padding: .6rem .8rem; border-radius: .4rem; font-size: .85rem; }
92
+ code { background: #f5f6f8; padding: 0 .2rem; border-radius: .2rem; } pre code { background: none; padding: 0; }
93
+ .hint { color: #666; font-size: .85rem; }
94
+ </style></head><body>${body}</body></html>`;
95
+ }
96
+
97
+ /** GET /fragments/reader?u=… */
98
+ async function serveReader(res, raw, { fetchImpl = fetch, lookup = dns.lookup } = {}) {
99
+ const out = (status, html) => {
100
+ res.writeHead(status, {
101
+ 'Content-Type': 'text/html; charset=utf-8',
102
+ 'Content-Security-Policy': "default-src 'none'; img-src https: data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
103
+ 'Cache-Control': 'no-store',
104
+ 'X-Content-Type-Options': 'nosniff',
105
+ });
106
+ res.end(html);
107
+ };
108
+ let u;
109
+ try { u = new URL(String(raw || '')); } catch (_) { return out(400, page('Not a page', '<p class="hint">That is not a link.</p>')); }
110
+ const refuse = (why, status = 403) => out(status, page(u.hostname, `<h1>${esc(u.hostname)}</h1><p class="hint">${esc(why)}.</p><p class="hint">${esc(u.href)}</p>`));
111
+ try {
112
+ for (let hop = 0; hop < 6; hop++) {
113
+ const why = await allowed(u, lookup);
114
+ if (why) return refuse(why);
115
+ const r = await fetchImpl(u.href, { redirect: 'manual', signal: AbortSignal.timeout(12000),
116
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Webship Workspace reader)', Accept: 'text/html,text/plain;q=0.9' } });
117
+ if (r.status >= 300 && r.status < 400 && r.headers.get('location')) { u = new URL(r.headers.get('location'), u); continue; }
118
+ if (!r.ok) return refuse(`that page answered ${r.status}`, r.status === 404 ? 404 : 502);
119
+ const type = String(r.headers.get('content-type') || '');
120
+ const body = (await r.text()).slice(0, MAX_BYTES);
121
+ const crumb = `<p class="hint">${esc(u.hostname)}${esc(u.pathname.length > 1 ? u.pathname.slice(0, 90) : '')}</p>`;
122
+ if (/text\/plain/.test(type)) return out(200, page(u.pathname.split('/').pop() || u.hostname, `${crumb}<pre>${esc(body.slice(0, 200000))}</pre>`));
123
+ if (!/text\/html|application\/xhtml/.test(type)) return refuse('that is not a page the reader can show', 415);
124
+ if (isChallenge(body)) return refuse(`${u.hostname} only opens in a real browser tab`);
125
+ const title = ((body.match(/<title[^>]*>([\s\S]{1,2000}?)<\/title>/i) || [])[1] || u.hostname).replace(/\s+/g, ' ').replace(/&amp;/g, '&').trim().slice(0, 140);
126
+ return out(200, page(title, `${crumb}${cleanHtml(body, u.href)}`));
127
+ }
128
+ return refuse('too many redirects');
129
+ } catch (_) { return refuse('that page could not be reached', 502); }
130
+ }
131
+
132
+ module.exports = { serveReader, allowed, privateIp, cleanHtml, isChallenge, readerLink };
package/repo-blobs.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Git blob identity, cheap on both sides.
3
+ *
4
+ * Remote: ONE recursive Trees call per repo@ref answers every "what is in the
5
+ * repo and at which sha" question a page has — cached for 5 minutes AND
6
+ * single-flighted, so ten rows rendering during a cold cache still cost one
7
+ * network call, not ten. `null` means unreachable, and is cached too: an
8
+ * unauthenticated container must not retry on every render.
9
+ *
10
+ * Local: sha1("blob <size>\0" + content), memoized by (size, mtime) — a page
11
+ * of fifty unchanged files hashes nothing on re-render.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const crypto = require('crypto');
16
+
17
+ const { run } = require('./jobs');
18
+ const { githubEnv } = require('./github-auth');
19
+
20
+ const TTL_MS = 5 * 60 * 1000;
21
+ const _trees = new Map(); // `${repo}@${ref}` -> { at, promise }
22
+
23
+ /** Map<path, blob sha> for the whole repo at ref, or null when unreachable. */
24
+ function repoTree(repo, ref) {
25
+ const key = `${repo}@${ref}`;
26
+ const hit = _trees.get(key);
27
+ if (hit && Date.now() - hit.at < TTL_MS) return hit.promise;
28
+ const promise = (async () => {
29
+ const res = await run('gh',
30
+ ['api', `repos/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`,
31
+ '--jq', '.tree[] | select(.type=="blob") | .path + " " + .sha'],
32
+ process.env.HOME, { timeoutMs: 30 * 1000, env: githubEnv() });
33
+ if (res.code !== 0) return null;
34
+ const map = new Map();
35
+ for (const line of String(res.stdout || '').split('\n')) {
36
+ const sp = line.lastIndexOf(' ');
37
+ if (sp < 1) continue;
38
+ map.set(line.slice(0, sp), line.slice(sp + 1));
39
+ }
40
+ return map;
41
+ })();
42
+ _trees.set(key, { at: Date.now(), promise });
43
+ return promise;
44
+ }
45
+
46
+ function invalidateRepoTree(repo) {
47
+ for (const key of _trees.keys()) {
48
+ if (key.startsWith(`${repo}@`)) _trees.delete(key);
49
+ }
50
+ }
51
+
52
+ const _blobMemo = new Map(); // file -> { size, mtimeMs, sha }
53
+
54
+ /** git's blob sha for a local file. Throws like fs does when it cannot be read. */
55
+ function gitBlobSha(file) {
56
+ const st = fs.statSync(file);
57
+ const hit = _blobMemo.get(file);
58
+ if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.sha;
59
+ const buf = fs.readFileSync(file);
60
+ const sha = crypto.createHash('sha1')
61
+ .update(`blob ${buf.length}\0`).update(buf).digest('hex');
62
+ _blobMemo.set(file, { size: st.size, mtimeMs: st.mtimeMs, sha });
63
+ return sha;
64
+ }
65
+
66
+ module.exports = { repoTree, invalidateRepoTree, gitBlobSha };