@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.
- package/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/graphs.js +107 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/obsidian.js +59 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/graphs.js +104 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/obsidian.js +405 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +448 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1440 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +8140 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +557 -0
package/changelog.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the CHANGELOG of anything in the tree that keeps one.
|
|
3
|
+
*
|
|
4
|
+
* Not only `products/`. A changelog turns up wherever work does — a built site in `dev/` carries
|
|
5
|
+
* the one Drupal CMS ships, a module checkout in `modules/` carries its own, a recipe in
|
|
6
|
+
* `recipes/` carries the one its maintainer writes. 68 of them across the registered workspaces
|
|
7
|
+
* today, and the number is not the point: the point is that somebody adding a checkout tomorrow
|
|
8
|
+
* gets it read without anybody wiring it up.
|
|
9
|
+
*
|
|
10
|
+
* Answering "what shipped in Haven last week" meant opening a file and scrolling, which is
|
|
11
|
+
* why nobody did it — and a changelog nobody reads is a changelog that stops being written.
|
|
12
|
+
*
|
|
13
|
+
* So the files are parsed into releases and entries and rendered as a page. What this module does
|
|
14
|
+
* NOT do is rewrite anything: a changelog is the product's own record, edited in its own repository
|
|
15
|
+
* by whoever cut the release. This only reads.
|
|
16
|
+
*
|
|
17
|
+
* Three formats are in the tree and all three are parsed, because the alternative is a reader that
|
|
18
|
+
* works for the products somebody remembered:
|
|
19
|
+
*
|
|
20
|
+
* 1. **Keep a Changelog** — `## [1.0.3] - 2026-09-14`, with `### Added` / `### Fixed` sections.
|
|
21
|
+
* Most of the recipes and the site templates.
|
|
22
|
+
* 2. **The Webship suite's own** — `# 11.0.0` as a top-level heading, with prose sections under it
|
|
23
|
+
* and entries prefixed `feat:` / `fix:` / `task:`.
|
|
24
|
+
* 3. **The old Drupal .txt** — `Menu Position 7.x-1.1, 2012-02-22` underlined with dashes. Two
|
|
25
|
+
* products still carry one, and they are the oldest things here.
|
|
26
|
+
*
|
|
27
|
+
* A release nobody can date is still a release, so a missing date is left empty rather than guessed.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
|
|
35
|
+
const { ROOT, loadWorkspaces, isValidWorkspace, workspaceDir } = require('./workspaces');
|
|
36
|
+
|
|
37
|
+
/** The file names a product might keep its changelog in, in the order they are preferred. */
|
|
38
|
+
const CHANGELOG_NAMES = ['CHANGELOG.md', 'CHANGELOG.txt', 'changelog.md', 'changelog.txt'];
|
|
39
|
+
|
|
40
|
+
/** Nothing bigger than this is parsed: a changelog is a document, not a log file. */
|
|
41
|
+
const MAX_BYTES = 512 * 1024;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The kinds of change, and how each is shown.
|
|
45
|
+
*
|
|
46
|
+
* Keep a Changelog names six; our own entries are prefixed in the Drupal commit-type style
|
|
47
|
+
* (`feat:`, `fix:`, `task:`), and drupal.org adds a few more. Both vocabularies map onto the same
|
|
48
|
+
* small set, because a reader should not have to know which convention a given product follows.
|
|
49
|
+
*
|
|
50
|
+
* `tone` is a UIkit label modifier, so the badge is coloured by the component rather than by a rule
|
|
51
|
+
* of ours — success, warning, danger are UIkit's own.
|
|
52
|
+
*/
|
|
53
|
+
const KINDS = {
|
|
54
|
+
added: { label: 'Added', tone: 'success' },
|
|
55
|
+
feat: { label: 'Added', tone: 'success' },
|
|
56
|
+
feature: { label: 'Added', tone: 'success' },
|
|
57
|
+
fixed: { label: 'Fixed', tone: 'danger' },
|
|
58
|
+
fix: { label: 'Fixed', tone: 'danger' },
|
|
59
|
+
bug: { label: 'Fixed', tone: 'danger' },
|
|
60
|
+
changed: { label: 'Changed', tone: '' },
|
|
61
|
+
change: { label: 'Changed', tone: '' },
|
|
62
|
+
update: { label: 'Changed', tone: '' },
|
|
63
|
+
task: { label: 'Task', tone: '' },
|
|
64
|
+
chore: { label: 'Task', tone: '' },
|
|
65
|
+
docs: { label: 'Docs', tone: '' },
|
|
66
|
+
removed: { label: 'Removed', tone: 'warning' },
|
|
67
|
+
remove: { label: 'Removed', tone: 'warning' },
|
|
68
|
+
deprecated: { label: 'Deprecated', tone: 'warning' },
|
|
69
|
+
security: { label: 'Security', tone: 'danger' },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** What a section heading or an entry prefix means, or null when it means nothing in particular. */
|
|
73
|
+
function kindOf(word) {
|
|
74
|
+
if (!word) return null;
|
|
75
|
+
const key = String(word).trim().toLowerCase().replace(/[^a-z]/g, '');
|
|
76
|
+
return KINDS[key] || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Everything in the tree that keeps a changelog, with the file it keeps it in.
|
|
81
|
+
*
|
|
82
|
+
* Every registered workspace is walked, and in each one: the folder's own changelog, if it has one,
|
|
83
|
+
* and then every item inside it. Discovery rather than a list, for the same reason the builders are
|
|
84
|
+
* found by filename — a checkout added tomorrow should appear without anybody editing this file.
|
|
85
|
+
*
|
|
86
|
+
* Memoised for two seconds, the same window `loadWorkspaces` uses. 68 files is a cheap walk, but
|
|
87
|
+
* the index parses every one of them and a page can ask for the list more than once.
|
|
88
|
+
*/
|
|
89
|
+
let _cache = null;
|
|
90
|
+
let _cacheAt = 0;
|
|
91
|
+
|
|
92
|
+
function listChangelogs() {
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
if (_cache && now - _cacheAt < 2000) return _cache;
|
|
95
|
+
|
|
96
|
+
const out = [];
|
|
97
|
+
const fileIn = (dir) => {
|
|
98
|
+
for (const name of CHANGELOG_NAMES) {
|
|
99
|
+
const full = path.join(dir, name);
|
|
100
|
+
let stat;
|
|
101
|
+
try { stat = fs.statSync(full); } catch (_) { continue; }
|
|
102
|
+
if (!stat.isFile() || !stat.size || stat.size > MAX_BYTES) continue;
|
|
103
|
+
return { file: name, path: full, bytes: stat.size, changed: stat.mtimeMs };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
for (const ws of Object.values(loadWorkspaces())) {
|
|
109
|
+
if (!ws.dir) continue;
|
|
110
|
+
// The workspace folder's own, if it keeps one.
|
|
111
|
+
const own = fileIn(ws.dir);
|
|
112
|
+
if (own) out.push({ workspace: ws.key, item: '', label: ws.label || ws.key, ...own });
|
|
113
|
+
|
|
114
|
+
let entries = [];
|
|
115
|
+
try {
|
|
116
|
+
entries = fs.readdirSync(ws.dir, { withFileTypes: true })
|
|
117
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
118
|
+
.map((e) => e.name)
|
|
119
|
+
.sort();
|
|
120
|
+
} catch (_) { continue; }
|
|
121
|
+
|
|
122
|
+
for (const item of entries) {
|
|
123
|
+
const found = fileIn(path.join(ws.dir, item));
|
|
124
|
+
if (found) out.push({ workspace: ws.key, item, label: item, ...found });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_cache = out;
|
|
129
|
+
_cacheAt = now;
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Forget the walk — for a test that writes a changelog and expects to see it. */
|
|
134
|
+
function invalidate() { _cache = null; _cacheAt = 0; }
|
|
135
|
+
|
|
136
|
+
/** An item name that cannot climb out of its workspace. The folder must exist to be used anyway. */
|
|
137
|
+
const isItem = (name) => name === '' || /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(String(name || ''));
|
|
138
|
+
|
|
139
|
+
/** The changelog of one item, or null when it keeps none. */
|
|
140
|
+
function changelogFor(workspace, item = '') {
|
|
141
|
+
if (!isValidWorkspace(workspace) || !isItem(item)) return null;
|
|
142
|
+
return listChangelogs().find((c) => c.workspace === workspace && c.item === (item || '')) || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* ---------------- parsing ---------------- */
|
|
146
|
+
|
|
147
|
+
// `## [1.0.3] - 2026-09-14`, `## [Unreleased]`, `## 1.0.3 — 2026-09-14`, `# 11.0.0`.
|
|
148
|
+
//
|
|
149
|
+
// The separator before the date must be SURROUNDED by whitespace. Without that, `# 11.0.0-rc1`
|
|
150
|
+
// parses as version `11.0.0` with `rc1` where the date goes — and the file then shows three
|
|
151
|
+
// releases called 11.0.0, which is how this was found.
|
|
152
|
+
const MD_HEADING = /^(#{1,4})\s+\[?([^\]\n]+?)\]?(?:\s+[-—–]\s+(.+?))?\s*$/;
|
|
153
|
+
// `Menu Position 7.x-1.1, 2012-02-22` followed by a line of dashes.
|
|
154
|
+
const TXT_HEADING = /^(.+?),\s*([0-9x]{4}-[0-9x]{2}-[0-9x]{2}|\d{2}-\d{2}-\d{4})\s*(\(.*\))?\s*$/;
|
|
155
|
+
// A date on its own, in any of the shapes these files use.
|
|
156
|
+
const DATE_ONLY = /^(\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4}|[0-9x]{4}-[0-9x]{2}-[0-9x]{2})$/;
|
|
157
|
+
// `- feat: [#3622833](…) Add a Newsletter Canvas page`
|
|
158
|
+
const ENTRY_PREFIX = /^([A-Za-z]+)\s*:\s*(.+)$/s;
|
|
159
|
+
|
|
160
|
+
/** Is this heading text a version rather than prose? */
|
|
161
|
+
function looksLikeVersion(text) {
|
|
162
|
+
const t = String(text).trim();
|
|
163
|
+
if (/^unreleased$/i.test(t)) return true;
|
|
164
|
+
// 1.0.3, 11.0.0-rc1, 8.x-1.1, v2.3
|
|
165
|
+
return /^v?\d/.test(t) && /[\d.x]/.test(t) && t.length < 40 && !/\s{2}/.test(t);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One changelog, as releases.
|
|
170
|
+
*
|
|
171
|
+
* Entries keep their Markdown: the page renders it, so a `[#3622833](https://…)` stays a link and
|
|
172
|
+
* `` `config/optional` `` stays code. Stripping it here would mean the reader shows less than the
|
|
173
|
+
* file does, which is the one thing a reader must not do.
|
|
174
|
+
*/
|
|
175
|
+
function parseChangelog(text) {
|
|
176
|
+
const lines = String(text).replace(/\r\n?/g, '\n').split('\n');
|
|
177
|
+
const releases = [];
|
|
178
|
+
let current = null;
|
|
179
|
+
let section = null; // the `### Added` a bullet sits under
|
|
180
|
+
let group = null; // the `#### Website Starter 1.0.1` it belongs to
|
|
181
|
+
let pending = null; // an entry being continued on the next line
|
|
182
|
+
|
|
183
|
+
const flush = () => {
|
|
184
|
+
if (pending && current) current.entries.push(pending);
|
|
185
|
+
pending = null;
|
|
186
|
+
};
|
|
187
|
+
const open = (version, date) => {
|
|
188
|
+
flush();
|
|
189
|
+
section = null;
|
|
190
|
+
group = null;
|
|
191
|
+
current = { version: String(version).trim(), date: (date || '').trim(), entries: [], notes: [] };
|
|
192
|
+
releases.push(current);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < lines.length; i++) {
|
|
196
|
+
const raw = lines[i];
|
|
197
|
+
const line = raw.trim();
|
|
198
|
+
|
|
199
|
+
// The old .txt shape: a title line, underlined with dashes.
|
|
200
|
+
const under = lines[i + 1] && /^-{3,}$/.test(lines[i + 1].trim());
|
|
201
|
+
if (under) {
|
|
202
|
+
const m = TXT_HEADING.exec(line);
|
|
203
|
+
if (m) { open(m[1], m[2]); i++; continue; }
|
|
204
|
+
if (line) { open(line, ''); i++; continue; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const h = MD_HEADING.exec(line);
|
|
208
|
+
if (h) {
|
|
209
|
+
const [, hashes, title, rest] = h;
|
|
210
|
+
if (looksLikeVersion(title)) {
|
|
211
|
+
// `## [1.0.3] - 2026-09-14` — the date is what follows the dash, when it is a date.
|
|
212
|
+
const date = rest && DATE_ONLY.test(rest.trim()) ? rest.trim() : '';
|
|
213
|
+
// A `## 1.0.3 - some words` heading keeps its version and simply has no date, rather than
|
|
214
|
+
// showing "some words" where a date belongs.
|
|
215
|
+
open(title, date);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// `#### Website Starter 1.0.1` inside a release: the package a bullet belongs to. The Webship
|
|
219
|
+
// suite writes one release as a stack of per-package sections, and 239 entries in a flat list
|
|
220
|
+
// is a list nobody reads — the group is what makes it navigable.
|
|
221
|
+
if (current && hashes.length === 4) {
|
|
222
|
+
flush();
|
|
223
|
+
group = title.trim();
|
|
224
|
+
section = null;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
// `### Added` under a release: the kind every bullet below it carries.
|
|
228
|
+
if (current && hashes.length === 3) {
|
|
229
|
+
flush();
|
|
230
|
+
const k = kindOf(title);
|
|
231
|
+
if (k) { section = k; } else { section = null; group = title.trim(); }
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
// Any other heading before the first release is the file's own preamble.
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!current) continue;
|
|
239
|
+
|
|
240
|
+
const bullet = /^[-*+]\s+(.*)$/.exec(line);
|
|
241
|
+
if (bullet) {
|
|
242
|
+
flush();
|
|
243
|
+
let body = bullet[1];
|
|
244
|
+
let kind = section;
|
|
245
|
+
const pre = ENTRY_PREFIX.exec(body);
|
|
246
|
+
// A `feat:` prefix wins over the section it sits under: it is the more specific statement,
|
|
247
|
+
// and the two disagree in practice — a `fix:` often lives under `### Added`.
|
|
248
|
+
if (pre && kindOf(pre[1])) { kind = kindOf(pre[1]); body = pre[2]; }
|
|
249
|
+
pending = { kind: kind ? kind.label : '', tone: kind ? kind.tone : '', group: group || '', text: body.trim() };
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// A wrapped bullet: these files hard-wrap at 100 columns, so most entries are several lines.
|
|
254
|
+
if (pending && line) { pending.text += ` ${line}`; continue; }
|
|
255
|
+
if (pending && !line) { flush(); continue; }
|
|
256
|
+
|
|
257
|
+
// Prose inside a release that is not a bullet — Webship's "At a glance" paragraphs.
|
|
258
|
+
if (line) current.notes.push(line);
|
|
259
|
+
}
|
|
260
|
+
flush();
|
|
261
|
+
|
|
262
|
+
return releases.filter((r) => r.entries.length || r.notes.length);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** An item's releases, parsed. An unreadable file is no releases rather than an error page. */
|
|
266
|
+
function releasesFor(workspace, item = '') {
|
|
267
|
+
const found = changelogFor(workspace, item);
|
|
268
|
+
if (!found) return null;
|
|
269
|
+
let text = '';
|
|
270
|
+
try { text = fs.readFileSync(found.path, 'utf8'); } catch (_) { return { ...found, releases: [] }; }
|
|
271
|
+
return { ...found, releases: parseChangelog(text) };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Every changelog in the tree, summarised for the index: how many releases, and the newest one.
|
|
276
|
+
*
|
|
277
|
+
* The newest release is the FIRST in the file, not the highest version number: every one of these
|
|
278
|
+
* files is written newest-first, and comparing versions across three numbering schemes (1.0.3,
|
|
279
|
+
* 8.x-1.1, 11.0.0-rc1) would be a guess dressed as a sort.
|
|
280
|
+
*/
|
|
281
|
+
function changelogIndex() {
|
|
282
|
+
return listChangelogs().map((c) => {
|
|
283
|
+
let releases = [];
|
|
284
|
+
try { releases = parseChangelog(fs.readFileSync(c.path, 'utf8')); } catch (_) { /* unreadable */ }
|
|
285
|
+
const newest = releases.find((r) => !/^unreleased$/i.test(r.version)) || releases[0] || null;
|
|
286
|
+
const unreleased = releases.find((r) => /^unreleased$/i.test(r.version));
|
|
287
|
+
return {
|
|
288
|
+
...c,
|
|
289
|
+
releases: releases.length,
|
|
290
|
+
entries: releases.reduce((n, r) => n + r.entries.length, 0),
|
|
291
|
+
latest: newest ? newest.version : '',
|
|
292
|
+
latestDate: newest ? newest.date : '',
|
|
293
|
+
unreleasedEntries: unreleased ? unreleased.entries.length : 0,
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = {
|
|
299
|
+
CHANGELOG_NAMES, KINDS,
|
|
300
|
+
listChangelogs, changelogFor, releasesFor, changelogIndex, invalidate,
|
|
301
|
+
parseChangelog, kindOf, looksLikeVersion,
|
|
302
|
+
};
|
package/chat-threads.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assistant's conversation, kept so it survives the next page.
|
|
3
|
+
*
|
|
4
|
+
* Every ask used to be a one-shot `claude -p --no-session-persistence`: the assistant remembered
|
|
5
|
+
* nothing between turns, and deep-chat's transcript lived only in the page that drew it — so moving
|
|
6
|
+
* from /dev to /test, or following the assistant's own NAVIGATE, threw the conversation away. On a
|
|
7
|
+
* dashboard whose whole point is moving between workspaces, that is most of the conversation.
|
|
8
|
+
*
|
|
9
|
+
* Two halves, and both are needed:
|
|
10
|
+
*
|
|
11
|
+
* - the TRANSCRIPT, so the next page can draw what was said (deep-chat's `history`);
|
|
12
|
+
* - the claude SESSION id, so the assistant itself still remembers — a redrawn transcript in
|
|
13
|
+
* front of an agent with no memory is worse than none, because it reads as memory.
|
|
14
|
+
*
|
|
15
|
+
* The id travels in a COOKIE, not localStorage, and that is the crux: the dashboard spans
|
|
16
|
+
* <workspace>.<hub> subdomains and localStorage is per-origin, so a thread kept there would be a
|
|
17
|
+
* different thread on every workspace page — exactly the bug this fixes. The lists' page-size cookie
|
|
18
|
+
* already had to be scoped to the hub domain for the same reason.
|
|
19
|
+
*
|
|
20
|
+
* The file is this machine's chat log. It is gitignored, never synced, and holds whatever was typed,
|
|
21
|
+
* so it is treated like the rest of the runtime state: capped, pruned, and never committed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const crypto = require('crypto');
|
|
27
|
+
|
|
28
|
+
const { hubDomain } = require('./workspaces');
|
|
29
|
+
|
|
30
|
+
const DIR = path.join(__dirname, '.chats');
|
|
31
|
+
|
|
32
|
+
// What one thread keeps. A long conversation is trimmed from the front rather than refused: the
|
|
33
|
+
// newest turns are the ones the next page has to draw, and claude's own session holds the rest.
|
|
34
|
+
const MAX_TURNS = 60;
|
|
35
|
+
// One turn's rendered HTML can be a whole report; past this it is stored as its text alone.
|
|
36
|
+
const MAX_TURN_CHARS = 20000;
|
|
37
|
+
// A thread nobody has touched in a month is not a conversation any more.
|
|
38
|
+
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
const isId = (id) => /^[a-f0-9]{32}$/.test(String(id || ''));
|
|
41
|
+
const newId = () => crypto.randomBytes(16).toString('hex');
|
|
42
|
+
const fileFor = (id) => path.join(DIR, `${id}.json`);
|
|
43
|
+
|
|
44
|
+
/** The thread id this request carries, or null. Only our own shape is accepted — the id names a file. */
|
|
45
|
+
function threadIdFrom(req) {
|
|
46
|
+
const raw = String((req && req.headers && req.headers.cookie) || '');
|
|
47
|
+
const hit = raw.split(';').map((c) => c.trim().split('=')).find((p) => p[0] === 'ws-chat');
|
|
48
|
+
return hit && isId(hit[1]) ? hit[1] : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The Set-Cookie that keeps one thread across the workspace subdomains. No Max-Age: the conversation
|
|
53
|
+
* belongs to this browsing session, and a chat log that outlives the browser being closed is a
|
|
54
|
+
* surprise, not a feature. The store prunes the file either way.
|
|
55
|
+
*/
|
|
56
|
+
function threadCookie(id) {
|
|
57
|
+
return `ws-chat=${id}; Path=/; Domain=.${hubDomain()}; SameSite=Lax`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readThread(id) {
|
|
61
|
+
if (!isId(id)) return null;
|
|
62
|
+
try {
|
|
63
|
+
const t = JSON.parse(fs.readFileSync(fileFor(id), 'utf8'));
|
|
64
|
+
return t && Array.isArray(t.turns) ? t : null;
|
|
65
|
+
} catch (_) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The thread this request is in, created only when something is written to it. */
|
|
71
|
+
function loadThread(req) {
|
|
72
|
+
const id = threadIdFrom(req);
|
|
73
|
+
return (id && readThread(id)) || { id: id || newId(), sessionId: null, turns: [], updated: 0 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeThread(t) {
|
|
77
|
+
const thread = { ...t, updated: Date.now(), turns: t.turns.slice(-MAX_TURNS) };
|
|
78
|
+
try {
|
|
79
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
80
|
+
// 0600: it is a chat log. The dashboard runs as one user, but the file says so on disk too.
|
|
81
|
+
fs.writeFileSync(fileFor(thread.id), JSON.stringify(thread), { mode: 0o600 });
|
|
82
|
+
} catch (_) { /* a thread that cannot be written is a conversation without memory, not an error */ }
|
|
83
|
+
return thread;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One turn. `html` is what deep-chat draws for the assistant; the user's turn is plain text. */
|
|
87
|
+
function appendTurn(thread, turn) {
|
|
88
|
+
const t = { ...turn, at: Date.now() };
|
|
89
|
+
if (t.html && t.html.length > MAX_TURN_CHARS) t.html = null;
|
|
90
|
+
if (t.text && t.text.length > MAX_TURN_CHARS) t.text = `${t.text.slice(0, MAX_TURN_CHARS)}…`;
|
|
91
|
+
return writeThread({ ...thread, turns: [...thread.turns, t] });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The transcript as deep-chat's `history`. Its own shape: `role` is 'user' or 'ai', and a message
|
|
96
|
+
* carries either `text` or `html` — the assistant's replies are already rendered markdown, so they
|
|
97
|
+
* are handed back as html rather than re-escaped into text.
|
|
98
|
+
*/
|
|
99
|
+
function historyFor(thread) {
|
|
100
|
+
return (thread.turns || [])
|
|
101
|
+
.map((t) => (t.role === 'ai'
|
|
102
|
+
? (t.html ? { role: 'ai', html: t.html } : { role: 'ai', text: t.text || '' })
|
|
103
|
+
: { role: 'user', text: t.text || '' }))
|
|
104
|
+
.filter((m) => m.html || m.text);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Forget this conversation: the file, and with it the claude session it resumed. */
|
|
108
|
+
function clearThread(id) {
|
|
109
|
+
if (!isId(id)) return;
|
|
110
|
+
try { fs.unlinkSync(fileFor(id)); } catch (_) { /* already gone */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Drop threads nobody has touched in a month. Called when one is written rather than on a timer:
|
|
115
|
+
* there is no scheduler in this app, and the moment a thread is used is a fine moment to sweep.
|
|
116
|
+
*/
|
|
117
|
+
function pruneThreads(now = Date.now()) {
|
|
118
|
+
let removed = 0;
|
|
119
|
+
let files = [];
|
|
120
|
+
try { files = fs.readdirSync(DIR); } catch (_) { return 0; }
|
|
121
|
+
for (const f of files) {
|
|
122
|
+
if (!/^[a-f0-9]{32}\.json$/.test(f)) continue;
|
|
123
|
+
try {
|
|
124
|
+
if (now - fs.statSync(path.join(DIR, f)).mtimeMs > MAX_AGE_MS) { fs.unlinkSync(path.join(DIR, f)); removed += 1; }
|
|
125
|
+
} catch (_) { /* raced with another write */ }
|
|
126
|
+
}
|
|
127
|
+
return removed;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
DIR, MAX_TURNS, MAX_AGE_MS,
|
|
132
|
+
isId, newId, threadIdFrom, threadCookie,
|
|
133
|
+
loadThread, readThread, writeThread, appendTurn, historyFor, clearThread, pruneThreads,
|
|
134
|
+
};
|
package/checks.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Checks button knows before you press it.
|
|
3
|
+
*
|
|
4
|
+
* Two questions, both answered without running anything: how many checks this project actually
|
|
5
|
+
* declares, and how the last run went. The first comes from the SAME resolver the command uses
|
|
6
|
+
* (core/scripts/libs/checks_plan.js), so the caption and the command can never disagree about what a
|
|
7
|
+
* package offers. The second comes from the marker the command writes outside the package.
|
|
8
|
+
*
|
|
9
|
+
* Memoised for two seconds: a workspace page renders thirty rows, and each one would otherwise read
|
|
10
|
+
* a package.json and stat a directory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const { ROOT } = require('./workspaces');
|
|
17
|
+
|
|
18
|
+
// The resolver lives with the shell tooling, not with the dashboard — it is the shell verb's own
|
|
19
|
+
// library, and the dashboard is the second caller rather than the owner.
|
|
20
|
+
const { resolvePlan } = require(path.join(ROOT, 'core', 'scripts', 'libs', 'checks_plan'));
|
|
21
|
+
|
|
22
|
+
const STATE_DIR = path.join(ROOT, 'core', 'state', 'checks');
|
|
23
|
+
|
|
24
|
+
let _cache = new Map();
|
|
25
|
+
let _cacheAt = 0;
|
|
26
|
+
|
|
27
|
+
const fresh = () => {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
if (now - _cacheAt > 2000) { _cache = new Map(); _cacheAt = now; }
|
|
30
|
+
return _cache;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How many checks a project declares, and how many of those are runnable right now. A package with
|
|
35
|
+
* six declared checks and no node_modules is worth saying out loud: the button is not broken, the
|
|
36
|
+
* dependencies are missing.
|
|
37
|
+
*/
|
|
38
|
+
function checksDeclaredOf(dir, project) {
|
|
39
|
+
const cache = fresh();
|
|
40
|
+
const key = `d:${dir}/${project}`;
|
|
41
|
+
if (cache.has(key)) return cache.get(key);
|
|
42
|
+
let out = { declared: 0, ready: 0, blocked: 0, hasPackageJson: false };
|
|
43
|
+
try {
|
|
44
|
+
const plan = resolvePlan(path.join(dir, project));
|
|
45
|
+
const counted = plan.rows.filter((r) => r.status !== 'none');
|
|
46
|
+
out = {
|
|
47
|
+
declared: counted.length,
|
|
48
|
+
ready: counted.filter((r) => r.status === 'ready').length,
|
|
49
|
+
blocked: counted.filter((r) => ['deps-missing', 'unresolved-runner'].includes(r.status)).length,
|
|
50
|
+
hasPackageJson: plan.hasPackageJson,
|
|
51
|
+
};
|
|
52
|
+
} catch (_) { /* an unreadable package.json is a project with nothing to run */ }
|
|
53
|
+
cache.set(key, out);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The rows themselves, for the menu that lists them: which canonical check, which script the package
|
|
59
|
+
* declared for it, and whether it can run right now. The menu shows the ALIAS beside the real script
|
|
60
|
+
* name — `js → lint:js` — because the whole point of the verb is that a package's own vocabulary is
|
|
61
|
+
* what runs, and seeing the pair is how you know the mapping is right.
|
|
62
|
+
*/
|
|
63
|
+
function checksRowsOf(dir, project) {
|
|
64
|
+
const cache = fresh();
|
|
65
|
+
const key = `r:${dir}/${project}`;
|
|
66
|
+
if (cache.has(key)) return cache.get(key);
|
|
67
|
+
let rows = [];
|
|
68
|
+
try {
|
|
69
|
+
rows = resolvePlan(path.join(dir, project)).rows.filter((r) => r.status !== 'none');
|
|
70
|
+
} catch (_) { /* nothing declared */ }
|
|
71
|
+
cache.set(key, rows);
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The last run, from the marker the command wrote. Null when it has never been run. */
|
|
76
|
+
function checksStateOf(workspace, project) {
|
|
77
|
+
const cache = fresh();
|
|
78
|
+
const key = `s:${workspace}/${project}`;
|
|
79
|
+
if (cache.has(key)) return cache.get(key);
|
|
80
|
+
let out = null;
|
|
81
|
+
try {
|
|
82
|
+
out = JSON.parse(fs.readFileSync(path.join(STATE_DIR, workspace, `${project}.json`), 'utf8'));
|
|
83
|
+
} catch (_) { /* never run */ }
|
|
84
|
+
cache.set(key, out);
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The word under the button. It answers "is there anything to look at yet" in one glance:
|
|
90
|
+
* a failure count when there is one, "passed" when the last run was clean, how many checks are
|
|
91
|
+
* declared when it has never run, and why nothing can run when that is the answer.
|
|
92
|
+
*/
|
|
93
|
+
function checksCaption(workspace, dir, project) {
|
|
94
|
+
const state = checksStateOf(workspace, project);
|
|
95
|
+
if (state) {
|
|
96
|
+
if (state.failed) return `${state.failed} failed`;
|
|
97
|
+
if (state.ran) return `${state.ran} passed`;
|
|
98
|
+
}
|
|
99
|
+
const d = checksDeclaredOf(dir, project);
|
|
100
|
+
if (!d.declared) return 'none';
|
|
101
|
+
if (d.blocked && !d.ready) return 'deps missing';
|
|
102
|
+
return `${d.declared} declared`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { checksDeclaredOf, checksRowsOf, checksStateOf, checksCaption };
|