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
@@ -8,6 +8,7 @@
8
8
  import { existsSync, readdirSync, readFileSync } from 'fs'
9
9
  import { join, resolve } from 'path'
10
10
  import { defaultHubPort } from '../serve/hub-port.ts'
11
+ import { portHolders } from '../core/port-holders.ts'
11
12
 
12
13
  export type ComfyWorkflow = { id: string; file: string }
13
14
  export type ComfyPanelUp = { url: string; port: number }
@@ -127,22 +128,6 @@ const KILL_STEP_MS = 25
127
128
  const QUIET_TRIES = 60
128
129
  const QUIET_STEP_MS = 50
129
130
 
130
- /** the pids LISTENING on a tcp port, on darwin and linux. `-sTCP:LISTEN`
131
- * carries the whole answer: `-i tcp:<port>` alone also matches every client
132
- * connected to it, this process included, and those must never be killed.
133
- * lsof sits in /usr/sbin on darwin, which a gui-launched shell can miss */
134
- function portHolders(port: number): number[] {
135
- const lsof = Bun.which('lsof') ?? ['/usr/sbin/lsof', '/usr/bin/lsof'].find((p) => existsSync(p))
136
- if (lsof == null) return []
137
- const r = Bun.spawnSync([lsof, '-ti', `tcp:${port}`, '-sTCP:LISTEN'], { stdout: 'pipe', stderr: 'ignore' })
138
- const pids = r.stdout
139
- .toString()
140
- .split('\n')
141
- .map((l) => Number(l.trim()))
142
- .filter((n) => Number.isInteger(n) && n > 0)
143
- return [...new Set(pids)]
144
- }
145
-
146
131
  /** a pid that died between the scan and the signal is the outcome we wanted */
