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
@@ -1,16 +1,11 @@
1
- // The OTHER house repos on this disk, as the live strip reads them: their
2
- // runs in flight from their own run record, read only, and their hub url
3
- // when one serves. Which repos count is houseRepos (project-gutter.ts), the
4
- // same discovery the project gutter lists, minus this repo.
5
- //
6
- // Cheap on purpose, because the strip asks every second: the list is read at
7
- // most every LIST_TTL_MS, and a repo's record is opened only when its file or
8
- // its write ahead log moved since the last read.
9
- import { existsSync, realpathSync } from 'fs'
1
+ // The OTHER house repos on this disk, as a hub serving on its own lists them
2
+ // for the dock (house-runs.ts). Which repos count is houseRepos
3
+ // (project-gutter.ts), the same discovery the project gutter lists, minus
4
+ // this repo. The list is read at most every LIST_TTL_MS, since the dock asks
5
+ // every few seconds.
6
+ import { realpathSync } from 'fs'
10
7
  import { homedir } from 'os'
11
- import type { LiveRow } from '../ledger/live-runs.ts'
12
- import { readLiveReadonly } from '../ledger/run-log.ts'
13
- import { houseRepos, hubPortOf, recordOf, recordStamp, type WiredRepo } from './project-gutter.ts'
8
+ import { houseRepos, type WiredRepo } from './project-gutter.ts'
14
9
 
15
10
  /** how long the list of repos is trusted before the dev root is read again */
16
11
  export const LIST_TTL_MS = 30_000
@@ -30,11 +25,10 @@ export function otherHouseRepos(root: string, userHome: string = homedir()): Wir
30
25
  return repos.filter((r) => real(r.path) !== me)
31
26
  }
32
27
 
33
- /** the other repos and their live rows, re-read only when something moved */
28
+ /** the other repos, the list re-read at most every LIST_TTL_MS */
34
29
  export class OtherRepos {
35
30
  private list: WiredRepo[] = []
36
31
  private listedAt = -Infinity
37
- private rows = new Map<string, { stamp: string; rows: LiveRow[] }>()
38
32
 
39
33
  constructor(private root: string, private now: () => number = Date.now, private userHome: string = homedir()) {}
40
34
 
@@ -45,35 +39,4 @@ export class OtherRepos {
45
39
  }
46
40
  return this.list
47
41
  }
48
-
49
- /** every repo's live rows, as its record holds them */
50
- read(): { repo: string; rows: LiveRow[] }[] {
51
- const out: { repo: string; rows: LiveRow[] }[] = []
52
- for (const r of this.repos()) {
53
- const path = recordOf(r.path)
54
- if (path == null || !existsSync(path)) continue
55
- const stamp = recordStamp(path)
56
- let hit = this.rows.get(r.path)
57
- if (hit == null || hit.stamp !== stamp) {
58
- hit = { stamp, rows: readLiveReadonly(path) }
59
- this.rows.set(r.path, hit)
60
- }
61
- out.push({ repo: r.name, rows: hit.rows })
62
- }
63
- return out
64
- }
65
-
66
- /** a repo's hub url when it serves, checked by asking it who it is */
67
- async hubOf(repo: string): Promise<string | null> {
68
- const r = this.repos().find((x) => x.name === repo)
69
- if (r == null) return null
70
- const url = `http://localhost:${hubPortOf(r.path)}`
71
- try {
72
- const res = await fetch(`${url}/hub.json`, { signal: AbortSignal.timeout(500) })
73
- const j = (await res.json()) as { hub?: string; root?: string }
74
- return j.hub === 'shipkit' && j.root != null && real(j.root) === real(r.path) ? url : null
75
- } catch {
76
- return null
77
- }
78
- }
79
42
  }
@@ -5,20 +5,23 @@
5
5
  // the index moved, a few repos per poll, and a repo's run record is opened
6
6
  // only when it moved, so a steady poll spawns ps and lsof and nothing else. The icon route
7
7
  // serves only the icon files a discovered repo carries, and POST
