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,116 @@
1
+ // shipkit cross-repo-activity: the activity page in the console. The rows come
2
+ // from activityOf, the one computation the daemon's route serves the page, so
3
+ // the two can never disagree; this file only renders them as text. It reads
4
+ // local git and each repo's run record directly and needs no daemon: when one
5
+ // answers, its plan says which checks each repo declares today, as the page's
6
+ // status dot counts them.
7
+ import { homedir } from 'os'
8
+ import { Command, Option } from 'clipanion'
9
+ import { activityOf, type Declared } from '../home/activity.ts'
10
+ import { ageShort, clampCap, DEFAULT_CAP, DEFAULT_MINUTES, dirtWords, idleRow, upstreamWords, type ActivityAnswer, type ActivityCommit, type ActivityRow } from '../home/activity-shape.ts'
11
+ import { railRepos } from '../serve/project-gutter-route.ts'
12
+
13
+ /** a span as typed: `10m`, `1h`, `3d`, `90` (minutes); null when it is none */
14
+ export function spanMinutes(v: string): number | null {
15
+ const m = /^(\d+(?:\.\d+)?)\s*(m|min|h|d)?$/.exec(v.trim())
16
+ if (m == null) return null
17
+ const n = Number(m[1]) * (m[2] === 'h' ? 60 : m[2] === 'd' ? 1440 : 1)
18
+ return n > 0 ? Math.round(n) : null
19
+ }
20
+
21
+ /** single dash spellings of the long options, as typed: `-max-commit 3` */
22
+ export function aliasArgs(argv: string[]): string[] {
23
+ return argv.map((a) => (/^-max-(commit|age)(=|$)/.test(a) ? `-${a}` : a))
24
+ }
25
+
26
+ /** the declared check ids from a running daemon's plan; null when none answers */
27
+ export async function daemonDeclared(port = Number(process.env.SHIPKIT_DAEMON_PORT ?? 4798)): Promise<Declared | null> {
28
+ try {
29
+ const r = await fetch(`http://127.0.0.1:${port}/api/home/planned.json`, { signal: AbortSignal.timeout(1_500) })
30
+ if (!r.ok) return null
31
+ const j = (await r.json()) as { rows: { repo: string; check: string }[] }
32
+ const by = new Map<string, Set<string>>()
33
+ for (const row of j.rows) {
34
+ if (!by.has(row.repo)) by.set(row.repo, new Set())
35
+ by.get(row.repo)!.add(row.check)
36
+ }
37
+ return (name) => by.get(name) ?? null
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ const dot = (r: ActivityRow): string => {
44
+ const c = r.status
45
+ if (c == null) return '·'
46
+ if (c.state === 'red') return `● ${c.reds.length} red`
47
+ if (c.state === 'orange') return `● ${c.warns.length} warn`
48
+ return '● green'
49
+ }
50
+
51
+ const commitLine = (c: ActivityCommit, now: number, old: boolean): string =>
52
+ ` ${ageShort(c.at, now).padStart(3)} ${c.sha} ${c.pushed ? '✓' : '↑'} ${c.branch == null ? '' : `[${c.branch}] `}${c.title}${old ? ' (last commit, before the window)' : ''}`
53
+
54
+ /** the page's rows as text: busy repos first, then the quiet ones, each with
55
+ * its status, branch, upstream, uncommitted line and commits */
56
+ export function renderActivity(a: ActivityAnswer, cap: number): string[] {
57
+ const out: string[] = []
58
+ const busy = a.rows.filter((r) => !idleRow(r))
59
+ const quiet = a.rows.filter(idleRow)
60
+ for (const r of [...busy, ...quiet]) {
61
+ const branch = r.branch ?? (r.detached == null ? '' : `detached ${r.detached}`)
62
+ out.push(`${r.name} ${dot(r)} ${branch} (${upstreamWords(r)})`)
63
+ if (r.status != null && r.status.reds.length > 0) out.push(` failing: ${r.status.reds.map((id) => (r.status!.since[id] == null ? id : `${id} (${ageShort(r.status!.since[id]!, a.now)})`)).join(', ')}`)
64
+ if (r.error != null) out.push(` git failed: ${r.error}`)
65
+ const dirt = r.error == null ? dirtWords(r) : ''
66
+ if (dirt !== '') {
67
+ out.push(` uncommitted: ${dirt}`)
68
+ const mark = { staged: 'S', modified: 'M', untracked: '?' } as const
69
+ for (const f of r.files) out.push(` ${mark[f.kind]} ${f.path}`)
70
+ }
71
+ for (const c of r.commits.slice(0, cap)) out.push(commitLine(c, a.now, false))
72
+ if (r.commits.length > cap) out.push(` … ${r.commits.length - cap} more`)
73
+ if (r.last != null) out.push(commitLine(r.last, a.now, true))
74
+ if (r.commits.length === 0 && r.last == null && r.error == null) out.push(' no commit yet')
75
+ }
76
+ out.push(`${busy.length} busy, ${quiet.length} quiet, read in ${a.ms}ms`)
77
+ return out
78
+ }
79
+
80
+ export class CrossRepoActivityCommand extends Command {
81
+ static override paths = [['cross-repo-activity'], ['activity']]
82
+
83
+ static override usage = Command.Usage({
84
+ category: 'Home',
85
+ description: 'the activity page in the console: every house repo (archived ones left out), its checks, branch, upstream, uncommitted work and commits in a window. The same computation the page reads, local git only, no daemon needed',
86
+ examples: [
87
+ ['the last 30 minutes, 3 commits per repo, as the page opens', 'shipkit cross-repo-activity'],
88
+ ['the same, by the page name', 'shipkit activity'],
89
+ ['the last 3 hours, up to 10 commits per repo', 'shipkit cross-repo-activity --max-age 3h --max-commit 10'],
90
+ ['the route\'s exact payload', 'shipkit cross-repo-activity --max-age 30m --json'],
91
+ ],
92
+ })
93
+
94
+ maxAge = Option.String('--max-age', { required: false, description: `how far back: 10m, 1h, 3d, or minutes (default ${DEFAULT_MINUTES}m)` })
95
+ maxCommit = Option.String('--max-commit', { required: false, description: `commits shown per repo, 1 to 15 (default ${DEFAULT_CAP}). -max-commit works too` })
96
+ json = Option.Boolean('--json', false, { description: 'the daemon route\'s exact payload: { now, minutes, rows, ms }' })
97
+
98
+ async execute(): Promise<number> {
99
+ const minutes = this.maxAge == null ? DEFAULT_MINUTES : spanMinutes(this.maxAge)
100
+ if (minutes == null) {
101
+ this.context.stderr.write(`--max-age must be a span like 10m, 1h or 3d, got ${JSON.stringify(this.maxAge)}\n`)
102
+ return 2
103
+ }
104
+ const cap = clampCap(this.maxCommit ?? DEFAULT_CAP)
105
+ const declared = await daemonDeclared()
106
+ // HOME as the shell has it now: the house include names `~`
107
+ const a = await activityOf(railRepos(process.cwd(), process.env.HOME || homedir()), minutes, Date.now(), declared)
108
+ if (this.json) {
109
+ this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
110
+ return 0
111
+ }
112
+ for (const l of renderActivity(a, cap)) this.context.stdout.write(`${l}\n`)
113
+ if (declared == null) this.context.stdout.write('note: no daemon answered, so every check a run record holds counts, declared today or not\n')
114
+ return 0
115
+ }
116
+ }
@@ -0,0 +1,155 @@
1
+ // shipkit global-processes: the overview's processes table in the console,
2
+ // shipkit's own (pm, the daemon, the web) first. The rows come from pm's own
3
+ // answer through process-rows.ts, the one model the page draws, so the two can
4
+ // never disagree; this file only asks pm and the daemon and renders them as
5
+ // text. With pm down it reads the declared units the way pm does
6
+ // (pm-config.ts) and marks each one unknown.
7
+ import { homedir } from 'os'
8
+ import { Command, Option } from 'clipanion'
9
+ import { daemonPort, daemonUrl, homePort, pmPort, pmUrl } from '../home/home-paths.ts'
10
+ import { houseRootOf } from '../home/home-start.ts'
11
+ import type { PmAnswer } from '../home/pm.ts'
12
+ import { coreGroup, declaredRow, groupRows, processGroups, stateWords, type ProcessGroup } from '../home/process-rows.ts'
13
+ import type { PmConfig } from '../home/pm-config.ts'
14
+ import { readPmConfig } from '../home/run-pm.ts'
15
+ import { railRepos } from '../serve/project-gutter-route.ts'
16
+
17
+ export type GlobalProcessesAnswer = {
18
+ /** pm as it answered, null when it did not */
19
+ pm: { pid: number; version: string } | null
20
+ now: number
21
+ groups: ProcessGroup[]
22
+ /** the unit files that would not load */
23
+ errors: string[]
24
+ /** with pm down: whether a house was found to read the declared units from */
25
+ house: boolean
26
+ }
27
+
28
+ export type GlobalProcessesDeps = {
29
+ pm: () => Promise<PmAnswer | null>
30
+ /** the daemon's identity, for its version; null when it does not answer */
31
+ daemon?: () => Promise<{ version: string } | null>
32
+ /** the declared units, read like pm reads them; null when no house is found */
33
+ declared: () => Promise<Pick<PmConfig, 'processes' | 'errors'> | null>
34
+ /** the project list's order */
35
+ order: string[]
36
+ now: number
37
+ }
38
+
39
+ /** the ports this shell would find pm and its children on */
40
+ const localPorts = () => ({ pm: pmPort(), daemon: daemonPort(), web: homePort() })
41
+
42
+ /** pm's answer, shipkit's own rows first; the declared units as unknown when
43
+ * pm does not answer */
44
+ export async function globalProcessesAnswer(d: GlobalProcessesDeps): Promise<GlobalProcessesAnswer> {
45
+ const [pm, daemon] = await Promise.all([d.pm(), d.daemon?.() ?? null])
46
+ const core = { daemonVersion: daemon?.version ?? null, ports: localPorts() }
47
+ if (pm != null) return { pm: { pid: pm.pid, version: pm.version }, now: d.now, groups: processGroups(pm, d.order, core), errors: pm.unitErrors, house: true }
48
+ const c = await d.declared()
49
+ return { pm: null, now: d.now, groups: [coreGroup(null, core), ...groupRows((c?.processes ?? []).map(declaredRow), d.order)], errors: c?.errors ?? [], house: c != null }
50
+ }
51
+
52
+ /** the daemon's identity at /hub.json, or null */
53
+ export async function askDaemon(url = daemonUrl()): Promise<{ version: string } | null> {
54
+ try {
55
+ const r = await fetch(`${url}/hub.json`, { signal: AbortSignal.timeout(3_000) })
56
+ const j = r.ok ? ((await r.json()) as { hub?: string; version?: string }) : null
57
+ return j?.hub === 'shipkit-home' && typeof j.version === 'string' ? { version: j.version } : null
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ /** pm's status answer, or null when it does not answer */
64
+ export async function askPm(url = pmUrl()): Promise<PmAnswer | null> {
65
+ try {
66
+ const r = await fetch(`${url}/api/pm/status.json`, { signal: AbortSignal.timeout(1_500) })
67
+ if (!r.ok) return null
68
+ const j = (await r.json()) as PmAnswer
69
+ return j.pm === 'shipkit-pm' ? j : null
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ /** the project list's order and the declared units, read from disk the way
76
+ * pm reads them: what the table falls back on while pm does not answer */
77
+ export function localProcessDeps(cwd: string): Pick<GlobalProcessesDeps, 'declared' | 'order'> {
78
+ // HOME as the shell has it now: the house include names `~`
79
+ const home = process.env.HOME || homedir()
80
+ let order: string[] = []
81
+ try {
82
+ order = railRepos(cwd, home).map((r) => r.name)
83
+ } catch {
84
+ // no rail from here: the groups sort by name
85
+ }
86
+ return {
87
+ order,
88
+ declared: async () => {
89
+ const root = houseRootOf(cwd)
90
+ if (root == null) return null
91
+ try {
92
+ return await readPmConfig(root)
93
+ } catch (e) {
94
+ return { processes: [], errors: [(e as Error).message] }
95
+ }
96
+ },
97
+ }
98
+ }
99
+
100
+ const pad = (s: string, n: number): string => (s.length >= n ? s : s + ' '.repeat(n - s.length))
101
+
102
+ /** the table as text: shipkit's own, then per repo, one line per process,
103
+ * its note and last log line under it */
104
+ export function renderGlobalProcesses(a: GlobalProcessesAnswer): string[] {
105
+ const out: string[] = []
106
+ const n = a.groups.reduce((k, g) => k + (g.core ? 0 : g.rows.length), 0)
107
+ if (a.pm != null) out.push(`pm pid ${a.pm.pid}, shipkit ${a.pm.version}: ${n} global process${n === 1 ? '' : 'es'}`)
108
+ else if (!a.house) out.push(`pm does not answer on ${pmUrl()}, and no house wiki is found from ${process.cwd()}, so no declared process is known`)
109
+ else out.push(`pm does not answer on ${pmUrl()}: the ${n} declared process${n === 1 ? '' : 'es'} below, each state unknown`)
110
+ const rows = a.groups.flatMap((g) => g.rows)
111
+ const w = {
112
+ state: Math.max(0, ...rows.map((r) => stateWords(r, a.now).length)),
113
+ label: Math.max(0, ...rows.map((r) => r.label.length)),
114
+ host: Math.max(0, ...rows.map((r) => (r.host ?? '').length)),
115
+ }
116
+ for (const g of a.groups) {
117
+ out.push(g.repo)
118
+ for (const r of g.rows) {
119
+ const cells = [` ● ${pad(stateWords(r, a.now), w.state)}`, pad(r.label, w.label), pad(r.host ?? '', w.host), pad(r.pid == null ? '' : `pid ${r.pid}`, 10), r.version == null ? '' : `shipkit ${r.version}`]
120
+ out.push(cells.join(' ').trimEnd())
121
+ if (r.note != null) out.push(` ${r.note}`)
122
+ // shipkit's own logs only matter while one is not up
123
+ if (r.lastLog != null && !(r.core && r.status === 'up')) out.push(` log: ${r.lastLog}`)
124
+ }
125
+ }
126
+ if (a.pm != null && n === 0) out.push('none declared: a repo declares one in a <name>.shipkit.ts file, as export default globalProcess({...})')
127
+ for (const e of a.errors) out.push(`WARN ${e}`)
128
+ return out
129
+ }
130
+
131
+ export class GlobalProcessesCommand extends Command {
132
+ static override paths = [['global-processes'], ['gp']]
133
+
134
+ static override usage = Command.Usage({
135
+ category: 'Home',
136
+ description: 'every process on the machine and its state, as the overview shows them: shipkit\'s own (pm, the daemon, the web, with their version) first, then per repo, one line per global process with its state, url, pid and last log line. When pm is down, the declared ones with their state unknown. Always exits 0',
137
+ examples: [
138
+ ['the table', 'shipkit global-processes'],
139
+ ['the same, short', 'shipkit gp'],
140
+ ['the rows the overview draws, as json', 'shipkit global-processes --json'],
141
+ ],
142
+ })
143
+
144
+ json = Option.Boolean('--json', false, { description: 'the rows as json: { pm, now, groups, errors, house }, groups exactly as the overview draws them' })
145
+
146
+ async execute(): Promise<number> {
147
+ const a = await globalProcessesAnswer({ ...localProcessDeps(process.cwd()), pm: () => askPm(), daemon: () => askDaemon(), now: Date.now() })
148
+ if (this.json) {
149
+ this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
150
+ return 0
151
+ }
152
+ for (const l of renderGlobalProcesses(a)) this.context.stdout.write(`${l}\n`)
153
+ return 0
154
+ }
155
+ }
@@ -0,0 +1,170 @@
1
+ // shipkit pm, daemon, web and gui: the machine's one app. Thin clipanion surface; logic in home/.
2
+ import { Command, Option } from 'clipanion'
3
+ import { LIB_VERSION } from '../core/lib-version.ts'
4
+ import { fileURLToPath } from 'url'
5
+ import { basename } from 'path'
6
+ import { markAutostart } from '../home/autostart.ts'
7
+ import { installHome, uninstallHome } from '../home/home-launchd.ts'
8
+ import { homeInstalled, houseCli, houseRootOf, openWindow, startHome } from '../home/home-start.ts'
9
+ import { homeUp, homeUrl, hubBase } from '../home/home-paths.ts'
10
+ import { homeRepo, houseIndex } from '../serve/project-gutter.ts'
11
+ import { homedir } from 'os'
12
+ import { execPm, runPm } from '../home/run-pm.ts'
13
+ import { runDaemon } from '../home/run-daemon.ts'
14
+ import { runWeb } from '../home/run-web.ts'
15
+
16
+ const OWN_CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
17
+
18
+ const io = (c: Command) => ({
19
+ print: (l: string) => c.context.stdout.write(`${l}\n`),
20
+ printErr: (l: string) => c.context.stderr.write(`${l}\n`),
21
+ })
22
+
23
+ /** the --port value, undefined when absent, null when it is not a port */
24
+ function portOf(c: Command, raw: string | undefined): number | undefined | null {
25
+ if (raw == null) return undefined
26
+ const port = Number(raw)
27
+ if (Number.isInteger(port) && port > 0) return port
28
+ c.context.stderr.write(`FAIL --port '${raw}' is not a port number\n`)
29
+ return null
30
+ }
31
+
32
+ export class PmCommand extends Command {
33
+ // `home` is the name agents installed before pm still run
34
+ static override paths = [['pm'], ['home']]
35
+
36
+ static override usage = Command.Usage({
37
+ category: 'Serve',
38
+ description: 'the machine\'s process manager: runs `shipkit daemon` and `shipkit web` as detached children, starts one again when it dies, owns the global processes every house repo declares in its .shipkit.ts files, and serves its api on port 4797 (SHIPKIT_PM_PORT). A pm restarted or killed leaves its children running and the next one adopts them. Run from the repo that hosts the house wiki',
39
+ examples: [
40
+ ['run pm in this terminal', 'shipkit pm'],
41
+ ['on test ports', 'SHIPKIT_PM_PORT=4887 SHIPKIT_HOME_PORT=4889 SHIPKIT_DAEMON_PORT=4888 SHIPKIT_HOME_DIR=/tmp/home shipkit pm'],
42
+ ],
43
+ })
44
+
45
+ port = Option.String('--port', { required: false, description: 'pm port (default 4797, or SHIPKIT_PM_PORT)' })
46
+
47
+ async execute(): Promise<number> {
48
+ const port = portOf(this, this.port)
49
+ if (port === null) return 1
50
+ if (execPm({ port })) return 0
51
+ return runPm(process.cwd(), io(this), { port })
52
+ }
53
+ }
54
+
55
+ export class DaemonCommand extends Command {
56
+ static override paths = [['daemon']]
57
+
58
+ static override usage = Command.Usage({
59
+ category: 'Serve',
60
+ description: 'the home\'s daemon: loads every house repo hub in this one process and serves their api, their run streams and the home api under /r/<repo>/ on an internal port. No page: `shipkit web` serves it. Run from the repo that hosts the house wiki',
61
+ examples: [
62
+ ['the daemon alone', 'shipkit daemon'],
63
+ ['on another port', 'shipkit daemon --port 4888'],
64
+ ],
65
+ })
66
+
67
+ port = Option.String('--port', { required: false, description: 'daemon port (default 4798, or SHIPKIT_DAEMON_PORT)' })
68
+
69
+ async execute(): Promise<number> {
70
+ const port = portOf(this, this.port)
71
+ if (port === null) return 1
72
+ return runDaemon(process.cwd(), io(this), { port })
73
+ }
74
+ }
75
+
76
+ export class WebCommand extends Command {
77
+ static override paths = [['web']]
78
+
79
+ static override usage = Command.Usage({
80
+ category: 'Serve',
81
+ description: 'the home\'s page: one server on the home port that serves the page and proxies every api request to the daemon, so a browser only sees this origin. It loads no repo',
82
+ examples: [
83
+ ['the page alone', 'shipkit web'],
84
+ ['on another port', 'shipkit web --port 4889'],
85
+ ],
86
+ })
87
+
88
+ port = Option.String('--port', { required: false, description: 'home port (default 4799, or SHIPKIT_HOME_PORT)' })
89
+
90
+ async execute(): Promise<number> {
91
+ const port = portOf(this, this.port)
92
+ if (port === null) return 1
93
+ return runWeb(io(this), { port })
94
+ }
95
+ }
96
+
97
+ export class PmInstallCommand extends Command {
98
+ static override paths = [['pm', 'install'], ['home', 'install']]
99
+
100
+ static override usage = Command.Usage({
101
+ category: 'Serve',
102
+ description: 'keep pm running, and through it the daemon and the web: a macOS LaunchAgent starts it now and at every login, and restarts it when it dies. Idempotent',
103
+ examples: [['install from the house repo', 'shipkit pm install']],
104
+ })
105
+
106
+ async execute(): Promise<number> {
107
+ markAutostart(true)
108
+ return installHome({ root: process.cwd(), cli: houseCli(process.cwd(), OWN_CLI), print: io(this).print })
109
+ }
110
+ }
111
+
112
+ export class PmUninstallCommand extends Command {
113
+ static override paths = [['pm', 'uninstall'], ['home', 'uninstall']]
114
+
115
+ static override usage = Command.Usage({
116
+ category: 'Serve',
117
+ description: 'stop pm, the daemon and the web, and remove the LaunchAgent. The resources and repo processes keep running. No later command starts pm on its own until `shipkit pm install`',
118
+ examples: [['remove the agent', 'shipkit pm uninstall']],
119
+ })
120
+
121
+ async execute(): Promise<number> {
122
+ // a machine that said no: no later command starts pm again on its own
123
+ markAutostart(false)
124
+ return uninstallHome({ print: io(this).print })
125
+ }
126
+ }
127
+
128
+ export class GuiCommand extends Command {
129
+ static override paths = [['gui']]
130
+
131
+ static override usage = Command.Usage({
132
+ category: 'Serve',
133
+ description: 'open the home in its own window, outside VS Code and the browser, starting pm first when the web does not answer (and installing its LaunchAgent the first time). Every call opens one more window',
134
+ examples: [
135
+ ['a window on the home', 'shipkit gui'],
136
+ ['a window on one repo', 'shipkit gui my-app'],
137
+ ['a window on the repo you stand in', 'shipkit gui .'],
138
+ ],
139
+ })
140
+
141
+ repo = Option.String({ required: false, name: 'repo' })
142
+
143
+ async execute(): Promise<number> {
144
+ const out = io(this)
145
+ if (!(await homeUp())) {
146
+ // pm runs the web: ensure pm, then wait for the web
147
+ try {
148
+ if (!homeInstalled()) {
149
+ const root = houseRootOf(process.cwd())
150
+ if (root == null) {
151
+ out.printErr(`FAIL: no pm runs, and ${process.cwd()} is not a house repo, so there is none to install it from. Run \`shipkit gui\` once from any repo whose CLAUDE.md includes the house wiki`)
152
+ return 1
153
+ }
154
+ const code = await installHome({ root, cli: houseCli(root, OWN_CLI), print: out.print })
155
+ if (code !== 0) return code
156
+ }
157
+ await startHome({ need: 'web' })
158
+ } catch (e) {
159
+ out.printErr(`FAIL: ${(e as Error).message}`)
160
+ return 1
161
+ }
162
+ }
163
+ // `.` = the repo you stand in, named the way the rail names it
164
+ const name = this.repo === '.' && houseIndex(homeRepo(process.cwd()), homedir()) != null ? basename(homeRepo(process.cwd())) : this.repo
165
+ const url = `${homeUrl()}${name != null && name !== '.' ? `${hubBase(name)}/` : '/'}`
166
+ const cmd = openWindow(url, { version: LIB_VERSION, warn: out.printErr })
167
+ out.print(`ok: a home window on ${url}${cmd[0] === 'open' ? ' (the shipkit app)' : ''}`)
168
+ return 0
169
+ }
170
+ }
@@ -8,7 +8,8 @@
8
8
  import { createHash } from 'crypto'
9
9
  import { Command, Option } from 'clipanion'
10
10
  import { loadRepoConfig } from '../repo-config.ts'
11
- import { envTier, FULL_TESTS_ENV, gateChecks } from '../ledger/gate-checks.ts'
11
+ import { envTier, FULL_TESTS_ENV, gateInventory } from '../ledger/gate-checks.ts'
12
+ import { planGap, plannable } from '../plan/repo-source.ts'
12
13
  import { KeyLayer } from '../ledger/git-key.ts'
13
14
  import { Ledger } from '../ledger/ledger.ts'
14
15
  import { gateBinary } from '../ledger/toolchain-identity.ts'
@@ -42,14 +43,22 @@ export class KeysCommand extends Command {
42
43
  // every task's env, which is in the key, so a surface that renders keys
43
44
  // without it renders a tier nobody asked for
44
45
  const tier = this.full ? 'full' : envTier()
45
- const decls = await gateChecks(root, c, { tier })
46
+ const rows = await gateInventory(root, c, { tier })
47
+ const decls = rows.map((r) => r.decl)
48
+ // the plan's side of the burn-down: a check nothing keys and that
49
+ // declares no cadence is re-asked on a guess. A built-in there is a
50
+ // toolkit bug, a repo check rides the default range until it declares
51
+ const planGaps = rows.filter(plannable).flatMap((r) => {
52
+ const gap = planGap(r)
53
+ return gap == null ? [] : [{ id: r.decl.id, from: r.from, gap }]
54
+ })
46
55
  const layer = KeyLayer.open(root, decls, { cliEntry: gateBinary() })
47
56
  const keys = layer.keys()
48
57
  const out = (l: string): void => { this.context.stdout.write(`${l}\n`) }
49
58
 
50
59
  if (this.json) {
51
60
  const drift = this.rescan ? layer.rescan() : null
52
- out(JSON.stringify({ tier, checks: keys, burnDown: layer.registration.burnDown, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
61
+ out(JSON.stringify({ tier, checks: keys, burnDown: layer.registration.burnDown, planGaps, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
53
62
  return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
54
63
  }
55
64
 
@@ -115,6 +124,11 @@ export class KeysCommand extends Command {
115
124
  const decided = new Set(layer.decls.filter((d) => d.never != null && d.never !== '').map((d) => d.id))
116
125
  const shown = this.burnDown ? keys.filter((k) => k.population === 'none' && !decided.has(k.id)) : keys
117
126
  for (const k of shown) out(`${k.population.padEnd(7)} ${k.id.padEnd(28)} ${k.key ?? `— ${k.reason ?? ''}`}`)
127
+ if (planGaps.length > 0) out('')
128
+ for (const g of planGaps)
129
+ out(g.from === 'repo'
130
+ ? `WARN ${g.id}: ${g.gap}, so the plan runs it every 20h..28h. Declare inputs, a cacheKey, ttl or every`
131
+ : `FAIL: built-in ${g.id}: ${g.gap}. A toolkit bug: the plan waits for a save until it declares one`)
118
132
 
119
133
  const content = keys.filter((k) => k.population === 'content').length
120
134
  const reads = keys.filter((k) => k.population === 'reads').length
@@ -0,0 +1,98 @@
1
+ // shipkit overview: the overview page in the console, top to bottom as the
2
+ // page: the monitor widgets as the daemon last rendered them, their html read
3
+ // as text by widget-text.ts, then the processes through
4
+ // globalProcessesAnswer (the rows `shipkit gp` prints: pm, the daemon and the
5
+ // web first, then the global processes). Nothing renders a widget here: the
6
+ // daemon holds the render, and while it is down the monitor is unknown.
7
+ import { Command, Option } from 'clipanion'
8
+ import { daemonUrl } from '../home/home-paths.ts'
9
+ import type { WidgetsAnswer } from '../home/home-routes.ts'
10
+ import type { ChildView, PmAnswer } from '../home/pm.ts'
11
+ import { splitWidgetHead, widgetText } from '../home/widget-text.ts'
12
+ import { askDaemon, askPm, globalProcessesAnswer, localProcessDeps, renderGlobalProcesses, type GlobalProcessesAnswer, type GlobalProcessesDeps } from './cmd_GlobalProcesses.ts'
13
+
14
+ export type OverviewAnswer = {
15
+ now: number
16
+ /** pm as it answered: its own pid and version, its children; null when it did not */
17
+ pm: { pid: number; since: number | null; version: string; children: ChildView[] } | null
18
+ /** the daemon's identity, asked of the daemon itself; null when it does not answer */
19
+ daemon: { version: string } | null
20
+ /** the global processes, exactly as `shipkit global-processes --json` */
21
+ processes: GlobalProcessesAnswer
22
+ /** the daemon's widgets route payload; null when the daemon does not answer */
23
+ widgets: WidgetsAnswer | null
24
+ }
25
+
26
+ export type OverviewDeps = Pick<GlobalProcessesDeps, 'declared' | 'order'> & {
27
+ pm: () => Promise<PmAnswer | null>
28
+ daemon: () => Promise<{ version: string } | null>
29
+ widgets: () => Promise<WidgetsAnswer | null>
30
+ now: number
31
+ }
32
+
33
+ async function getJson<T>(url: string): Promise<T | null> {
34
+ try {
35
+ const r = await fetch(url, { signal: AbortSignal.timeout(3_000) })
36
+ return r.ok ? ((await r.json()) as T) : null
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ /** the daemon's widgets route payload, or null */
43
+ export const askWidgets = (url = daemonUrl()): Promise<WidgetsAnswer | null> => getJson<WidgetsAnswer>(`${url}/api/home/widgets.json`)
44
+
45
+ export async function overviewAnswer(d: OverviewDeps): Promise<OverviewAnswer> {
46
+ const [pm, daemon, widgets] = await Promise.all([d.pm(), d.daemon(), d.widgets()])
47
+ const processes = await globalProcessesAnswer({ pm: async () => pm, daemon: async () => daemon, declared: d.declared, order: d.order, now: d.now })
48
+ return { now: d.now, pm: pm == null ? null : { pid: pm.pid, since: pm.since ?? null, version: pm.version, children: pm.children }, daemon, processes, widgets }
49
+ }
50
+
51
+ const indent = (lines: string[], by = ' '): string[] => lines.map((l) => by + l)
52
+
53
+ /** the page as text: the monitor, then the processes (shipkit's own first),
54
+ * each section saying what is unknown and why when pm or the daemon does not
55
+ * answer */
56
+ export function renderOverview(a: OverviewAnswer): string[] {
57
+ const out: string[] = ['monitor']
58
+ if (a.widgets == null) out.push(` the daemon does not answer on ${daemonUrl()}, and it renders the widgets: the monitor is unknown`)
59
+ else {
60
+ if (a.widgets.widgets.length === 0 && a.widgets.errors.length === 0) out.push(' no widget declared: a repo declares one in a <name>.shipkit.ts file, as export default widget({...})')
61
+ for (const x of a.widgets.widgets) {
62
+ const { head, body } = splitWidgetHead(x.html ?? '')
63
+ out.push(` ${x.title}${head == null ? '' : ` · ${widgetText(head).join(' ')}`}`)
64
+ if (x.error != null) out.push(` WARN ${x.error}`)
65
+ if (x.html == null) out.push(' not rendered yet')
66
+ else out.push(...indent(widgetText(body), ' '))
67
+ }
68
+ for (const e of a.widgets.errors) out.push(` WARN ${e}`)
69
+ }
70
+ out.push('processes', ...indent(renderGlobalProcesses(a.processes)))
71
+ if (a.daemon == null) out.push(` the daemon does not answer on ${daemonUrl()}`)
72
+ return out
73
+ }
74
+
75
+ export class OverviewCommand extends Command {
76
+ static override paths = [['overview']]
77
+
78
+ static override usage = Command.Usage({
79
+ category: 'Home',
80
+ description: 'the overview page in the console: every monitor widget as text, from the html the daemon last rendered, then the processes as `shipkit gp` prints them (pm, the daemon and the web with pid, uptime and version, then every global process). When pm or the daemon does not answer, it says which and prints what is still known. Always exits 0',
81
+ examples: [
82
+ ['the overview', 'shipkit overview'],
83
+ ['the answers the page reads, as json', 'shipkit overview --json'],
84
+ ],
85
+ })
86
+
87
+ json = Option.Boolean('--json', false, { description: 'the answers as json: { now, pm, daemon, processes, widgets }, processes as `shipkit gp --json`, widgets the daemon route\'s payload' })
88
+
89
+ async execute(): Promise<number> {
90
+ const a = await overviewAnswer({ ...localProcessDeps(process.cwd()), pm: () => askPm(), daemon: () => askDaemon(), widgets: () => askWidgets(), now: Date.now() })
91
+ if (this.json) {
92
+ this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
93
+ return 0
94
+ }
95
+ for (const l of renderOverview(a)) this.context.stdout.write(`${l}\n`)
96
+ return 0
97
+ }
98
+ }