rvlib-shipkit 1.1.0 → 1.2.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 (137) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/package.json +3 -1
  3. package/src/check.ts +24 -0
  4. package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
  5. package/src/checks/SK023-stores-under-cap.check.ts +49 -0
  6. package/src/checks/SK024-identity-colors.check.ts +49 -0
  7. package/src/cli/cmd_Check.ts +26 -1
  8. package/src/cli/cmd_Ci.ts +18 -5
  9. package/src/cli/cmd_Home.ts +170 -0
  10. package/src/cli.ts +22 -2
  11. package/src/comfy/comfy-panel.ts +1 -16
  12. package/src/core/attach.ts +14 -9
  13. package/src/core/check-file-names.ts +8 -0
  14. package/src/core/check-runner.ts +40 -18
  15. package/src/core/discover-checks.ts +36 -18
  16. package/src/core/identity-color.ts +203 -0
  17. package/src/core/identity.ts +91 -0
  18. package/src/core/job-follow.ts +102 -0
  19. package/src/core/job-run.ts +27 -0
  20. package/src/core/jobs.ts +87 -27
  21. package/src/core/legacy-shapes.ts +1 -14
  22. package/src/core/machine-db.ts +83 -0
  23. package/src/core/one-lib.ts +85 -0
  24. package/src/core/port-holders.ts +43 -0
  25. package/src/core/retention.ts +213 -0
  26. package/src/core/run-check.ts +6 -2
  27. package/src/core/run-ci.ts +3 -0
  28. package/src/core/slots.ts +5 -0
  29. package/src/core/statement-end.ts +17 -0
  30. package/src/home/attach.ts +95 -0
  31. package/src/home/autostart.ts +108 -0
  32. package/src/home/global-units.ts +86 -0
  33. package/src/home/gui-app/ShipkitGui.swift +159 -0
  34. package/src/home/gui-app.ts +99 -0
  35. package/src/home/handoff.ts +51 -0
  36. package/src/home/home-launchd.ts +104 -0
  37. package/src/home/home-paths.ts +88 -0
  38. package/src/home/home-routes.ts +208 -0
  39. package/src/home/home-start.ts +105 -0
  40. package/src/home/home-state.ts +43 -0
  41. package/src/home/home-words.ts +12 -0
  42. package/src/home/hubs.ts +286 -0
  43. package/src/home/lib-watch.ts +139 -0
  44. package/src/home/machine-stores.ts +115 -0
  45. package/src/home/open-app.ts +16 -0
  46. package/src/home/pm-config.ts +37 -0
  47. package/src/home/pm.ts +141 -0
  48. package/src/home/resources.ts +338 -0
  49. package/src/home/run-daemon.ts +137 -0
  50. package/src/home/run-pm.ts +149 -0
  51. package/src/home/run-unit-action.ts +25 -0
  52. package/src/home/run-web.ts +37 -0
  53. package/src/home/serve-daemon.ts +31 -0
  54. package/src/home/serve-web.ts +172 -0
  55. package/src/home/supervise.ts +346 -0
  56. package/src/home/via-hub.ts +142 -0
  57. package/src/home/widgets.ts +102 -0
  58. package/src/ledger/check-stats.ts +19 -45
  59. package/src/ledger/db.ts +69 -17
  60. package/src/ledger/gate-checks.ts +5 -0
  61. package/src/ledger/git-scan.ts +31 -7
  62. package/src/ledger/ledger.ts +40 -14
  63. package/src/ledger/run-log.ts +58 -23
  64. package/src/ledger/store.ts +228 -103
  65. package/src/ledger/world-cache.ts +15 -13
  66. package/src/plan/cadence.ts +237 -0
  67. package/src/plan/planner.ts +303 -0
  68. package/src/plan/repo-source.ts +100 -0
  69. package/src/problems/problem-line.ts +1 -1
  70. package/src/repo-config.ts +51 -2
  71. package/src/serve/hub-app/check-view.tsx +9 -9
  72. package/src/serve/hub-app/check-words.ts +1 -1
  73. package/src/serve/hub-app/checks-svc.ts +12 -8
  74. package/src/serve/hub-app/col-card.tsx +4 -4
  75. package/src/serve/hub-app/cols-svc.ts +92 -13
  76. package/src/serve/hub-app/comfy-svc.ts +12 -6
  77. package/src/serve/hub-app/comfy-view.tsx +3 -3
  78. package/src/serve/hub-app/floating.tsx +49 -0
  79. package/src/serve/hub-app/geometry-view.tsx +4 -4
  80. package/src/serve/hub-app/hash-route.ts +1 -1
  81. package/src/serve/hub-app/header-bar.tsx +44 -12
  82. package/src/serve/hub-app/health-svc.ts +3 -3
  83. package/src/serve/hub-app/help.tsx +10 -7
  84. package/src/serve/hub-app/home-st.ts +241 -0
  85. package/src/serve/hub-app/home-strip.css +70 -0
  86. package/src/serve/hub-app/home-strip.tsx +245 -0
  87. package/src/serve/hub-app/home.html +17 -0
  88. package/src/serve/hub-app/hub-app.tsx +201 -93
  89. package/src/serve/hub-app/hub-types.ts +0 -3
  90. package/src/serve/hub-app/hub.css +43 -23
  91. package/src/serve/hub-app/icons.tsx +14 -0
  92. package/src/serve/hub-app/index.html +3 -1
  93. package/src/serve/hub-app/keys.ts +25 -0
  94. package/src/serve/hub-app/layout-st.ts +55 -0
  95. package/src/serve/hub-app/live-svc.ts +12 -9
  96. package/src/serve/hub-app/machine-st.ts +270 -0
  97. package/src/serve/hub-app/media-svc.ts +63 -44
  98. package/src/serve/hub-app/media-view.tsx +20 -20
  99. package/src/serve/hub-app/next-run.ts +24 -30
  100. package/src/serve/hub-app/palette-st.ts +88 -0
  101. package/src/serve/hub-app/planned-st.ts +171 -0
  102. package/src/serve/hub-app/planned-view.tsx +109 -0
  103. package/src/serve/hub-app/planned.css +39 -0
  104. package/src/serve/hub-app/processes-svc.ts +63 -0
  105. package/src/serve/hub-app/project-list.css +97 -0
  106. package/src/serve/hub-app/project-list.tsx +167 -0
  107. package/src/serve/hub-app/projects-svc.ts +139 -17
  108. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  109. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  110. package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
  111. package/src/serve/hub-app/runs-tabs.ts +1 -1
  112. package/src/serve/hub-app/screen-cache.ts +74 -0
  113. package/src/serve/hub-app/test-view.tsx +4 -4
  114. package/src/serve/hub-app/tests-svc.ts +3 -3
  115. package/src/serve/hub-app/tests-view.tsx +10 -10
  116. package/src/serve/hub-app/timeline-chart.tsx +24 -12
  117. package/src/serve/hub-app/timeline-svc.ts +5 -4
  118. package/src/serve/hub-app/timeline-view.tsx +13 -13
  119. package/src/serve/hub-app/tree-row.tsx +2 -2
  120. package/src/serve/hub-app/tree-svc.ts +2 -2
  121. package/src/serve/hub-app/wikis-view.tsx +10 -6
  122. package/src/serve/hub-runtime.ts +542 -0
  123. package/src/serve/project-gutter-route.ts +21 -0
  124. package/src/serve/project-gutter.ts +3 -2
  125. package/src/serve/repo-processes.ts +89 -0
  126. package/src/serve/run-serve.ts +110 -402
  127. package/src/serve/serve-hub.ts +142 -44
  128. package/src/serve/stale-restart.ts +5 -0
  129. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
  130. package/src/serve/warm-cell.ts +95 -0
  131. package/src/shipkit-files.ts +114 -0
  132. package/src/shipkit.ts +2 -1
  133. package/src/suite/kept-timings.ts +1 -1
  134. package/src/wiki/graph-positions.ts +1 -1
  135. package/src/serve/health-schedule.ts +0 -102
  136. package/src/serve/hub-app/project-gutter.css +0 -65
  137. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -0,0 +1,213 @@
1
+ // NOTHING GROWS UNBOUND: every store shipkit writes declares a cap here (an
2
+ // age, a count, a size), and one sweep enforces all of them. The daemon runs
3
+ // it on boot and hourly for every repo it loads and for the machine; rule
4
+ // SK023 fails when a store is past its cap, so a sweep that stopped running
5
+ // is loud. Each store's sweep lives beside the code that writes it, so a gate
6
+ // on a machine with no daemon still keeps its own stores in bounds.
7
+ //
8
+ // The one constraint a reader breaks first: a sweep never removes what a live
9
+ // process may still use. Every sweep below checks a pid or an age first.
10
+ import { existsSync, readdirSync, statSync } from 'fs'
11
+ import { join } from 'path'
12
+ import { INFLIGHT_KEEP_MS, sweepSlots } from './attach.ts'
13
+ import { RUN_DIR_KEEP_MS, RUN_DIR_MAX, runEntries, sweepRunDirs } from './check-runner.ts'
14
+ import { gitRoot } from './git-root.ts'
15
+ import { JOBS, JOBS_KEEP_MS, JOBS_MAX, sweepJobs } from './jobs.ts'
16
+ import { CI_LOG_DIR } from './layout.ts'
17
+ import { compactDb, dbPath, shipkitRoot } from '../ledger/db.ts'
18
+ import { SCAN_INDEX_KEEP_MS, sweepScanIndexes } from '../ledger/git-scan.ts'
19
+ import { capLog, LOG_MAX_BYTES } from '../home/supervise.ts'
20
+ import { processDir } from '../serve/repo-processes.ts'
21
+ import { KEEP_MS as RECORD_KEEP_MS, MAX_ROWS, recordUse, sweepRuns } from '../ledger/run-log.ts'
22
+ import { answersDb, answersUse, importLegacyStore, KEEP_MS as ANSWERS_KEEP_MS, legacyStoreDir, MAX_ANSWERS, sweepAnswers } from '../ledger/store.ts'
23
+
24
+ export type Cap = {
25
+ /** the oldest entry may be this old */
26
+ maxAgeMs?: number
27
+ /** at most this many entries */
28
+ maxCount?: number
29
+ /** every file at most this big */
30
+ maxFileBytes?: number
31
+ /** all of it at most this big */
32
+ maxBytes?: number
33
+ }
34
+
35
+ export type Use = { count: number; oldestAt: number | null; bytes: number; largestFile: number }
36
+
37
+ export type Store = {
38
+ id: string
39
+ /** where it lives, as a person would look for it */
40
+ where: string
41
+ cap: Cap
42
+ /** what it holds now, null when it cannot be read */
43
+ measure(): Use | null
44
+ /** bring it back under its cap */
45
+ sweep(now: number): void | Promise<void>
46
+ }
47
+
48
+ /** how far past its cap a store may drift between two sweeps before SK023
49
+ * calls it over: the daemon sweeps hourly, and gates write in between */
50
+ export const SLACK = { factor: 2, ageMs: 2 * 60 * 60 * 1000 }
51
+
52
+ /** why a store is past its cap (with the drift a sweep interval allows), or
53
+ * null while it is inside */
54
+ export function overCap(use: Use, cap: Cap, now: number, slack = SLACK): string | null {
55
+ if (cap.maxCount != null && use.count > Math.max(cap.maxCount * slack.factor, cap.maxCount)) return `${use.count} entries, cap ${cap.maxCount}`
56
+ if (cap.maxAgeMs != null && use.oldestAt != null && now - use.oldestAt > cap.maxAgeMs + slack.ageMs) return `oldest ${fmtAge(now - use.oldestAt)} old, cap ${fmtAge(cap.maxAgeMs)}`
57
+ if (cap.maxFileBytes != null && use.largestFile > cap.maxFileBytes * slack.factor) return `a file of ${fmtBytes(use.largestFile)}, cap ${fmtBytes(cap.maxFileBytes)}`
58
+ if (cap.maxBytes != null && use.bytes > cap.maxBytes * slack.factor) return `${fmtBytes(use.bytes)}, cap ${fmtBytes(cap.maxBytes)}`
59
+ return null
60
+ }
61
+
62
+ export const fmtBytes = (n: number): string => (n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`)
63
+ export const fmtAge = (ms: number): string => (ms >= 86_400_000 ? `${Math.round(ms / 86_400_000)}d` : ms >= 3_600_000 ? `${Math.round(ms / 3_600_000)}h` : `${Math.round(ms / 60_000)}m`)
64
+
65
+ /** every file under a dir (not following links), skipping dirs `skip` names */
66
+ export function walkUse(dir: string, o: { match?: (name: string) => boolean; skip?: (name: string) => boolean } = {}): Use | null {
67
+ if (!existsSync(dir)) return { count: 0, oldestAt: null, bytes: 0, largestFile: 0 }
68
+ const use: Use = { count: 0, oldestAt: null, bytes: 0, largestFile: 0 }
69
+ const walk = (d: string): void => {
70
+ let list
71
+ try { list = readdirSync(d, { withFileTypes: true }) } catch { return }
72
+ for (const e of list) {
73
+ const p = join(d, e.name)
74
+ if (e.isDirectory()) { if (o.skip?.(e.name) !== true) walk(p); continue }
75
+ if (!e.isFile() || (o.match != null && !o.match(e.name))) continue
76
+ try {
77
+ const s = statSync(p)
78
+ use.count++
79
+ use.bytes += s.size
80
+ use.largestFile = Math.max(use.largestFile, s.size)
81
+ use.oldestAt = use.oldestAt == null ? s.mtimeMs : Math.min(use.oldestAt, s.mtimeMs)
82
+ } catch { /* raced: gone */ }
83
+ }
84
+ }
85
+ walk(dir)
86
+ return use
87
+ }
88
+
89
+ const counted = (count: number, oldestAt: number | null): Use => ({ count, oldestAt, bytes: 0, largestFile: 0 })
90
+
91
+ /** the stores one repo holds */
92
+ export function repoStores(root: string): Store[] {
93
+ const sk = shipkitRoot(root)
94
+ const logs = join(root, CI_LOG_DIR)
95
+ const jobs = join(root, JOBS)
96
+ const gitDir = gitRoot(root)?.gitDir
97
+ const stores: Store[] = [
98
+ {
99
+ id: 'answers',
100
+ where: `${answersDb(root)} (answers)`,
101
+ cap: { maxAgeMs: ANSWERS_KEEP_MS, maxCount: MAX_ANSWERS },
102
+ measure: () => {
103
+ if (!existsSync(answersDb(root))) return counted(0, null)
104
+ const u = answersUse(answersDb(root))
105
+ return u == null ? null : counted(u.count, u.oldestUsed)
106
+ },
107
+ sweep: (now) => { sweepAnswers(answersDb(root), now) },
108
+ },
109
+ {
110
+ // the json store this table replaced: read once, then gone
111
+ id: 'json answers',
112
+ where: legacyStoreDir(root),
113
+ cap: { maxCount: 0 },
114
+ measure: () => walkUse(legacyStoreDir(root)),
115
+ sweep: async (now) => { await importLegacyStore(root, now) },
116
+ },
117
+ {
118
+ id: 'run record',
119
+ where: `${dbPath(root)} (runs, invocations, suites)`,
120
+ cap: { maxAgeMs: RECORD_KEEP_MS, maxCount: MAX_ROWS },
121
+ measure: () => {
122
+ if (!existsSync(dbPath(root))) return counted(0, null)
123
+ const u = recordUse(dbPath(root))
124
+ return u == null ? null : counted(u.count, u.oldest)
125
+ },
126
+ // the last db store: the answers above and these rows are one
127
+ // file, which the deletes leave full of free pages
128
+ sweep: (now) => {
129
+ sweepRuns(dbPath(root), now)
130
+ compactDb(dbPath(root))
131
+ },
132
+ },
133
+ {
134
+ id: 'inflight',
135
+ where: join(sk, 'inflight'),
136
+ cap: { maxAgeMs: INFLIGHT_KEEP_MS },
137
+ measure: () => walkUse(join(sk, 'inflight')),
138
+ sweep: (now) => sweepSlots(join(sk, 'inflight'), now),
139
+ },
140
+ {
141
+ id: 'run logs',
142
+ where: logs,
143
+ cap: { maxAgeMs: RUN_DIR_KEEP_MS, maxCount: RUN_DIR_MAX },
144
+ measure: () => {
145
+ const all = runEntries(logs)
146
+ return counted(all.length, all.length === 0 ? null : Math.min(...all.map((e) => e.started)))
147
+ },
148
+ sweep: (now) => sweepRunDirs(logs, now),
149
+ },
150
+ {
151
+ id: 'jobs',
152
+ where: jobs,
153
+ cap: { maxAgeMs: JOBS_KEEP_MS, maxCount: JOBS_MAX },
154
+ measure: () => {
155
+ let names: string[] = []
156
+ try { names = readdirSync(jobs) } catch { return counted(0, null) }
157
+ let oldest: number | null = null
158
+ for (const n of names) try { const m = statSync(join(jobs, n)).mtimeMs; oldest = oldest == null ? m : Math.min(oldest, m) } catch { /* raced */ }
159
+ return counted(names.length, oldest)
160
+ },
161
+ sweep: (now) => sweepJobs(root, now),
162
+ },
163
+ {
164
+ // the dev servers the hub runs for the repo (serve.processes)
165
+ id: 'process logs',
166
+ where: `${processDir(root)}/*.log`,
167
+ cap: { maxFileBytes: LOG_MAX_BYTES },
168
+ measure: () => walkUse(processDir(root), { match: (n) => n.endsWith('.log') }),
169
+ sweep: () => {
170
+ let names: string[] = []
171
+ try { names = readdirSync(processDir(root)) } catch { return }
172
+ for (const n of names) if (n.endsWith('.log')) capLog(join(processDir(root), n))
173
+ },
174
+ },
175
+ ]
176
+ if (gitDir != null) {
177
+ const dir = join(gitDir, 'shipkit')
178
+ stores.push({
179
+ id: 'scan indexes',
180
+ where: `${dir}/scan-<pid>.idx`,
181
+ // one per process scanning at once, and a machine runs a handful
182
+ cap: { maxAgeMs: SCAN_INDEX_KEEP_MS, maxCount: 16 },
183
+ measure: () => walkUse(dir, { match: (n) => /^scan-\d+\.idx(\.tmp)?$/.test(n), skip: () => true }),
184
+ sweep: (now) => sweepScanIndexes(dir, now),
185
+ })
186
+ }
187
+ return stores
188
+ }
189
+
190
+ export type Measured = { store: Store; use: Use | null; over: string | null }
191
+
192
+ export function measureStores(stores: Store[], now = Date.now()): Measured[] {
193
+ return stores.map((store) => {
194
+ const use = store.measure()
195
+ return { store, use, over: use == null ? null : overCap(use, store.cap, now) }
196
+ })
197
+ }
198
+
199
+ /** sweep every store, one after the other, yielding between them so a
200
+ * daemon keeps serving. A sweep that throws is reported, never fatal */
201
+ export async function sweepStores(stores: Store[], now = Date.now()): Promise<{ id: string; error: string | null }[]> {
202
+ const out: { id: string; error: string | null }[] = []
203
+ for (const s of stores) {
204
+ try {
205
+ await s.sweep(now)
206
+ out.push({ id: s.id, error: null })
207
+ } catch (e) {
208
+ out.push({ id: s.id, error: (e as Error).message })
209
+ }
210
+ await Bun.sleep(0)
211
+ }
212
+ return out
213
+ }
@@ -12,7 +12,7 @@ import { join } from 'path'
12
12
  import { composeChecks, envTier, gateInventory, type GateCheck, type TestTier } from '../ledger/gate-checks.ts'
13
13
  import { Ledger } from '../ledger/ledger.ts'
14
14
  import { RunLog, type InvokedBy } from '../ledger/run-log.ts'
15
- import { storeRoot } from '../ledger/store.ts'
15
+ import { answersDb } from '../ledger/store.ts'
16
16
  import { gateBinary } from '../ledger/toolchain-identity.ts'
17
17
  import { runChecks, selectChecks, type Audience, type CheckEvent, type CheckResult, type RunnableCheck } from './check-runner.ts'
18
18
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
@@ -55,6 +55,9 @@ export type CheckOpts = {
55
55
  onlyBlocking?: boolean
56
56
  /** which test tier the caller is asking for */
57
57
  tier?: TestTier
58
+ /** colour the wire lines. Default: this process's stdout is a terminal. A
59
+ * hub running a terminal's gate passes the terminal's answer */
60
+ color?: boolean
58
61
  /** which question: the gate's set (`shipkit ci`) or the health pass's */
59
62
  audience?: Audience
60
63
  /** the caller narrowed the run to these ids (`--only`) */
@@ -187,11 +190,12 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
187
190
  root,
188
191
  config: c,
189
192
  logDir: c.logDir ?? join(root, CI_LOG_DIR),
190
- cache: { storeDir: storeRoot(root), ns: c.name, now: Date.now },
193
+ cache: { db: answersDb(root), ns: c.name, now: Date.now },
191
194
  ledger,
192
195
  log,
193
196
  childEnv: { [SHARE_ENV]: sharePath, ...(o.fresh === true ? { SHIPKIT_FRESH: '1' } : {}) },
194
197
  bail: o.bail,
198
+ color: o.color,
195
199
  print: sink,
196
200
  onCheck: (e) => { collected.push(...e.lines); o.onCheck?.(e) },
197
201
  })