147
132
  function kill(pid: number, signal: 'SIGTERM' | 'SIGKILL'): void {
148
133
  try {
@@ -39,9 +39,11 @@ export type ShareOpts = {
39
39
  abort?: () => boolean
40
40
  }
41
41
 
42
- /** how long an answer file stays on disk. Only an in flight asker can adopt
43
- * one, so this is housekeeping, not a validity window. */
44
- const KEEP_MS = 60 * 60 * 1000
42
+ /** how long a slot file stays on disk. Only an in flight asker can adopt an
43
+ * answer, so this is housekeeping, not a validity window. A live holder
44
+ * heartbeats its lock (locked-write.ts), so a lock this old is one a killed
45
+ * run left behind. */
46
+ export const INFLIGHT_KEEP_MS = 60 * 60 * 1000
45
47
 
46
48
  const readShared = <T>(path: string): Shared<T> | null => {
47
49
  try {
@@ -50,12 +52,15 @@ const readShared = <T>(path: string): Shared<T> | null => {
50
52
  } catch { return null }
51
53
  }
52
54
 
53
- const sweep = (dir: string, now: number): void => {
55
+ /** every slot file past the keep window goes: answers, the locks killed runs
56
+ * left, the graves of a steal. One level of namespace dirs below `dir` is
57
+ * walked too. */
58
+ export function sweepSlots(dir: string, now: number, keepMs = INFLIGHT_KEEP_MS): void {
54
59
  try {
55
- for (const f of readdirSync(dir)) {
56
- if (f.endsWith('.lock')) continue
57
- const p = `${dir}/${f}`
58
- try { if (statSync(p).mtimeMs < now - KEEP_MS) rmSync(p, { force: true }) } catch { /* raced: gone either way */ }
60
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
61
+ const p = `${dir}/${e.name}`
62
+ if (e.isDirectory()) { sweepSlots(p, now, keepMs); continue }
63
+ try { if (statSync(p).mtimeMs < now - keepMs) rmSync(p, { force: true }) } catch { /* raced: gone either way */ }
59
64
  }
60
65
  } catch { /* housekeeping never fails a run */ }
61
66
  }
@@ -86,7 +91,7 @@ export async function shareRun<T>(slot: string, o: ShareOpts, body: () => Promis
86
91
  // an answer nobody can adopt costs the waiters a measurement, never a verdict
87
92
  }
88
93
  }
89
- sweep(dirname(slot), now)
94
+ sweepSlots(dirname(slot), now)
90
95
  return { kind: 'measured', value: r.value }
91
96
  })
92
97
  if (leased.ok) return leased.value
@@ -5,6 +5,14 @@
5
5
  import { basename, relative } from 'path'
6
6
 
7
7
  export const CHECK_SUFFIX = '.check.ts'
8
+ /** the unit file: a check, a global process or a widget, by its default
9
+ * export (shipkit-files.ts). Discovery reads both suffixes, and will later
10
+ * read this one alone */
11
+ export const UNIT_SUFFIX = '.shipkit.ts'
12
+
13
+ /** the name a unit file answers to when it declares no id: its basename,
14
+ * `.check` dropped too, so `x.check.shipkit.ts` is `x` */
15
+ export const unitBase = (file: string): string => basename(file).slice(0, -UNIT_SUFFIX.length).replace(/\.check$/, '')
8
16
 
9
17
  /** dirs discovery never walks into, beside every dot dir: installs, build
10
18
  * output, scratch, and templates meant for other repos */
@@ -346,35 +346,57 @@ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'ful
346
346
  * was still streaming into, so the surviving FAIL line pointed at a path
347
347
  * that no longer existed. Now each run owns `run-<pid>-<started>/`.
348
348
  *
349
- * Old run dirs are swept on the way in, and only ones nothing can be using:
350
- * the pid must not be running AND the dir must be older than the grace
351
- * window, so a recycled pid cannot cost a live run its logs. */
349
+ * Old run dirs are swept on the way in (sweepRunDirs), and only ones nothing
350
+ * can be using: the pid must not be running, so a live run keeps its logs. */
352
351
  function openRunDir(base: string): string {
353
352
  // the pid plus the millisecond separates two PROCESSES; the counter
354
353
  // separates two runs inside one (the TUI can start a second before the
355
354
  // clock ticks), so no run ever writes into another's dir
356
355
  const dir = join(base, `run-${process.pid}-${Date.now()}-${runSeq++}`)
357
356
  mkdirSync(dir, { recursive: true })
358
- const cutoff = Date.now() - RUN_DIR_KEEP_MS
357
+ sweepRunDirs(base, Date.now(), { keep: basename(dir) })
358
+ return dir
359
+ }
360
+
361
+ /** how long a finished run's logs stay readable, and the pid-reuse guard */
362
+ export const RUN_DIR_KEEP_MS = 6 * 60 * 60 * 1000
363
+ /** the most finished runs whose logs stay, newest first */
364
+ export const RUN_DIR_MAX = 40
365
+
366
+ const RUN_ENTRY = /^(?:run|releases)-(\d+)-(\d+)(?:-\d+)?(?:\.json(?:\.lock)?)?$/
367
+
368
+ const pidRuns = (pid: number): boolean => {
369
+ try { process.kill(pid, 0); return true } catch (e) { return (e as { code?: string }).code === 'EPERM' }
370
+ }
371
+
372
+ /** the run dirs under a log base, and the per run release survey files a
373
+ * killed run leaves beside them: both carry the pid and the start time */
374
+ export function runEntries(base: string): { name: string; pid: number; started: number }[] {
359
375
  try {
360
- for (const name of readdirSync(base)) {
361
- // run dirs AND the per-run release survey files, which a killed
362
- // run leaves behind: both carry the pid and the start time, so one
363
- // sweep covers both rather than one path leaking forever
364
- const m = /^(?:run|releases)-(\d+)-(\d+)(?:-\d+)?(?:\.json(?:\.lock)?)?$/.exec(name)
365
- if (m == null || name === basename(dir)) continue
366
- if (Number(m[2]) > cutoff) continue
367
- try { process.kill(Number(m[1]), 0); continue } catch (e) { if ((e as { code?: string }).code === 'EPERM') continue }
368
- rmSync(join(base, name), { recursive: true, force: true })
369
- }
376
+ return readdirSync(base).flatMap((name) => {
377
+ const m = RUN_ENTRY.exec(name)
378
+ return m == null ? [] : [{ name, pid: Number(m[1]), started: Number(m[2]) }]
379
+ })
370
380
  } catch {
371
- // an unreadable log base is a housekeeping miss, never a gate failure
381
+ return []
372
382
  }
373
- return dir
374
383
  }
375
384
 
376
- /** how long a finished run's logs stay readable, and the pid-reuse guard */
377
- const RUN_DIR_KEEP_MS = 6 * 60 * 60 * 1000
385
+ /** the run logs' cap: every entry past the keep window (no run lasts that
386
+ * long, so a live pid there is a long lived process's finished run, or a
387
+ * reused pid), then the oldest past `max` whose pid no longer runs. `keep`
388
+ * spares the caller's own dir */
389
+ export function sweepRunDirs(base: string, now: number, o: { keep?: string; keepMs?: number; max?: number } = {}): void {
390
+ const all = runEntries(base).sort((a, b) => b.started - a.started)
391
+ const cutoff = now - (o.keepMs ?? RUN_DIR_KEEP_MS)
392
+ all.forEach((e, i) => {
393
+ if (e.name === o.keep) return
394
+ const old = e.started <= cutoff
395
+ if (!old && i < (o.max ?? RUN_DIR_MAX)) return
396
+ if (!old && pidRuns(e.pid)) return
397
+ try { rmSync(join(base, e.name), { recursive: true, force: true }) } catch { /* an unreadable log base is a housekeeping miss */ }
398
+ })
399
+ }
378
400
  let runSeq = 0
379
401
 
380
402
  type Live = RunnableCheck & CheckResult & {
@@ -21,7 +21,8 @@ import { readdirSync, readFileSync, realpathSync, statSync } from 'fs'
21
21
  import { basename, join, relative } from 'path'
22
22
  import { check, type CheckDef, type CheckFrom } from '../check.ts'
23
23
 
24
- import { CHECK_SUFFIX as SUFFIX, fallbackNames, isLegacyCheckSource, SKIPPED_DIRS as IGNORED_DIRS } from './check-file-names.ts'
24
+ import { CHECK_SUFFIX as SUFFIX, fallbackNames, isLegacyCheckSource, SKIPPED_DIRS as IGNORED_DIRS, UNIT_SUFFIX, unitBase } from './check-file-names.ts'
25
+ import { unitKind } from '../shipkit-files.ts'
25
26
 
26
27
  export { fallbackNames, isLegacyCheckSource } from './check-file-names.ts'
27
28
 
@@ -54,16 +55,39 @@ export type DiscoveredCheck = {
54
55
  from: CheckFrom
55
56
  }
56
57
 
57
- function walk(dir: string, out: string[]): void {
58
+ function walk(dir: string, out: string[], suffix = SUFFIX): void {
58
59
  let entries
59
60
  try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
60
61
  for (const e of entries) {
61
62
  if (e.isDirectory()) {
62
- if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out)
63
- } else if (e.name.endsWith(SUFFIX)) out.push(join(dir, e.name))
63
+ if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix)
64
+ } else if (e.name.endsWith(suffix)) out.push(join(dir, e.name))
64
65
  }
65
66
  }
