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
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
// one repo's hub, alive: its deps resolved from repo.config.ts, its request
|
|
2
|
+
// handler, the save watch over its tracked files, its declared processes,
|
|
3
|
+
// and its RED pings. No server and nothing process wide: `shipkit serve` puts
|
|
4
|
+
// one behind a Bun.serve of its own (run-serve.ts), the daemon runs one per
|
|
5
|
+
// house repo behind /r/<repo>/ of its one server (home/hubs.ts).
|
|
6
|
+
//
|
|
7
|
+
// the one constraint a reader breaks first: a hub starts no run on its own.
|
|
8
|
+
// It joins the host's plan (plan/planner.ts), which decides what runs when,
|
|
9
|
+
// and a save only tells the plan that something moved.
|
|
10
|
+
//
|
|
11
|
+
// what mounts comes from convention + repo.config's `serve` block: the wiki
|
|
12
|
+
// graph is always on (custom provider via serve.wikiGraph, else the generic md
|
|
13
|
+
// scan), the base-context wiki when a CLAUDE.md include names one, agent/
|
|
14
|
+
// graph when the dir exists, the studio when .shipkit/assets/kit.ts loads.
|
|
15
|
+
// Port of a hub serving on its own: --port > serve.port > a deterministic
|
|
16
|
+
// house-range default (4800-4979, hashed from the repo name, stable across
|
|
17
|
+
// launches; pin serve.port when two repos collide).
|
|
18
|
+
import { existsSync, readFileSync, statSync } from 'fs'
|
|
19
|
+
import { homedir } from 'os'
|
|
20
|
+
import { basename, dirname, join, resolve, sep } from 'path'
|
|
21
|
+
import { loadKit, type ResolvedKit } from '../assets/kit-config.ts'
|
|
22
|
+
import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
|
|
23
|
+
import { projectNeeds } from '../collections/collection-types.ts'
|
|
24
|
+
import { comfyCollection } from '../collections/comfy.ts'
|
|
25
|
+
import { ComfyPanel, comfyBox, defaultComfyPanelPort, scanComfyWorkflows } from '../comfy/comfy-panel.ts'
|
|
26
|
+
import { wikisCollection } from '../collections/wikis.ts'
|
|
27
|
+
import { pingTick } from '../collections/pings.ts'
|
|
28
|
+
import { buildCollections } from '../collections/providers.ts'
|
|
29
|
+
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
30
|
+
import { CheckCtx } from '../core/check-ctx.ts'
|
|
31
|
+
import { proseView } from '../prose/prose-view.ts'
|
|
32
|
+
import { allTasks, gateInventory } from '../ledger/gate-checks.ts'
|
|
33
|
+
import { runCheck, type CheckEvent } from '../core/run-check.ts'
|
|
34
|
+
import { runCi, type CiOpts } from '../core/run-ci.ts'
|
|
35
|
+
import type { GraphMount } from '../wiki/graph-mount.ts'
|
|
36
|
+
import { scanMdGraph } from '../wiki/scan-md.ts'
|
|
37
|
+
import { readInvocations, readLive, readRuns, readRunsSince, runsPath, sweepLive } from '../ledger/run-log.ts'
|
|
38
|
+
import { LIVE_HISTORY_MS, shapeLive, type LiveView } from '../ledger/live-timeline.ts'
|
|
39
|
+
import { parallelCap } from '../core/parallel.ts'
|
|
40
|
+
import { autoGatePool, takeSlot } from '../core/slots.ts'
|
|
41
|
+
import { liveJobs, startJob } from '../core/jobs.ts'
|
|
42
|
+
import { followJob } from '../core/job-follow.ts'
|
|
43
|
+
import { RUN_JOB } from '../home/home-words.ts'
|
|
44
|
+
import { OtherRepos } from './other-repos.ts'
|
|
45
|
+
import { recordStamp } from './project-gutter.ts'
|
|
46
|
+
import { RepoProcesses } from './repo-processes.ts'
|
|
47
|
+
import { shapeTimeline } from '../ledger/run-timeline.ts'
|
|
48
|
+
import { readSuiteRuns, readSuites } from '../ledger/suite-log.ts'
|
|
49
|
+
import { shapeTestCheck, shapeTestsRoot } from '../collections/test-time.ts'
|
|
50
|
+
import type { TestFacts } from '../collections/collection-types.ts'
|
|
51
|
+
import { declaredSlowFiles } from '../suite/test-units.ts'
|
|
52
|
+
import { BUILD_TIMEOUT_MS, CHECK_TIMEOUT_MS } from '../collections/checks.ts'
|
|
53
|
+
import { watchTracked } from './tracked-watch.ts'
|
|
54
|
+
import { repoSource } from '../plan/repo-source.ts'
|
|
55
|
+
import type { PlanSource, Planner } from '../plan/planner.ts'
|
|
56
|
+
import { ProblemBoard } from '../problems/problem-board.ts'
|
|
57
|
+
import { HubProblems } from './hub-problems.ts'
|
|
58
|
+
import { hubHandler, type CheckTap, type HubDeps, type HubHandler } from './serve-hub.ts'
|
|
59
|
+
import type { projectGutter } from './project-gutter-route.ts'
|
|
60
|
+
import { defaultHubPort } from './hub-port.ts'
|
|
61
|
+
import { LAYOUT } from '../core/layout.ts'
|
|
62
|
+
export { defaultHubPort }
|
|
63
|
+
|
|
64
|
+
export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
// the BASE-CONTEXT wiki: the shared wiki this repo's CLAUDE.md include chain
|
|
68
|
+
// points at (`@~/dev/<other-repo>/wiki/index.md`). It lives in ANOTHER repo and
|
|
69
|
+
// binds every agent working here, so the hub makes it browsable. Never linted
|
|
70
|
+
// from here. The local-include forms (`@./agent/index.md`,
|
|
71
|
+
// `@./CLAUDE.local.md`) and any index inside this repo are skipped: those are
|
|
72
|
+
// already their own mounts.
|
|
73
|
+
export function baseWikiRoot(root: string): string | null {
|
|
74
|
+
const abs = resolve(root)
|
|
75
|
+
for (const f of ['CLAUDE.md', 'CLAUDE.local.md', 'CLAUDE.private.md']) {
|
|
76
|
+
const p = join(abs, f)
|
|
77
|
+
if (!existsSync(p)) continue
|
|
78
|
+
for (const line of readFileSync(p, 'utf8').split('\n')) {
|
|
79
|
+
const m = line.trim().match(/^@(\S+\.md)$/)
|
|
80
|
+
if (m == null) continue
|
|
81
|
+
const raw = m[1]!
|
|
82
|
+
const target = resolve(abs, raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : raw)
|
|
83
|
+
const dir = dirname(target)
|
|
84
|
+
if (!existsSync(target)) continue
|
|
85
|
+
if (dir === abs || dir.startsWith(abs + sep)) continue
|
|
86
|
+
return dir
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `~/dev/<repo>/wiki` reads as "<repo> wiki" — a dir literally named wiki says
|
|
93
|
+
// nothing on its own, so the owning repo names the card
|
|
94
|
+
/** the tab of the repo's own wiki, beside the house wiki named for its repo */
|
|
95
|
+
const WIKI_LABEL = "this repo's wiki"
|
|
96
|
+
const wikiLabel = (dir: string): string => (basename(dir) === 'wiki' ? `${basename(dirname(dir))} wiki` : basename(dir))
|
|
97
|
+
|
|
98
|
+
/** the config file's bytes, cheaply: a re-read costs a new module instance,
|
|
99
|
+
* so it happens only when the file really moved */
|
|
100
|
+
export function configStamp(root: string): string {
|
|
101
|
+
try {
|
|
102
|
+
const st = statSync(join(root, 'repo.config.ts'))
|
|
103
|
+
return `${st.mtimeMs}:${st.size}`
|
|
104
|
+
} catch {
|
|
105
|
+
return ''
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** a host that runs every gate as a detached job (the daemon): the cli the
|
|
110
|
+
* job runs, so a reload of the host never kills a gate in flight */
|
|
111
|
+
export type GateJobs = { cli: string; pollMs?: number }
|
|
112
|
+
|
|
113
|
+
export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo, o?: { port?: number; base?: string; jobs?: GateJobs; planName?: string }): Promise<HubDeps & { plan: PlanSource }> {
|
|
114
|
+
// THE SWITCH WRITES repo.config.ts, so the surfaces built on the config
|
|
115
|
+
// read the one on disk rather than the one this process booted with. A
|
|
116
|
+
// toggle that never reached the rows would read as a toggle that did
|
|
117
|
+
// nothing. What is resolved ONCE stays once: the kit, the graphs, the
|
|
118
|
+
// studio and the comfy panel are mounts, and remounting a server under a
|
|
119
|
+
// live page is a different question from re-reading a declaration.
|
|
120
|
+
let c = boot
|
|
121
|
+
let stamp = configStamp(root)
|
|
122
|
+
const refresh = async (): Promise<void> => {
|
|
123
|
+
const now = configStamp(root)
|
|
124
|
+
if (now === stamp || now === '') return
|
|
125
|
+
stamp = now
|
|
126
|
+
try {
|
|
127
|
+
c = await loadRepoConfig(root, { fresh: true })
|
|
128
|
+
} catch (e) {
|
|
129
|
+
io.print(`WARN repo.config.ts did not load, so the page is reading the one this process booted with — ${(e as Error).message}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ONE list: the routes, the /hub.json tools and the wikis section all
|
|
134
|
+
// read it, so nothing can advertise a graph the server does not serve.
|
|
135
|
+
// The repo's OWN wiki mounts when it has one (a game repo's hub
|
|
136
|
+
// advertising an empty wiki route was startup noise), a config-declared
|
|
137
|
+
// provider always mounts, and the base-context wiki (the shared one named
|
|
138
|
+
// by the CLAUDE.md include chain) mounts wherever it is reachable: an
|
|
139
|
+
// agent working here obeys that wiki, so a reader can open it.
|
|
140
|
+
const graphs: GraphMount[] = []
|
|
141
|
+
if (c.serve?.wikiGraph != null)
|
|
142
|
+
graphs.push({ id: 'wiki', label: WIKI_LABEL, title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load, lint: c.serve.wikiGraph.lint != null ? { checks: c.serve.wikiGraph.lint } : { root } })
|
|
143
|
+
else if (existsSync(join(root, 'wiki')))
|
|
144
|
+
graphs.push({ id: 'wiki', label: WIKI_LABEL, title: `${c.name} wiki`, load: () => scanMdGraph({ root }), lint: { root } })
|
|
145
|
+
const baseWiki = baseWikiRoot(root)
|
|
146
|
+
if (baseWiki != null)
|
|
147
|
+
graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), lint: null })
|
|
148
|
+
const agentRoot = join(root, 'agent')
|
|
149
|
+
if (existsSync(agentRoot))
|
|
150
|
+
graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
|
|
151
|
+
// ONE page for every repo: the studio ALWAYS
|
|
152
|
+
// mounts — kit-less repos get an empty media section, never a second
|
|
153
|
+
// page. A broken kit degrades to the same empty kit, loudly.
|
|
154
|
+
let kit: ResolvedKit | null = null
|
|
155
|
+
if (existsSync(join(root, '.shipkit/assets/kit.ts'))) {
|
|
156
|
+
try {
|
|
157
|
+
kit = await loadKit(root)
|
|
158
|
+
} catch (e) {
|
|
159
|
+
io.print(`WARN kit not loaded (media section empty): ${(e as Error).message}`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const emptyKit: ResolvedKit = { kinds: [], workspace: '.shipkit/assets/out' }
|
|
163
|
+
let studio: StudioRoutes | null = null
|
|
164
|
+
try {
|
|
165
|
+
studio = await studioRoutes({ kit: kit ?? emptyKit, root })
|
|
166
|
+
} catch (e) {
|
|
167
|
+
io.print(`WARN media API not mounted: ${(e as Error).message}`)
|
|
168
|
+
}
|
|
169
|
+
// a repo with no workflow module gets NO comfy section and no panel
|
|
170
|
+
// process: an empty section header is a surface that teaches nothing
|
|
171
|
+
const hasComfy = scanComfyWorkflows(root).length > 0
|
|
172
|
+
const panel = hasComfy ? new ComfyPanel({ root, port: c.serve?.comfyPort ?? defaultComfyPanelPort(c.name) }) : null
|
|
173
|
+
const allCollections = () => [
|
|
174
|
+
...buildCollections(root, c, kit),
|
|
175
|
+
...(hasComfy ? [comfyCollection(root)] : []),
|
|
176
|
+
wikisCollection(graphs, root),
|
|
177
|
+
]
|
|
178
|
+
const port = o?.port ?? c.serve?.port ?? defaultHubPort(c.name)
|
|
179
|
+
// every run this process has going, whoever started it
|
|
180
|
+
let active = 0
|
|
181
|
+
// ONE run at a time, whoever asked: two gates over one tree would measure
|
|
182
|
+
// the same checks twice and race each other's repair pass
|
|
183
|
+
let chain: Promise<unknown> = Promise.resolve()
|
|
184
|
+
// the latest verdict of every check, printed as one problem frame after
|
|
185
|
+
// each run for the vscode problems pane (problems/problem-line.ts)
|
|
186
|
+
const board = new ProblemBoard()
|
|
187
|
+
// what is wrong with the hub itself rides the same frame, the moment it
|
|
188
|
+
// moves, so the pane never reads 0 while the page shows a hub red
|
|
189
|
+
const hubProblems = new HubProblems((all) => {
|
|
190
|
+
if (board.setHub(all)) for (const l of board.frame()) io.print(l)
|
|
191
|
+
})
|
|
192
|
+
const jobs = o?.jobs ?? null
|
|
193
|
+
// the frame the problems pane reads: one per verdict that moved the board
|
|
194
|
+
const onBoard = (e: CheckEvent): void => { if (board.take(e)) for (const l of board.frame()) io.print(l) }
|
|
195
|
+
// ONE way a run executes. In process for a hub of its own; as a detached
|
|
196
|
+
// job in the daemon, which follows it: its lines and verdicts reach the
|
|
197
|
+
// same taps, and a reload of the daemon leaves it running
|
|
198
|
+
const exec = async (kind: 'ci' | 'check', opts: Record<string, unknown>, tap: CheckTap, background: boolean): Promise<number> => {
|
|
199
|
+
if (jobs == null) {
|
|
200
|
+
const turn = background ? await takeSlot(autoGatePool(), { repo: c.name, check: 'auto gate' }, { pollMs: 250 }) : null
|
|
201
|
+
try {
|
|
202
|
+
if (kind === 'ci') return await runCi(root, { ...(opts as CiOpts), print: tap.line, config: c, onCheck: tap.check })
|
|
203
|
+
return await runCheck(root, { ...(opts as Parameters<typeof runCheck>[1]), print: tap.line, config: c, onCheck: tap.check })
|
|
204
|
+
} finally {
|
|
205
|
+
turn?.release()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// a job still running here, started before this image or by a terminal's --detach
|
|
209
|
+
while (liveJobs(root).length > 0) await Bun.sleep(jobs.pollMs ?? 250)
|
|
210
|
+
const j = startJob(root, { cli: jobs.cli, kind, args: [], job: { opts, oneLib: true, turn: background } })
|
|
211
|
+
tap.job?.(j.id)
|
|
212
|
+
return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
|
|
213
|
+
}
|
|
214
|
+
const runChecks: HubDeps['runChecks'] = (tap, o) => {
|
|
215
|
+
const run = chain.then(async () => {
|
|
216
|
+
active++
|
|
217
|
+
// a run the hub starts on its own takes the machine's background
|
|
218
|
+
// turn, once it is this hub's turn, never while it queues
|
|
219
|
+
const background = o?.auto === true
|
|
220
|
+
try {
|
|
221
|
+
return await runIn({
|
|
222
|
+
...tap,
|
|
223
|
+
check: (e) => {
|
|
224
|
+
// a frame per verdict that moved the board, so a tsc
|
|
225
|
+
// error reaches the pane when typecheck ends rather
|
|
226
|
+
// than when the whole gate does
|
|
227
|
+
onBoard(e)
|
|
228
|
+
tap.check(e)
|
|
229
|
+
},
|
|
230
|
+
}, o, background)
|
|
231
|
+
} finally {
|
|
232
|
+
active--
|
|
233
|
+
// and one per run, so every run ends on a frame
|
|
234
|
+
for (const l of board.frame()) io.print(l)
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
chain = run.catch(() => {})
|
|
238
|
+
return run
|
|
239
|
+
}
|
|
240
|
+
// a `shipkit ci` or `shipkit check` from a terminal, run HERE (home/via-hub.ts):
|
|
241
|
+
// in the same one-at-a-time chain as the hub's own runs, feeding the same
|
|
242
|
+
// problems frame, recorded as the cli's
|
|
243
|
+
const runCli: HubDeps['runCli'] = (kind, opts, print) => {
|
|
244
|
+
// never on a --json run: its output is the json and nothing else
|
|
245
|
+
if ((active > 0 || (jobs != null && liveJobs(root).length > 0)) && opts.json !== true) print('note: this hub has a run in flight, so this one starts when it ends')
|
|
246
|
+
const run = chain.then(async () => {
|
|
247
|
+
active++
|
|
248
|
+
try {
|
|
249
|
+
await refresh()
|
|
250
|
+
return await exec(kind, { ...opts, invokedBy: 'cli' }, { line: print, check: onBoard, job: (id) => print(`${RUN_JOB}${id}`) }, false)
|
|
251
|
+
} finally {
|
|
252
|
+
active--
|
|
253
|
+
for (const l of board.frame()) io.print(l)
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
chain = run.catch(() => {})
|
|
257
|
+
return run
|
|
258
|
+
}
|
|
259
|
+
const runIn = async (tap: CheckTap, o: Parameters<HubDeps['runChecks']>[1], background: boolean): Promise<number> => {
|
|
260
|
+
const only = o?.only != null && o.only.length > 0 ? o.only : undefined
|
|
261
|
+
const by = o?.by ?? 'hub'
|
|
262
|
+
const buildIds = new Set((await allTasks(c, root)).tasks.map((t) => t.id))
|
|
263
|
+
const namedTasks = (only ?? []).filter((id) => buildIds.has(id))
|
|
264
|
+
// ONE runner, two questions: a build step is the gate's, so
|
|
265
|
+
// anything naming one (or asking for the gate itself) goes through
|
|
266
|
+
// runCi, and a set of everything else takes the health pass
|
|
267
|
+
if (o?.gate === true || namedTasks.length > 0)
|
|
268
|
+
return exec('ci', { ...hubGateOpts({ only, auto: o?.auto === true }), buildOnly: only != null && namedTasks.length === only.length, invokedBy: by }, tap, background)
|
|
269
|
+
// a NARROWED check run must not stamp STATUS.md: that file carries
|
|
270
|
+
// the verdict of a whole run, and one check is not one
|
|
271
|
+
return exec('check', { only, status: only == null, invokedBy: by }, tap, background)
|
|
272
|
+
}
|
|
273
|
+
// a daemon image that boots while a job of its previous image still runs
|
|
274
|
+
// follows it to its end: its verdicts reach the problems pane, and the
|
|
275
|
+
// next run queues behind it
|
|
276
|
+
if (jobs != null) {
|
|
277
|
+
const adopted = liveJobs(root)
|
|
278
|
+
if (adopted.length > 0) {
|
|
279
|
+
io.print(`ok: following ${adopted.length} gate job(s) still running: ${adopted.map((j) => j.id).join(', ')}`)
|
|
280
|
+
active++
|
|
281
|
+
const run = Promise.all(adopted.map((j) => followJob(root, j.id, { line: () => {}, check: onBoard }, { pollMs: jobs.pollMs }))).finally(() => {
|
|
282
|
+
active--
|
|
283
|
+
for (const l of board.frame()) io.print(l)
|
|
284
|
+
})
|
|
285
|
+
chain = run.catch(() => {})
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// resolved once: it shells out to git, and the page asks on every poll
|
|
289
|
+
let record = ''
|
|
290
|
+
try { record = runsPath(root) } catch { record = '' }
|
|
291
|
+
// nothing runs in this process yet, so a live row holding its pid was
|
|
292
|
+
// left by an earlier image of it: `bun --watch` restarts in place
|
|
293
|
+
if (record !== '') sweepLive(record, Date.now(), { ownPid: process.pid })
|
|
294
|
+
// the live strip asks every second, from every open tab: the record is
|
|
295
|
+
// read only when its files moved, and one shaped answer serves every
|
|
296
|
+
// request inside LIVE_SHARE_MS
|
|
297
|
+
const others = new OtherRepos(root)
|
|
298
|
+
let local: { stamp: string; at: number; live: ReturnType<typeof readLive>; runs: ReturnType<typeof readRunsSince>; invocations: ReturnType<typeof readInvocations> } | null = null
|
|
299
|
+
let shaped: { at: number; view: LiveView } | null = null
|
|
300
|
+
const LIVE_SHARE_MS = 500
|
|
301
|
+
const live = (): LiveView => {
|
|
302
|
+
const now = Date.now()
|
|
303
|
+
if (shaped != null && now - shaped.at < LIVE_SHARE_MS) return shaped.view
|
|
304
|
+
const stamp = recordStamp(record)
|
|
305
|
+
// the window slides even when nothing is written, so the history is
|
|
306
|
+
// re-read at least every few seconds
|
|
307
|
+
if (local == null || local.stamp !== stamp || now - local.at > 5_000) {
|
|
308
|
+
const since = now - LIVE_HISTORY_MS
|
|
309
|
+
local = { stamp, at: now, live: readLive(record), runs: readRunsSince(record, since), invocations: readInvocations(record, since) }
|
|
310
|
+
}
|
|
311
|
+
const view = shapeLive({ now, cap: parallelCap(), live: local.live, runs: local.runs, invocations: local.invocations, others: others.read() })
|
|
312
|
+
shaped = { at: now, view }
|
|
313
|
+
return view
|
|
314
|
+
}
|
|
315
|
+
// what the plan asks of this repo: its checks, their last results, what
|
|
316
|
+
// a save moved, and a batch through this runner, in the background
|
|
317
|
+
const plan = repoSource({
|
|
318
|
+
repo: o?.planName ?? c.name,
|
|
319
|
+
root,
|
|
320
|
+
config: () => c,
|
|
321
|
+
record,
|
|
322
|
+
run: (ids, g, why) => runChecks({ line: () => {}, check: () => {} }, { only: ids, gate: g.gate, by: why === 'asked' ? 'hub' : 'schedule', auto: true }),
|
|
323
|
+
})
|
|
324
|
+
return {
|
|
325
|
+
root,
|
|
326
|
+
name: c.name,
|
|
327
|
+
uses: c.serve?.uses ?? [],
|
|
328
|
+
processes: new RepoProcesses(root, c.serve?.processes ?? []),
|
|
329
|
+
port,
|
|
330
|
+
graphs,
|
|
331
|
+
studio,
|
|
332
|
+
refresh,
|
|
333
|
+
// the config and the run record, by their stats: what the warm rows
|
|
334
|
+
// and the runs page read besides the tracked files the watch sees
|
|
335
|
+
stamp: () => `${configStamp(root)}|${record === '' ? '' : recordStamp(record)}`,
|
|
336
|
+
runCli,
|
|
337
|
+
// the run happens IN THIS PROCESS, so every check result reaches the
|
|
338
|
+
// page as it finishes. A shelled run could only ever deliver one wall
|
|
339
|
+
// of text, which is what made seventy checks share one destination
|
|
340
|
+
runChecks,
|
|
341
|
+
plan,
|
|
342
|
+
// every run in flight, whoever started it: they all write the same
|
|
343
|
+
// record, so a terminal gate shows here the same as a button's
|
|
344
|
+
live,
|
|
345
|
+
repoHub: (repo) => others.hubOf(repo),
|
|
346
|
+
timeline: async () => {
|
|
347
|
+
const ceilings: Record<string, number> = {}
|
|
348
|
+
const budgets: Record<string, number> = {}
|
|
349
|
+
const inventory = await gateInventory(root, c)
|
|
350
|
+
for (const row of inventory) {
|
|
351
|
+
ceilings[row.decl.id] = row.timeoutMs ?? (row.build ? BUILD_TIMEOUT_MS : CHECK_TIMEOUT_MS)
|
|
352
|
+
budgets[row.decl.id] = row.budgetMs
|
|
353
|
+
}
|
|
354
|
+
return shapeTimeline({
|
|
355
|
+
runs: readRuns(record),
|
|
356
|
+
invocations: readInvocations(record),
|
|
357
|
+
suites: readSuites(record),
|
|
358
|
+
ceilings,
|
|
359
|
+
budgets,
|
|
360
|
+
inventory: inventory.map((row) => row.decl.id),
|
|
361
|
+
declaredSlow: declaredSlowFiles(c),
|
|
362
|
+
cap: parallelCap(),
|
|
363
|
+
})
|
|
364
|
+
},
|
|
365
|
+
tests: async (check) => {
|
|
366
|
+
// the SAME test files the tree lists, from the same provider
|
|
367
|
+
const col = allCollections().find((x) => x.id === 'tests')
|
|
368
|
+
const facts = col == null ? [] : (await col.list()).flatMap((i): TestFacts[] => (i.test == null ? [] : [i.test]))
|
|
369
|
+
const input = { facts, runs: readRuns(record), suiteRuns: readSuiteRuns(record) }
|
|
370
|
+
return check == null || check === '' ? shapeTestsRoot(input) : shapeTestCheck(check, input)
|
|
371
|
+
},
|
|
372
|
+
prose: async () => proseView(CheckCtx.open({ root, config: c }), c),
|
|
373
|
+
needs: () => projectNeeds(allCollections(), hubProblems.collector),
|
|
374
|
+
hubProblems,
|
|
375
|
+
// a job outlives this image, so only an in process run holds a reload back
|
|
376
|
+
busy: () => jobs == null && active > 0,
|
|
377
|
+
collections: allCollections,
|
|
378
|
+
asksDir: c.asks?.dir ?? LAYOUT.asks,
|
|
379
|
+
comfy: panel == null ? null : { ensure: () => panel.ensure(), restart: () => panel.restart(), box: () => comfyBox(root), stop: () => panel.stop() },
|
|
380
|
+
// RED-only pings while serving; notify command is repo config data.
|
|
381
|
+
// Delivery failure must be LOUD — a silently broken notify path means
|
|
382
|
+
// a silent one reads as no blockers at all
|
|
383
|
+
pingTick: (needs) =>
|
|
384
|
+
pingTick(root, c.asks?.dir ?? LAYOUT.asks, needs, {
|
|
385
|
+
link: c.serve?.publicHost != null ? `http://${c.serve.publicHost}:${port}${o?.base ?? ''}/?f=needs` : undefined,
|
|
386
|
+
run: (cmd) => {
|
|
387
|
+
const p = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' })
|
|
388
|
+
void p.exited.then(async (code) => {
|
|
389
|
+
if (code !== 0) io.printErr(`WARN ping delivery failed (exit ${code}): ${await new Response(p.stderr).text().then((s) => s.trim().slice(0, 160))}`)
|
|
390
|
+
})
|
|
391
|
+
},
|
|
392
|
+
notifyCommand: c.notifyCommand,
|
|
393
|
+
}),
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** a run the hub starts on its own waits for the machine's one background
|
|
398
|
+
* turn (autoGatePool), so several hubs never gate at once */
|
|
399
|
+
export async function inAutoTurn<T>(repo: string, run: () => Promise<T>, pool = autoGatePool()): Promise<T> {
|
|
400
|
+
const turn = await takeSlot(pool, { repo, check: 'auto gate' }, { pollMs: 250 })
|
|
401
|
+
try {
|
|
402
|
+
return await run()
|
|
403
|
+
} finally {
|
|
404
|
+
turn?.release()
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** how a gate the hub starts differs from `shipkit ci`. The gate repairs and
|
|
409
|
+
* stamps STATUS.md, a narrowed run does neither: one check is not the verdict
|
|
410
|
+
* of a gate. A run the hub starts ON ITS OWN never mutates the tree someone
|
|
411
|
+
* is editing: no repair pass, frozen (SK005 never updates a dependency), no
|
|
412
|
+
* status files, and the quick tier */
|
|
413
|
+
export function hubGateOpts(o: { only?: string[]; auto: boolean }): Pick<CiOpts, 'onlyIds' | 'fix' | 'status' | 'frozen' | 'tests'> {
|
|
414
|
+
const whole = o.only == null
|
|
415
|
+
if (o.auto) return { onlyIds: o.only, fix: false, status: false, frozen: true, tests: 'quick' }
|
|
416
|
+
return { onlyIds: o.only, fix: whole, status: whole }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** how often the RED ping looks, on a hub that owns its timers */
|
|
420
|
+
export const PING_EVERY_MS = 60_000
|
|
421
|
+
|
|
422
|
+
export type HubRuntime = {
|
|
423
|
+
name: string
|
|
424
|
+
config: RepoConfig
|
|
425
|
+
deps: HubDeps
|
|
426
|
+
handler: HubHandler
|
|
427
|
+
/** the live half: the save watch, its place in the plan, the declared
|
|
428
|
+
* processes, and the ping interval when the runtime owns it. Nothing
|
|
429
|
+
* runs before it, so a hub that could not bind its port started nothing */
|
|
430
|
+
start: () => void
|
|
431
|
+
/** one RED ping tick */
|
|
432
|
+
ping: () => Promise<void>
|
|
433
|
+
/** a run is in flight: a reload now would kill it */
|
|
434
|
+
busy: () => boolean
|
|
435
|
+
/** everything this runtime started stops, and it leaves the plan. `procs`
|
|
436
|
+
* also stops the repo's declared processes: a real stop of the hub,
|
|
437
|
+
* never a process restart */
|
|
438
|
+
stop: (o?: { procs?: boolean }) => void
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export type LoadHubOpts = {
|
|
442
|
+
/** where the page sits on the server that answers: '' or '/r/<repo>' */
|
|
443
|
+
base?: string
|
|
444
|
+
port?: number
|
|
445
|
+
/** the rail, shared when one process serves many repos */
|
|
446
|
+
projects?: ReturnType<typeof projectGutter>
|
|
447
|
+
/** the plan this hub joins: the daemon's one for the machine, or a hub
|
|
448
|
+
* of its own's one for its repo. It decides every run nobody asked for */
|
|
449
|
+
plan: Planner
|
|
450
|
+
/** the repo's name in that plan, which the activity signals use */
|
|
451
|
+
planName?: string
|
|
452
|
+
/** a start counts as a save: the checks whose answer would not replay
|
|
453
|
+
* run once it settles. Off where one process loads many repos */
|
|
454
|
+
gateAtStart: boolean
|
|
455
|
+
/** the ping interval runs here. Off = the host calls ping() itself */
|
|
456
|
+
ownTimers: boolean
|
|
457
|
+
/** re-import repo.config.ts rather than reuse the module already loaded */
|
|
458
|
+
fresh?: boolean
|
|
459
|
+
/** overrides where a lane click of the live strip sends another repo */
|
|
460
|
+
repoHub?: HubDeps['repoHub']
|
|
461
|
+
/** every gate runs as a detached job this host follows */
|
|
462
|
+
jobs?: GateJobs
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** how long a save burst settles before the warm answers recompute */
|
|
466
|
+
export const WARM_SETTLE_MS = 800
|
|
467
|
+
|
|
468
|
+
/** the hubs of this process warm one after the other: twenty repos walking
|
|
469
|
+
* at once would slow the one a page is waiting on */
|
|
470
|
+
let warming: Promise<void> = Promise.resolve()
|
|
471
|
+
|
|
472
|
+
export async function loadHub(root: string, io: ServeIo, o: LoadHubOpts): Promise<HubRuntime> {
|
|
473
|
+
const c = await loadRepoConfig(root, { fresh: o.fresh === true })
|
|
474
|
+
const deps = await resolveHubDeps(root, c, io, { port: o.port, base: o.base, jobs: o.jobs, planName: o.planName })
|
|
475
|
+
if (o.repoHub != null) deps.repoHub = o.repoHub
|
|
476
|
+
const name = deps.plan.repo
|
|
477
|
+
deps.planned = () => o.plan.rows(name)
|
|
478
|
+
deps.ask = (check) => {
|
|
479
|
+
const ok = o.plan.ask(name, check)
|
|
480
|
+
if (ok) o.plan.tick()
|
|
481
|
+
return ok
|
|
482
|
+
}
|
|
483
|
+
const handler = hubHandler(deps, { base: o.base, projects: o.projects })
|
|
484
|
+
const procs = deps.processes
|
|
485
|
+
// tick failures are LOUD (the hub's own lines), never swallowed
|
|
486
|
+
const ping = async (): Promise<void> => {
|
|
487
|
+
if (deps.pingTick == null) return
|
|
488
|
+
try {
|
|
489
|
+
deps.pingTick(await deps.needs())
|
|
490
|
+
} catch (e) {
|
|
491
|
+
io.printErr(`WARN ping tick broke: ${(e as Error).message}`)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const live: { stop: () => void }[] = []
|
|
495
|
+
const start = (): void => {
|
|
496
|
+
// the plan hears every save burst and asks the ledger what it moved.
|
|
497
|
+
// The watch decides nothing: the runner measures or replays like any run
|
|
498
|
+
void o.plan.add(deps.plan).then(() => {
|
|
499
|
+
// a start is a change like a save
|
|
500
|
+
if (o.gateAtStart) o.plan.touched(name)
|
|
501
|
+
})
|
|
502
|
+
// a burst of saves is one recompute of the warm answers, once it settles
|
|
503
|
+
let settle: ReturnType<typeof setTimeout> | null = null
|
|
504
|
+
const watcher = watchTracked(root, () => {
|
|
505
|
+
o.plan.touched(name)
|
|
506
|
+
if (settle != null) clearTimeout(settle)
|
|
507
|
+
settle = setTimeout(() => handler.invalidate(), WARM_SETTLE_MS)
|
|
508
|
+
}, { onError: (e) => io.printErr(`WARN the file watch stopped, so the plan hears no save until the hub reloads: ${e.message}`) })
|
|
509
|
+
if (watcher.error != null) io.printErr(`WARN no file watch (${watcher.error}), so the plan hears no save in this repo`)
|
|
510
|
+
live.push({ stop: () => watcher.close() }, { stop: () => o.plan.remove(name) }, { stop: () => { if (settle != null) clearTimeout(settle) } })
|
|
511
|
+
// the answers a page waits on, computed now, one hub at a time
|
|
512
|
+
// across the process, so a first visit reads them from memory
|
|
513
|
+
warming = warming.then(() => handler.warm())
|
|
514
|
+
// the repo's own processes: the auto ones start, a restart adopts the
|
|
515
|
+
// ones still running from their pid records
|
|
516
|
+
void procs?.startAuto()
|
|
517
|
+
if (procs != null && procs.size > 0) {
|
|
518
|
+
const t = setInterval(() => void procs.tick(), 2_000)
|
|
519
|
+
live.push({ stop: () => clearInterval(t) })
|
|
520
|
+
}
|
|
521
|
+
if (o.ownTimers) {
|
|
522
|
+
// one tick now (the state file guarantees a restart never re-fires), then every minute
|
|
523
|
+
void ping()
|
|
524
|
+
const t = setInterval(() => void ping(), PING_EVERY_MS)
|
|
525
|
+
live.push({ stop: () => clearInterval(t) })
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
name: c.name,
|
|
530
|
+
config: c,
|
|
531
|
+
deps,
|
|
532
|
+
handler,
|
|
533
|
+
start,
|
|
534
|
+
ping,
|
|
535
|
+
busy: () => deps.busy?.() === true,
|
|
536
|
+
stop: (s) => {
|
|
537
|
+
for (const l of live.splice(0)) l.stop()
|
|
538
|
+
if (s?.procs === true) procs?.stopAll()
|
|
539
|
+
handler.stop()
|
|
540
|
+
},
|
|
541
|
+
}
|
|
542
|
+
}
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
recordOf, recordStamp, RELEASE_TAGS, renderOrder, versionOf, vscodeStoragePath, type Gate, type WiredRepo,
|
|
20
20
|
} from './project-gutter.ts'
|
|
21
21
|
import { readLastStatusReadonly } from '../ledger/run-log.ts'
|
|
22
|
+
import { identityOfText, identityProblem, type Identity } from '../core/identity-color.ts'
|
|
23
|
+
import { writeIdentity } from '../core/identity.ts'
|
|
22
24
|
|
|
23
25
|
export type { Gate }
|
|
24
26
|
|
|
@@ -63,6 +65,8 @@ export type ProjectRow = {
|
|
|
63
65
|
release: Release
|
|
64
66
|
/** the deployed url `smoke.base` declares, or null */
|
|
65
67
|
live: string | null
|
|
68
|
+
/** the colours its repo.config.ts declares (SK024), null when it declares none */
|
|
69
|
+
identity: Identity | null
|
|
66
70
|
/** epoch ms of the newest of: the last commit, the last shipkit run, the
|
|
67
71
|
* newest Claude Code transcript. null when none of them exists */
|
|
68
72
|
activity: number | null
|
|
@@ -278,6 +282,7 @@ export function projectGutter(d: GutterDeps) {
|
|
|
278
282
|
gate: gateOfRepo(r.path),
|
|
279
283
|
release: facts[i]!.release,
|
|
280
284
|
live: liveUrlOf(config),
|
|
285
|
+
identity: identityOfText(config),
|
|
281
286
|
activity: lastActivity(facts[i]!.commitAt, lastRunAt(r.path), transcripts.get(r.path)),
|
|
282
287
|
}
|
|
283
288
|
})
|
|
@@ -321,6 +326,22 @@ export function projectGutter(d: GutterDeps) {
|
|
|
321
326
|
headers: { 'content-type': ICON_TYPES[r.icon.split('.').pop()!] ?? 'application/octet-stream', 'cache-control': 'max-age=86400' },
|
|
322
327
|
})
|
|
323
328
|
}
|
|
329
|
+
if (path === '/api/projects/identity' && req.method === 'POST') {
|
|
330
|
+
if (repos.length === 0) await answer()
|
|
331
|
+
const b = (await req.json().catch(() => ({}))) as { name?: unknown; colorLight?: unknown; colorDark?: unknown }
|
|
332
|
+
const r = find(String(b.name))
|
|
333
|
+
if (r == null) return new Response(`not a house repo: ${String(b.name)}`, { status: 404 })
|
|
334
|
+
// a colour left out keeps the config's own, so only the sent ones are checked
|
|
335
|
+
const bad = identityProblem({ colorLight: b.colorLight ?? '#000000', colorDark: b.colorDark ?? '#000000' })
|
|
336
|
+
if (bad != null) return new Response(bad, { status: 400 })
|
|
337
|
+
try {
|
|
338
|
+
const id = writeIdentity(r.path, { colorLight: b.colorLight as string | undefined, colorDark: b.colorDark as string | undefined })
|
|
339
|
+
if (last != null) last = { ...last, projects: last.projects.map((p) => (p.name === r.name ? { ...p, identity: id } : p)) }
|
|
340
|
+
return Response.json({ identity: id })
|
|
341
|
+
} catch (e) {
|
|
342
|
+
return new Response((e as Error).message, { status: 409 })
|
|
343
|
+
}
|
|
344
|
+
}
|
|
324
345
|
if (path === '/api/projects/order' && req.method === 'POST') {
|
|
325
346
|
if (repos.length === 0) await answer()
|
|
326
347
|
const b = (await req.json().catch(() => ({}))) as { names?: unknown }
|
|
@@ -46,8 +46,9 @@ export function houseIndex(repo: string, home: string): string | null {
|
|
|
46
46
|
return targets.find((t) => !inside(t, repo)) ?? targets.find((t) => !basename(t).startsWith('CLAUDE')) ?? null
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/** a repo's own icon, first hit wins.
|
|
50
|
-
|
|
49
|
+
/** a repo's own icon, first hit wins. `.shipkit/identity/icon.png` is the rail
|
|
50
|
+
* icon a repo with no public surface carries, the identity kit writes public/ */
|
|
51
|
+
export const ICON_FILES = ['.shipkit/identity/icon.png', 'public/favicon.png', 'public/favicon.svg', 'public/favicon.ico', 'favicon.ico', 'public/logo.png']
|
|
51
52
|
|
|
52
53
|
export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
|
|
53
54
|
|