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.
- package/CHANGELOG.md +67 -0
- package/package.json +3 -1
- package/src/check.ts +24 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
- package/src/checks/SK023-stores-under-cap.check.ts +49 -0
- package/src/checks/SK024-identity-colors.check.ts +49 -0
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +18 -5
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli.ts +22 -2
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/attach.ts +14 -9
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +40 -18
- package/src/core/discover-checks.ts +36 -18
- package/src/core/identity-color.ts +203 -0
- package/src/core/identity.ts +91 -0
- package/src/core/job-follow.ts +102 -0
- package/src/core/job-run.ts +27 -0
- package/src/core/jobs.ts +87 -27
- package/src/core/legacy-shapes.ts +1 -14
- package/src/core/machine-db.ts +83 -0
- package/src/core/one-lib.ts +85 -0
- package/src/core/port-holders.ts +43 -0
- package/src/core/retention.ts +213 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +3 -0
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +86 -0
- package/src/home/gui-app/ShipkitGui.swift +159 -0
- package/src/home/gui-app.ts +99 -0
- package/src/home/handoff.ts +51 -0
- package/src/home/home-launchd.ts +104 -0
- package/src/home/home-paths.ts +88 -0
- package/src/home/home-routes.ts +208 -0
- package/src/home/home-start.ts +105 -0
- package/src/home/home-state.ts +43 -0
- package/src/home/home-words.ts +12 -0
- package/src/home/hubs.ts +286 -0
- package/src/home/lib-watch.ts +139 -0
- package/src/home/machine-stores.ts +115 -0
- package/src/home/open-app.ts +16 -0
- package/src/home/pm-config.ts +37 -0
- package/src/home/pm.ts +141 -0
- package/src/home/resources.ts +338 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +149 -0
- package/src/home/run-unit-action.ts +25 -0
- package/src/home/run-web.ts +37 -0
- package/src/home/serve-daemon.ts +31 -0
- package/src/home/serve-web.ts +172 -0
- package/src/home/supervise.ts +346 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widgets.ts +102 -0
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +5 -0
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/run-log.ts +58 -23
- package/src/ledger/store.ts +228 -103
- package/src/ledger/world-cache.ts +15 -13
- package/src/plan/cadence.ts +237 -0
- package/src/plan/planner.ts +303 -0
- package/src/plan/repo-source.ts +100 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/repo-config.ts +51 -2
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +1 -1
- package/src/serve/hub-app/checks-svc.ts +12 -8
- package/src/serve/hub-app/col-card.tsx +4 -4
- package/src/serve/hub-app/cols-svc.ts +92 -13
- package/src/serve/hub-app/comfy-svc.ts +12 -6
- package/src/serve/hub-app/comfy-view.tsx +3 -3
- package/src/serve/hub-app/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +1 -1
- package/src/serve/hub-app/header-bar.tsx +44 -12
- package/src/serve/hub-app/health-svc.ts +3 -3
- package/src/serve/hub-app/help.tsx +10 -7
- package/src/serve/hub-app/home-st.ts +241 -0
- package/src/serve/hub-app/home-strip.css +70 -0
- package/src/serve/hub-app/home-strip.tsx +245 -0
- package/src/serve/hub-app/home.html +17 -0
- package/src/serve/hub-app/hub-app.tsx +201 -93
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +43 -23
- package/src/serve/hub-app/icons.tsx +14 -0
- package/src/serve/hub-app/index.html +3 -1
- package/src/serve/hub-app/keys.ts +25 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +12 -9
- package/src/serve/hub-app/machine-st.ts +270 -0
- package/src/serve/hub-app/media-svc.ts +63 -44
- package/src/serve/hub-app/media-view.tsx +20 -20
- package/src/serve/hub-app/next-run.ts +24 -30
- package/src/serve/hub-app/palette-st.ts +88 -0
- package/src/serve/hub-app/planned-st.ts +171 -0
- package/src/serve/hub-app/planned-view.tsx +109 -0
- package/src/serve/hub-app/planned.css +39 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +97 -0
- package/src/serve/hub-app/project-list.tsx +167 -0
- package/src/serve/hub-app/projects-svc.ts +139 -17
- package/src/serve/hub-app/prose-hits-st.ts +3 -3
- package/src/serve/hub-app/prose-hits-view.tsx +6 -6
- package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/screen-cache.ts +74 -0
- package/src/serve/hub-app/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +10 -10
- package/src/serve/hub-app/timeline-chart.tsx +24 -12
- package/src/serve/hub-app/timeline-svc.ts +5 -4
- package/src/serve/hub-app/timeline-view.tsx +13 -13
- package/src/serve/hub-app/tree-row.tsx +2 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-app/wikis-view.tsx +10 -6
- package/src/serve/hub-runtime.ts +542 -0
- package/src/serve/project-gutter-route.ts +21 -0
- package/src/serve/project-gutter.ts +3 -2
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +142 -44
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +114 -0
- package/src/shipkit.ts +2 -1
- package/src/suite/kept-timings.ts +1 -1
- package/src/wiki/graph-positions.ts +1 -1
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
package/src/comfy/comfy-panel.ts
CHANGED
|
@@ -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 {
|
package/src/core/attach.ts
CHANGED
|
@@ -39,9 +39,11 @@ export type ShareOpts = {
|
|
|
39
39
|
abort?: () => boolean
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** how long
|
|
43
|
-
*
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
try { if (statSync(p).mtimeMs < now -
|
|
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
|
-
|
|
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 */
|
package/src/core/check-runner.ts
CHANGED
|
@@ -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
|
|
350
|
-
* the pid must not be running
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
381
|
+
return []
|
|
372
382
|
}
|
|
373
|
-
return dir
|
|
374
383
|
}
|
|
375
384
|
|
|
376
|
-
/**
|
|
377
|
-
|
|
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(
|
|
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
|
-
|
|
111
|
-
//
|
|
112
|
-
|
|
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
|
+
}
|