66
67
 
68
+ /** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted */
69
+ export function repoUnitFiles(root: string): string[] {
70
+ const out: string[] = []
71
+ walk(root, out, UNIT_SUFFIX)
72
+ return out.sort()
73
+ }
74
+
75
+ /** a file's default export, re-imported whenever the file on disk moves. An
76
+ * ES module is cached by path for the life of the process, so a repo that
77
+ * rewrites a file mid process would keep the old one without the stamp */
78
+ export async function importFresh(path: string): Promise<unknown> {
79
+ let stamp = ''
80
+ // no dot in the stamp: a query ending `.1234` reads as the module's
81
+ // extension, and a load plugin then never sees the file
82
+ try { stamp = String(statSync(path).mtimeMs).replace('.', '_') } catch {}
83
+ // the REAL path: through a linked one, the resolver misses a file
84
+ // created after it first listed the directory, which is exactly the
85
+ // file a fix pass just wrote
86
+ let real = path
87
+ try { real = realpathSync(path) } catch {}
88
+ return ((await import(`${real}?v=${stamp}`)) as { default?: unknown }).default
89
+ }
90
+
67
91
  /** every `*.check.ts` file under a repo root, check files and legacy
68
92
  * scripts alike, by the same walk discovery takes, without importing one.
69
93
  * Sorted. */
