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,83 @@
1
+ // THE MACHINE DB: what shipkit remembers about this machine, whatever repo
2
+ // asks, in one sqlite file: pm's children and resources (their pid records),
3
+ // the repos flagged active, the rail order, the self update answer, the
4
+ // instance registry. One `state` table of named json values.
5
+ //
6
+ // The one constraint a reader breaks first: a failure reads as "nothing
7
+ // remembered", and every caller treats that as the safe side (no adoption,
8
+ // no replay, the pid rule). Slot LOCKS are not here: they are leases a dead
9
+ // pid must release at once, which a file does and a row does not.
10
+ import { Database } from 'bun:sqlite'
11
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
12
+ import { homedir } from 'os'
13
+ import { dirname, join } from 'path'
14
+ import { ensureWal } from '../ledger/db.ts'
15
+
16
+ export const MACHINE_DB_ENV = 'SHIPKIT_MACHINE_DB'
17
+
18
+ /** `~/.config/shipkit/shipkit.sqlite`; beside a moved home dir when one is
19
+ * set, so a smoke or a suite with its own home never touches the real one */
20
+ export function machineDbPath(): string {
21
+ const env = process.env[MACHINE_DB_ENV]
22
+ if (env != null && env !== '') return env
23
+ const home = process.env.SHIPKIT_HOME_DIR
24
+ if (home != null && home !== '') return join(home, 'machine.sqlite')
25
+ return join(homedir(), '.config', 'shipkit', 'shipkit.sqlite')
26
+ }
27
+
28
+ /** a machine write waits this long on another process's */
29
+ const BUSY_MS = 2_000
30
+
31
+ const SCHEMA = 'create table if not exists state (name text primary key, value text not null, at integer not null)'
32
+
33
+ function withMachine<R>(body: (db: Database) => R, path = machineDbPath()): R | null {
34
+ let db: Database | null = null
35
+ try {
36
+ mkdirSync(dirname(path), { recursive: true })
37
+ db = new Database(path, { create: true, readwrite: true })
38
+ db.exec(`pragma busy_timeout = ${BUSY_MS}`)
39
+ ensureWal(db, BUSY_MS)
40
+ db.exec(SCHEMA)
41
+ return body(db)
42
+ } catch {
43
+ return null
44
+ } finally {
45
+ try { db?.close() } catch { /* never opened */ }
46
+ }
47
+ }
48
+
49
+ export function machineGet<T>(name: string): T | null {
50
+ const row = withMachine((db) => db.query('select value from state where name = ?').get(name) as { value: string } | null)
51
+ if (row == null) return null
52
+ try { return JSON.parse(row.value) as T } catch { return null }
53
+ }
54
+
55
+ export function machineSet(name: string, value: unknown): boolean {
56
+ return withMachine((db) => {
57
+ db.query('insert or replace into state (name, value, at) values (?, ?, ?)').run(name, JSON.stringify(value), Date.now())
58
+ return true
59
+ }) ?? false
60
+ }
61
+
62
+ export function machineDelete(name: string): void {
63
+ withMachine((db) => db.query('delete from state where name = ?').run(name))
64
+ }
65
+
66
+ /** every value whose name starts with `prefix` */
67
+ export function machineList<T>(prefix: string): { name: string; value: T; at: number }[] {
68
+ const rows = withMachine((db) => db.query('select name, value, at from state where substr(name, 1, ?) = ?').all(prefix.length, prefix) as { name: string; value: string; at: number }[]) ?? []
69
+ return rows.flatMap((r) => {
70
+ try { return [{ name: r.name, value: JSON.parse(r.value) as T, at: r.at }] } catch { return [] }
71
+ })
72
+ }
73
+
74
+ /** the migration of one json file: its value becomes the row, then the file
75
+ * goes. Only when the row does not exist yet, so a file an older copy wrote
76
+ * after the move never overwrites a newer row */
77
+ export function importMachineFile<T>(name: string, file: string, parse: (text: string) => T = (t) => JSON.parse(t) as T): void {
78
+ if (!existsSync(file)) return
79
+ try {
80
+ if (machineGet(name) == null) machineSet(name, parse(readFileSync(file, 'utf8')))
81
+ rmSync(file, { force: true })
82
+ } catch { /* an unreadable file is left for a person to look at */ }
83
+ }
@@ -0,0 +1,85 @@
1
+ // one copy of the toolkit per process: a process that loads several repos
2
+ // (the home) makes every `rvlib-shipkit` import, from any repo config or check
3
+ // file, land on its OWN copy. A repo's install is never loaded, so there is one
4
+ // module instance per lib file: one mobx, one class identity, one version.
5
+ //
6
+ // the one constraint a reader breaks first: register it BEFORE the first repo
7
+ // config is imported. A module already loaded keeps the copy it resolved.
8
+ import { readFileSync, realpathSync } from 'fs'
9
+ import { extname, join } from 'path'
10
+ import { LIB_ROOT } from './lib-root.ts'
11
+
12
+ const NAME = 'rvlib-shipkit'
13
+
14
+ type Exports = Record<string, string>
15
+
16
+ const readExports = (root: string): Exports => {
17
+ try {
18
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { exports?: Exports }
19
+ return pkg.exports ?? { '.': './src/shipkit.ts' }
20
+ } catch {
21
+ return { '.': './src/shipkit.ts' }
22
+ }
23
+ }
24
+
25
+ /** the file a specifier names inside `root`, through its package exports
26
+ * (`.`, `./contracts`, `./src/*`), or null when it is not this package */
27
+ export function libTarget(spec: string, root: string = LIB_ROOT, exports: Exports = readExports(root)): string | null {
28
+ if (spec !== NAME && !spec.startsWith(`${NAME}/`)) return null
29
+ const sub = `.${spec.slice(NAME.length)}`
30
+ const exact = exports[sub]
31
+ if (exact != null) return join(root, exact)
32
+ for (const [k, v] of Object.entries(exports)) {
33
+ if (!k.endsWith('/*') || !sub.startsWith(k.slice(0, -1))) continue
34
+ return join(root, v.slice(0, -1) + sub.slice(k.length - 1))
35
+ }
36
+ return join(root, sub)
37
+ }
38
+
39
+ /** a file of ANOTHER copy of the package (a repo's node_modules, bun's
40
+ * install cache), as the path inside that copy, else null */
41
+ export function foreignLibPath(path: string, root: string = LIB_ROOT): string | null {
42
+ const m = /\/(?:node_modules\/rvlib-shipkit|install\/cache\/rvlib-shipkit@[^/]*)\/(.+)$/.exec(path)
43
+ if (m == null) return null
44
+ const own = real(root)
45
+ if (path.startsWith(`${root}/`) || real(path).startsWith(`${own}/`)) return null
46
+ return m[1]!
47
+ }
48
+
49
+ const real = (p: string): string => {
50
+ try {
51
+ return realpathSync(p)
52
+ } catch {
53
+ return p
54
+ }
55
+ }
56
+
57
+ const LOADERS: Record<string, 'ts' | 'tsx' | 'js' | 'jsx'> = { '.ts': 'ts', '.tsx': 'tsx', '.js': 'js', '.mjs': 'js', '.jsx': 'jsx' }
58
+
59
+ let installed = false
60
+
61
+ /** route every import of the package to this process's copy. Idempotent */
62
+ export function useOneLib(root: string = LIB_ROOT): void {
63
+ if (installed) return
64
+ installed = true
65
+ const exports = readExports(root)
66
+ Bun.plugin({
67
+ name: 'shipkit-one-lib',
68
+ setup(build) {
69
+ // a subpath is resolved here, before any other copy is looked at
70
+ build.onResolve({ filter: /^rvlib-shipkit\// }, (a) => {
71
+ const path = libTarget(a.path, root, exports)
72
+ return path == null ? undefined : { path }
73
+ })
74
+ // the bare name never reaches onResolve at runtime, so the file
75
+ // another copy resolved to is loaded as a re-export of ours
76
+ build.onLoad({ filter: /\/(node_modules\/rvlib-shipkit|install\/cache\/rvlib-shipkit@[^/]*)\// }, (a) => {
77
+ const loader = LOADERS[extname(a.path)] ?? 'ts'
78
+ const sub = foreignLibPath(a.path, root)
79
+ if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader }
80
+ const target = JSON.stringify(join(root, sub))
81
+ return { contents: `export * from ${target}\nimport * as own from ${target}\nexport default (own as { default?: unknown }).default\n`, loader: 'ts' }
82
+ })
83
+ },
84
+ })
85
+ }
@@ -0,0 +1,43 @@
1
+ // who holds a local tcp port, and the process group a pid runs in: what a
2
+ // takeover of a server started elsewhere needs to stop it. Unreadable (no
3
+ // lsof, no ps) means an empty answer, never a throw.
4
+ import { existsSync } from 'fs'
5
+
6
+ /** the pids LISTENING on a tcp port, on darwin and linux. `-sTCP:LISTEN`
7
+ * carries the whole answer: `-i tcp:<port>` alone also matches every client
8
+ * connected to it, this process included, and those must never be killed.
9
+ * lsof sits in /usr/sbin on darwin, which a gui-launched shell can miss */
10
+ export function portHolders(port: number): number[] {
11
+ const lsof = Bun.which('lsof') ?? ['/usr/sbin/lsof', '/usr/bin/lsof'].find((p) => existsSync(p))
12
+ if (lsof == null) return []
13
+ const r = Bun.spawnSync([lsof, '-ti', `tcp:${port}`, '-sTCP:LISTEN'], { stdout: 'pipe', stderr: 'ignore' })
14
+ const pids = r.stdout
15
+ .toString()
16
+ .split('\n')
17
+ .map((l) => Number(l.trim()))
18
+ .filter((n) => Number.isInteger(n) && n > 0)
19
+ return [...new Set(pids)]
20
+ }
21
+
22
+ /** the process group of a pid, null when ps cannot say */
23
+ export function groupOf(pid: number): number | null {
24
+ const ps = Bun.which('ps') ?? (existsSync('/bin/ps') ? '/bin/ps' : null)
25
+ if (ps == null) return null
26
+ const r = Bun.spawnSync([ps, '-o', 'pgid=', '-p', String(pid)], { stdout: 'pipe', stderr: 'ignore' })
27
+ const g = Number(r.stdout.toString().trim())
28
+ return Number.isInteger(g) && g > 0 ? g : null
29
+ }
30
+
31
+ /** the port a local url serves on, null for a remote host or no url */
32
+ export function localPort(url: string | null): number | null {
33
+ if (url == null) return null
34
+ let u: URL
35
+ try {
36
+ u = new URL(url)
37
+ } catch {
38
+ return null
39
+ }
40
+ if (!['127.0.0.1', 'localhost', '[::1]', '0.0.0.0'].includes(u.hostname)) return null
41
+ const p = u.port !== '' ? Number(u.port) : u.protocol === 'https:' ? 443 : u.protocol === 'http:' ? 80 : NaN
42
+ return Number.isInteger(p) && p > 0 ? p : null
43
+ }
@@ -0,0 +1,14 @@
1
+ // what a repo declares about itself and its root unit, read from its config.
2
+ // Its own module, free of any fs import, so a check that asks it stays keyed
3
+ // on what it read.
4
+ import type { RepoConfig } from '../repo-config.ts'
5
+
6
+ /** the github visibility this repo declares (absent = 'private') */
7
+ export const declaredVisibility = (c: RepoConfig): 'private' | 'public' => c.visibility ?? 'private'
8
+
9
+ /** declared public: strangers read the tracked tree */
10
+ export const declaredPublic = (c: RepoConfig): boolean => c.visibility === 'public'
11
+
12
+ /** the root unit declares an npm target. Declared, never inferred from a
13
+ * missing `private` flag, so an app that forgot the flag is still an app */
14
+ export const rootPublishesNpm = (c: RepoConfig): boolean => c.release?.npm === true
@@ -0,0 +1,222 @@
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; the
4
+ // house repo's one `stores-under-cap` check fails when a store is past its
5
+ // cap, so a sweep that stopped running 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 the
49
+ * house check 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
+ /** a cap in words: `200 entries, 30d, 5.0 MB a file` */
63
+ export const capWords = (s: Store): string =>
64
+ [
65
+ s.cap.maxCount != null ? `${s.cap.maxCount} entries` : null,
66
+ s.cap.maxAgeMs != null ? fmtAge(s.cap.maxAgeMs) : null,
67
+ s.cap.maxFileBytes != null ? `${fmtBytes(s.cap.maxFileBytes)} a file` : null,
68
+ s.cap.maxBytes != null ? fmtBytes(s.cap.maxBytes) : null,
69
+ ].filter((x) => x != null).join(', ')
70
+
71
+ export const fmtBytes = (n: number): string => (n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`)
72
+ 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`)
73
+
74
+ /** every file under a dir (not following links), skipping dirs `skip` names */
75
+ export function walkUse(dir: string, o: { match?: (name: string) => boolean; skip?: (name: string) => boolean } = {}): Use | null {
76
+ if (!existsSync(dir)) return { count: 0, oldestAt: null, bytes: 0, largestFile: 0 }
77
+ const use: Use = { count: 0, oldestAt: null, bytes: 0, largestFile: 0 }
78
+ const walk = (d: string): void => {
79
+ let list
80
+ try { list = readdirSync(d, { withFileTypes: true }) } catch { return }
81
+ for (const e of list) {
82
+ const p = join(d, e.name)
83
+ if (e.isDirectory()) { if (o.skip?.(e.name) !== true) walk(p); continue }
84
+ if (!e.isFile() || (o.match != null && !o.match(e.name))) continue
85
+ try {
86
+ const s = statSync(p)
87
+ use.count++
88
+ use.bytes += s.size
89
+ use.largestFile = Math.max(use.largestFile, s.size)
90
+ use.oldestAt = use.oldestAt == null ? s.mtimeMs : Math.min(use.oldestAt, s.mtimeMs)
91
+ } catch { /* raced: gone */ }
92
+ }
93
+ }
94
+ walk(dir)
95
+ return use
96
+ }
97
+
98
+ const counted = (count: number, oldestAt: number | null): Use => ({ count, oldestAt, bytes: 0, largestFile: 0 })
99
+
100
+ /** the stores one repo holds */
101
+ export function repoStores(root: string): Store[] {
102
+ const sk = shipkitRoot(root)
103
+ const logs = join(root, CI_LOG_DIR)
104
+ const jobs = join(root, JOBS)
105
+ const gitDir = gitRoot(root)?.gitDir
106
+ const stores: Store[] = [
107
+ {
108
+ id: 'answers',
109
+ where: `${answersDb(root)} (answers)`,
110
+ cap: { maxAgeMs: ANSWERS_KEEP_MS, maxCount: MAX_ANSWERS },
111
+ measure: () => {
112
+ if (!existsSync(answersDb(root))) return counted(0, null)
113
+ const u = answersUse(answersDb(root))
114
+ return u == null ? null : counted(u.count, u.oldestUsed)
115
+ },
116
+ sweep: (now) => { sweepAnswers(answersDb(root), now) },
117
+ },
118
+ {
119
+ // the json store this table replaced: read once, then gone
120
+ id: 'json answers',
121
+ where: legacyStoreDir(root),
122
+ cap: { maxCount: 0 },
123
+ measure: () => walkUse(legacyStoreDir(root)),
124
+ sweep: async (now) => { await importLegacyStore(root, now) },
125
+ },
126
+ {
127
+ id: 'run record',
128
+ where: `${dbPath(root)} (runs, invocations, suites)`,
129
+ cap: { maxAgeMs: RECORD_KEEP_MS, maxCount: MAX_ROWS },
130
+ measure: () => {
131
+ if (!existsSync(dbPath(root))) return counted(0, null)
132
+ const u = recordUse(dbPath(root))
133
+ return u == null ? null : counted(u.count, u.oldest)
134
+ },
135
+ // the last db store: the answers above and these rows are one
136
+ // file, which the deletes leave full of free pages
137
+ sweep: (now) => {
138
+ sweepRuns(dbPath(root), now)
139
+ compactDb(dbPath(root))
140
+ },
141
+ },
142
+ {
143
+ id: 'inflight',
144
+ where: join(sk, 'inflight'),
145
+ cap: { maxAgeMs: INFLIGHT_KEEP_MS },
146
+ measure: () => walkUse(join(sk, 'inflight')),
147
+ sweep: (now) => sweepSlots(join(sk, 'inflight'), now),
148
+ },
149
+ {
150
+ id: 'run logs',
151
+ where: logs,
152
+ cap: { maxAgeMs: RUN_DIR_KEEP_MS, maxCount: RUN_DIR_MAX },
153
+ measure: () => {
154
+ const all = runEntries(logs)
155
+ return counted(all.length, all.length === 0 ? null : Math.min(...all.map((e) => e.started)))
156
+ },
157
+ sweep: (now) => sweepRunDirs(logs, now),
158
+ },
159
+ {
160
+ id: 'jobs',
161
+ where: jobs,
162
+ cap: { maxAgeMs: JOBS_KEEP_MS, maxCount: JOBS_MAX },
163
+ measure: () => {
164
+ let names: string[] = []
165
+ try { names = readdirSync(jobs) } catch { return counted(0, null) }
166
+ let oldest: number | null = null
167
+ for (const n of names) try { const m = statSync(join(jobs, n)).mtimeMs; oldest = oldest == null ? m : Math.min(oldest, m) } catch { /* raced */ }
168
+ return counted(names.length, oldest)
169
+ },
170
+ sweep: (now) => sweepJobs(root, now),
171
+ },
172
+ {
173
+ // the dev servers the hub runs for the repo (serve.processes)
174
+ id: 'process logs',
175
+ where: `${processDir(root)}/*.log`,
176
+ cap: { maxFileBytes: LOG_MAX_BYTES },
177
+ measure: () => walkUse(processDir(root), { match: (n) => n.endsWith('.log') }),
178
+ sweep: () => {
179
+ let names: string[] = []
180
+ try { names = readdirSync(processDir(root)) } catch { return }
181
+ for (const n of names) if (n.endsWith('.log')) capLog(join(processDir(root), n))
182
+ },
183
+ },
184
+ ]
185
+ if (gitDir != null) {
186
+ const dir = join(gitDir, 'shipkit')
187
+ stores.push({
188
+ id: 'scan indexes',
189
+ where: `${dir}/scan-<pid>.idx`,
190
+ // one per process scanning at once, and a machine runs a handful
191
+ cap: { maxAgeMs: SCAN_INDEX_KEEP_MS, maxCount: 16 },
192
+ measure: () => walkUse(dir, { match: (n) => /^scan-\d+\.idx(\.tmp)?$/.test(n), skip: () => true }),
193
+ sweep: (now) => sweepScanIndexes(dir, now),
194
+ })
195
+ }
196
+ return stores
197
+ }
198
+
199
+ export type Measured = { store: Store; use: Use | null; over: string | null }
200
+
201
+ export function measureStores(stores: Store[], now = Date.now()): Measured[] {
202
+ return stores.map((store) => {
203
+ const use = store.measure()
204
+ return { store, use, over: use == null ? null : overCap(use, store.cap, now) }
205
+ })
206
+ }
207
+
208
+ /** sweep every store, one after the other, yielding between them so a
209
+ * daemon keeps serving. A sweep that throws is reported, never fatal */
210
+ export async function sweepStores(stores: Store[], now = Date.now()): Promise<{ id: string; error: string | null }[]> {
211
+ const out: { id: string; error: string | null }[] = []
212
+ for (const s of stores) {
213
+ try {
214
+ await s.sweep(now)
215
+ out.push({ id: s.id, error: null })
216
+ } catch (e) {
217
+ out.push({ id: s.id, error: (e as Error).message })
218
+ }
219
+ await Bun.sleep(0)
220
+ }
221
+ return out
222
+ }
@@ -0,0 +1,7 @@
1
+ // built-in check ids that no longer exist, and where their question went. An
2
+ // id is never reused, so a doc or a changelog may still name one, and
3
+ // discovery refuses a built-in that takes one again.
4
+ export const RETIRED_CHECK_IDS: Readonly<Record<string, string>> = {
5
+ SK021: "the gh login is a machine fact: the house repo's github-login check runs src/core/gh-login.ts",
6
+ SK023: "the store caps are the machine daemon's health: the house repo's stores-under-cap check",
7
+ }
@@ -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'
@@ -37,6 +37,9 @@ export type CheckOpts = {
37
37
  status?: boolean
38
38
  /** line sink — commands pass their context stream; defaults to console */
39
39
  print?: (line: string) => void
40
+ /** where the lines nobody acts on go: the parked list and the green
41
+ * summary. `shipkit ci` shows them only under --verbose. Default: print */
42
+ detail?: (line: string) => void
40
43
  /** per-CHECK completion tap: the real status, whether it replayed, what it
41
44
  * cost. The demotion of a non-blocking failure happens in the LINES (a
42
45
  * FAIL is rewritten to a warn before anything sees it), so `status` here
@@ -55,6 +58,9 @@ export type CheckOpts = {
55
58
  onlyBlocking?: boolean
56
59
  /** which test tier the caller is asking for */
57
60
  tier?: TestTier
61
+ /** colour the wire lines. Default: this process's stdout is a terminal. A
62
+ * hub running a terminal's gate passes the terminal's answer */
63
+ color?: boolean
58
64
  /** which question: the gate's set (`shipkit ci`) or the health pass's */
59
65
  audience?: Audience
60
66
  /** the caller narrowed the run to these ids (`--only`) */
@@ -187,16 +193,18 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
187
193
  root,
188
194
  config: c,
189
195
  logDir: c.logDir ?? join(root, CI_LOG_DIR),
190
- cache: { storeDir: storeRoot(root), ns: c.name, now: Date.now },
196
+ cache: { db: answersDb(root), ns: c.name, now: Date.now },
191
197
  ledger,
192
198
  log,
193
199
  childEnv: { [SHARE_ENV]: sharePath, ...(o.fresh === true ? { SHIPKIT_FRESH: '1' } : {}) },
194
200
  bail: o.bail,
201
+ color: o.color,
195
202
  print: sink,
203
+ detail: o.detail,
196
204
  onCheck: (e) => { collected.push(...e.lines); o.onCheck?.(e) },
197
205
  })