@@ -53,6 +53,8 @@ export type CiOpts = {
53
53
  * own panel, and running them twice per refresh proves the same thing
54
54
  * twice. */
55
55
  buildOnly?: boolean
56
+ /** colour the wire lines (run-check.ts CheckOpts.color) */
57
+ color?: boolean
56
58
  /** line sink — commands pass their context stream; defaults to console */
57
59
  print?: (line: string) => void
58
60
  /** per-CHECK completion tap, handed straight to the runner. The hub passes
@@ -187,6 +189,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
187
189
  buildOnly: o.buildOnly,
188
190
  bail: o.bail,
189
191
  fresh: o.fresh,
192
+ color: o.color,
190
193
  tier,
191
194
  }
192
195
  try {
package/src/core/slots.ts CHANGED
@@ -30,6 +30,11 @@ export const poolDir = (): string => {
30
30
  return env != null && env !== '' ? env : join(homedir(), '.cache', 'shipkit', 'slots')
31
31
  }
32
32
 
33
+ /** the TURN the background gates take, one at a time on the whole machine:
34
+ * every batch the plan runs, for every repo. A gate someone asked for
35
+ * (a terminal, a button) never takes it, so it never waits behind one */
36
+ export const autoGatePool = (): SlotPool => ({ dir: `${poolDir()}-auto-gate`, size: 1 })
37
+
33
38
  /** this process's pool, or null when it runs inside a check that holds one */
34
39
  export function machinePool(): SlotPool | null {
35
40
  return process.env[SLOT_HELD_ENV] === '1' ? null : { dir: poolDir(), size: parallelCap() }
@@ -0,0 +1,17 @@
1
+ // where a statement of a config file ends, read as text. Pure, so the page
2
+ // can import it too.
3
+
4
+ /** a statement that starts on `i`, ending where its brackets balance and the
5
+ * line is not obviously continued. Deliberately simple: anything it cannot
6
+ * read lands as a hand pass rather than as a half rewritten config. */
7
+ export function statementEnd(lines: string[], i: number): number {
8
+ let depth = 0
9
+ for (let j = i; j < lines.length; j++) {
10
+ for (const ch of lines[j]!) {
11
+ if (ch === '(' || ch === '[' || ch === '{') depth++
12
+ else if (ch === ')' || ch === ']' || ch === '}') depth--
13
+ }
14
+ if (depth <= 0) return j
15
+ }
16
+ return lines.length - 1
17
+ }
@@ -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,86 @@
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
+ app: boolean
25
+ actions: ActionDecl[]
26
+ /** the repo that declares it, and the file, repo-relative */
27
+ repo: string
28
+ root: string
29
+ file: string
30
+ }
31
+
32
+ export type RepoRef = { name: string; path: string }
33
+
34
+ export type LoadedWidget = { repo: string; file: string; def: WidgetUnit }
35
+
36
+ export type GlobalUnits = {
37
+ processes: GlobalProcessDecl[]
38
+ widgets: LoadedWidget[]
39
+ /** a file that would not load, or a process id declared twice */
40
+ errors: string[]
41
+ }
42
+
43
+ export function toDecl(u: GlobalProcessUnit, repo: RepoRef, file: string): GlobalProcessDecl {
44
+ return {
45
+ id: u.id,
46
+ label: u.label ?? u.id,
47
+ description: u.description ?? null,
48
+ where: u.where ?? null,
49
+ cmd: u.cmd,
50
+ down: u.down ?? null,
51
+ cwd: resolve(repo.path, u.cwd ?? '.'),
52
+ url: u.url ?? null,
53
+ probe: u.probe ?? u.url ?? null,
54
+ app: u.app === true,
55
+ actions: Object.entries(u.actions ?? {}).map(([id, a]) => ({ id, label: a.label, cmd: a.cmd ?? null })),
56
+ repo: repo.name,
57
+ root: repo.path,
58
+ file: relative(repo.path, file).replace(/\\/g, '/'),
59
+ }
60
+ }
61
+
62
+ /** every global process and widget the repos declare. A process id is the
63
+ * machine's: a second declaration of one is an error, the first one wins */
64
+ export async function scanGlobalUnits(repos: RepoRef[], o: { files?: (root: string) => string[] } = {}): Promise<GlobalUnits> {
65
+ const out: GlobalUnits = { processes: [], widgets: [], errors: [] }
66
+ for (const repo of repos) {
67
+ for (const f of (o.files ?? repoUnitFiles)(repo.path)) {
68
+ const rel = relative(repo.path, f).replace(/\\/g, '/')
69
+ let d: unknown
70
+ try {
71
+ d = await importFresh(f)
72
+ } catch (e) {
73
+ out.errors.push(`${repo.name}/${rel}: ${(e as Error).message}`)
74
+ continue
75
+ }
76
+ const kind = unitKind(d)
77
+ if (kind === 'globalProcess') {
78
+ const u = d as GlobalProcessUnit
79
+ const first = out.processes.find((p) => p.id === u.id)
80
+ if (first != null) out.errors.push(`${repo.name}/${rel}: global process '${u.id}' is already declared by ${first.repo}/${first.file}`)
81
+ else out.processes.push(toDecl(u, repo, f))
82
+ } else if (kind === 'widget') out.widgets.push({ repo: repo.name, file: rel, def: d as WidgetUnit })
83
+ }
84
+ }
85
+ return out
86
+ }