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

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 (311) 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/graphs.js +107 -0
  16. package/actions/images-svg.js +33 -0
  17. package/actions/images.js +145 -0
  18. package/actions/navigation.js +190 -0
  19. package/actions/node.js +222 -0
  20. package/actions/notes.js +55 -0
  21. package/actions/obsidian.js +59 -0
  22. package/actions/plugins.js +36 -0
  23. package/actions/project-make.js +28 -0
  24. package/actions/rag.js +202 -0
  25. package/actions/row-actions.js +262 -0
  26. package/actions/screens.js +270 -0
  27. package/actions/testing.js +121 -0
  28. package/actions/video-edit.js +54 -0
  29. package/ai-inventory.js +182 -0
  30. package/ai-items.js +202 -0
  31. package/assistant-cast.js +176 -0
  32. package/assistant-suggestions.js +91 -0
  33. package/assistant.js +484 -0
  34. package/audio-voice.js +528 -0
  35. package/auth-google.js +260 -0
  36. package/build-ai.js +52 -0
  37. package/build-order-page.js +191 -0
  38. package/build-order.js +313 -0
  39. package/builder-args.js +117 -0
  40. package/changelog.js +302 -0
  41. package/chat-threads.js +134 -0
  42. package/checks.js +105 -0
  43. package/claude-args.js +282 -0
  44. package/claude-sessions.js +129 -0
  45. package/command-author.js +117 -0
  46. package/command-block.js +20 -0
  47. package/commands/defaults.yml +159 -0
  48. package/commands-page.js +460 -0
  49. package/commands.js +568 -0
  50. package/cucumber.js +43 -0
  51. package/ddev-addons-page.js +124 -0
  52. package/ddev-addons.js +154 -0
  53. package/ddev-custom-commands.js +96 -0
  54. package/ddev-domains.js +84 -0
  55. package/ddev-import.js +93 -0
  56. package/ddev-mail-page.js +167 -0
  57. package/ddev-mail.js +110 -0
  58. package/ddev-options.js +145 -0
  59. package/ddev-snapshots.js +126 -0
  60. package/ddev.js +145 -0
  61. package/docs/DEVELOPER-GUIDE.md +224 -0
  62. package/docs/README.md +252 -0
  63. package/docs/REFERENCE.md +684 -0
  64. package/docs/USER-GUIDE.md +697 -0
  65. package/docs/screenshots/assistant/avatar-board.png +0 -0
  66. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  67. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  68. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  69. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  70. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  71. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  72. package/docs/screenshots/assistant/avatar-status.png +0 -0
  73. package/docs/screenshots/create-card/create-card.png +0 -0
  74. package/docs/screenshots/create-card/start-ai.png +0 -0
  75. package/docs/screenshots/guide-clone-address.png +0 -0
  76. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  77. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  78. package/docs/screenshots/guide-ddev-menu.png +0 -0
  79. package/docs/screenshots/guide-domains.png +0 -0
  80. package/docs/screenshots/guide-home.png +0 -0
  81. package/docs/screenshots/guide-ide-menu.png +0 -0
  82. package/docs/screenshots/guide-remove-refusal.png +0 -0
  83. package/docs/screenshots/guide-rows-worktree.png +0 -0
  84. package/docs/screenshots/guide-screens.png +0 -0
  85. package/docs/screenshots/guide-settings.png +0 -0
  86. package/docs/screenshots/guide-workspace-page.png +0 -0
  87. package/docs/screenshots/home/card-settings.png +0 -0
  88. package/docs/screenshots/home/drag-reorder.png +0 -0
  89. package/docs/screenshots/home/edit-card.png +0 -0
  90. package/docs/screenshots/images/claude-code-image.png +0 -0
  91. package/docs/screenshots/images/edit-with-ai.png +0 -0
  92. package/docs/screenshots/images/gallery.png +0 -0
  93. package/docs/screenshots/images/generate-description.png +0 -0
  94. package/docs/screenshots/images/generate-plan.png +0 -0
  95. package/docs/screenshots/images/providers.png +0 -0
  96. package/docs/screenshots/images/svg-form.png +0 -0
  97. package/docs/screenshots/images/svg-result.png +0 -0
  98. package/docs/screenshots/images/upload.png +0 -0
  99. package/docs/screenshots/new-folder/2-form.png +0 -0
  100. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  101. package/docs/screenshots/new-folder/files-folder.png +0 -0
  102. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  103. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  104. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  105. package/docs/screenshots/readme/dashboard-light.png +0 -0
  106. package/docs/screenshots/readme/dev-dark.png +0 -0
  107. package/docs/screenshots/readme/dev-light.png +0 -0
  108. package/docs/screenshots/readme/docs-dark.png +0 -0
  109. package/docs/screenshots/readme/docs-light.png +0 -0
  110. package/docs/screenshots/readme/icons/ai.svg +4 -0
  111. package/docs/screenshots/readme/icons/code.svg +4 -0
  112. package/docs/screenshots/readme/icons/docs.svg +4 -0
  113. package/docs/screenshots/readme/icons/services.svg +4 -0
  114. package/docs/screenshots/readme/icons/sites.svg +4 -0
  115. package/docs/screenshots/readme/test-report-dark.png +0 -0
  116. package/docs/screenshots/readme/test-report-light.png +0 -0
  117. package/docs/screenshots/readme/test-scenarios.png +0 -0
  118. package/docs/screenshots/readme/test-steps.png +0 -0
  119. package/docs/screenshots/readme/videos-dark.png +0 -0
  120. package/docs/screenshots/readme/videos-light.png +0 -0
  121. package/docs/screenshots/screens-menu.png +0 -0
  122. package/docs/screenshots/screens-new.png +0 -0
  123. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  124. package/docs/screenshots/settings/delete-1-row.png +0 -0
  125. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  126. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  127. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  128. package/event-log.js +59 -0
  129. package/files-browser.js +247 -0
  130. package/folders.js +121 -0
  131. package/format.js +73 -0
  132. package/git-info.js +81 -0
  133. package/github-auth.js +62 -0
  134. package/graphs.js +104 -0
  135. package/html.js +10 -0
  136. package/icons.js +83 -0
  137. package/images-ai.js +447 -0
  138. package/images-create.js +226 -0
  139. package/images-page.js +417 -0
  140. package/images-records.js +39 -0
  141. package/images-render.js +36 -0
  142. package/images-svg-core.js +214 -0
  143. package/images-svg-sanitize.js +188 -0
  144. package/images-svg.js +241 -0
  145. package/images.js +270 -0
  146. package/jobs.js +430 -0
  147. package/linger.js +21 -0
  148. package/lists.js +292 -0
  149. package/media-info.js +136 -0
  150. package/navigation-page.js +264 -0
  151. package/navigation.js +394 -0
  152. package/notes.js +0 -0
  153. package/obsidian.js +405 -0
  154. package/package.json +47 -4
  155. package/palette-index.js +46 -0
  156. package/playwright.config.js +23 -0
  157. package/plugin-install.js +239 -0
  158. package/plugin-loader.js +448 -0
  159. package/plugin-manifest.js +83 -0
  160. package/plugin-settings.js +44 -0
  161. package/plugins/workspace-notes/index.js +59 -0
  162. package/plugins-page.js +103 -0
  163. package/project-make.js +241 -0
  164. package/project-name.js +210 -0
  165. package/public/assistant-avatar-moves.js +646 -0
  166. package/public/assistant-avatar-ui.js +1223 -0
  167. package/public/assistant-avatar.js +530 -0
  168. package/public/avatar/druplicon.vrm +0 -0
  169. package/public/css/base.css +260 -0
  170. package/public/css/components/ai-assistant.css +263 -0
  171. package/public/css/components/assistant-avatar.css +279 -0
  172. package/public/css/components/assistant-views.css +198 -0
  173. package/public/css/components/build-order.css +74 -0
  174. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  175. package/public/css/components/command-palette.css +61 -0
  176. package/public/css/components/commands-center.css +139 -0
  177. package/public/css/components/confirm-dialog.css +40 -0
  178. package/public/css/components/create-card.css +8 -0
  179. package/public/css/components/ddev-menu.css +23 -0
  180. package/public/css/components/deep-chat.css +17 -0
  181. package/public/css/components/density.css +11 -0
  182. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  183. package/public/css/components/folders.css +5 -0
  184. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  185. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  186. package/public/css/components/grid-and-list.css +203 -0
  187. package/public/css/components/help-page.css +36 -0
  188. package/public/css/components/hero.css +16 -0
  189. package/public/css/components/images.css +70 -0
  190. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  191. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  192. package/public/css/components/modal-fit.css +113 -0
  193. package/public/css/components/page-width.css +23 -0
  194. package/public/css/components/plugins-page.css +9 -0
  195. package/public/css/components/project-cards.css +256 -0
  196. package/public/css/components/project-rows.css +390 -0
  197. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  198. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  199. package/public/css/components/running-commands-as-toasts.css +327 -0
  200. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  201. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  202. package/public/css/components/sound-player.css +84 -0
  203. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  204. package/public/css/components/tab-modal.css +142 -0
  205. package/public/css/components/terminals.css +244 -0
  206. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  207. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  208. package/public/css/components/video-edit.css +6 -0
  209. package/public/css/components/web-ide-open-with.css +23 -0
  210. package/public/css/components/working-indicators.css +158 -0
  211. package/public/css/components/workspace-cards.css +79 -0
  212. package/public/favicon.ico +0 -0
  213. package/public/favicon.png +0 -0
  214. package/public/htmx.min.js +1 -0
  215. package/public/logo.png +0 -0
  216. package/public/media-player.js +215 -0
  217. package/public/mic.js +255 -0
  218. package/public/narration.js +110 -0
  219. package/public/palette.js +278 -0
  220. package/public/sortable.js +251 -0
  221. package/public/suggest.js +337 -0
  222. package/public/terminal.js +903 -0
  223. package/public/themes/default/theme.css +267 -0
  224. package/public/themes/ember/theme.css +180 -0
  225. package/public/themes/press/theme.css +290 -0
  226. package/public/themes/slate/theme.css +182 -0
  227. package/public/touch-icon.png +0 -0
  228. package/public/ui.js +2721 -0
  229. package/public/vendor/ace/LICENSE.txt +24 -0
  230. package/public/vendor/ace/ace.js +23 -0
  231. package/public/vendor/ace/ext-language_tools.js +8 -0
  232. package/public/vendor/ace/ext-searchbox.js +8 -0
  233. package/public/vendor/ace/mode-css.js +8 -0
  234. package/public/vendor/ace/mode-html.js +8 -0
  235. package/public/vendor/ace/mode-javascript.js +8 -0
  236. package/public/vendor/ace/mode-markdown.js +8 -0
  237. package/public/vendor/ace/mode-sh.js +8 -0
  238. package/public/vendor/ace/mode-yaml.js +8 -0
  239. package/public/vendor/ace/theme-textmate.js +8 -0
  240. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  241. package/public/vendor/ace/worker-yaml.js +1 -0
  242. package/public/vendor/deep-chat.LICENSE +21 -0
  243. package/public/vendor/deep-chat.bundle.js +1 -0
  244. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  245. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  246. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  248. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  249. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  251. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  252. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  253. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  254. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  255. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  256. package/public/vendor/tabler-LICENSE.txt +21 -0
  257. package/public/vendor/tabler-sprite.svg +1 -0
  258. package/public/vendor/three/LICENSE +21 -0
  259. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  260. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  261. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  262. package/public/vendor/three/three.core.min.js +6 -0
  263. package/public/vendor/three/three.module.min.js +6 -0
  264. package/public/vendor/three-vrm/LICENSE +22 -0
  265. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  266. package/public/vendor/uikit-icons.min.js +1 -0
  267. package/public/vendor/uikit.min.css +1 -0
  268. package/public/vendor/uikit.min.js +1 -0
  269. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  270. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  271. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  272. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  273. package/public/vendor/webship-icons/ui/play.svg +1 -0
  274. package/public/vendor/webship-icons/ui/share.svg +1 -0
  275. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  276. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  277. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  278. package/public/vendor/xterm/addon-fit.js +2 -0
  279. package/public/vendor/xterm/addon-unicode11.js +2 -0
  280. package/public/vendor/xterm/addon-web-links.js +2 -0
  281. package/public/vendor/xterm/xterm.css +218 -0
  282. package/public/vendor/xterm/xterm.js +2 -0
  283. package/rag.js +169 -0
  284. package/reader.js +132 -0
  285. package/repo-blobs.js +66 -0
  286. package/report-pdf.js +337 -0
  287. package/row-layout.js +1440 -0
  288. package/screenrc +37 -0
  289. package/screens.js +1063 -0
  290. package/scripts/code-server-start.sh +190 -0
  291. package/scripts/images-claude-finish.js +107 -0
  292. package/scripts/screen-host.sh +114 -0
  293. package/scripts/screen-runner.js +362 -0
  294. package/scripts/screen-then.js +47 -0
  295. package/scripts/update.sh +350 -0
  296. package/server.js +8140 -0
  297. package/settings-file.js +423 -0
  298. package/speech.js +297 -0
  299. package/tab-modal.js +75 -0
  300. package/test-reports.js +260 -0
  301. package/themes.js +381 -0
  302. package/transcripts.js +168 -0
  303. package/urls.js +13 -0
  304. package/validate.js +18 -0
  305. package/version-check.js +94 -0
  306. package/video-edit.js +588 -0
  307. package/waiting.js +85 -0
  308. package/workspace-card-tools.js +41 -0
  309. package/workspace-delete.js +139 -0
  310. package/workspace-types.js +85 -0
  311. package/workspaces.js +557 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Mailpit, as one feature: its menu item, its route, its panel and where its mailbox is.