198
206
  const lines: Line[] = []
199
- const say = (l: Line): void => { lines.push(l); print(formatLine(l, false)) }
207
+ const say = (l: Line, to: (line: string) => void = print): void => { lines.push(l); to(formatLine(l, false)) }
200
208
  // a declaration in a shape the check file replaced is reported by check
201
209
  // SK016, which also carries the fixer that rewrites it: one owner, one
202
210
  // line, one command, rather than a warning per surface.
@@ -206,7 +214,7 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
206
214
  // page just replaced
207
215
  if (summary.parked.length > 0) {
208
216
  const head = summary.parked.slice(0, 8).join(', ')
209
- say({ kind: 'skip', text: `${summary.parked.length} check(s) not in this run (health only, release only at the quick tier, or a skipped group): ${head}${summary.parked.length > 8 ? `, and ${summary.parked.length - 8} more` : ''}` })
217
+ say({ kind: 'skip', text: `${summary.parked.length} check(s) not in this run (health only, release only at the quick tier, or a skipped group): ${head}${summary.parked.length > 8 ? `, and ${summary.parked.length - 8} more` : ''}` }, o.detail ?? print)
210
218
  }
211
219
  // the survey dies with the run that made it, and only the run that made it
212
220
  if (inherited == null || inherited === '') for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }