rvlib-shipkit 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -0,0 +1,333 @@
1
+ // the machine, as a page on the home sees it: every repo's hub state and the
2
+ // active flags from the daemon's /api/home.json, the widgets from its
3
+ // /api/home/widgets.json, the global processes and pm's children from pm's
4
+ // /api/pm/status.json. A node of the page tree
5
+ // (home-st.ts): the root reads it for the list's dots, and says loudly when the
6
+ // daemon behind the page does not answer. A hub serving on its own has none
7
+ // of this (HomeSt.machine is null).
8
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
9
+ import type { HomeAnswer, WidgetsAnswer } from '../../home/home-routes.ts'
10
+ import type { WorkerView } from '../../home/hubs.ts'
11
+ import type { ResourceView } from '../../home/resources.ts'
12
+ import type { PmAnswer, Role } from '../../home/pm.ts'
13
+ import { processGroups, type ProcessGroup } from '../../home/process-rows.ts'
14
+ import { ClockSvc } from './clock-svc.ts'
15
+ import type { HomeSt } from './home-st.ts'
16
+ import type { HubDot } from './projects-svc.ts'
17
+
18
+ export const MACHINE_POLL_MS = 1_500
19
+ /** past the web's own wait on the daemon (serve-web.ts MACHINE_ANSWER_MS), so
20
+ * a hung daemon reads in its words, and a hung web still reads as down */
21
+ export const MACHINE_TIMEOUT_MS = 8_000
22
+
23
+ /** where this browser keeps the machine as it last read it, painted at once
24
+ * on the next load (the overview) while the daemon and pm are asked again */
25
+ export const MACHINE_KEY = 'hub:machine'
26
+
27
+ /** how long a copied pid says so */
28
+ export const COPIED_MS = 1_200
29
+
30
+ /** how open opens a global process's url, kept per browser */
31
+ export const OPEN_HOW_KEY = 'hub:open-how'
32
+ export type OpenHow = 'browser' | 'app'
33
+
34
+ function loadOpenHow(): OpenHow {
35
+ try {
36
+ return globalThis.localStorage?.getItem(OPEN_HOW_KEY) === 'app' ? 'app' : 'browser'
37
+ } catch {
38
+ return 'browser'
39
+ }
40
+ }
41
+
42
+ type Kept = { data: HomeAnswer | null; pm: PmAnswer | null; widgets: WidgetsAnswer | null }
43
+
44
+ function loadKept(): Kept | null {
45
+ try {
46
+ const v = JSON.parse(globalThis.localStorage?.getItem(MACHINE_KEY) ?? 'null') as Kept | null
47
+ return v != null && typeof v === 'object' && (v.data == null || Array.isArray(v.data.workers)) ? v : null
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ /** the poll failed because nothing behind the page answers, not on an error of an answer */
54
+ class DownError extends Error {}
55
+
56
+ export type MachineOpts = { reload?: () => void; timeoutMs?: number }
57
+
58
+ export class MachineSt {
59
+ data: HomeAnswer | null = null
60
+ /** pm's answer: the resources and the daemon and web states */
61
+ pm: PmAnswer | null = null
62
+ /** every house repo's widgets, as the daemon last rendered them */
63
+ widgets: WidgetsAnswer | null = null
64
+ /** why pm did not answer the last poll, or '' while it does */
65
+ pmDown = ''
66
+ /** the last action that failed */
67
+ error = ''
68
+ /** where open sends a global process's url: the default browser, or a
69
+ * shipkit app window */
70
+ openHow: OpenHow = loadOpenHow()
71
+ /** why the daemon did not answer the last poll, or '' while it does */
72
+ down = ''
73
+ /** the daemon answered once since the load: until then data, pm and
74
+ * widgets are what this browser kept, shown but never acted on */
75
+ fresh = false
76
+ /** the one second the process ages move on, ticking while the machine polls */
77
+ clock: ClockSvc
78
+ /** the pid a click just copied, which says so for COPIED_MS */
79
+ copiedPid: number | null = null
80
+ private copiedTimer: ReturnType<typeof setTimeout> | null = null
81
+ /** what was last kept, so an unchanged machine is not written again */
82
+ private kept = ''
83
+ private timer: ReturnType<typeof setInterval> | null = null
84
+ private wasDown = false
85
+ private polling = false
86
+ private widgetsPolling = false
87
+ /** `error` holds a poll's error, which the next good poll clears */
88
+ private pollError = false
89
+
90
+ constructor(private st: HomeSt, private o: MachineOpts = {}) {
91
+ this.clock = new ClockSvc()
92
+ const k = loadKept()
93
+ if (k != null) {
94
+ this.data = k.data
95
+ this.pm = k.pm
96
+ this.widgets = k.widgets
97
+ }
98
+ makeAutoObservable<this, 'st' | 'timer' | 'copiedTimer' | 'wasDown' | 'polling' | 'widgetsPolling' | 'pollError' | 'o' | 'kept' | 'keep'>(this, { st: false, clock: false, timer: false, copiedTimer: false, wasDown: false, polling: false, widgetsPolling: false, pollError: false, o: false, kept: false, keep: false, data: observableRef, pm: observableRef, widgets: observableRef })
99
+ }
100
+
101
+ /** the repo the page shows, null on the overview and the plan */
102
+ get repo(): string | null {
103
+ return this.st.page === 'repo' ? this.st.selected : null
104
+ }
105
+
106
+ worker(name: string | null): WorkerView | null {
107
+ return name == null ? null : this.data?.workers.find((w) => w.repo === name) ?? null
108
+ }
109
+
110
+ get shown(): WorkerView | null {
111
+ return this.worker(this.repo)
112
+ }
113
+
114
+ /** the processes table: shipkit's own first, then one group per repo in
115
+ * the project list's order, one row per process (process-rows.ts, what
116
+ * `shipkit global-processes` prints) */
117
+ get processGroups(): ProcessGroup[] {
118
+ return processGroups(this.pm, this.st.projects.list.map((p) => p.name), { daemonVersion: this.data?.version ?? null })
119
+ }
120
+
121
+ resource(id: string): ResourceView | null {
122
+ return this.pm?.resources.find((r) => r.id === id) ?? null
123
+ }
124
+
125
+ dotOf(name: string): HubDot {
126
+ const s = this.worker(name)?.status
127
+ return s === 'starting' || s === 'crashed' ? s : null
128
+ }
129
+
130
+ /** the resources this repo uses that are not up, as one line */
131
+ warnOf(name: string): string | null {
132
+ const off = (this.worker(name)?.uses ?? []).filter((id) => this.resource(id)?.status !== 'up')
133
+ return off.length === 0 ? null : `needs ${off.join(', ')}, which ${off.length === 1 ? 'is' : 'are'} off`
134
+ }
135
+
136
+ isActive(name: string): boolean {
137
+ return this.data?.active.includes(name) === true
138
+ }
139
+
140
+ boot(): void {
141
+ void this.load().then(() => {
142
+ // a repo page asks its hub to load when it is down
143
+ if (this.repo != null && this.data != null) void this.hub(this.repo, 'ensure')
144
+ })
145
+ this.timer = setInterval(() => void this.load(), MACHINE_POLL_MS)
146
+ this.clock.start()
147
+ }
148
+
149
+ stop(): void {
150
+ if (this.timer != null) clearInterval(this.timer)
151
+ this.clock.stop()
152
+ }
153
+
154
+ /** pm is polled beside the daemon: either one down leaves the other shown */
155
+ private async loadPm(): Promise<void> {
156
+ try {
157
+ const r = await fetch('/api/pm/status.json', { signal: AbortSignal.timeout(this.o.timeoutMs ?? MACHINE_TIMEOUT_MS) })
158
+ if (!r.ok) throw new Error(await r.text())
159
+ const j = (await r.json()) as PmAnswer
160
+ runInAction(() => {
161
+ this.pm = j
162
+ this.pmDown = ''
163
+ })
164
+ } catch (e) {
165
+ runInAction(() => {
166
+ this.pm = null
167
+ this.pmDown = (e as Error).message
168
+ })
169
+ }
170
+ }
171
+
172
+ /** a widget poll that fails keeps the last html shown: the daemon's own
173
+ * poll says why */
174
+ private async loadWidgets(): Promise<void> {
175
+ try {
176
+ const r = await fetch('/api/home/widgets.json', { signal: AbortSignal.timeout(this.o.timeoutMs ?? MACHINE_TIMEOUT_MS) })
177
+ if (!r.ok) return
178
+ const j = (await r.json()) as WidgetsAnswer
179
+ runInAction(() => { this.widgets = j })
180
+ } catch {
181
+ // the daemon poll beside it reports a daemon that does not answer
182
+ }
183
+ }
184
+
185
+ async load(): Promise<void> {
186
+ // a stalled poll is not stacked on
187
+ if (this.polling) return
188
+ this.polling = true
189
+ const pm = this.loadPm()
190
+ // never held on: a widget that answers late only shows late
191
+ if (!this.widgetsPolling) {
192
+ this.widgetsPolling = true
193
+ void this.loadWidgets().finally(() => { this.widgetsPolling = false })
194
+ }
195
+ try {
196
+ let r: Response
197
+ try {
198
+ r = await fetch('/api/home.json', { signal: AbortSignal.timeout(this.o.timeoutMs ?? MACHINE_TIMEOUT_MS) })
199
+ } catch (e) {
200
+ throw new DownError(`the home does not answer: ${(e as Error).message}`)
201
+ }
202
+ if (r.headers.get('x-shipkit-daemon') === 'down') throw new DownError(await r.text())
203
+ if (!r.ok) throw new Error(`/api/home.json answered ${r.status}: ${await r.text()}`)
204
+ const j = (await r.json()) as HomeAnswer
205
+ const back = this.wasDown
206
+ const cleared = this.pollError
207
+ this.wasDown = false
208
+ this.pollError = false
209
+ runInAction(() => {
210
+ this.data = j
211
+ this.down = ''
212
+ this.fresh = true
213
+ if (cleared) this.error = ''
214
+ })
215
+ // the shown repo starts once its hub answers
216
+ this.st.wake()
217
+ // and every other one is read ahead behind it
218
+ void this.st.prefetch()
219
+ // what the page loaded while the daemon was away failed: start over
220
+ if (back) this.reload()
221
+ } catch (e) {
222
+ const msg = (e as Error).message
223
+ if (e instanceof DownError) {
224
+ this.wasDown = true
225
+ runInAction(() => { this.down = msg })
226
+ } else {
227
+ this.pollError = true
228
+ runInAction(() => {
229
+ this.down = ''
230
+ this.error = msg
231
+ })
232
+ }
233
+ } finally {
234
+ await pm
235
+ this.polling = false
236
+ this.keep()
237
+ }
238
+ }
239
+
240
+ /** the machine as read now, kept for the next load. Only a change is written */
241
+ private keep(): void {
242
+ if (!this.fresh) return
243
+ try {
244
+ // the log tails change on every poll and only show on a hover: not kept
245
+ const pm = this.pm == null ? null : { ...this.pm, children: this.pm.children.map((c) => ({ ...c, tail: [] })), resources: this.pm.resources.map((r) => ({ ...r, tail: [] })) }
246
+ const s = JSON.stringify({ data: this.data, pm, widgets: this.widgets } satisfies Kept)
247
+ if (s === this.kept) return
248
+ globalThis.localStorage?.setItem(MACHINE_KEY, s)
249
+ this.kept = s
250
+ } catch { /* private window or blocked site data: the next load waits on the daemon */ }
251
+ }
252
+
253
+ private reload(): void {
254
+ if (this.o.reload != null) this.o.reload()
255
+ else window.location.reload()
256
+ }
257
+
258
+ private async post(path: string, body: unknown): Promise<boolean> {
259
+ try {
260
+ const r = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
261
+ if (!r.ok) throw new Error(await r.text())
262
+ this.pollError = false
263
+ runInAction(() => { this.error = '' })
264
+ void this.load()
265
+ return true
266
+ } catch (e) {
267
+ runInAction(() => { this.error = (e as Error).message })
268
+ return false
269
+ }
270
+ }
271
+
272
+ hub(name: string, act: 'ensure' | 'restart'): Promise<boolean> {
273
+ return this.post('/api/home/hub', { name, act })
274
+ }
275
+
276
+ setActive(name: string, on: boolean): Promise<boolean> {
277
+ return this.post('/api/home/active', { name, on })
278
+ }
279
+
280
+ /** a global process: start, stop, restart, take over the copy started
281
+ * elsewhere, open its url in the default browser or as an app window
282
+ * (started first when it is down), or run one of its actions */
283
+ processAct(id: string, act: 'start' | 'stop' | 'restart' | 'takeover' | 'open' | 'app' | 'action', action?: string): Promise<boolean> {
284
+ return this.post('/api/pm/resource', { id, act, action })
285
+ }
286
+
287
+ /** the url of a global process, the way this browser picked (started
288
+ * first when it is down) */
289
+ openProcess(id: string): Promise<boolean> {
290
+ return this.processAct(id, this.openHow === 'app' ? 'app' : 'open')
291
+ }
292
+
293
+ /** a process's pid to the clipboard, and the "copied" flash on it; a
294
+ * refusal (plain http, an unfocused page) is said in the strip */
295
+ async copyPid(pid: number): Promise<void> {
296
+ try {
297
+ if (globalThis.navigator?.clipboard == null) throw new Error('this page has no clipboard')
298
+ await navigator.clipboard.writeText(String(pid))
299
+ } catch (e) {
300
+ runInAction(() => { this.error = `could not copy pid ${pid}: ${(e as Error).message}` })
301
+ return
302
+ }
303
+ runInAction(() => { this.copiedPid = pid })
304
+ if (this.copiedTimer != null) clearTimeout(this.copiedTimer)
305
+ this.copiedTimer = setTimeout(() => runInAction(() => { this.copiedPid = null }), COPIED_MS)
306
+ }
307
+
308
+ setOpenHow(how: OpenHow): void {
309
+ this.openHow = how
310
+ try {
311
+ globalThis.localStorage?.setItem(OPEN_HOW_KEY, how)
312
+ } catch { /* private window or blocked site data: the pick lasts the tab */ }
313
+ }
314
+
315
+ /** restart the daemon or the web through pm */
316
+ restartChild(id: Role): Promise<boolean> {
317
+ return this.post('/api/pm/child', { id, act: 'restart' })
318
+ }
319
+
320
+ modernize(name: string): Promise<boolean> {
321
+ return this.post('/api/home/modernize', { name })
322
+ }
323
+
324
+ /** one more window on this page. In a gui window (a Chrome app) a popup
325
+ * opens as its own app window */
326
+ newWindow(): void {
327
+ window.open(`${window.location.origin}${window.location.pathname}`, '_blank', 'popup,width=1400,height=900')
328
+ }
329
+
330
+ openInCode(name: string): void {
331
+ void this.st.projects.open(name)
332
+ }
333
+ }
@@ -8,13 +8,15 @@ import sanitizeHtml from 'sanitize-html'
8
8
 
9
9
  marked.use({ gfm: true, breaks: false, async: false })
10
10
 
11
+ const CARD_TAGS = [
12
+ 'h1', 'h2', 'h3', 'h4', 'p', 'ul', 'ol', 'li',
13
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
14
+ 'code', 'pre', 'strong', 'em', 'b', 'i', 'a', 'br', 'blockquote', 'hr',
15
+ ]
16
+
11
17
  export const renderMd = (src: string): string =>
12
18
  sanitizeHtml(marked.parse(src) as string, {
13
- allowedTags: [
14
- 'h1', 'h2', 'h3', 'h4', 'p', 'ul', 'ol', 'li',
15
- 'table', 'thead', 'tbody', 'tr', 'th', 'td',
16
- 'code', 'pre', 'strong', 'em', 'b', 'i', 'a', 'br', 'blockquote', 'hr',
17
- ],
19
+ allowedTags: CARD_TAGS,
18
20
  allowedAttributes: { a: ['href', 'target', 'rel'] },
19
21
  allowedSchemes: ['http', 'https'],
20
22
  allowProtocolRelative: false,
@@ -37,3 +39,34 @@ export const renderMdInline = (src: string): string =>
37
39
  a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }),
38
40
  },
39
41
  })