@@ -98,22 +122,14 @@ export function builtinCheckIds(): string[] {
98
122
  /** the module, re-imported whenever the file on disk moves. An ES module is
99
123
  * cached by path for the life of the process, so a repo that rewrites a
100
124
  * check file mid process would keep running the old body without this. */
101
- async function load(path: string): Promise<CheckDef> {
102
- let stamp = ''
103
- // no dot in the stamp: a query ending `.1234` reads as the module's
104
- // extension, and a load plugin then never sees the file
105
- try { stamp = String(statSync(path).mtimeMs).replace('.', '_') } catch {}
125
+ async function load(path: string): Promise<CheckDef | null> {
106
126
  // the path rides on every message from here down, because `check()` knows
107
127
  // the object and not where it was written, and an agent reading a refused
108
128
  // declaration needs the file before it needs the rule
109
129
  try {
110
- // the REAL path: through a linked one, the resolver misses a file
111
- // created after it first listed the directory, which is exactly the
112
- // file a fix pass just wrote
113
- let real = path
114
- try { real = realpathSync(path) } catch {}
115
- const mod = (await import(`${real}?v=${stamp}`)) as { default?: unknown }
116
- const d = mod.default
130
+ const d = await importFresh(path)
131
+ // a unit file of another kind is its reader's, never a check
132
+ if (path.endsWith(UNIT_SUFFIX) && unitKind(d) !== 'check') return null
117
133
  if (d == null || typeof d !== 'object' || (!('run' in d) && !('cmd' in d)))
118
134
  throw new Error(`a check file must \`export default check({...})\` with a \`run\` or a \`cmd\``)
119
135
  // re-validated here rather than trusted: a file can build the object
@@ -133,6 +149,7 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
133
149
  walk(BUILTIN_CHECK_DIR, builtins)
134
150
  const found: string[] = []
135
151
  walk(root, found)
152
+ const units = repoUnitFiles(root)
136
153
  // the repo that HOSTS the toolkit walks over `src/checks/` too, and a file
137
154
  // discovered twice is a duplicate id rather than a second check. Real paths,
138
155
  // because a consumer reaches the same files through a linked node_modules
@@ -147,13 +164,14 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
147
164
 
148
165
  const out: DiscoveredCheck[] = []
149
166
  const ids = new Set<string>()
150
- for (const f of [...builtins, ...own]) {
167
+ for (const f of [...builtins, ...own, ...units]) {
151
168
  const isBuiltin = builtins.includes(f)
152
169
  const def = await load(f)
170
+ if (def == null) continue
153
171
  const rel = isBuiltin ? null : relative(root, f).replace(/\\/g, '/')
154
172
  const fallback = isBuiltin
155
173
  ? (BUILTIN_ID_RE.exec(base(f))?.[1] ?? base(f))
156
- : names.get(f)!
174
+ : f.endsWith(UNIT_SUFFIX) ? unitBase(f) : names.get(f)!
157
175
  const id = def.id ?? fallback
158
176
  if (isBuiltin) {
159
177
  if (id !== fallback) throw new Error(`${f}: declares id '${id}' but its file name says '${fallback}' — a built-in id is its file name`)
@@ -0,0 +1,203 @@
1
+ // a repo's identity colours: `identity: { colorLight, colorDark }` in its
2
+ // repo.config.ts, one hex each. colorLight reads on a light background,
3
+ // colorDark on a dark one. This module is pure (the page imports it): hue
4
+ // maths, the fallback from the repo name, and the byte preserving edit of the
5
+ // config text. Reading an icon is core/identity.ts.
6
+
7
+ export type Identity = { colorLight: string; colorDark: string }
8
+
9
+ export const HEX_RE = /^#[0-9a-fA-F]{6}$/
10
+
11
+ /** why this value is not an identity, or null */
12
+ export function identityProblem(v: unknown): string | null {
13
+ if (v == null) return 'no identity colours'
14
+ if (typeof v !== 'object') return 'identity is not an object'
15
+ const o = v as Record<string, unknown>
16
+ for (const k of ['colorLight', 'colorDark'] as const)
17
+ if (typeof o[k] !== 'string' || !HEX_RE.test(o[k] as string)) return `identity.${k} is ${o[k] == null ? 'missing' : `'${String(o[k])}'`}, not a #rrggbb hex`
18
+ return null
19
+ }
20
+
21
+ const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v))
22
+
23
+ /** hsl (h in degrees, s and l in 0..1) as #rrggbb */
24
+ export function hslHex(h: number, s: number, l: number): string {
25
+ const a = s * Math.min(l, 1 - l)
26
+ const f = (n: number): string => {
27
+ const k = (n + h / 30) % 12
28
+ const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
29
+ return Math.round(c * 255).toString(16).padStart(2, '0')
30
+ }
31
+ return `#${f(0)}${f(8)}${f(4)}`
32
+ }
33
+
34
+ /** #rrggbb as hsl, h in degrees */
35
+ export function hexHsl(hex: string): { h: number; s: number; l: number } {
36
+ const n = Number.parseInt(hex.slice(1), 16)
37
+ return rgbHsl((n >> 16) & 255, (n >> 8) & 255, n & 255)
38
+ }
39
+
40
+ export function rgbHsl(r8: number, g8: number, b8: number): { h: number; s: number; l: number } {
41
+ const r = r8 / 255
42
+ const g = g8 / 255
43
+ const b = b8 / 255
44
+ const max = Math.max(r, g, b)
45
+ const min = Math.min(r, g, b)
46
+ const l = (max + min) / 2
47
+ const d = max - min
48
+ if (d === 0) return { h: 0, s: 0, l }
49
+ const s = d / (1 - Math.abs(2 * l - 1))
50
+ let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4
51
+ h *= 60
52
+ if (h < 0) h += 360
53
+ return { h, s, l }
54
+ }
55
+
56
+ /** the pair for one hue: saturated enough to read as a colour, dark enough
57
+ * for white paper, light enough for a dark page */
58
+ export function identityOfHue(h: number, s = 0.7): Identity {
59
+ const sat = clamp(s, 0.5, 0.85)
60
+ return { colorLight: hslHex(h, sat, 0.38), colorDark: hslHex(h, sat, 0.68) }
61
+ }
62
+
63
+ /** a hue hashed from the name, stable from one run to the next */
64
+ export function nameHue(name: string): number {
65
+ let h = 0
66
+ for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
67
+ return h % 360
68
+ }
69
+
70
+ export const identityOfName = (name: string): Identity => identityOfHue(nameHue(name))
71
+
72
+ /** the dominant saturated hue of rgba pixels, weighted by saturation, or
73
+ * null when the image is grey, transparent or empty */
74
+ export function dominantHue(rgba: Uint8Array | Buffer, channels = 4): { h: number; s: number } | null {
75
+ const BINS = 36
76
+ const weight = new Float64Array(BINS)
77
+ const sumS = new Float64Array(BINS)
78
+ const vecX = new Float64Array(BINS)
79
+ const vecY = new Float64Array(BINS)
80
+ for (let i = 0; i + channels - 1 < rgba.length; i += channels) {
81
+ if (channels === 4 && rgba[i + 3]! < 128) continue
82
+ const { h, s, l } = rgbHsl(rgba[i]!, rgba[i + 1]!, rgba[i + 2]!)
83
+ if (s < 0.3 || l < 0.12 || l > 0.9) continue
84
+ const b = Math.floor(h / (360 / BINS)) % BINS
85
+ const w = s * (1 - Math.abs(2 * l - 1))
86
+ weight[b]! += w
87
+ sumS[b]! += s * w
88
+ vecX[b]! += Math.cos((h * Math.PI) / 180) * w
89
+ vecY[b]! += Math.sin((h * Math.PI) / 180) * w
90
+ }
91
+ let best = -1
92
+ let bestW = 0
93
+ // a bin and its two neighbours, so a hue on a bin edge is not split
94
+ for (let b = 0; b < BINS; b++) {
95
+ const w = weight[b]! + weight[(b + 1) % BINS]! + weight[(b + BINS - 1) % BINS]!
96
+ if (w > bestW) { bestW = w; best = b }
97
+ }
98
+ if (best < 0 || bestW === 0) return null
99
+ let x = 0, y = 0, s = 0, w = 0
100
+ for (const b of [(best + BINS - 1) % BINS, best, (best + 1) % BINS]) {
101
+ x += vecX[b]!
102
+ y += vecY[b]!
103
+ s += sumS[b]!
104
+ w += weight[b]!
105
+ }
106
+ let h = (Math.atan2(y, x) * 180) / Math.PI
107
+ if (h < 0) h += 360
108
+ return { h, s: s / w }
109
+ }
110
+
111
+ // ── the config text ─────────────────────────────────────────────────────
112
+
113
+ // the key where a config writes it: at a line start, or inline after `{` or
114
+ // `,` (a one line config), bare or quoted
115
+ const KEY_RE = /(?:^|[{,])\s*(['"]?)identity\1\s*:\s*/gm
116
+
117
+ /** per char of the text: the object depth inside `defineRepo({` after that
118
+ * char (1 on the top level keys), or -1 inside a string or a comment */
119
+ function configDepths(text: string): Int32Array {
120
+ const depth = new Int32Array(text.length).fill(0)
121
+ const open = /defineRepo\(\s*\{/.exec(text)
122
+ if (open == null) return depth
123
+ let d = 0
124
+ let quote: string | null = null
125
+ let comment: 'line' | 'block' | null = null
126
+ for (let i = open.index + open[0].length - 1; i < text.length; i++) {
127
+ const ch = text[i]!
128
+ if (comment === 'line') { if (ch === '\n') comment = null; else { depth[i] = -1; continue } }
129
+ else if (comment === 'block') { depth[i] = -1; if (ch === '/' && text[i - 1] === '*') comment = null; continue }
130
+ else if (quote != null) { depth[i] = -1; if (ch === '\\') { depth[++i] = -1 } else if (ch === quote) quote = null; continue }
131
+ else if (ch === '/' && text[i + 1] === '/') { comment = 'line'; depth[i] = -1; continue }
132
+ else if (ch === '/' && text[i + 1] === '*') { comment = 'block'; depth[i] = -1; continue }
133
+ else if (ch === "'" || ch === '"' || ch === '`') { quote = ch; depth[i] = -1; continue }
134
+ if (ch === '{' || ch === '[' || ch === '(') d++
135
+ else if (ch === '}' || ch === ']' || ch === ')') d--
136
+ depth[i] = d
137
+ }
138
+ return depth
139
+ }
140
+
141
+ /** every top level `identity` key of `defineRepo({…})`, as the offsets of
142
+ * its `{…}` value (end exclusive), null when the value is not an object
143
+ * literal. A key nested deeper, in a string or in a comment is not it */
144
+ function identityKeys(text: string): { value: { start: number; end: number } | null }[] {
145
+ const depth = configDepths(text)
146
+ const out: { value: { start: number; end: number } | null }[] = []
147
+ for (const m of text.matchAll(KEY_RE)) {
148
+ const sep = /^[{,]/.test(m[0]) ? m.index : m.index - 1
149
+ if (sep < 0 || depth[sep] !== 1) continue
150
+ const at = m.index + m[0].length
151
+ if (text[at] !== '{') { out.push({ value: null }); continue }
152
+ let end = -1
153
+ for (let j = at; j < text.length; j++) if (depth[j] === 1 && text[j] === '}') { end = j + 1; break }
154
+ out.push({ value: end === -1 ? null : { start: at, end } })
155
+ }
156
+ return out
157
+ }
158
+
159
+ /** whether the text declares an `identity` key at all, readable or not */
160
+ export const declaresIdentity = (text: string): boolean => identityKeys(text).length > 0
161
+
162
+ /** the identity the config text declares, read without loading it: the page
163
+ * rows and the daemon read many configs per poll */
164
+ export function identityOfText(text: string): Identity | null {
165
+ const keys = identityKeys(text)
166
+ const v = keys.length === 1 ? keys[0]!.value : null
167
+ if (v == null) return null
168
+ const body = text.slice(v.start, v.end)
169
+ const light = /(['"]?)colorLight\1\s*:\s*['"`](#[0-9a-fA-F]{6})['"`]/.exec(body)?.[2]
170
+ const dark = /(['"]?)colorDark\1\s*:\s*['"`](#[0-9a-fA-F]{6})['"`]/.exec(body)?.[2]
171
+ return light == null || dark == null ? null : { colorLight: light.toLowerCase(), colorDark: dark.toLowerCase() }
172
+ }
173
+
174
+ /** the config text with `identity` set to these colours. Only that key
175
+ * moves: an existing object value is replaced in place, a missing key lands
176
+ * as the first key of `defineRepo({`, and every other byte stays. Throws
177
+ * when the text holds the key twice, holds it with a value that is not an
178
+ * object literal, or has no `defineRepo({` to write into */
179
+ export function setIdentityText(text: string, id: Identity): string {
180
+ const bad = identityProblem(id)
181
+ if (bad != null) throw new Error(bad)
182
+ const q = /\bname\s*:\s*"/.test(text) && !/\bname\s*:\s*'/.test(text) ? '"' : "'"
183
+ const value = `{ colorLight: ${q}${id.colorLight.toLowerCase()}${q}, colorDark: ${q}${id.colorDark.toLowerCase()}${q} }`
184
+ const keys = identityKeys(text)
185
+ if (keys.length > 1) throw new Error('repo.config.ts declares `identity` more than once, set it by hand')
186
+ if (keys.length === 1) {
187
+ const v = keys[0]!.value
188
+ if (v == null) throw new Error('repo.config.ts declares `identity` as something other than `{ … }`, set it by hand')
189
+ return `${text.slice(0, v.start)}${value}${text.slice(v.end)}`
190
+ }
191
+ const lines = text.split('\n')
192
+ const open = lines.findIndex((l) => /defineRepo\(\s*\{/.test(l))
193
+ if (open === -1) throw new Error('repo.config.ts has no `defineRepo({` to write `identity` into, set it by hand')
194
+ const line = lines[open]!
195
+ if (/defineRepo\(\s*\{\s*$/.test(line)) {
196
+ const next = lines.slice(open + 1).find((l) => l.trim() !== '' && !/^\s*}/.test(l))
197
+ const pad = next != null ? /^(\s*)/.exec(next)![1]! : `${/^(\s*)/.exec(line)![1]!} `
198
+ return [...lines.slice(0, open + 1), `${pad}identity: ${value},`, ...lines.slice(open + 1)].join('\n')
199
+ }
200
+ // a config on one line: the key goes right after the brace
201
+ lines[open] = line.replace(/defineRepo\(\s*\{\s*/, (m) => `${m}identity: ${value}, `)
202
+ return lines.join('\n')
203
+ }
@@ -0,0 +1,91 @@
1
+ // a repo's identity colours, from disk: the dominant saturated colour of its
2
+ // rail icon (serve/project-gutter.ts ICON_FILES), else a hue hashed from its
3
+ // name, written into repo.config.ts with only the `identity` key moving. SK024
4
+ // warns when the key is missing, its fix and the daemon's load both call
5
+ // ensureIdentity, and the page's two swatches reach writeIdentity.
6
+ import { existsSync, readFileSync } from 'fs'
7
+ import { basename, join } from 'path'
8
+ import sharp from 'sharp'
9
+ import { atomicWrite } from './locked-write.ts'
10
+ import { dominantHue, identityOfHue, identityOfName, identityOfText, identityProblem, setIdentityText, type Identity } from './identity-color.ts'
11
+ import { ICON_FILES } from '../serve/project-gutter.ts'
12
+
13
+ export const CONFIG_FILE = 'repo.config.ts'
14
+
15
+ /** where a repo's own copy declares the config keys it knows */
16
+ export const INSTALLED_CONFIG_SRC = 'node_modules/rvlib-shipkit/src/repo-config.ts'
17
+
18
+ /** why the key cannot be written in this repo yet, or null. `source` is the
19
+ * text of its own copy's repo-config.ts, null when it has none. A copy that
20
+ * does not know the key would refuse it (its config load in ci, its
21
+ * typecheck), so nothing writes it there. Read by capability, not version:
22
+ * a linked copy is the source itself, whatever its package.json says */
23
+ export const identityRefusal = (source: string | null): string | null =>
24
+ source != null && !source.includes("'identity'")
25
+ ? `its own rvlib-shipkit predates the identity key, so writing it would break that copy: update rvlib-shipkit first`
26
+ : null
27
+
28
+ const installedSource = (root: string): string | null => {
29
+ try {
30
+ return readFileSync(join(root, INSTALLED_CONFIG_SRC), 'utf8')
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ /** the name the config declares, else the folder name */
37
+ export const configName = (text: string, root: string): string => /\bname\s*:\s*['"`]([^'"`]+)['"`]/.exec(text)?.[1] ?? basename(root)
38
+
39
+ /** the colours an image's pixels suggest, or null when it is grey, unreadable
40
+ * or a format sharp does not read (an .ico) */
41
+ export async function identityOfImage(bytes: Uint8Array): Promise<Identity | null> {
42
+ try {
43
+ const { data } = await sharp(bytes).resize(64, 64, { fit: 'inside' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
44
+ const d = dominantHue(data, 4)
45
+ return d == null ? null : identityOfHue(d.h, d.s)
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ /** the repo's icon file, first hit wins, repo-relative */
52
+ export const iconFileOf = (root: string): string | null => ICON_FILES.find((f) => existsSync(join(root, f))) ?? null
53
+
54
+ /** what the colours should be: from the icon when it has a colour, else from the name */
55
+ export async function deriveIdentity(root: string, name: string, read: (rel: string) => Uint8Array | null = (rel) => readFileSync(join(root, rel))): Promise<{ id: Identity; from: string }> {
56
+ const icon = iconFileOf(root)
57
+ if (icon != null) {
58
+ const bytes = read(icon)
59
+ const id = bytes == null ? null : await identityOfImage(bytes)
60
+ if (id != null) return { id, from: icon }
61
+ }
62
+ return { id: identityOfName(name), from: `the name '${name}'` }
63
+ }
64
+
65
+ /** the config with valid colours: derived and written when the key is
66
+ * missing or malformed, the text left byte for byte otherwise */
67
+ export async function ensureIdentity(root: string): Promise<{ changed: boolean; id: Identity | null; from: string | null; refused?: string }> {
68
+ const path = join(root, CONFIG_FILE)
69
+ if (!existsSync(path)) return { changed: false, id: null, from: null }
70
+ const text = readFileSync(path, 'utf8')
71
+ const have = identityOfText(text)
72
+ if (have != null && identityProblem(have) == null) return { changed: false, id: have, from: null }
73
+ const refused = identityRefusal(installedSource(root))
74
+ if (refused != null) return { changed: false, id: identityOfName(configName(text, root)), from: null, refused }
75
+ const { id, from } = await deriveIdentity(root, configName(text, root))
76
+ atomicWrite(path, setIdentityText(text, id))
77
+ return { changed: true, id, from }
78
+ }
79
+
80
+ /** set the colours by hand; a value left out keeps what the config has */
81
+ export function writeIdentity(root: string, next: Partial<Identity>): Identity {
82
+ const refused = identityRefusal(installedSource(root))
83
+ if (refused != null) throw new Error(refused)
84
+ const path = join(root, CONFIG_FILE)
85
+ const text = readFileSync(path, 'utf8')
86
+ const have = identityOfText(text) ?? identityOfName(configName(text, root))
87
+ const id = { colorLight: next.colorLight ?? have.colorLight, colorDark: next.colorDark ?? have.colorDark }
88
+ const out = setIdentityText(text, id)
89
+ if (out !== text) atomicWrite(path, out)
90
+ return { colorLight: id.colorLight.toLowerCase(), colorDark: id.colorDark.toLowerCase() }
91
+ }