3
+ *
4
+ * `server.js` knows four things about mail, and each of them is one line: which URL a listing row
5
+ * carries, the menu item for a project, the fragment route, and the lookup the actions are handed.
6
+ * Everything else — the markup, the wiring, the "is it running" rule — lives here, so adding a column
7
+ * to the panel or a verb to the toolbar never touches the server file.
8
+ *
9
+ * The API client is `ddev-mail.js`; the three write verbs are `actions/ddev-mail.js`. This module is
10
+ * what joins them to the dashboard.
11
+ */
12
+
13
+ const path = require('path');
14
+
15
+ const { esc } = require('./html');
16
+ const { humanSize, humanTime } = require('./format');
17
+ const { workspaceDir } = require('./workspaces');
18
+ const { ddevProjectName } = require('./jobs');
19
+ const { NAME_RE } = require('./validate');
20
+ const mail = require('./ddev-mail');
21
+
22
+ // `/fragments/projects/mail/<workspace>/<project>` — the one path this feature answers on, matched
23
+ // here rather than in the server's chain of patterns.
24
+ const FRAGMENT_RE = /^\/fragments\/projects\/mail\/([a-z0-9_-]+)\/([a-zA-Z0-9_-]+)$/;
25
+
26
+ /**
27
+ * The Mailpit URL a `ddev list` row carries, if any.
28
+ *
29
+ * https first: it is the same service on the other port, with the certificate DDEV issued.
30
+ */
31
+ const urlFromListing = (row) => String((row && (row.mailpit_https_url || row.mailpit_url)) || '');
32
+
33
+ /**
34
+ * Where a project's mailbox is, given the status map the dashboard already keeps.
35
+ *
36
+ * Takes a GETTER rather than the map, because the map is replaced on every refresh — holding the
37
+ * object would pin whichever one happened to be current when this was wired up. And it is ASYNCHRONOUS,
38
+ * because straight after a restart that map is empty for a second or two: a synchronous read answers
39
+ * "this project has no Mailpit — is it running?" about a project that is running perfectly well.
40
+ * Awaiting the dashboard's own getter refreshes a cold cache first.
41
+ *
42
+ * A stopped project answers '': DDEV reports a URL for one (51 of them here), but Mailpit only runs
43
+ * while the containers do, and a link that cannot answer is worse than none.
44
+ */
45
+ function mailpitFor(statusMap) {
46
+ return async (workspace, project) => {
47
+ try {
48
+ const dir = workspaceDir(workspace);
49
+ if (!dir) return '';
50
+ const st = (await statusMap()) || {};
51
+ const projectPath = path.join(dir, project);
52
+ const byPath = st[`@${projectPath}`];
53
+ const byName = st[ddevProjectName(projectPath)];
54
+ // The same rule the row's status uses: an entry whose approot is some other directory is a
55
+ // stale registration, not this project.
56
+ const entry = byPath || (byName && (!byName.approot || byName.approot === projectPath) ? byName : null);
57
+ return entry && entry.status === 'running' ? (entry.mailpit || '') : '';
58
+ } catch (_) {
59
+ return '';
60
+ }
61
+ };
62
+ }
63
+
64
+ /**
65
+ * The DDEV-menu entry, or '' when there is no mailbox to open.
66
+ *
67
+ * DDEV captures every mail a project sends rather than delivering it, and Mailpit holds them on the
68
+ * project's own hostname — so a site that "sends no email" locally has almost always sent them all,
69
+ * into here. The URL costs nothing to know, because the 20-second status refresh already carries it.
70
+ */
71
+ function menuItemHtml({ workspace, project, running, mailpit }) {
72
+ if (!running || !mailpit) return '';
73
+ return `<li><a href hx-get="/fragments/projects/mail/${encodeURIComponent(workspace)}/${encodeURIComponent(project)}"
74
+ hx-target="#editor-modal-body" hx-swap="innerHTML"
75
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"
76
+ title="Every mail this project sends is captured rather than delivered — read, search and clear it"><span uk-icon="icon: mail; ratio: .7"></span> Mail it captured (Mailpit)…</a></li>`;
77
+ }
78
+
79
+ /**
80
+ * The fragment route. Returns true when it answered, false when the path was not ours — so the
81
+ * server's chain asks one question rather than carrying the whole branch.
82
+ */
83
+ async function handleFragment({ pathname, req, res, isValidWorkspace, mailpit }) {
84
+ const m = FRAGMENT_RE.exec(pathname);
85
+ if (!m || !isValidWorkspace(m[1]) || !NAME_RE.test(m[2])) return false;
86
+ const q = new URL(req.url, 'http://localhost').searchParams.get('q') || '';
87
+ const html = await mailPanelHtml({ workspace: m[1], project: m[2], base: await mailpit(m[1], m[2]), q });
88
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
89
+ res.end(html);
90
+ return true;
91
+ }
92
+
93
+ const SHOW = 25;
94
+
95
+ async function mailPanelHtml({ workspace, project, base, q = '' }) {
96
+ const panelUrl = `/fragments/projects/mail/${encodeURIComponent(workspace)}/${encodeURIComponent(project)}`;
97
+ const vals = (extra) => esc(JSON.stringify({ workspace, projectName: project, ...extra }));
98
+ const info = await mail.mailInfo(base);
99
+
100
+ // Mailpit not answering is not an empty mailbox, and saying so is the whole reason this panel is
101
+ // worth more than a link: "no mail" would send somebody looking for a bug in their site.
102
+ if (!info.ok) {
103
+ return `
104
+ <div class="editor-card">
105
+ <h3 class="uk-margin-small-bottom">Mail from ${esc(project)}</h3>
106
+ <div class="msg error">Mailpit is not answering: ${esc(info.error)}.
107
+ <p class="uk-text-meta">It runs with the project, so start the project and try again. This is not the same as
108
+ an empty mailbox — nothing here says whether the site sent anything.</p></div>
109
+ </div>`;
110
+ }
111
+
112
+ const listed = await mail.mailList(base, { limit: SHOW, query: q });
113
+ const messages = listed.ok ? listed.messages : [];
114
+ const rows = messages.map((m) => `
115
+ <li class="${m.read ? '' : 'mail-unread'}">
116
+ <div class="mail-line">
117
+ <a class="mail-subject" href="${esc(mail.messageUrl(base, m.id))}" target="_blank" rel="noopener"
118
+ title="Open this message in Mailpit">${m.read ? '' : '<span class="mail-dot" aria-label="unread">●</span> '}${esc(m.subject)}</a>
119
+ <span class="uk-text-meta mail-meta">${esc(m.created ? humanTime(m.created) : '')} · ${esc(humanSize(m.size))}${
120
+ m.attachments ? ` · ${m.attachments} attachment${m.attachments === 1 ? '' : 's'}` : ''}</span>
121
+ <span class="mail-actions">
122
+ <button class="uk-button uk-button-link mail-drop arm-step" title="Delete this message"
123
+ hx-post="/actions/ddev-mail-delete" hx-vals='${vals({ id: m.id, confirm: 'yes' })}'
124
+ hx-target="#editor-modal-body" hx-swap="innerHTML" hx-trigger="confirmed-remove">✕</button>
125
+ </span>
126
+ </div>
127
+ <p class="uk-text-meta mail-who">${esc(m.fromName ? `${m.fromName} <${m.from}>` : m.from)} → ${esc(m.to.join(', ') || '(nobody)')}</p>
128
+ </li>`).join('');
129
+
130
+ return `
131
+ <div class="editor-card">
132
+ <h3 class="uk-margin-small-bottom">Mail from ${esc(project)}
133
+ <span class="uk-badge">${info.total}</span>${info.unread ? ` <span class="uk-label uk-label-warning">${info.unread} unread</span>` : ''}</h3>
134
+ <p class="uk-text-meta">DDEV captures every mail this project sends instead of delivering it, so this is
135
+ where a password reset, a webform notification or a cron warning actually went. Nothing here has left
136
+ the machine.</p>
137
+
138
+ <div class="mail-tools">
139
+ <form class="mail-search" hx-get="${esc(panelUrl)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
140
+ hx-trigger="submit, keyup[key=='Enter'] from:find input">
141
+ <input class="uk-input uk-form-small" type="search" name="q" value="${esc(q)}"
142
+ placeholder="Search subject, address, body…" aria-label="Search this mailbox">
143
+ <button class="uk-button uk-button-default uk-button-small" type="submit">Search</button>
144
+ ${q ? `<a class="uk-button uk-button-link uk-button-small" href hx-get="${esc(panelUrl)}" hx-target="#editor-modal-body" hx-swap="innerHTML">clear</a>` : ''}
145
+ </form>
146
+ <span class="mail-bulk">
147
+ ${info.unread ? `<button class="uk-button uk-button-default uk-button-small" hx-post="/actions/ddev-mail-read"
148
+ hx-vals='${vals({})}' hx-target="#editor-modal-body" hx-swap="innerHTML">Mark all read</button>` : ''}
149
+ ${info.total ? `<button class="uk-button uk-button-danger uk-button-small arm-step"
150
+ hx-post="/actions/ddev-mail-clear" hx-vals='${vals({ confirm: 'yes' })}'
151
+ hx-target="#editor-modal-body" hx-swap="innerHTML" hx-trigger="confirmed-remove"
152
+ title="Delete every message in this mailbox">Empty the mailbox</button>` : ''}
153
+ <a class="uk-button uk-button-default uk-button-small" href="${esc(base)}" target="_blank" rel="noopener"
154
+ title="Mailpit's own interface, which renders a message properly">Open Mailpit</a>
155
+ </span>
156
+ </div>
157
+
158
+ ${messages.length
159
+ ? `<ul class="uk-list uk-list-divider mail-list">${rows}</ul>${
160
+ listed.total > messages.length ? `<p class="uk-text-meta">Showing the newest ${messages.length} of ${listed.total} — the rest are in Mailpit.</p>` : ''}`
161
+ : q
162
+ ? `<p class="uk-text-meta">Nothing in this mailbox matches “${esc(q)}”.</p>`
163
+ : '<p class="uk-text-meta">The mailbox is empty. This project has sent nothing since it was last cleared — which, for a site under test, is worth knowing before you go looking for the mail somewhere else.</p>'}
164
+ </div>`;
165
+ }
166
+
167
+ module.exports = { mailPanelHtml, menuItemHtml, handleFragment, mailpitFor, urlFromListing, FRAGMENT_RE, SHOW };
package/ddev-mail.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * A project's captured mail, over Mailpit's own API.
3
+ *
4
+ * DDEV does not deliver a project's outgoing email — it captures it. Which means Mailpit is not a
5
+ * curiosity: it is where every password reset, webform notification and cron warning a site "sent"
6
+ * actually went, and until something reads it, nobody can tell a mail that was never sent from one
7
+ * that was sent and never looked at.
8
+ *
9
+ * The link to Mailpit's own UI stays (it renders a message better than anything here would), but the
10
+ * things you do REPEATEDLY belong on the row: how many are waiting, what the last ones were, is the
11
+ * one I just triggered there, and clear them out before a test run.
12
+ *
13
+ * Three things shape this module:
14
+ *
15
+ * - **The API is reachable from here.** This container resolves the project hostnames and the
16
+ * router terminates their TLS, so `https://<project>.ddev.site:8026/api/v1/…` answers directly —
17
+ * no `ddev exec`, no shelling out per request.
18
+ * - **Mailpit is only up while the project is.** Every call is given a short timeout and a failure
19
+ * is reported as "not answering" rather than as an empty mailbox: an empty inbox and an inbox
20
+ * that could not be read are different facts, and only one of them means the mail was not sent.
21
+ * - **Nothing is cached.** A mailbox is what you are looking at right now — you trigger a reset
22
+ * mail and refresh — so a cache here would only ever be a way of showing yesterday's answer.
23
+ */
24
+
25
+ const MAIL_TIMEOUT_MS = 8000;
26
+
27
+ /** One call to a project's Mailpit, or a reason it could not be made. */
28
+ async function api(base, path, { method = 'GET', body } = {}) {
29
+ if (!base) return { ok: false, error: 'This project has no Mailpit URL — is it running?' };
30
+ const url = `${base.replace(/\/$/, '')}/api/v1${path}`;
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), MAIL_TIMEOUT_MS);
33
+ try {
34
+ const res = await fetch(url, {
35
+ method,
36
+ signal: controller.signal,
37
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
38
+ body: body ? JSON.stringify(body) : undefined,
39
+ });
40
+ if (!res.ok) return { ok: false, error: `Mailpit answered ${res.status}` };
41
+ const text = (await res.text()).trim();
42
+ if (!text) return { ok: true, data: null };
43
+ if (!/^[[{]/.test(text)) return { ok: true, data: text };
44
+ try {
45
+ return { ok: true, data: JSON.parse(text) };
46
+ } catch (_) {
47
+ return { ok: false, error: 'Mailpit sent something this could not read' };
48
+ }
49
+ } catch (err) {
50
+ // A project that is stopped, a Mailpit that is still starting, a certificate nobody trusts: all
51
+ // of them are "it did not answer", and none of them is "there is no mail".
52
+ return { ok: false, error: err.name === 'AbortError' ? 'Mailpit did not answer in time' : String(err.message || err) };
53
+ } finally {
54
+ clearTimeout(timer);
55
+ }
56
+ }
57
+
58
+ /** How much is in there: total and unread. */
59
+ async function mailInfo(base) {
60
+ const r = await api(base, '/info');
61
+ if (!r.ok) return r;
62
+ return { ok: true, total: Number(r.data.Messages || 0), unread: Number(r.data.Unread || 0), version: r.data.Version || '' };
63
+ }
64
+
65
+ /**
66
+ * The messages themselves, newest first — a search when one was typed, the plain list otherwise.
67
+ *
68
+ * Only the fields a list needs are kept. A message's body is one click away in Mailpit, which renders
69
+ * HTML mail, attachments and the raw source far better than this panel ever would.
70
+ */
71
+ async function mailList(base, { limit = 25, query = '' } = {}) {
72
+ const q = String(query || '').trim();
73
+ const r = q
74
+ ? await api(base, `/search?query=${encodeURIComponent(q)}&limit=${limit}`)
75
+ : await api(base, `/messages?limit=${limit}`);
76
+ if (!r.ok) return r;
77
+ const messages = (r.data.messages || []).map((m) => ({
78
+ id: String(m.ID || ''),
79
+ subject: String(m.Subject || '(no subject)'),
80
+ from: (m.From && m.From.Address) || '',
81
+ fromName: (m.From && m.From.Name) || '',
82
+ to: (m.To || []).map((t) => t.Address).filter(Boolean),
83
+ created: m.Created ? new Date(m.Created) : null,
84
+ size: Number(m.Size || 0),
85
+ read: !!m.Read,
86
+ attachments: Number(m.Attachments || 0),
87
+ }));
88
+ return { ok: true, messages, total: Number(r.data.messages_count ?? r.data.total ?? messages.length) };
89
+ }
90
+
91
+ /** Everything in the mailbox, gone. The confirm belongs to the caller; this is the call. */
92
+ const deleteAllMail = (base) => api(base, '/messages', { method: 'DELETE' });
93
+
94
+ /** One message, gone — the id comes from a listing, never from a path. */
95
+ const deleteOneMail = (base, id) => api(base, '/messages', { method: 'DELETE', body: { IDs: [String(id)] } });
96
+
97
+ /** Everything marked read, which is how you tell the next one apart from all of these. */
98
+ const markAllRead = (base) => api(base, '/messages', { method: 'PUT', body: { Read: true } });
99
+
100
+ /** Where Mailpit shows one message, for the link out. */
101
+ const messageUrl = (base, id) => `${String(base).replace(/\/$/, '')}/view/${encodeURIComponent(id)}`;
102
+
103
+ // A Mailpit message id, as its API hands them out: a short URL-safe token. Checked before it is put
104
+ // in a URL or a delete, because it arrives from a browser.
105
+ const MAIL_ID_RE = /^[A-Za-z0-9_-]{6,64}$/;
106
+
107
+ module.exports = {
108
+ MAIL_ID_RE, MAIL_TIMEOUT_MS,
109
+ mailInfo, mailList, deleteAllMail, deleteOneMail, markAllRead, messageUrl,
110
+ };
@@ -0,0 +1,145 @@
1
+ /**
2
+ * What `ddev config` accepts, as the DDEV docs list it (docs.ddev.com, users/configuration/config and
3
+ * users/quickstart, checked against `ddev config --help` on v1.25.4), and the form that offers it.
4
+ *
5
+ * Every value is picked from a list, never typed into a shell line.
6
+ */
7
+
8
+ const { esc } = require('./html');
9
+
10
+ const PROJECT_TYPES = ['php', 'generic', 'drupal12', 'drupal11', 'drupal10', 'drupal9', 'drupal8', 'drupal7', 'drupal6', 'drupal',
11
+ 'backdrop', 'wordpress', 'wp-bedrock', 'typo3', 'laravel', 'symfony', 'craftcms', 'shopware6', 'magento2', 'magento', 'maho',
12
+ 'joomla', 'silverstripe', 'cakephp', 'codeigniter', 'modx', 'asterios'];
13
+
14
+ const PHP_VERSIONS = ['8.5', '8.4', '8.3', '8.2', '8.1', '8.0', '7.4', '7.3', '7.2', '7.1', '7.0', '5.6'];
15
+
16
+ const range = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => String(b - i));
17
+ const DATABASES = [
18
+ ...['12.3', '11.8', '11.4', '10.11', '10.8', '10.7', '10.6', '10.5', '10.4', '10.3', '10.2', '10.1', '10.0', '5.5'].map((v) => `mariadb:${v}`),
19
+ ...['9.7', '8.4', '8.0', '5.7', '5.6', '5.5'].map((v) => `mysql:${v}`),
20
+ ...range(9, 18).map((v) => `postgres:${v}`),
21
+ ];
22
+
23
+ const WEBSERVERS = ['nginx-fpm', 'apache-fpm', 'generic'];
24
+ // "" uses the version already in the image; auto and engine read it from the project's own files.
25
+ const NODE_VERSIONS = ['24', '22', '20', '18', 'auto', 'engine', ''];
26
+ const COMPOSER_VERSIONS = ['2', '2.2', 'stable', 'preview', 'snapshot', ''];
27
+ // The packages DDEV's own Python quickstart (Wagtail) adds; the version is the one Debian ships in the web image.
28
+ const PYTHON_PACKAGES = 'python3-pip,python3-venv';
29
+ const DOCROOT_RE = /^(?:[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*)?$/;
30
+
31
+ const CHOICES = {
32
+ project_type: PROJECT_TYPES, php_version: PHP_VERSIONS, database: DATABASES,
33
+ webserver_type: WEBSERVERS, nodejs_version: NODE_VERSIONS, composer_version: COMPOSER_VERSIONS,
34
+ };
35
+
36
+ // The DDEV quickstarts, with the settings each one's `ddev config` uses. Unset fields keep DDEV's defaults.
37
+ const PRESETS = [
38
+ { id: 'php', label: 'Plain PHP', project_type: 'php' },
39
+ { id: 'asterios', label: 'Asterios', project_type: 'asterios', docroot: 'public' },
40
+ { id: 'backdrop', label: 'Backdrop', project_type: 'backdrop' },
41
+ { id: 'cakephp', label: 'CakePHP', project_type: 'cakephp', docroot: 'webroot' },
42
+ { id: 'codeigniter', label: 'CodeIgniter', project_type: 'codeigniter', docroot: 'public' },
43
+ { id: 'contao', label: 'Contao', project_type: 'php', docroot: 'public', php_version: '8.4', webserver_type: 'apache-fpm' },
44
+ { id: 'craftcms', label: 'Craft CMS', project_type: 'craftcms', docroot: 'web', database: 'mysql:8.0' },
45
+ { id: 'drupal12', label: 'Drupal 12', project_type: 'drupal12', docroot: 'web' },
46
+ { id: 'drupal11', label: 'Drupal 11 / Drupal CMS', project_type: 'drupal11', docroot: 'web' },
47
+ { id: 'drupal10', label: 'Drupal 10', project_type: 'drupal10', docroot: 'web' },
48
+ { id: 'expressionengine', label: 'ExpressionEngine', project_type: 'php', database: 'mysql:8.0' },
49
+ { id: 'grav', label: 'Grav', project_type: 'php', php_version: '8.3', omit_db: true },
50
+ { id: 'ibexa', label: 'Ibexa DXP', project_type: 'php', docroot: 'public' },
51
+ { id: 'joomla', label: 'Joomla', project_type: 'joomla' },
52
+ { id: 'kirby', label: 'Kirby CMS', project_type: 'php', webserver_type: 'apache-fpm', omit_db: true },
53
+ { id: 'laravel', label: 'Laravel', project_type: 'laravel', docroot: 'public' },
54
+ { id: 'magento2', label: 'Magento 2', project_type: 'magento2', docroot: 'pub' },
55
+ { id: 'maho', label: 'Maho', project_type: 'maho', docroot: 'public' },
56
+ { id: 'modx', label: 'MODX Revolution', project_type: 'modx' },
57
+ { id: 'moodle', label: 'Moodle', project_type: 'php', docroot: 'public', webserver_type: 'apache-fpm' },
58
+ { id: 'nodejs', label: 'Node.js (Express, SvelteKit)', project_type: 'generic', webserver_type: 'generic' },
59
+ { id: 'openmage', label: 'OpenMage', project_type: 'magento', php_version: '8.1' },
60
+ { id: 'october', label: 'October CMS', project_type: 'laravel', webserver_type: 'apache-fpm' },
61
+ { id: 'pimcore', label: 'Pimcore', project_type: 'php', docroot: 'public' },
62
+ { id: 'processwire', label: 'ProcessWire', project_type: 'php', webserver_type: 'apache-fpm' },
63
+ { id: 'shopware6', label: 'Shopware 6', project_type: 'shopware6', docroot: 'public' },
64
+ { id: 'silverstripe', label: 'Silverstripe', project_type: 'silverstripe', docroot: 'public' },
65
+ { id: 'statamic', label: 'Statamic', project_type: 'laravel', docroot: 'public' },
66
+ { id: 'sulu', label: 'Sulu', project_type: 'php', docroot: 'public', database: 'mysql:8.0' },
67
+ { id: 'symfony', label: 'Symfony', project_type: 'symfony', docroot: 'public' },
68
+ { id: 'tempest', label: 'Tempest', project_type: 'php', docroot: 'public', php_version: '8.5', omit_db: true },
69
+ { id: 'typo3', label: 'TYPO3', project_type: 'typo3', docroot: 'public' },
70
+ { id: 'wagtail', label: 'Wagtail (Python)', project_type: 'generic', webserver_type: 'generic', python: true, omit_db: true },
71
+ { id: 'wordpress', label: 'WordPress', project_type: 'wordpress' },
72
+ { id: 'wp-bedrock', label: 'WordPress (Bedrock)', project_type: 'wp-bedrock' },
73
+ ];
74
+
75
+ /** The `ddev config` flags a form asks for, each checked against the lists above. Unchosen values are left to DDEV. */
76
+ function configArgs(form) {
77
+ const args = [];
78
+ const pick = (field, flag) => {
79
+ const v = form[field];
80
+ if (v === undefined || v === null || v === '__default') return;
81
+ if (CHOICES[field].includes(String(v))) args.push(`${flag}=${String(v)}`);
82
+ };
83
+ pick('project_type', '--project-type');
84
+ const docroot = String(form.docroot === undefined ? '' : form.docroot).trim();
85
+ if (form.docroot !== undefined && DOCROOT_RE.test(docroot) && !docroot.split('/').includes('..')) args.push(`--docroot=${docroot}`);
86
+ pick('php_version', '--php-version');
87
+ pick('database', '--database');
88
+ pick('webserver_type', '--webserver-type');
89
+ pick('nodejs_version', '--nodejs-version');
90
+ pick('composer_version', '--composer-version');
91
+ if (form.omit_db === '1') args.push('--omit-containers=db');
92
+ if (form.python === '1') args.push(`--webimage-extra-packages=${PYTHON_PACKAGES}`);
93
+ return args;
94
+ }
95
+
96
+ /** Shell-quote each argument, for a line run in a screen session. */
97
+ function shellArgs(args) {
98
+ return args.map((a) => `'${a.replace(/'/g, `'\\''`)}'`).join(' ');
99
+ }
100
+
101
+ /**
102
+ * The fields, for a form. `current` holds the values a project already has (Configure DDEV on an existing
103
+ * project); a field with no current value starts on "DDEV's default", so nothing is changed unasked.
104
+ */
105
+ function fieldsHtml({ current = {}, prefix = 'ddev' } = {}) {
106
+ const sel = (name, label, values, hint, def = '__default') => {
107
+ const now = current[name] !== undefined ? String(current[name]) : def;
108
+ const opts = [`<option value="__default"${now === '__default' ? ' selected' : ''}>DDEV's default</option>`]
109
+ .concat(values.map((v) => `<option value="${esc(v)}"${now === v ? ' selected' : ''}>${esc(v === '' ? '(empty: what the image has)' : v)}</option>`));
110
+ return `<div><label class="uk-form-label" for="${prefix}-${name}">${esc(label)}</label>
111
+ <select class="uk-select uk-form-small" id="${prefix}-${name}" name="${name}">${opts.join('')}</select>
112
+ <div class="uk-text-meta">${hint}</div></div>`;
113
+ };
114
+ const presetOpts = PRESETS.map((p) => {
115
+ const data = ['project_type', 'docroot', 'php_version', 'database', 'webserver_type']
116
+ .map((k) => `data-${k.replace(/_/g, '-')}="${esc(p[k] === undefined ? '__default' : p[k])}"`).join(' ');
117
+ return `<option value="${esc(p.id)}" ${data} data-omit-db="${p.omit_db ? 1 : 0}" data-python="${p.python ? 1 : 0}">${esc(p.label)}</option>`;
118
+ }).join('');
119
+ return `
120
+ <div class="ddev-options">
121
+ <label class="uk-form-label" for="${prefix}-preset">Start from a DDEV quickstart <span class="uk-text-meta">(fills the fields below; change any of them)</span></label>
122
+ <select class="uk-select uk-form-small uk-margin-small-bottom" id="${prefix}-preset" data-ddev-preset>
123
+ <option value="">Choose…</option>${presetOpts}
124
+ </select>
125
+ <div class="uk-grid-small uk-child-width-1-2@m" uk-grid>
126
+ ${sel('project_type', 'Project type', PROJECT_TYPES, 'What DDEV configures the container for.', current.project_type ? current.project_type : 'php')}
127
+ <div><label class="uk-form-label" for="${prefix}-docroot">Docroot</label>
128
+ <input class="uk-input uk-form-small" id="${prefix}-docroot" name="docroot" value="${esc(current.docroot || '')}"
129
+ pattern="[a-zA-Z0-9_/-]*" maxlength="80" placeholder="(the folder itself)">
130
+ <div class="uk-text-meta">The folder the web server serves, like <code>web</code> or <code>public</code>.</div></div>
131
+ ${sel('php_version', 'PHP version', PHP_VERSIONS, 'In the web container.')}
132
+ ${sel('database', 'Database', DATABASES, 'Engine and version; the volume is made on first start.')}
133
+ ${sel('webserver_type', 'Web server', WEBSERVERS, '<code>generic</code> runs your own server, for Node.js or Python.')}
134
+ ${sel('nodejs_version', 'Node.js version', NODE_VERSIONS, '<code>auto</code> reads it from the project, like <code>.nvmrc</code>.')}
135
+ ${sel('composer_version', 'Composer version', COMPOSER_VERSIONS, 'Composer 2 unless the project needs another.')}
136
+ <div><span class="uk-form-label">Extras</span>
137
+ <label class="uk-display-block"><input class="uk-checkbox" type="checkbox" name="omit_db" value="1"${current.omit_db ? ' checked' : ''}> No database container</label>
138
+ <label class="uk-display-block"><input class="uk-checkbox" type="checkbox" name="python" value="1"${current.python ? ' checked' : ''}> Python (pip and venv) in the web container</label>
139
+ <div class="uk-text-meta">DDEV has no Python version setting; this is the Python the web image ships.</div></div>
140
+ </div>
141
+ </div>`;
142
+ }
143
+
144
+ module.exports = { PROJECT_TYPES, PHP_VERSIONS, DATABASES, WEBSERVERS, NODE_VERSIONS, COMPOSER_VERSIONS, PRESETS, CHOICES,
145
+ PYTHON_PACKAGES, configArgs, shellArgs, fieldsHtml };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * A project's database snapshots: what there is, and what can be done with one.
3
+ *
4
+ * The row could already TAKE a snapshot and never do anything else with it — no list, no restore, no
5
+ * way to remove one. A backup you cannot restore is a file, and the whole reason snapshots exist is
6
+ * that restoring one is fast: they are a physical copy of the database's own files (mariabackup /
7
+ * xtrabackup, or pg_basebackup), not a SQL dump replayed statement by statement.
8
+ *
9
+ * Two facts about them shape everything here, and both come from DDEV's own documentation:
10
+ *
11
+ * - **A snapshot belongs to a database engine and version.** The file is named
12
+ * `<name>-<engine>_<version>.zst`, and a `mariadb_11.8` snapshot does not restore into a
13
+ * `mariadb_10.11` project. So the version is listed beside every one: it is the difference
14
+ * between a restore and an error nobody expected.
15
+ * - **Restoring replaces the database.** There is no merge and no undo, which is why it is behind
16
+ * the confirm dialog like the other things here that cannot be taken back.
17
+ *
18
+ * They are read from `.ddev/db_snapshots/` rather than from `ddev snapshot --list`: it is the same
19
+ * answer, and a project row must not cost a subprocess to draw.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ // `<name>-<engine>_<version>` plus the compression's extension, which is how DDEV writes them. The
26
+ // engine and version are part of the FILE name rather than metadata beside it, which is what makes
27
+ // listing them a readdir.
28
+ const SNAPSHOT_FILE_RE = /^(.+)-([a-z]+)_([0-9.]+)(\.zst|\.gz|\.tgz)?$/i;
29
+
30
+ // What may be typed into the name box. DDEV puts the name straight into a filename, so a path
31
+ // separator or a leading dot in it is a file somewhere else.
32
+ const SNAPSHOT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,60}$/;
33
+
34
+ const snapshotDir = (projectDir) => path.join(projectDir, '.ddev', 'db_snapshots');
35
+
36
+ /**
37
+ * Every snapshot this project holds, newest first.
38
+ *
39
+ * An uncompressed snapshot is a DIRECTORY of the same name shape, so both are listed — the entry
40
+ * type is what says whether it was compressed, since that is exactly what the extension encodes.
41
+ */
42
+ function listSnapshots(projectDir) {
43
+ const dir = snapshotDir(projectDir);
44
+ let entries = [];
45
+ try {
46
+ entries = fs.readdirSync(dir, { withFileTypes: true });
47
+ } catch (_) {
48
+ return [];
49
+ }
50
+ const out = [];
51
+ for (const e of entries) {
52
+ if (e.name.startsWith('.')) continue;
53
+ const m = SNAPSHOT_FILE_RE.exec(e.name);
54
+ if (!m) continue;
55
+ const full = path.join(dir, e.name);
56
+ let st;
57
+ try { st = fs.statSync(full); } catch (_) { continue; }
58
+ out.push({
59
+ name: m[1],
60
+ file: e.name,
61
+ engine: m[2].toLowerCase(),
62
+ version: m[3],
63
+ dbVersion: `${m[2].toLowerCase()}_${m[3]}`,
64
+ compression: m[4] === '.zst' ? 'zstd' : m[4] ? m[4].replace('.', '') : 'none',
65
+ size: st.isDirectory() ? dirSize(full) : st.size,
66
+ mtime: st.mtime,
67
+ });
68
+ }
69
+ return out.sort((a, b) => b.mtime - a.mtime);
70
+ }
71
+
72
+ /** An uncompressed snapshot is a folder, and its size is what is in it. One level is enough: that is
73
+ * how the backup tools write them. */
74
+ function dirSize(dir) {
75
+ let n = 0;
76
+ try {
77
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
78
+ if (!e.isFile()) continue;
79
+ try { n += fs.statSync(path.join(dir, e.name)).size; } catch (_) { /* skip */ }
80
+ }
81
+ } catch (_) { /* unreadable */ }
82
+ return n;
83
+ }
84
+
85
+ /**
86
+ * Does this project have a snapshot by this name, and what is it?
87
+ *
88
+ * The name comes from the browser, so it is matched against what is actually on disk rather than
89
+ * pasted into a command line — a snapshot that is not there is refused here rather than by ddev,
90
+ * three seconds later, in a screen session somebody has to open to read.
91
+ */
92
+ const findSnapshot = (projectDir, name) => listSnapshots(projectDir).find((s) => s.name === name) || null;
93
+
94
+ /**
95
+ * Can this snapshot restore into this project as it stands?
96
+ *
97
+ * The file names the engine and version it was taken from, and the project's own config names the
98
+ * one it runs. When they differ DDEV refuses the restore, so the row says so first — with both
99
+ * versions, because "it did not work" is not the useful half of that sentence.
100
+ */
101
+ function restorable(snapshot, projectDbVersion) {
102
+ if (!snapshot) return { ok: false, why: 'No such snapshot.' };
103
+ if (!projectDbVersion) return { ok: true, why: '' }; // unknown: let DDEV be the judge
104
+ if (snapshot.dbVersion === projectDbVersion) return { ok: true, why: '' };
105
+ return { ok: false,
106
+ why: `This snapshot is ${snapshot.dbVersion} and the project runs ${projectDbVersion}.`
107
+ + ' A snapshot restores only into the database engine and version it was taken from.' };
108
+ }
109
+
110
+ /** What the project's `.ddev/config.yaml` says it runs, as `<engine>_<version>`, or ''. */
111
+ function projectDbVersion(projectDir) {
112
+ let raw = '';
113
+ try { raw = fs.readFileSync(path.join(projectDir, '.ddev', 'config.yaml'), 'utf8'); } catch (_) { return ''; }
114
+ // database:
115
+ // type: mariadb
116
+ // version: "11.8"
117
+ const block = raw.match(/^database:\s*$([\s\S]*?)(?=^\S|\Z)/m);
118
+ if (!block) return '';
119
+ const type = block[1].match(/^\s+type:\s*["']?([a-z]+)["']?\s*$/m);
120
+ const version = block[1].match(/^\s+version:\s*["']?([0-9.]+)["']?\s*$/m);
121
+ return type && version ? `${type[1].toLowerCase()}_${version[1]}` : '';
122
+ }
123
+
124
+ module.exports = {
125
+ SNAPSHOT_NAME_RE, SNAPSHOT_FILE_RE, snapshotDir, listSnapshots, findSnapshot, restorable, projectDbVersion,
126
+ };