8
- // /api/projects/open opens a discovered repo in VS Code: a request names a
9
- // repo, never a path. The rail ORDER is one text file for the whole machine
8
+ // /api/projects/open opens a discovered repo in VS Code, POST
9
+ // /api/projects/fork in Fork: a request names a repo, never a path. The rail ORDER is one text file for the whole machine
10
10
  // (ORDER_FILE), so every hub shows the same order and a hand edit is seen
11
11
  // within one poll; POST /api/projects/order writes it.
12
- import { mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
13
13
  import { readFile, stat } from 'fs/promises'
14
14
  import { homedir } from 'os'
15
15
  import { dirname, join } from 'path'
16
16
  import {
17
- asksDirOf, branchOf, countSessions, descriptionOf, dirtyCount, gateOf, gitStamp, houseRepos, iconStamp, ICON_TYPES, lastActivity,
17
+ asksDirOf, branchOf, countSessions, descriptionOf, dirtyCount, gateFrom, checkStateOf, gitStamp, houseRepos, iconStamp, ICON_TYPES, lastActivity,
18
18
  lastRunAt, lastSessionAt, liveUrlOf, mergeOrder, openAsks, openFolders, parseClaudePids, parseDescribe, parseLsofCwd, parseOrder,
19
- recordOf, recordStamp, RELEASE_TAGS, renderOrder, versionOf, vscodeStoragePath, type Gate, type WiredRepo,
19
+ recordOf, recordStamp, RELEASE_TAGS, renderOrder, versionOf, vscodeStoragePath, type CheckState, type Gate, type LastRow, type WiredRepo,
20
20
  } from './project-gutter.ts'
21
21
  import { readLastStatusReadonly } from '../ledger/run-log.ts'
22
+ import { identityOfText, identityProblem, type Identity } from '../core/identity-color.ts'
23
+ import { writeIdentity } from '../core/identity.ts'
24
+ import { writeArchived } from '../core/archived.ts'
22
25
 
23
26
  export type { Gate }
24
27
 
@@ -60,14 +63,23 @@ export type ProjectRow = {
60
63
  dirty: number | null
61
64
  /** the last gate its own run record holds */
62
65
  gate: Gate
66
+ /** every check's last outcome as one colour, warnings included */
67
+ checks: CheckState
63
68
  release: Release
64
69
  /** the deployed url `smoke.base` declares, or null */
65
70
  live: string | null
71
+ /** the colours its repo.config.ts declares (SK024), null when it declares none */
72
+ identity: Identity | null
66
73
  /** epoch ms of the newest of: the last commit, the last shipkit run, the
67
74
  * newest Claude Code transcript. null when none of them exists */
68
75
  activity: number | null
76
+ /** epoch ms of the last commit on HEAD, null when git cannot say */
77
+ commit: number | null
69
78
  }
70
- export type ProjectsAnswer = { projects: ProjectRow[]; ms: number; at: number }
79
+ /** a repo whose repo.config.ts says `archived: true`: only the archived page lists it */
80
+ export type ArchivedRow = { name: string; icon: string | null }
81
+
82
+ export type ProjectsAnswer = { projects: ProjectRow[]; archived: ArchivedRow[]; ms: number; at: number }
71
83
 
72
84
  /** a command's exit code and stdout, or null when its binary is missing */
73
85
  export type Run = (cmd: string[], cwd?: string) => Promise<{ code: number; out: string } | null>
@@ -79,12 +91,18 @@ export type GutterDeps = {
79
91
  run?: Run
80
92
  /** opens a folder in VS Code; throws with the reason it could not */
81
93
  open?: (path: string) => void
94
+ /** opens a folder in Fork, the git client; throws with the reason it could not */
95
+ fork?: (path: string) => void
82
96
  warn?: (line: string) => void
83
97
  now?: () => number
84
98
  /** where the rail order lives, default ~/ORDER_FILE */
85
99
  orderFile?: string
86
100
  /** the Claude Code `projects` folders, default the `projects` folder of every ~/.claude config root */
87
101
  transcriptRoots?: string[]
102
+ /** the check ids a repo declares today (the daemon's plan), so a check
103
+ * it no longer declares never colours it. Absent or null = not known,
104
+ * every recorded check counts */
105
+ declared?: (name: string) => ReadonlySet<string> | null
88
106
  }
89
107
 
90
108
  const runCmd: Run = async (cmd, cwd) => {
@@ -107,6 +125,21 @@ function openInCode(path: string): void {
107
125
  throw new Error('the `code` command is not on PATH: in VS Code, run "Shell Command: Install \'code\' command in PATH"')
108
126
  }
109
127
 
128
+ /** where Fork is installed, the places macOS keeps apps */
129
+ export const FORK_APPS = ['/Applications/Fork.app', join(homedir(), 'Applications', 'Fork.app')]
130
+
131
+ /** the command that opens a folder in Fork; throws when Fork is not installed */
132
+ export function forkCommand(path: string, installed: (app: string) => boolean = existsSync, platform: string = process.platform): string[] {
133
+ if (platform !== 'darwin') throw new Error('opening in Fork is macOS only here')
134
+ const app = FORK_APPS.find(installed)
135
+ if (app == null) throw new Error(`Fork is not installed (looked in ${FORK_APPS.join(', ')})`)
136
+ return ['open', '-a', app, path]
137
+ }
138
+
139
+ function openInFork(path: string): void {
140
+ Bun.spawn(forkCommand(path), { stdio: ['ignore', 'ignore', 'ignore'] }).unref()
141
+ }
142
+
110
143
  const epochMs = (out: string | undefined): number | null => {
111
144
  const secs = Number(out?.trim())
112
145
  return Number.isFinite(secs) && secs > 0 ? secs * 1000 : null
@@ -126,6 +159,15 @@ const readText = (file: string): string => {
126
159
  }
127
160
  }
128
161
 
162
+ /** the house repos in the rail's order, archived ones left out: the rows of
163
+ * the project list without the facts the list measures, for a caller that
164
+ * needs only names and paths (`shipkit cross-repo-activity`) */
165
+ export function railRepos(root: string, userHome: string = homedir(), orderFile: string = join(userHome, ORDER_FILE)): WiredRepo[] {
166
+ const repos = houseRepos(root, userHome).repos
167
+ const by = new Map(repos.map((r) => [r.name, r]))
168
+ return mergeOrder(parseOrder(readText(orderFile)), repos.map((r) => r.name)).map((n) => by.get(n)!)
169
+ }
170
+
129
171
  export function projectGutter(d: GutterDeps) {
130
172
  let home = d.root
131
173
  const userHome = d.home ?? homedir()
@@ -141,21 +183,25 @@ export function projectGutter(d: GutterDeps) {
141
183
  }
142
184
 
143
185
  let repos: WiredRepo[] = []
186
+ let archivedRepos: WiredRepo[] = []
144
187
  let last: ProjectsAnswer | null = null
145
188
  let inFlight: Promise<ProjectsAnswer> | null = null
146
189
  let folders: { mtime: number; list: string[] } | null = null
147
190
  const releases = new Map<string, { stamp: string | null; at: number; release: Release; commitAt: number | null; dirty: number | null }>()
148
- const gates = new Map<string, { stamp: string; gate: Gate }>()
149
- /** a repo's last gate, its record opened only when it moved */
150
- const gateOfRepo = (path: string): Gate => {
151
- const record = recordOf(path)
152
- if (record == null) return null
191
+ const records = new Map<string, { stamp: string; rows: LastRow[] }>()
192
+ /** a repo's last gate and check colour, its record opened only when it
193
+ * moved, counted over the checks it declares today */
194
+ const gateOfRepo = (r: WiredRepo): { gate: Gate; checks: CheckState } => {
195
+ const record = recordOf(r.path)
196
+ if (record == null) return { gate: null, checks: null }
153
197
  const stamp = recordStamp(record)
154
- const hit = gates.get(path)
155
- if (hit != null && hit.stamp === stamp) return hit.gate
156
- const gate = gateOf(readLastStatusReadonly(record))
157
- gates.set(path, { stamp, gate })
158
- return gate
198
+ let hit = records.get(r.path)
199
+ if (hit == null || hit.stamp !== stamp) {
200
+ hit = { stamp, rows: readLastStatusReadonly(record) }
201
+ records.set(r.path, hit)
202
+ }
203
+ const checks = checkStateOf(hit.rows, d.declared?.(r.name) ?? null)
204
+ return { gate: gateFrom(checks), checks }
159
205
  }
160
206
  const orderFile = d.orderFile ?? join(userHome, ORDER_FILE)
161
207
  const transcriptRoots = (): string[] => {
@@ -247,12 +293,15 @@ export function projectGutter(d: GutterDeps) {
247
293
  return paths.map((p) => ({ release: releases.get(p)?.release ?? null, commitAt: releases.get(p)?.commitAt ?? null, dirty: releases.get(p)?.dirty ?? null }))
248
294
  }
249
295
 
296
+ const iconUrl = (r: WiredRepo): string | null => (r.icon == null ? null : `/api/projects/icon/${encodeURIComponent(r.name)}?v=${iconStamp(join(r.path, r.icon))}`)
297
+
250
298
  async function measure(): Promise<ProjectsAnswer> {
251
299
  const t0 = performance.now()
252
300
  const house = houseRepos(d.root, userHome)
253
301
  home = house.home
254
302
  if (house.index == null) warnOnce('house', `WARN project gutter: ${home} includes no house wiki index, so no sibling shares one and the gutter is empty`)
255
303
  repos = house.repos
304
+ archivedRepos = house.archived
256
305
  const [open, cwds, facts] = await Promise.all([vscodeFolders(), sessionCwds(), gitFacts(repos.map((r) => r.path))])
257
306
  const transcripts = lastSessionAt(transcriptRoots(), repos.map((r) => r.path))
258
307
  const counts = cwds == null ? null : countSessions(cwds, repos.map((r) => r.path))
@@ -262,10 +311,11 @@ export function projectGutter(d: GutterDeps) {
262
311
  const asks = openAsks(join(r.path, asksDirOf(config, r.path)))
263
312
  // the README is read only when package.json says nothing
264
313
  const description = descriptionOf(pkg, '') ?? descriptionOf('', readText(join(r.path, 'README.md')))
314
+ const state = gateOfRepo(r)
265
315
  return {
266
316
  name: r.name,
267
317
  path: r.path,
268
- icon: r.icon == null ? null : `/api/projects/icon/${encodeURIComponent(r.name)}?v=${iconStamp(join(r.path, r.icon))}`,
318
+ icon: iconUrl(r),
269
319
  current: r.path === home,
270
320
  vscode: open == null ? null : open.some((f) => f === r.path),
271
321
  sessions: counts?.get(r.path) ?? null,
@@ -275,13 +325,17 @@ export function projectGutter(d: GutterDeps) {
275
325
  version: versionOf(pkg),
276
326
  branch: branchOf(readText(join(r.path, '.git', 'HEAD'))),
277
327
  dirty: facts[i]!.dirty,
278
- gate: gateOfRepo(r.path),
328
+ gate: state.gate,
329
+ checks: state.checks,
279
330
  release: facts[i]!.release,
280
331
  live: liveUrlOf(config),
332
+ identity: identityOfText(config),
281
333
  activity: lastActivity(facts[i]!.commitAt, lastRunAt(r.path), transcripts.get(r.path)),
334
+ commit: facts[i]!.commitAt,
282
335
  }
283
336
  })
284
- return { projects: inOrder(projects, parseOrder(readText(orderFile))), ms: Math.round(performance.now() - t0), at: now() }
337
+ const archived = archivedRepos.map((r) => ({ name: r.name, icon: iconUrl(r) }))
338
+ return { projects: inOrder(projects, parseOrder(readText(orderFile))), archived, ms: Math.round(performance.now() - t0), at: now() }
285
339
  }
286
340
 
287
341
  /** the last answer while it is younger than POLL_MS, one measure otherwise,
@@ -296,6 +350,8 @@ export function projectGutter(d: GutterDeps) {
296
350
  }
297
351
 
298
352
  const find = (name: string): WiredRepo | undefined => repos.find((r) => r.name === name)
353
+ /** a live repo or an archived one: the icon and the archive toggle reach both */
354
+ const findAny = (name: string): WiredRepo | undefined => find(name) ?? archivedRepos.find((r) => r.name === name)
299
355
 
300
356
  /** saves the rail order: the names given, then every discovered repo they
301
357
  * leave out. Written whole through a temp file, so a reader never sees
@@ -315,12 +371,43 @@ export function projectGutter(d: GutterDeps) {
315
371
  if (path === '/api/projects.json') return Response.json(await answer())
316
372
  if (path.startsWith('/api/projects/icon/')) {
317
373
  if (repos.length === 0) await answer()
318
- const r = find(decodeURIComponent(path.slice('/api/projects/icon/'.length)))
374
+ const r = findAny(decodeURIComponent(path.slice('/api/projects/icon/'.length)))
319
375
  if (r?.icon == null) return new Response('no icon', { status: 404 })
320
376
  return new Response(Bun.file(join(r.path, r.icon)), {
321
377
  headers: { 'content-type': ICON_TYPES[r.icon.split('.').pop()!] ?? 'application/octet-stream', 'cache-control': 'max-age=86400' },
322
378
  })
323
379
  }
380
+ if (path === '/api/projects/identity' && req.method === 'POST') {
381
+ if (repos.length === 0) await answer()
382
+ const b = (await req.json().catch(() => ({}))) as { name?: unknown; colorLight?: unknown; colorDark?: unknown }
383
+ const r = find(String(b.name))
384
+ if (r == null) return new Response(`not a house repo: ${String(b.name)}`, { status: 404 })
385
+ // a colour left out keeps the config's own, so only the sent ones are checked
386
+ const bad = identityProblem({ colorLight: b.colorLight ?? '#000000', colorDark: b.colorDark ?? '#000000' })
387
+ if (bad != null) return new Response(bad, { status: 400 })
388
+ try {
389
+ const id = writeIdentity(r.path, { colorLight: b.colorLight as string | undefined, colorDark: b.colorDark as string | undefined })
390
+ if (last != null) last = { ...last, projects: last.projects.map((p) => (p.name === r.name ? { ...p, identity: id } : p)) }
391
+ return Response.json({ identity: id })
392
+ } catch (e) {
393
+ return new Response((e as Error).message, { status: 409 })
394
+ }
395
+ }
396
+ if (path === '/api/projects/archived' && req.method === 'POST') {
397
+ if (repos.length === 0) await answer()
398
+ const b = (await req.json().catch(() => ({}))) as { name?: unknown; archived?: unknown }
399
+ const r = findAny(String(b.name))
400
+ if (r == null) return new Response(`not a house repo: ${String(b.name)}`, { status: 404 })
401
+ if (typeof b.archived !== 'boolean') return new Response('archived must be true or false', { status: 400 })
402
+ try {
403
+ const on = writeArchived(r.path, b.archived)
404
+ // the repo moves between the two lists: the next answer measures again
405
+ last = null
406
+ return Response.json({ archived: on })
407
+ } catch (e) {
408
+ return new Response((e as Error).message, { status: 409 })
409
+ }
410
+ }
324
411
  if (path === '/api/projects/order' && req.method === 'POST') {
325
412
  if (repos.length === 0) await answer()
326
413
  const b = (await req.json().catch(() => ({}))) as { names?: unknown }
@@ -331,6 +418,20 @@ export function projectGutter(d: GutterDeps) {
331
418
  return new Response(`the order could not be saved to ${orderFile}: ${(e as Error).message}`, { status: 500 })
332
419
  }
333
420
  }
421
+ if (path === '/api/projects/fork' && req.method === 'POST') {
422
+ if (repos.length === 0) await answer()
423
+ const b = (await req.json().catch(() => ({}))) as { name?: unknown }
424
+ const name = typeof b.name === 'string' ? b.name : ''
425
+ if (archivedRepos.some((r) => r.name === name)) return new Response(`${name} is archived: unarchive it first`, { status: 409 })
426
+ const r = find(name)
427
+ if (r == null) return new Response(`not a house repo: ${JSON.stringify(b.name ?? '')}`, { status: 404 })
428
+ try {
429
+ ;(d.fork ?? openInFork)(r.path)
430
+ } catch (e) {
431
+ return new Response((e as Error).message, { status: 400 })
432
+ }
433
+ return Response.json({ ok: true })
434
+ }
334
435
  if (path === '/api/projects/open' && req.method === 'POST') {
335
436
  if (repos.length === 0) await answer()
336
437
  const b = (await req.json().catch(() => ({}))) as { name?: string }
@@ -2,7 +2,7 @@
2
2
  // read of each one: which folders VS Code has open, which Claude Code
3
3
  // sessions run in which repo, its open asks, its distance to the last
4
4
  // release, its declared live url, its run record and its hub port. The
5
- // project gutter and the live strip both list repos through houseRepos, so
5
+ // project gutter and the dock both list repos through houseRepos, so
6
6
  // the two surfaces show the same repos by construction. The parsers read
7
7
  // text they are handed, so sample output pins them; project-gutter-route.ts
8
8
  // and other-repos.ts own the spawns, the reads and the caches.
@@ -13,6 +13,7 @@ import { gitRoot } from '../core/git-root.ts'
13
13
  import { DB_FILE } from '../ledger/db.ts'
14
14
  import { defaultHubPort } from './hub-port.ts'
15
15
  import { LAYOUT } from '../core/layout.ts'
16
+ import { isArchived } from '../core/archived.ts'
16
17
 
17
18
  export type WiredRepo = { name: string; path: string; icon: string | null }
18
19
 
@@ -72,11 +73,16 @@ export function wiredRepos(parent: string, index: string, home: string): WiredRe
72
73
 
73
74
  /** the house repos around the repo at `root`: its main checkout (`home`),
74
75
  * the house index its own includes name, and every sibling that includes the
75
- * same index, `home` among them. No index = no repos */
76
- export function houseRepos(root: string, userHome: string): { home: string; index: string | null; repos: WiredRepo[] } {
76
+ * same index, `home` among them. No index = no repos. An archived repo
77
+ * (`archived: true`, core/archived.ts) is never in `repos`, so nothing that
78
+ * lists through here processes it: it is in `archived`, which only the
79
+ * archived page reads */
80
+ export function houseRepos(root: string, userHome: string): { home: string; index: string | null; repos: WiredRepo[]; archived: WiredRepo[] } {
77
81
  const home = homeRepo(root)
78
82
  const index = houseIndex(home, userHome)
79
- return { home, index, repos: index == null ? [] : wiredRepos(dirname(home), index, userHome) }
83
+ const all = index == null ? [] : wiredRepos(dirname(home), index, userHome)
84
+ const archived = all.filter((r) => isArchived(r.path))
85
+ return { home, index, repos: all.filter((r) => !archived.includes(r)), archived }
80
86
  }
81
87
 
82
88
  /** the checkout a linked worktree belongs to, so a hub started in a worktree
@@ -289,15 +295,31 @@ export function branchOf(head: string): string | null {
289
295
  /** `git status --porcelain`: one line per changed or untracked path */
290
296
  export const dirtyCount = (porcelain: string): number => porcelain.split('\n').filter((l) => l.trim() !== '').length
291
297
 
292
- /** the last gate as its record holds it: green, or the checks whose last
293
- * outcome failed. No row at all = unknown */
294
- export type Gate = 'green' | { reds: string[] } | null
295
- export function gateOf(rows: { id: string; status: string }[]): Gate {
296
- if (rows.length === 0) return null
297
- const reds = rows.filter((r) => r.status === 'fail').map((r) => r.id).sort()
298
- return reds.length === 0 ? 'green' : { reds }
298
+ /** a check's last outcome as the run record keeps it */
299
+ export type LastRow = { id: string; status: string; at?: number }
300
+
301
+ /** every check's last outcome in one colour: red when one failed, orange
302
+ * when one only warned, green when all passed or skipped, with when each
303
+ * red or warning was recorded. Only the ids the repo declares today count
304
+ * (`declared`; null = not known, every row counts), so a renamed or deleted
305
+ * check never holds a repo red. No row left = unknown */
306
+ export type CheckState = { state: 'red' | 'orange' | 'green'; reds: string[]; warns: string[]; since: Record<string, number> } | null
307
+ export function checkStateOf(rows: LastRow[], declared: ReadonlySet<string> | null = null): CheckState {
308
+ const live = declared == null ? rows : rows.filter((r) => declared.has(r.id))
309
+ if (live.length === 0) return null
310
+ const reds = live.filter((r) => r.status === 'fail').map((r) => r.id).sort()
311
+ const warns = live.filter((r) => r.status === 'warn').map((r) => r.id).sort()
312
+ const since: Record<string, number> = {}
313
+ for (const r of live) if ((r.status === 'fail' || r.status === 'warn') && typeof r.at === 'number') since[r.id] = r.at
314
+ return { state: reds.length > 0 ? 'red' : warns.length > 0 ? 'orange' : 'green', reds, warns, since }
299
315
  }
300
316
 
317
+ /** the last gate: green, or the checks whose last outcome failed, read off
318
+ * the same state. Unknown when no declared row is left */
319
+ export type Gate = 'green' | { reds: string[] } | null
320
+ export const gateFrom = (c: CheckState): Gate => (c == null ? null : c.reds.length === 0 ? 'green' : { reds: c.reds })
321
+ export const gateOf = (rows: LastRow[], declared: ReadonlySet<string> | null = null): Gate => gateFrom(checkStateOf(rows, declared))
322
+
301
323
  /** the tag shapes a release leaves: `v1.2.3`, `name@1.2.3` (the shipkit
302
324
  * defaults) and a bare `1.2.3`. A date or a merge marker is no release */
303
325
  export const RELEASE_TAGS = ['v[0-9]*', '*@[0-9]*', '[0-9]*.[0-9]*']
@@ -0,0 +1,57 @@
1
+ // the repo's README for the hub page's empty state, and the images it points
2
+ // at. A file answers only when it is an image AND sits inside the repo root
3
+ // once symlinks resolve: the route must never become a way to read the disk
4
+ import { existsSync, realpathSync, statSync } from 'fs'
5
+ import { extname, isAbsolute, join, relative, resolve } from 'path'
6
+
7
+ const README_NAMES = ['README.md', 'readme.md', 'Readme.md', 'README.markdown', 'README']
8
+
9
+ const IMAGE_TYPES: Record<string, string> = {
10
+ '.png': 'image/png',
11
+ '.jpg': 'image/jpeg',
12
+ '.jpeg': 'image/jpeg',
13
+ '.gif': 'image/gif',
14
+ '.webp': 'image/webp',
15
+ '.svg': 'image/svg+xml',
16
+ '.avif': 'image/avif',
17
+ '.ico': 'image/x-icon',
18
+ }
19
+
20
+ /** the README file name at the root, null when the repo has none */
21
+ export function findReadme(root: string): string | null {
22
+ return README_NAMES.find((n) => existsSync(join(root, n)) && statSync(join(root, n)).isFile()) ?? null
23
+ }
24
+
25
+ /** an image under the root, or null for anything else: another type, a
26
+ * missing file, `..`, an absolute path, a symlink that leaves the root */
27
+ export function readmeAsset(root: string, rel: string): { path: string; type: string } | null {
28
+ if (rel === '' || isAbsolute(rel) || rel.includes('\0')) return null
29
+ const type = IMAGE_TYPES[extname(rel).toLowerCase()]
30
+ if (type == null) return null
31
+ const abs = resolve(root, rel)
32
+ if (!existsSync(abs)) return null
33
+ const real = realpathSync(abs)
34
+ const inside = relative(realpathSync(root), real)
35
+ if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) return null
36
+ if (!statSync(real).isFile()) return null
37
+ return { path: real, type }
38
+ }
39
+
40
+ /** `/api/readme.json` and `/api/readme-file/<path>`, null for any other path */
41
+ export async function readmeRoutes(root: string, path: string): Promise<Response | null> {
42
+ if (path === '/api/readme.json') {
43
+ const file = findReadme(root)
44
+ return Response.json({ file, md: file == null ? null : await Bun.file(join(root, file)).text() })
45
+ }
46
+ const prefix = '/api/readme-file/'
47
+ if (!path.startsWith(prefix)) return null
48
+ let rel: string
49
+ try {
50
+ rel = decodeURIComponent(path.slice(prefix.length))
51
+ } catch {
52
+ return new Response('bad path encoding', { status: 400 })
53
+ }
54
+ const asset = readmeAsset(root, rel)
55
+ if (asset == null) return new Response(`not an image inside this repo: ${rel}`, { status: 404 })
56
+ return new Response(Bun.file(asset.path), { headers: { 'content-type': asset.type, 'cache-control': 'no-cache' } })
57
+ }
@@ -0,0 +1,89 @@
1
+ // the long running processes a repo declares in `serve.processes` (a dev
2
+ // server, a watcher), supervised by its hub with the same supervisor the home
3
+ // uses for resources. Each runs detached with a pid record in the machine db
4
+ // (named by its path under .shipkit/tmp, where an older copy kept a pid file),
5
+ // so a hub restart in place adopts it, and only a real stop of the hub stops it.
6
+ import { join, resolve } from 'path'
7
+ import { Proc, realDeps, tailLines, type ProcView, type SuperviseDeps } from '../home/supervise.ts'
8
+ import type { ProcessDecl } from '../repo-config.ts'
9
+ import { LAYOUT } from '../core/layout.ts'
10
+
11
+ export type ProcessView = ProcView & { url: string | null; auto: boolean; cmd: string; tail: string[] }
12
+
13
+ export const processDir = (root: string): string => join(root, LAYOUT.tmp, 'processes')
14
+
15
+ const probeUrl = async (url: string): Promise<boolean> => {
16
+ try {
17
+ // any answer means it serves: a dev server may 404 its root
18
+ await fetch(url, { signal: AbortSignal.timeout(1_500) })
19
+ return true
20
+ } catch {
21
+ return false
22
+ }
23
+ }
24
+
25
+ export class RepoProcesses {
26
+ private procs: { decl: ProcessDecl; proc: Proc }[]
27
+
28
+ constructor(root: string, decls: ProcessDecl[], o: { deps?: SuperviseDeps; probe?: (url: string) => Promise<boolean> } = {}) {
29
+ const dir = processDir(root)
30
+ this.procs = decls.map((decl) => ({
31
+ decl,
32
+ proc: new Proc({
33
+ id: decl.id,
34
+ cmd: decl.cmd,
35
+ cwd: resolve(root, decl.cwd ?? '.'),
36
+ log: join(dir, `${decl.id}.log`),
37
+ pidKey: join(dir, `${decl.id}.pid`),
38
+ probe: decl.url == null ? undefined : () => (o.probe ?? probeUrl)(decl.url!),
39
+ }, o.deps ?? realDeps),
40
+ }))
41
+ }
42
+
43
+ /** the auto ones start with the hub; an adopted one is left as it runs */
44
+ async startAuto(): Promise<void> {
45
+ await Promise.all(this.procs.filter((p) => p.decl.auto === true && p.proc.pid == null).map((p) => p.proc.ensure()))
46
+ }
47
+
48
+ act(id: string, act: 'start' | 'stop' | 'restart'): ProcessView | null {
49
+ const p = this.procs.find((x) => x.decl.id === id)
50
+ if (p == null) return null
51
+ if (act === 'stop' || act === 'restart') p.proc.stop()
52
+ if (act === 'start') void p.proc.ensure()
53
+ if (act === 'restart') this.restartWhenGone(p.proc)
54
+ return this.view(p)
55
+ }
56
+
57
+ private restartWhenGone(proc: Proc): void {
58
+ const wait = async (): Promise<void> => {
59
+ for (let i = 0; i < 40 && proc.status === 'stopping'; i++) {
60
+ await Bun.sleep(250)
61
+ await proc.tick()
62
+ }
63
+ proc.start()
64
+ }
65
+ void wait()
66
+ }
67
+
68
+ async tick(): Promise<void> {
69
+ await Promise.all(this.procs.map((p) => p.proc.tick()))
70
+ }
71
+
72
+ /** a real stop of the hub: every process it started stops with it */
73
+ stopAll(): void {
74
+ for (const p of this.procs) if (p.proc.pid != null) p.proc.stop()
75
+ }
76
+
77
+ private view(p: { decl: ProcessDecl; proc: Proc }): ProcessView {
78
+ const v = p.proc.view()
79
+ return { ...v, url: p.decl.url ?? null, auto: p.decl.auto === true, cmd: p.decl.cmd.join(' '), tail: v.status === 'up' ? [] : tailLines(p.proc.spec.log, 20) }
80
+ }
81
+
82
+ views(): ProcessView[] {
83
+ return this.procs.map((p) => this.view(p))
84
+ }
85
+
86
+ get size(): number {
87
+ return this.procs.length
88
+ }
89
+ }