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,249 @@
1
+ // the activity page's data: for every house repo, its branch, how far it is
2
+ // from its upstream, what it holds uncommitted and what it committed in a
3
+ // window. Plain local git, never a fetch, so "ahead" is against the upstream
4
+ // as this disk last saw it.
5
+ //
6
+ // the one constraint a reader breaks first: this runs only when the page asks
7
+ // (no tick). A pass reads ACTIVITY_CONCURRENCY repos at once, and an answer is
8
+ // shared for ACTIVITY_TTL_MS per window, never served past it, so a page left
9
+ // open costs about one pass a minute however many tabs show it, and a closed
10
+ // one costs nothing.
11
+ import { gitBin } from '../core/git-bin.ts'
12
+ import { readLastStatusReadonly } from '../ledger/run-log.ts'
13
+ import { checkStateOf, recordOf } from '../serve/project-gutter.ts'
14
+ import type { Line } from '../core/wire.ts'
15
+ import { MAX_COMMITS, MAX_FILES, type ActivityAnswer, type ActivityCommit, type ActivityRepo, type ActivityRow, type FileKind, type StatusCheck } from './activity-shape.ts'
16
+
17
+ export * from './activity-shape.ts'
18
+
19
+ /** how long one window's answer is shared between readers: half the page's
20
+ * 60s cadence, so tabs a few seconds apart share a pass and none is shown
21
+ * an answer older than this */
22
+ export const ACTIVITY_TTL_MS = 30_000
23
+
24
+ export type ParsedStatus = Pick<ActivityRow, 'branch' | 'detached' | 'upstream' | 'gone' | 'ahead' | 'behind' | 'counts' | 'files'>
25
+
26
+ /** `git status --porcelain=v2 --branch`, read */
27
+ export function parseStatus(text: string): ParsedStatus {
28
+ const out: ParsedStatus = { branch: null, detached: null, upstream: null, gone: false, ahead: null, behind: null, counts: { staged: 0, modified: 0, untracked: 0 }, files: [] }
29
+ let oid = ''
30
+ const add = (kind: FileKind, path: string): void => {
31
+ if (out.files.length < MAX_FILES) out.files.push({ kind, path })
32
+ }
33
+ for (const line of text.split('\n')) {
34
+ if (line.startsWith('# branch.oid ')) oid = line.slice(13)
35
+ else if (line.startsWith('# branch.head ')) {
36
+ const h = line.slice(14)
37
+ if (h !== '(detached)') out.branch = h
38
+ } else if (line.startsWith('# branch.upstream ')) out.upstream = line.slice(18)
39
+ else if (line.startsWith('# branch.ab ')) {
40
+ const m = /^\+(\d+) -(\d+)$/.exec(line.slice(12))
41
+ if (m != null) {
42
+ out.ahead = Number(m[1])
43
+ out.behind = Number(m[2])
44
+ }
45
+ } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
46
+ // `1 XY sub mH mI mW hH hI path`, `2 … Xscore path<TAB>orig`
47
+ const f = line.split(' ')
48
+ const xy = f[1] ?? '..'
49
+ const path = f.slice(line.startsWith('1 ') ? 8 : 9).join(' ').split('\t')[0]!
50
+ if (xy[0] !== '.') out.counts.staged++
51
+ if (xy[1] !== '.') out.counts.modified++
52
+ add(xy[1] !== '.' ? 'modified' : 'staged', path)
53
+ } else if (line.startsWith('u ')) {
54
+ out.counts.modified++
55
+ add('modified', line.split(' ').slice(10).join(' '))
56
+ } else if (line.startsWith('? ')) {
57
+ out.counts.untracked++
58
+ add('untracked', line.slice(2))
59
+ }
60
+ }
61
+ if (out.branch == null && oid !== '' && oid !== '(initial)') out.detached = oid.slice(0, 7)
62
+ if (out.upstream != null && out.ahead == null) out.gone = true
63
+ return out
64
+ }
65
+
66
+ const SEP = '\x1f'
67
+ const END = '\x1e'
68
+ const FORMAT = `--format=%h${SEP}%ct${SEP}%an${SEP}%S${SEP}%s${SEP}%H${END}`
69
+
70
+ /** `git log` in FORMAT, read; `unpushed` holds the full shas no remote has */
71
+ export function parseLog(text: string, onHead: boolean, unpushed: (full: string) => boolean = () => false): ActivityCommit[] {
72
+ const out: ActivityCommit[] = []
73
+ for (const rec of text.split(END)) {
74
+ const f = rec.replace(/^\n/, '').split(SEP)
75
+ if (f.length < 6) continue
76
+ const src = f[3]!.replace(/^refs\/heads\//, '')
77
+ out.push({ sha: f[0]!, at: Number(f[1]) * 1000, author: f[2]!, branch: onHead || src === '' ? null : src, title: f[4]!, pushed: !unpushed(f[5]!) })
78
+ }
79
+ return out
80
+ }
81
+
82
+ async function git(cwd: string, args: string[]): Promise<{ code: number; out: string; err: string }> {
83
+ // no optional locks: a look never takes the index lock an agent's commit needs
84
+ const p = Bun.spawn([gitBin(), ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } })
85
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
86
+ return { code, out, err: err.trim() }
87
+ }
88
+
89
+ /** which check ids a repo declares today: the daemon's plan; null = not
90
+ * known, every recorded check counts */
91
+ export type Declared = (name: string) => ReadonlySet<string> | null
92
+
93
+ /** each declared check's title by id; null = not known, the id stands in */
94
+ export type Titles = (name: string) => ReadonlyMap<string, string> | null
95
+
96
+ /** the line a check printed about its problem: its first line of the same
97
+ * weight (a fail for a failure, a warning for a warning), else its first
98
+ * line that is not a pass, without the id its line leads with */
99
+ export function findingOf(lines: string, status: 'fail' | 'warn', id: string): string {
100
+ let parsed: Line[]
101
+ try {
102
+ parsed = JSON.parse(lines) as Line[]
103
+ } catch {
104
+ return ''
105
+ }
106
+ if (!Array.isArray(parsed)) return ''
107
+ const kinds = status === 'fail' ? ['fail', 'fix-fail'] : ['warn', 'fix-warn']
108
+ const hit = parsed.find((l) => kinds.includes(l.kind)) ?? parsed.find((l) => l.kind !== 'ok' && l.kind !== 'skip')
109
+ if (hit == null) return ''
110
+ const t = hit.text.trim()
111
+ // `[ID] text`, and a runner line's `ID — text`: the list names the id already
112
+ for (const lead of [`[${id}]`, `${id} —`]) if (t.startsWith(lead)) return t.slice(lead.length).trim()
113
+ return t
114
+ }
115
+
116
+ /** a repo's checks as its run record last saw them, counted over what it
117
+ * declares, and the failing and warning ones with what each said */
118
+ export function statusOf(repo: ActivityRepo, declared: Declared | null = null, titles: Titles | null = null): Pick<ActivityRow, 'status' | 'failing'> {
119
+ const record = recordOf(repo.path)
120
+ if (record == null) return { status: null, failing: [] }
121
+ const rows = readLastStatusReadonly(record, { lines: true })
122
+ const status = checkStateOf(rows, declared?.(repo.name) ?? null)
123
+ if (status == null) return { status, failing: [] }
124
+ const byId = new Map(rows.map((r) => [r.id, r]))
125
+ const named = titles?.(repo.name) ?? null
126
+ const one = (id: string, st: 'fail' | 'warn'): StatusCheck => {
127
+ const r = byId.get(id)
128
+ return { id, title: named?.get(id) ?? id, status: st, at: status.since[id] ?? null, line: r == null ? '' : findingOf(r.lines, st, id) }
129
+ }
130
+ return { status, failing: [...status.reds.map((id) => one(id, 'fail')), ...status.warns.map((id) => one(id, 'warn'))] }
131
+ }
132
+
133
+ /** one repo, from `since` (epoch ms) to now */
134
+ export async function repoActivity(repo: ActivityRepo, since: number, declared: Declared | null = null, titles: Titles | null = null): Promise<ActivityRow> {
135
+ const empty = parseStatus('')
136
+ const row: ActivityRow = { name: repo.name, ...empty, commits: [], last: null, ...statusOf(repo, declared, titles), error: null }
137
+ try {
138
+ const iso = new Date(since).toISOString()
139
+ const range = [`--since=${iso}`, `--max-count=${MAX_COMMITS}`, '--no-show-signature', FORMAT]
140
+ // every ask that needs no other answer goes at once: git calls are
141
+ // ~5ms each but queue behind a busy daemon, so waiting for them one
142
+ // by one multiplies the wait. The upstream diff and the last commit
143
+ // are cheap enough to ask even when the answer goes unused
144
+ const [st, up, head, refs, one] = await Promise.all([
145
+ git(repo.path, ['status', '--porcelain=v2', '--branch']),
146
+ git(repo.path, ['rev-list', `--max-count=${MAX_COMMITS}`, '@{upstream}..HEAD']),
147
+ // a repo with no commit yet has no HEAD to walk: nothing committed
148
+ git(repo.path, ['log', ...range, 'HEAD']),
149
+ // the other local branches (an agent's worktree) whose tip moved in
150
+ // the window, minus what HEAD has: a repo with hundreds of branches
151
+ // walks only the few that moved
152
+ git(repo.path, ['for-each-ref', '--format=%(refname:short)%09%(committerdate:unix)%09%(upstream:short)%09%(upstream:track)', 'refs/heads']),
153
+ git(repo.path, ['log', '-1', '--no-show-signature', FORMAT, 'HEAD']),
154
+ ])
155
+ if (st.code !== 0) return { ...row, error: st.err || `git status exited ${st.code}` }
156
+ Object.assign(row, parseStatus(st.out))
157
+ // HEAD's unpushed commits: all of them without an upstream, else the
158
+ // `ahead` ones
159
+ const headAway = row.upstream == null || row.gone
160
+ const away = !headAway && (row.ahead ?? 0) > 0 && up.code === 0 ? new Set(up.out.split('\n').filter((l) => l !== '')) : new Set<string>()
161
+ const own = head.code === 0 ? parseLog(head.out, true, (h) => headAway || away.has(h)) : []
162
+ const moved = (refs.code === 0 ? refs.out.split('\n') : [])
163
+ .map((l) => l.split('\t'))
164
+ .filter((f) => f.length >= 2 && f[0] !== row.branch && Number(f[1]) * 1000 >= since)
165
+ // an upstream that is gone holds nothing
166
+ .map((f) => (f[3] === '[gone]' ? [f[0]!, f[1]!, ''] : f))
167
+ let other: ActivityCommit[] = []
168
+ if (moved.length > 0) {
169
+ const side = await git(repo.path, ['log', ...range, '--source', ...moved.map((f) => `refs/heads/${f[0]}`), '--not', 'HEAD', '--'])
170
+ // a side branch's commit is pushed once its own upstream holds it;
171
+ // on a failed ask every one reads unpushed, never pushed
172
+ const tracked = moved.filter((f) => (f[2] ?? '') !== '')
173
+ let sideAway = new Set<string>()
174
+ if (tracked.length > 0) {
175
+ const r = await git(repo.path, ['rev-list', `--since=${iso}`, ...tracked.map((f) => `refs/heads/${f[0]}`), '--not', ...tracked.map((f) => f[2]!), '--'])
176
+ if (r.code === 0) sideAway = new Set(r.out.split('\n').filter((l) => l !== ''))
177
+ else sideAway = new Set(['*'])
178
+ }
179
+ const untracked = new Set(moved.filter((f) => (f[2] ?? '') === '').map((f) => f[0]!))
180
+ other = side.code === 0 ? parseLog(side.out, false, (h) => sideAway.has('*') || sideAway.has(h)).map((c) => (c.branch != null && untracked.has(c.branch) ? { ...c, pushed: false } : c)) : []
181
+ }
182
+ row.commits = [...own, ...other].sort((a, b) => b.at - a.at).slice(0, MAX_COMMITS)
183
+ // an empty window still says what came last on HEAD
184
+ if (row.commits.length === 0) {
185
+ row.last = one.code === 0 ? parseLog(one.out, true, (h) => headAway || away.has(h))[0] ?? null : null
186
+ }
187
+ return row
188
+ } catch (e) {
189
+ return { ...row, error: (e as Error).message }
190
+ }
191
+ }
192
+
193
+ /** how many repos a pass reads at once: five git calls each, so a pass
194
+ * never has more than 30 git processes alive */
195
+ export const ACTIVITY_CONCURRENCY = 6
196
+
197
+ /** every repo, ACTIVITY_CONCURRENCY at a time, rows in the order given.
198
+ * THE computation: the daemon's route and `shipkit cross-repo-activity`
199
+ * both call it, and only render it differently */
200
+ export async function activityOf(repos: ActivityRepo[], minutes: number, now = Date.now(), declared: Declared | null = null, titles: Titles | null = null): Promise<ActivityAnswer> {
201
+ const t0 = performance.now()
202
+ const rows: ActivityRow[] = new Array(repos.length)
203
+ let next = 0
204
+ const worker = async (): Promise<void> => {
205
+ while (next < repos.length) {
206
+ const i = next++
207
+ rows[i] = await repoActivity(repos[i]!, now - minutes * 60_000, declared, titles)
208
+ }
209
+ }
210
+ await Promise.all(Array.from({ length: Math.min(ACTIVITY_CONCURRENCY, repos.length) }, worker))
211
+ return { now, minutes, rows, ms: Math.round(performance.now() - t0) }
212
+ }
213
+
214
+ /** one answer per window, shared for ttlMs. Past it a read waits for a new
215
+ * pass: an old answer is never served as current. Callers during a pass
216
+ * share it. */
217
+ export class ActivityCache {
218
+ private kept = new Map<number, ActivityAnswer>()
219
+ private running = new Map<number, Promise<ActivityAnswer>>()
220
+ private ttlMs: number
221
+ private now: () => number
222
+ private declared: Declared | null
223
+ private titles: Titles | null
224
+
225
+ constructor(o: { ttlMs?: number; now?: () => number; declared?: Declared; titles?: Titles } = {}) {
226
+ this.ttlMs = o.ttlMs ?? ACTIVITY_TTL_MS
227
+ this.now = o.now ?? Date.now
228
+ this.declared = o.declared ?? null
229
+ this.titles = o.titles ?? null
230
+ }
231
+
232
+ private pass(repos: ActivityRepo[], minutes: number): Promise<ActivityAnswer> {
233
+ const going = this.running.get(minutes)
234
+ if (going != null) return going
235
+ const p = activityOf(repos, minutes, this.now(), this.declared, this.titles)
236
+ .then((a) => { this.kept.set(minutes, a); return a })
237
+ .finally(() => this.running.delete(minutes))
238
+ this.running.set(minutes, p)
239
+ return p
240
+ }
241
+
242
+ /** the kept answer while it is younger than the ttl, else a new pass.
243
+ * `fresh` always waits for a pass: the page's refresh button */
244
+ get(repos: ActivityRepo[], minutes: number, o: { fresh?: boolean } = {}): Promise<ActivityAnswer> {
245
+ const k = this.kept.get(minutes)
246
+ if (k == null || o.fresh === true || this.now() - k.now >= this.ttlMs) return this.pass(repos, minutes)
247
+ return Promise.resolve(k)
248
+ }
249
+ }
@@ -0,0 +1,95 @@
1
+ // `shipkit serve` while a home runs: the hub itself runs in the home, and
2
+ // this process only streams its lines. So the VS Code folder-open task keeps its
3
+ // problem matcher and its clickable url, with no change to any repo's
4
+ // tasks.json, and closing VS Code no longer stops the hub.
5
+ //
6
+ // the log is what the home prints for that hub, so the frames the matcher
7
+ // reads arrive as the hub prints them. On attach, the last complete frame is replayed first,
8
+ // so the problems pane fills at once instead of at the next run.
9
+ import { closeSync, existsSync, openSync, readSync, statSync } from 'fs'
10
+ import { FRAME_BEGIN, FRAME_END_HEAD } from '../problems/problem-line.ts'
11
+ import { daemonUrl, homeDir, homeUrl, hubBase, hubLog } from './home-paths.ts'
12
+
13
+ /** the last complete problem frame in a log's text, begin to end line, or [] */
14
+ export function lastFrame(text: string): string[] {
15
+ const lines = text.split('\n')
16
+ for (let end = lines.length - 1; end >= 0; end--) {
17
+ if (!lines[end]!.startsWith(FRAME_END_HEAD)) continue
18
+ for (let begin = end - 1; begin >= 0; begin--) {
19
+ if (lines[begin] === FRAME_BEGIN) return lines.slice(begin, end + 1)
20
+ if (lines[begin]!.startsWith(FRAME_END_HEAD)) break
21
+ }
22
+ }
23
+ return []
24
+ }
25
+
26
+ /** how often the log is looked at for new bytes */
27
+ export const FOLLOW_MS = 300
28
+ /** the home is asked again this often, so a stream says when it went away */
29
+ export const ENSURE_MS = 60_000
30
+
31
+ type EnsureAnswer = { status?: string; url?: string; blocked?: string | null; note?: string | null }
32
+
33
+ async function ensure(name: string): Promise<EnsureAnswer | string> {
34
+ try {
35
+ const r = await fetch(`${daemonUrl()}/api/home/hub`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, act: 'ensure' }), signal: AbortSignal.timeout(3_000) })
36
+ if (!r.ok) return `${r.status} ${await r.text()}`
37
+ return (await r.json()) as EnsureAnswer
38
+ } catch (e) {
39
+ return (e as Error).message
40
+ }
41
+ }
42
+
43
+ /** hand the hub to the home and stream its log until killed. 'serve' when
44
+ * the home will not run it (not one of its repos): the caller serves itself */
45
+ export async function attachToHome(name: string, io: { print: (l: string) => void; printErr: (l: string) => void }): Promise<number | 'serve'> {
46
+ const first = await ensure(name)
47
+ if (typeof first === 'string' || first.blocked != null) {
48
+ io.print(` the home did not take ${name}'s hub (${typeof first === 'string' ? first : first.blocked}), so this process serves it`)
49
+ return 'serve'
50
+ }
51
+ io.print(`${name} hub → ${homeUrl().replace('127.0.0.1', 'localhost')}${first.url ?? `${hubBase(name)}/`} (run by the home)`)
52
+ const log = hubLog(homeDir(), name)
53
+ let pos = 0
54
+ if (existsSync(log)) {
55
+ const text = await Bun.file(log).text()
56
+ for (const l of lastFrame(text)) io.print(l)
57
+ pos = Buffer.byteLength(text)
58
+ }
59
+ let partial = ''
60
+ const follow = (): void => {
61
+ let size: number
62
+ try {
63
+ size = statSync(log).size
64
+ } catch {
65
+ return
66
+ }
67
+ // the home cut the log (capLog) or restarted the hub on a new one
68
+ if (size < pos) pos = 0
69
+ if (size === pos) return
70
+ const fd = openSync(log, 'r')
71
+ try {
72
+ const buf = Buffer.alloc(size - pos)
73
+ readSync(fd, buf, 0, buf.length, pos)
74
+ pos = size
75
+ const text = partial + buf.toString('utf8')
76
+ const lines = text.split('\n')
77
+ partial = lines.pop() ?? ''
78
+ for (const l of lines) io.print(l)
79
+ } finally {
80
+ closeSync(fd)
81
+ }
82
+ }
83
+ setInterval(follow, FOLLOW_MS)
84
+ let warned = false
85
+ setInterval(() => {
86
+ void ensure(name).then((r) => {
87
+ if (typeof r === 'string') {
88
+ if (!warned) io.printErr(`WARN the home stopped answering (${r}), so this repo's hub is down with it until launchd brings the home back`)
89
+ warned = true
90
+ } else warned = false
91
+ })
92
+ }, ENSURE_MS)
93
+ await new Promise(() => {})
94
+ return 0
95
+ }
@@ -0,0 +1,108 @@
1
+ // any shipkit command on a machine with a house starts pm first when it does
2
+ // not answer: one localhost probe, then a launchd kickstart, and the agent is
3
+ // installed from the house repo the first time. The command itself never waits
4
+ // for pm: a client that needs the daemon (ci, check, serve) waits on its own.
5
+ //
6
+ // the one constraint a reader breaks first: a `shipkit pm uninstall` is a
7
+ // machine saying no. It leaves an off marker, and nothing here installs pm
8
+ // again until `shipkit pm install` lifts it.
9
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
10
+ import { join } from 'path'
11
+ import { fileURLToPath } from 'url'
12
+ import { SLOT_HELD_ENV } from '../core/slots.ts'
13
+ import { homeDir, pmUp, WORKER_ENV } from './home-paths.ts'
14
+ import { installHome, LABEL } from './home-launchd.ts'
15
+ import { homeInstalled, houseCli, houseRootOf } from './home-start.ts'
16
+
17
+ type Env = Record<string, string | undefined>
18
+ const set = (v: string | undefined): boolean => v != null && v !== ''
19
+
20
+ /** the marker `pm uninstall` leaves: pm stays down until `pm install` */
21
+ export const offMarker = (dir = homeDir()): string => join(dir, 'autostart-off')
22
+
23
+ export function markAutostart(on: boolean, dir = homeDir()): void {
24
+ if (on) rmSync(offMarker(dir), { force: true })
25
+ else {
26
+ mkdirSync(dir, { recursive: true })
27
+ writeFileSync(offMarker(dir), 'shipkit pm uninstall stopped pm here. `shipkit pm install` starts it again\n')
28
+ }
29
+ }
30
+
31
+ /** the commands that ARE the home, or talk to launchd themselves */
32
+ const OWN = new Set(['pm', 'home', 'daemon', 'web', 'gui'])
33
+
34
+ /** why this command does not start pm, or null when it should */
35
+ export function autostartSkip(argv: string[], env: Env, o: { platform?: string; off?: () => boolean } = {}): string | null {
36
+ if (OWN.has(argv[0] ?? '')) return 'a home command'
37
+ if (set(env.CI)) return 'CI'
38
+ if (set(env.SHIPKIT_NO_HOME)) return 'SHIPKIT_NO_HOME'
39
+ if (set(env[WORKER_ENV])) return 'inside the home'
40
+ if (set(env[SLOT_HELD_ENV])) return 'inside a check'
41
+ if ((o.platform ?? process.platform) !== 'darwin') return 'no launchd'
42
+ if ((o.off ?? (() => existsSync(offMarker())))()) return 'pm uninstalled'
43
+ return null
44
+ }
45
+
46
+ export type AutostartDeps = {
47
+ up: () => Promise<boolean>
48
+ installed: () => boolean
49
+ /** the repo that hosts the house wiki, or null on a machine with none */
50
+ house: () => string | null
51
+ install: (root: string) => Promise<number>
52
+ kickstart: () => Promise<number>
53
+ printErr: (l: string) => void
54
+ }
55
+
56
+ export type AutostartResult = 'up' | 'kicked' | 'installed' | 'no house' | 'failed'
57
+
58
+ /** pm answers, or is on its way. Never throws: a command runs even when pm
59
+ * cannot start, and says so once */
60
+ export async function ensurePm(d: AutostartDeps): Promise<AutostartResult> {
61
+ if (await d.up()) return 'up'
62
+ try {
63
+ if (!d.installed()) {
64
+ const root = d.house()
65
+ if (root == null) return 'no house'
66
+ if ((await d.install(root)) !== 0) {
67
+ d.printErr(`WARN pm is down and its LaunchAgent would not install from ${root}: \`shipkit pm install\` there says why`)
68
+ return 'failed'
69
+ }
70
+ return 'installed'
71
+ }
72
+ const code = await d.kickstart()
73
+ if (code !== 0) {
74
+ d.printErr(`WARN pm is down and launchctl kickstart ${LABEL} failed (exit ${code}): \`shipkit pm install\` from the house repo reinstalls it`)
75
+ return 'failed'
76
+ }
77
+ return 'kicked'
78
+ } catch (e) {
79
+ d.printErr(`WARN pm is down and could not be started: ${(e as Error).message}`)
80
+ return 'failed'
81
+ }
82
+ }
83
+
84
+ const OWN_CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
85
+ const LIB_DIR = fileURLToPath(new URL('../..', import.meta.url))
86
+
87
+ /** the real machine's wiring: cwd first, else the house this lib lives in */
88
+ export function machineAutostart(cwd: string): AutostartDeps {
89
+ const quiet = { print: (l: string) => { if (l.startsWith('FAIL')) process.stderr.write(`${l}\n`) } }
90
+ return {
91
+ up: () => pmUp(undefined, 300),
92
+ installed: homeInstalled,
93
+ house: () => houseRootOf(cwd) ?? houseRootOf(LIB_DIR),
94
+ install: (root) => installHome({ root, cli: houseCli(root, OWN_CLI), ...quiet }),
95
+ kickstart: async () => {
96
+ const p = Bun.spawn(['launchctl', 'kickstart', `gui/${process.getuid?.() ?? 501}/${LABEL}`], { stdout: 'ignore', stderr: 'ignore' })
97
+ return p.exited
98
+ },
99
+ printErr: (l) => process.stderr.write(`${l}\n`),
100
+ }
101
+ }
102
+
103
+ /** the cli's first step: start pm when this command should */
104
+ export async function autostart(argv: string[], env: Env = process.env, cwd = process.cwd()): Promise<AutostartResult | string> {
105
+ const skip = autostartSkip(argv, env)
106
+ if (skip != null) return skip
107
+ return ensurePm(machineAutostart(cwd))
108
+ }
@@ -0,0 +1,87 @@
1
+ // the `.shipkit.ts` units of every house repo that belong to the machine, not
2
+ // to one repo: its global processes (pm's) and its widgets (the daemon's).
3
+ // Read by importing each file, so the reader must have registered the one
4
+ // copy of the lib first (core/one-lib.ts): a unit file imports it by name.
5
+ import { relative, resolve } from 'path'
6
+ import { importFresh, repoUnitFiles } from '../core/discover-checks.ts'
7
+ import { unitKind, type GlobalProcessUnit, type WidgetUnit } from '../shipkit-files.ts'
8
+
9
+ /** one button of a global process, as pm runs it */
10
+ export type ActionDecl = { id: string; label: string; cmd: string[] | null }
11
+
12
+ /** a global process as pm holds it: plain data, paths absolute */
13
+ export type GlobalProcessDecl = {
14
+ id: string
15
+ label: string
16
+ description: string | null
17
+ where: string | null
18
+ cmd: string[]
19
+ down: string[] | null
20
+ /** absolute: where cmd, down and every action run */
21
+ cwd: string
22
+ url: string | null
23
+ probe: string | null
24
+ /** as declared: an emoji, or an image path relative to root */
25
+ icon: string | null
26
+ actions: ActionDecl[]
27
+ /** the repo that declares it, and the file, repo-relative */
28
+ repo: string
29
+ root: string
30
+ file: string
31
+ }
32
+
33
+ export type RepoRef = { name: string; path: string }
34
+
35
+ export type LoadedWidget = { repo: string; file: string; def: WidgetUnit }
36
+
37
+ export type GlobalUnits = {
38
+ processes: GlobalProcessDecl[]
39
+ widgets: LoadedWidget[]
40
+ /** a file that would not load, or a process id declared twice */
41
+ errors: string[]
42
+ }
43
+
44
+ export function toDecl(u: GlobalProcessUnit, repo: RepoRef, file: string): GlobalProcessDecl {
45
+ return {
46
+ id: u.id,
47
+ label: u.label ?? u.id,
48
+ description: u.description ?? null,
49
+ where: u.where ?? null,
50
+ cmd: u.cmd,
51
+ down: u.down ?? null,
52
+ cwd: resolve(repo.path, u.cwd ?? '.'),
53
+ url: u.url ?? null,
54
+ probe: u.probe ?? u.url ?? null,
55
+ icon: u.icon ?? null,
56
+ actions: Object.entries(u.actions ?? {}).map(([id, a]) => ({ id, label: a.label, cmd: a.cmd ?? null })),
57
+ repo: repo.name,
58
+ root: repo.path,
59
+ file: relative(repo.path, file).replace(/\\/g, '/'),
60
+ }
61
+ }
62
+
63
+ /** every global process and widget the repos declare. A process id is the
64
+ * machine's: a second declaration of one is an error, the first one wins */
65
+ export async function scanGlobalUnits(repos: RepoRef[], o: { files?: (root: string) => string[] } = {}): Promise<GlobalUnits> {
66
+ const out: GlobalUnits = { processes: [], widgets: [], errors: [] }
67
+ for (const repo of repos) {
68
+ for (const f of (o.files ?? repoUnitFiles)(repo.path)) {
69
+ const rel = relative(repo.path, f).replace(/\\/g, '/')
70
+ let d: unknown
71
+ try {
72
+ d = await importFresh(f)
73
+ } catch (e) {
74
+ out.errors.push(`${repo.name}/${rel}: ${(e as Error).message}`)
75
+ continue
76
+ }
77
+ const kind = unitKind(d)
78
+ if (kind === 'globalProcess') {
79
+ const u = d as GlobalProcessUnit
80
+ const first = out.processes.find((p) => p.id === u.id)
81
+ if (first != null) out.errors.push(`${repo.name}/${rel}: global process '${u.id}' is already declared by ${first.repo}/${first.file}`)
82
+ else out.processes.push(toDecl(u, repo, f))
83
+ } else if (kind === 'widget') out.widgets.push({ repo: repo.name, file: rel, def: d as WidgetUnit })
84
+ }
85
+ }
86
+ return out
87
+ }