42
+
43
+ /** where a README image loads from: an http(s) url as written, a path
44
+ * relative to the repo root through `fileUrl`, anything else (data:, file:,
45
+ * `//host`) nowhere */
46
+ export const readmeImageSrc = (src: string, fileUrl: (rel: string) => string): string | null => {
47
+ const s = src.trim()
48
+ if (/^https?:\/\//i.test(s)) return s
49
+ if (s === '' || s.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(s)) return null
50
+ const rel = s.replace(/[?#].*$/, '').replace(/^(\.\/|\/)+/, '')
51
+ return rel === '' ? null : fileUrl(rel.split('/').map(encodeURIComponent).join('/'))
52
+ }
53
+
54
+ /** a whole README: the card allowlist plus images and the few tags READMEs
55
+ * lean on. A relative image is rewritten to the repo's file route, an image
56
+ * with no loadable source is dropped */
57
+ export const renderReadme = (src: string, fileUrl: (rel: string) => string): string =>
58
+ sanitizeHtml(marked.parse(src) as string, {
59
+ allowedTags: [...CARD_TAGS, 'h5', 'h6', 'img', 'details', 'summary', 'kbd', 'sup', 'sub', 'del'],
60
+ allowedAttributes: { a: ['href', 'target', 'rel'], img: ['src', 'alt', 'title', 'width', 'height'] },
61
+ allowedSchemes: ['http', 'https'],
62
+ allowProtocolRelative: false,
63
+ transformTags: {
64
+ a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noreferrer' } }),
65
+ img: (tagName, attribs) => {
66
+ const { src: raw, ...rest } = attribs
67
+ const src = readmeImageSrc(raw ?? '', fileUrl)
68
+ return { tagName, attribs: src == null ? rest : { ...rest, src } }
69
+ },
70
+ },
71
+ exclusiveFilter: (frame) => frame.tag === 'img' && (frame.attribs.src ?? '') === '',
72
+ })
@@ -4,27 +4,15 @@
4
4
  // structure is read-only (kit.ts is code-reviewed); this edits WORKSPACE
5
5
  // state: picks, alts, prompt overlays, meta sidecars, geometry rects.
6
6
  import { makeAutoObservable, runInAction } from 'mobx'
7
- import type { HubSt } from './hub-st.ts'
7
+ import type { RepoSt } from './repo-st.ts'
8
8
  import type { AssetDetail, CatRow, Rect, SideAsset } from './hub-types.ts'
9
9
 
10
- const MEDIA = '/media'
11
10
  // thumbs change in place when a pick changes → ~8s cache buckets; roll
12
11
  // files are immutable (one name = one file forever), so their URLs carry
13
12
  // no bust — an <audio> src that never changes never restarts playback
14
13
  const V = (): number => Date.now() >> 13
15
- export const rollUrl = (id: string, name: string): string => `${MEDIA}/img?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`
16
- export const thumbUrl = (id: string): string => `${MEDIA}/api/thumb?id=${encodeURIComponent(id)}&t=${V()}`
17
14
  export const isAud = (n: string): boolean => ['mp3', 'wav', 'ogg', 'm4a'].includes(String(n ?? '').split('.').pop() ?? '')
18
15
 
19
- const j = async (u: string): Promise<any> => {
20
- const r = await fetch(MEDIA + u)
21
- if (!r.ok) throw new Error(`${u}: ${r.status}`)
22
- return r.json()
23
- }
24
- const post = async (u: string, b?: unknown): Promise<any> => {
25
- const r = await fetch(MEDIA + u, { method: 'POST', body: JSON.stringify(b ?? {}) })
26
- return r.json()
27
- }
28
16
 
29
17
  export class MediaSvc {
30
18
  assets: SideAsset[] = []
@@ -80,7 +68,7 @@ export class MediaSvc {
80
68
  /** stage overlay root (the image box) — registered by AssetStage, DOM only */
81
69
  overlayHost: HTMLElement | null = null
82
70
 
83
- constructor(private st: HubSt) {
71
+ constructor(private st: RepoSt) {
84
72
  makeAutoObservable<this, 'st' | 'hbTimer' | 'rectTimer' | 'promptTimer' | 'savedPrompt' | 'overlayHost'>(
85
73
  this, { st: false, hbTimer: false, rectTimer: false, promptTimer: false, savedPrompt: false, overlayHost: false })
86
74
  if (typeof localStorage !== 'undefined') {
@@ -102,15 +90,41 @@ export class MediaSvc {
102
90
  }
103
91
 
104
92
  start(): void {
93
+ if (this.hbTimer != null || this.dead != null) return
105
94
  void this.heartbeat()
106
95
  this.hbTimer = setInterval(() => void this.heartbeat(), 1500)
107
96
  }
108
- dispose(): void {
97
+ /** polling only: a pending prompt or rect save still lands */
98
+ stop(): void {
109
99
  if (this.hbTimer != null) clearInterval(this.hbTimer)
100
+ this.hbTimer = null
101
+ }
102
+ dispose(): void {
103
+ this.stop()
110
104
  if (this.rectTimer != null) clearTimeout(this.rectTimer)
111
105
  if (this.promptTimer != null) clearTimeout(this.promptTimer)
112
106
  }
113
107
 
108
+ /** the media API, under this repo's base */
109
+ private get api(): string {
110
+ return this.st.url('/media')
111
+ }
112
+ rollUrl(id: string, name: string): string {
113
+ return `${this.api}/img?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`
114
+ }
115
+ thumbUrl(id: string): string {
116
+ return `${this.api}/api/thumb?id=${encodeURIComponent(id)}&t=${V()}`
117
+ }
118
+ private async j(u: string): Promise<any> {
119
+ const r = await fetch(this.api + u)
120
+ if (!r.ok) throw new Error(`${u}: ${r.status}`)
121
+ return r.json()
122
+ }
123
+ private async post(u: string, b?: unknown): Promise<any> {
124
+ const r = await fetch(this.api + u, { method: 'POST', body: JSON.stringify(b ?? {}) })
125
+ return r.json()
126
+ }
127
+
114
128
  get curId(): string | null {
115
129
  return this.st.sel?.t === 'asset' ? this.st.sel.id : null
116
130
  }
@@ -145,7 +159,7 @@ export class MediaSvc {
145
159
  if (this.dead != null) return
146
160
  let s: any
147
161
  try {
148
- const r = await fetch(MEDIA + '/api/state')
162
+ const r = await fetch(this.api + '/api/state')
149
163
  if (r.status === 404) {
150
164
  // the mount failed at boot — it will not come back without a
151
165
  // restart; say so once, loudly, and stop wasting the wire
@@ -174,6 +188,7 @@ export class MediaSvc {
174
188
  })
175
189
  if (this.curId != null) await this.refreshRolls()
176
190
  if (this.catMode != null) await this.fillCat()
191
+ this.st.cols.mediaListed()
177
192
  this.st.afterFirstBeat()
178
193
  }
179
194
 
@@ -184,7 +199,7 @@ export class MediaSvc {
184
199
  this.statusLine = ''
185
200
  const a = this.assets.find((x) => x.id === id)
186
201
  if (a != null) this.openCats.add(a.cat)
187
- const r = (await j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
202
+ const r = (await this.j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
188
203
  if (this.curId !== id) return // selection changed mid-fetch — never clobber
189
204
  runInAction(() => {
190
205
  this.detail = r
@@ -206,7 +221,7 @@ export class MediaSvc {
206
221
  async refreshRolls(): Promise<void> {
207
222
  const id = this.curId
208
223
  if (id == null) return
209
- const r = (await j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
224
+ const r = (await this.j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
210
225
  if (this.curId !== id) return
211
226
  runInAction(() => {
212
227
  if (this.detail == null) this.detail = r
@@ -223,23 +238,23 @@ export class MediaSvc {
223
238
 
224
239
  async generate(): Promise<void> {
225
240
  if (this.curId == null) return
226
- await post('/api/generate', { id: this.curId, prompt: this.prompt, count: +this.n })
241
+ await this.post('/api/generate', { id: this.curId, prompt: this.prompt, count: +this.n })
227
242
  void this.heartbeat()
228
243
  }
229
244
  async genCat(cat: string, count: number): Promise<void> {
230
- const r = await post('/api/generate-cat', { cat, count })
245
+ const r = await this.post('/api/generate-cat', { cat, count })
231
246
  if (r?.error != null) runInAction(() => { this.statusLine = r.error })
232
247
  void this.heartbeat()
233
248
  }
234
249
  clearQueue(): void {
235
- void post('/api/queue/clear')
250
+ void this.post('/api/queue/clear')
236
251
  }
237
252
  stopCurrent(): void {
238
- void post('/api/interrupt')
253
+ void this.post('/api/interrupt')
239
254
  }
240
255
 
241
256
  async pick(name: string): Promise<void> {
242
- const res = await post('/api/pick', { id: this.curId, name })
257
+ const res = await this.post('/api/pick', { id: this.curId, name })
243
258
  runInAction(() => {
244
259
  this.statusLine = res.error != null ? `ship FAILED: ${res.error}` : res.shipped != null ? `shipped → ${res.shipped}` : 'picked (pick.json)'
245
260
  this.active = name
@@ -247,11 +262,11 @@ export class MediaSvc {
247
262
  await this.refreshRolls()
248
263
  }
249
264
  async alt(name: string): Promise<void> {
250
- await post('/api/alt', { id: this.curId, name })
265
+ await this.post('/api/alt', { id: this.curId, name })
251
266
  await this.refreshRolls()
252
267
  }
253
268
  async discard(name: string): Promise<void> {
254
- await post('/api/discard', { id: this.curId, name })
269
+ await this.post('/api/discard', { id: this.curId, name })
255
270
  runInAction(() => {
256
271
  if (this.active === name) this.active = null
257
272
  // the ✕ sits on the hovered tile and its unmount fires no
@@ -267,7 +282,7 @@ export class MediaSvc {
267
282
  const fd = new FormData()
268
283
  fd.append('id', this.curId)
269
284
  fd.append('file', f)
270
- const r = await fetch(`${MEDIA}/api/upload`, { method: 'POST', body: fd }).then((x) => x.json()).catch((e: Error) => ({ error: String(e) }))
285
+ const r = await fetch(`${this.api}/api/upload`, { method: 'POST', body: fd }).then((x) => x.json()).catch((e: Error) => ({ error: String(e) }))
271
286
  if (r.error != null) runInAction(() => { this.statusLine = `upload FAIL: ${r.error}` })
272
287
  }
273
288
  await this.refreshRolls()
@@ -290,7 +305,7 @@ export class MediaSvc {
290
305
  const text = this.prompt
291
306
  if (text === this.savedPrompt) return // unchanged — not a save
292
307
  void (async () => {
293
- const r = await post('/api/prompt', { id, prompt: text })
308
+ const r = await this.post('/api/prompt', { id, prompt: text })
294
309
  runInAction(() => {
295
310
  if (this.curId !== id) return // selection moved while the POST flew — a late response must not corrupt the new asset's baseline
296
311
  if (r.error != null) {
@@ -330,7 +345,7 @@ export class MediaSvc {
330
345
  }
331
346
  const id = this.curId
332
347
  const name = this.active
333
- const r = await j(`/api/rect?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`).catch(() => null)
348
+ const r = await this.j(`/api/rect?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`).catch(() => null)
334
349
  if (this.curId !== id || this.active !== name) return // roll switched mid-fetch
335
350
  runInAction(() => {
336
351
  this.rect = r?.rect ?? null
@@ -349,7 +364,7 @@ export class MediaSvc {
349
364
  if (this.rectTimer != null) clearTimeout(this.rectTimer)
350
365
  this.rectTimer = setTimeout(() => {
351
366
  void (async () => {
352
- const r = await post('/api/rect', { id, name, rect })
367
+ const r = await this.post('/api/rect', { id, name, rect })
353
368
  runInAction(() => {
354
369
  if (this.curId !== id || this.active !== name) return // selection moved on — never adopt a stale rect
355
370
  if (r.rect != null) this.rect = r.rect
@@ -360,7 +375,7 @@ export class MediaSvc {
360
375
  }
361
376
  async legsDefault(): Promise<void> {
362
377
  if (this.curId == null || this.active == null) return
363
- const r = await post('/api/legs-default', { id: this.curId, name: this.active })
378
+ const r = await this.post('/api/legs-default', { id: this.curId, name: this.active })
364
379
  if (r.rect != null) runInAction(() => { this.rect = r.rect })
365
380
  }
366
381
  removeLegs(): void {
@@ -374,7 +389,7 @@ export class MediaSvc {
374
389
  async fillCat(): Promise<void> {
375
390
  const mode = this.catMode
376
391
  if (mode == null) return
377
- const r = await j(`/api/catrects?cat=${encodeURIComponent(mode)}`)
392
+ const r = await this.j(`/api/catrects?cat=${encodeURIComponent(mode)}`)
378
393
  if (this.catMode !== mode) return // navigated away mid-fetch
379
394
  runInAction(() => { this.catRows = r.rows ?? [] })
380
395
  }
@@ -384,10 +399,10 @@ export class MediaSvc {
384
399
  const nv = Math.max(0.1, v || row.rect.size)
385
400
  if (nv === row.rect.size) return // blur without a change is not a save
386
401
  row.rect.size = nv
387
- await post('/api/rect', { id, name, rect: row.rect })
402
+ await this.post('/api/rect', { id, name, rect: row.rect })
388
403
  }
389
404
  async toggleFlip(id: string, name: string): Promise<void> {
390
- await post('/api/flip', { id, name })
405
+ await this.post('/api/flip', { id, name })
391
406
  await this.fillCat()
392
407
  }
393
408
  async applyAllSize(): Promise<void> {
@@ -397,7 +412,7 @@ export class MediaSvc {
397
412
  // one action for all mutations (post-await writes would land outside
398
413
  // it — mobx strict mode), then the POSTs
399
414
  runInAction(() => { for (const row of rows) row.rect!.size = v })
400
- for (const row of rows) await post('/api/rect', { id: row.id, name: row.name, rect: row.rect })
415
+ for (const row of rows) await this.post('/api/rect', { id: row.id, name: row.name, rect: row.rect })
401
416
  runInAction(() => { this.allMsg = 'applied' })
402
417
  setTimeout(() => runInAction(() => { this.allMsg = '' }), 1200)
403
418
  await this.fillCat()
@@ -433,12 +448,16 @@ export class MediaSvc {
433
448
  if (kind != null && typeof localStorage !== 'undefined') localStorage.setItem(`hub:tools:${kind}`, [...this.tools].join(','))
434
449
  this.syncHash()
435
450
  }
436
- /** the hash carries the working state: #<assetId>?tools=a,b — shareable, restored on load */
437
- private syncHash(): void {
438
- if (typeof location === 'undefined' || this.curId == null) return
451
+ /** the hash carries the working state: #<assetId>?tools=a,b — shareable,
452
+ * restored on load. null while no asset is open */
453
+ get hash(): string | null {
454
+ if (this.curId == null) return null
439
455
  const t = [...this.tools].join(',')
440
- const h = `${this.curId}${t === '' ? '' : `?tools=${t}`}`
441
- if (location.hash.slice(1) !== h) history.replaceState(null, '', `#${h}`)
456
+ return `${this.curId}${t === '' ? '' : `?tools=${t}`}`
457
+ }
458
+ private syncHash(): void {
459
+ const h = this.hash
460
+ if (h != null) this.st.replaceHash(h)
442
461
  }
443
462
  /** tabs = kit-declared presets first, then autos for tools no tab covers.
444
463
  * a tab swaps the enabled tool SET — the canvas underneath never moves */
@@ -493,12 +512,12 @@ export class MediaSvc {
493
512
  }
494
513
  async loadMeta(): Promise<void> {
495
514
  this.jsonMsg = ''
496
- const r = this.curId != null && this.active != null ? await post('/api/meta', { id: this.curId, name: this.active }) : { text: '' }
515
+ const r = this.curId != null && this.active != null ? await this.post('/api/meta', { id: this.curId, name: this.active }) : { text: '' }
497
516
  runInAction(() => { this.jsonText = r.text ?? '' })
498
517
  }
499
518
  async saveMeta(): Promise<void> {
500
519
  if (this.curId == null || this.active == null) return
501
- const r = await post('/api/meta', { id: this.curId, name: this.active, text: this.jsonText })
520
+ const r = await this.post('/api/meta', { id: this.curId, name: this.active, text: this.jsonText })
502
521
  runInAction(() => { this.jsonMsg = r.error != null ? `FAIL: ${r.error}` : 'saved' })
503
522
  }
504
523
  /** frozen per mount: a roll/asset switch remounts with a fresh api */
@@ -513,14 +532,14 @@ export class MediaSvc {
513
532
  /** the PICKED roll's name (null before any pick) — editors that
514
533
  * target shipped art can warn when the shown roll differs */
515
534
  picked: this.detail?.rolls.find((x) => x.picked)?.name ?? null,
516
- imgUrl: rollUrl(id, name),
535
+ imgUrl: this.rollUrl(id, name),
517
536
  getMeta: async () => {
518
- const r = await post('/api/meta', { id, name })
537
+ const r = await this.post('/api/meta', { id, name })
519
538
  if (r.error != null) throw new Error(r.error)
520
539
  return r.text ? JSON.parse(r.text) : null
521
540
  },
522
541
  saveMeta: async (data: unknown) => {
523
- const r = await post('/api/meta', { id, name, text: JSON.stringify(data) })
542
+ const r = await this.post('/api/meta', { id, name, text: JSON.stringify(data) })
524
543
  if (r.error != null) throw new Error(r.error)
525
544
  },
526
545
  // an element covering the DISPLAYED roll image, so an editor can