rvlib-shipkit 0.38.0 → 1.0.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 +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// the project gutter, the serving half: /api/projects.json answers every
|
|
2
|
+
// sibling repo that includes this repo's house wiki index, measured at most
|
|
3
|
+
// once per POLL_MS whatever the number of tabs asking. The release distance
|
|
4
|
+
// and the uncommitted count ask git only when a stat says a commit, a tag or
|
|
5
|
+
// the index moved, a few repos per poll, and a repo's run record is opened
|
|
6
|
+
// only when it moved, so a steady poll spawns ps and lsof and nothing else. The icon route
|
|
7
|
+
// serves only the icon files a discovered repo carries, and POST
|
|
8
|
+
// /api/projects/open opens a discovered repo in VS Code: a request names a
|
|
9
|
+
// repo, never a path. The rail ORDER is one text file for the whole machine
|
|
10
|
+
// (ORDER_FILE), so every hub shows the same order and a hand edit is seen
|
|
11
|
+
// within one poll; POST /api/projects/order writes it.
|
|
12
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
|
|
13
|
+
import { readFile, stat } from 'fs/promises'
|
|
14
|
+
import { homedir } from 'os'
|
|
15
|
+
import { dirname, join } from 'path'
|
|
16
|
+
import {
|
|
17
|
+
asksDirOf, branchOf, countSessions, descriptionOf, dirtyCount, gateOf, gitStamp, houseRepos, iconStamp, ICON_TYPES, lastActivity,
|
|
18
|
+
lastRunAt, lastSessionAt, liveUrlOf, mergeOrder, openAsks, openFolders, parseClaudePids, parseDescribe, parseLsofCwd, parseOrder,
|
|
19
|
+
recordOf, recordStamp, RELEASE_TAGS, renderOrder, versionOf, vscodeStoragePath, type Gate, type WiredRepo,
|
|
20
|
+
} from './project-gutter.ts'
|
|
21
|
+
import { readLastStatusReadonly } from '../ledger/run-log.ts'
|
|
22
|
+
|
|
23
|
+
export type { Gate }
|
|
24
|
+
|
|
25
|
+
export const POLL_MS = 5_000
|
|
26
|
+
/** how long a release answer holds for a checkout whose `.git` is not a
|
|
27
|
+
* folder, since no stat can say when it moved */
|
|
28
|
+
export const RELEASE_TTL_MS = 60_000
|
|
29
|
+
/** git asks per measure, so a cold start spreads its ~20 repos over a few
|
|
30
|
+
* polls instead of spending half a second on the first one */
|
|
31
|
+
export const RELEASE_ASKS_PER_POLL = 6
|
|
32
|
+
/** the rail order, under the user's home: one repo name per line */
|
|
33
|
+
export const ORDER_FILE = '.config/shipkit/projects-order.txt'
|
|
34
|
+
|
|
35
|
+
/** the last release tag, the commits since it and when it was cut (epoch
|
|
36
|
+
* ms), `never` when no tag has a release shape, null when git cannot say */
|
|
37
|
+
export type Release = { tag: string; ahead: number; at: number } | 'never' | null
|
|
38
|
+
|
|
39
|
+
export type ProjectRow = {
|
|
40
|
+
name: string
|
|
41
|
+
path: string
|
|
42
|
+
/** the icon url, or null for the initial on a hashed colour */
|
|
43
|
+
icon: string | null
|
|
44
|
+
current: boolean
|
|
45
|
+
/** null = unknown (no window state on this machine) */
|
|
46
|
+
vscode: boolean | null
|
|
47
|
+
/** null = unknown (no ps or lsof) */
|
|
48
|
+
sessions: number | null
|
|
49
|
+
/** open asks waiting on a human in the repo's queue */
|
|
50
|
+
asks: number
|
|
51
|
+
/** their titles, in file order */
|
|
52
|
+
askTitles: string[]
|
|
53
|
+
/** package.json `description`, else the README's first line of prose */
|
|
54
|
+
description: string | null
|
|
55
|
+
/** package.json `version` */
|
|
56
|
+
version: string | null
|
|
57
|
+
/** the checked out branch, or `detached <sha>` */
|
|
58
|
+
branch: string | null
|
|
59
|
+
/** uncommitted paths as of the last git operation, null = unknown */
|
|
60
|
+
dirty: number | null
|
|
61
|
+
/** the last gate its own run record holds */
|
|
62
|
+
gate: Gate
|
|
63
|
+
release: Release
|
|
64
|
+
/** the deployed url `smoke.base` declares, or null */
|
|
65
|
+
live: string | null
|
|
66
|
+
/** epoch ms of the newest of: the last commit, the last shipkit run, the
|
|
67
|
+
* newest Claude Code transcript. null when none of them exists */
|
|
68
|
+
activity: number | null
|
|
69
|
+
}
|
|
70
|
+
export type ProjectsAnswer = { projects: ProjectRow[]; ms: number; at: number }
|
|
71
|
+
|
|
72
|
+
/** a command's exit code and stdout, or null when its binary is missing */
|
|
73
|
+
export type Run = (cmd: string[], cwd?: string) => Promise<{ code: number; out: string } | null>
|
|
74
|
+
|
|
75
|
+
export type GutterDeps = {
|
|
76
|
+
root: string
|
|
77
|
+
platform?: string
|
|
78
|
+
home?: string
|
|
79
|
+
run?: Run
|
|
80
|
+
/** opens a folder in VS Code; throws with the reason it could not */
|
|
81
|
+
open?: (path: string) => void
|
|
82
|
+
warn?: (line: string) => void
|
|
83
|
+
now?: () => number
|
|
84
|
+
/** where the rail order lives, default ~/ORDER_FILE */
|
|
85
|
+
orderFile?: string
|
|
86
|
+
/** the Claude Code `projects` folders, default the `projects` folder of every ~/.claude config root */
|
|
87
|
+
transcriptRoots?: string[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const runCmd: Run = async (cmd, cwd) => {
|
|
91
|
+
if (Bun.which(cmd[0]!) == null) return null
|
|
92
|
+
const p = Bun.spawn(cmd, { cwd, stdout: 'pipe', stderr: 'ignore' })
|
|
93
|
+
const out = await new Response(p.stdout).text()
|
|
94
|
+
return { code: await p.exited, out }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function openInCode(path: string): void {
|
|
98
|
+
const code = Bun.which('code')
|
|
99
|
+
if (code != null) {
|
|
100
|
+
Bun.spawn([code, path], { stdio: ['ignore', 'ignore', 'ignore'] }).unref()
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (process.platform === 'darwin' && Bun.which('open') != null) {
|
|
104
|
+
Bun.spawn(['open', '-a', 'Visual Studio Code', path], { stdio: ['ignore', 'ignore', 'ignore'] }).unref()
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
throw new Error('the `code` command is not on PATH: in VS Code, run "Shell Command: Install \'code\' command in PATH"')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const epochMs = (out: string | undefined): number | null => {
|
|
111
|
+
const secs = Number(out?.trim())
|
|
112
|
+
return Number.isFinite(secs) && secs > 0 ? secs * 1000 : null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** the rows in the rail order: the saved names first, the rest after */
|
|
116
|
+
const inOrder = (rows: ProjectRow[], saved: string[]): ProjectRow[] => {
|
|
117
|
+
const by = new Map(rows.map((r) => [r.name, r]))
|
|
118
|
+
return mergeOrder(saved, rows.map((r) => r.name)).map((n) => by.get(n)!)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const readText = (file: string): string => {
|
|
122
|
+
try {
|
|
123
|
+
return readFileSync(file, 'utf8')
|
|
124
|
+
} catch {
|
|
125
|
+
return ''
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function projectGutter(d: GutterDeps) {
|
|
130
|
+
let home = d.root
|
|
131
|
+
const userHome = d.home ?? homedir()
|
|
132
|
+
const run = d.run ?? runCmd
|
|
133
|
+
const warn = d.warn ?? ((l: string) => console.warn(l))
|
|
134
|
+
const now = d.now ?? Date.now
|
|
135
|
+
const storage = vscodeStoragePath(d.platform ?? process.platform, userHome)
|
|
136
|
+
const warned = new Set<string>()
|
|
137
|
+
const warnOnce = (key: string, line: string): void => {
|
|
138
|
+
if (warned.has(key)) return
|
|
139
|
+
warned.add(key)
|
|
140
|
+
warn(line)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let repos: WiredRepo[] = []
|
|
144
|
+
let last: ProjectsAnswer | null = null
|
|
145
|
+
let inFlight: Promise<ProjectsAnswer> | null = null
|
|
146
|
+
let folders: { mtime: number; list: string[] } | null = null
|
|
147
|
+
const releases = new Map<string, { stamp: string | null; at: number; release: Release; commitAt: number | null; dirty: number | null }>()
|
|
148
|
+
const gates = new Map<string, { stamp: string; gate: Gate }>()
|
|
149
|
+
/** a repo's last gate, its record opened only when it moved */
|
|
150
|
+
const gateOfRepo = (path: string): Gate => {
|
|
151
|
+
const record = recordOf(path)
|
|
152
|
+
if (record == null) return null
|
|
153
|
+
const stamp = recordStamp(record)
|
|
154
|
+
const hit = gates.get(path)
|
|
155
|
+
if (hit != null && hit.stamp === stamp) return hit.gate
|
|
156
|
+
const gate = gateOf(readLastStatusReadonly(record))
|
|
157
|
+
gates.set(path, { stamp, gate })
|
|
158
|
+
return gate
|
|
159
|
+
}
|
|
160
|
+
const orderFile = d.orderFile ?? join(userHome, ORDER_FILE)
|
|
161
|
+
const transcriptRoots = (): string[] => {
|
|
162
|
+
if (d.transcriptRoots != null) return d.transcriptRoots
|
|
163
|
+
try {
|
|
164
|
+
return readdirSync(userHome).filter((n) => n.startsWith('.claude')).map((n) => join(userHome, n, 'projects'))
|
|
165
|
+
} catch {
|
|
166
|
+
return []
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function vscodeFolders(): Promise<string[] | null> {
|
|
171
|
+
if (storage == null) {
|
|
172
|
+
warnOnce('vscode', `WARN project gutter: no VS Code window state known on ${d.platform ?? process.platform}, no open dots`)
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const mtime = (await stat(storage)).mtimeMs
|
|
177
|
+
if (folders?.mtime !== mtime) folders = { mtime, list: openFolders(await readFile(storage, 'utf8')) }
|
|
178
|
+
return folders.list
|
|
179
|
+
} catch (e) {
|
|
180
|
+
warnOnce('vscode', `WARN project gutter: VS Code window state unreadable at ${storage} (${(e as Error).message}), no open dots`)
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function sessionCwds(): Promise<string[] | null> {
|
|
186
|
+
const ps = await run(['ps', '-axo', 'pid=,comm='])
|
|
187
|
+
if (ps == null) {
|
|
188
|
+
warnOnce('sessions', 'WARN project gutter: ps is not on PATH, no Claude session badges')
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
const list = parseClaudePids(ps.out)
|
|
192
|
+
if (list.length === 0) return []
|
|
193
|
+
const out = await run(['lsof', '-b', '-w', '-a', '-p', list.join(','), '-d', 'cwd', '-Fpn'])
|
|
194
|
+
if (out == null) {
|
|
195
|
+
warnOnce('sessions', 'WARN project gutter: lsof is not on PATH, no Claude session badges')
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
return [...parseLsofCwd(out.out).values()]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function askRelease(path: string): Promise<Release> {
|
|
202
|
+
const desc = await run(['git', 'describe', '--tags', '--long', ...RELEASE_TAGS.map((g) => `--match=${g}`)], path)
|
|
203
|
+
if (desc == null) {
|
|
204
|
+
warnOnce('git', 'WARN project gutter: git is not on PATH, no release distance')
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
const hit = desc.code === 0 ? parseDescribe(desc.out) : null
|
|
208
|
+
if (hit == null) {
|
|
209
|
+
// no tag in reach, or no commit at all: tell the two apart
|
|
210
|
+
const head = await run(['git', 'rev-parse', '--verify', '--quiet', 'HEAD'], path)
|
|
211
|
+
return head?.code === 0 ? 'never' : null
|
|
212
|
+
}
|
|
213
|
+
const log = await run(['git', 'log', '-1', '--format=%ct', hit.tag], path)
|
|
214
|
+
return { tag: hit.tag, ahead: hit.ahead, at: epochMs(log?.out) ?? 0 }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** the release and the last commit time, asked of git together, since
|
|
218
|
+
* the same stat says when either can have moved */
|
|
219
|
+
async function askGit(path: string): Promise<{ release: Release; commitAt: number | null; dirty: number | null }> {
|
|
220
|
+
const release = await askRelease(path)
|
|
221
|
+
if (release == null) return { release, commitAt: null, dirty: null }
|
|
222
|
+
// --no-optional-locks: a status that rewrote the index would move the
|
|
223
|
+
// stamp and ask again on every poll
|
|
224
|
+
const [head, status] = await Promise.all([
|
|
225
|
+
run(['git', 'log', '-1', '--format=%ct', 'HEAD'], path),
|
|
226
|
+
run(['git', '--no-optional-locks', 'status', '--porcelain'], path),
|
|
227
|
+
])
|
|
228
|
+
return { release, commitAt: head?.code === 0 ? epochMs(head.out) : null, dirty: status?.code === 0 ? dirtyCount(status.out) : null }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** every repo's release, asking git for at most RELEASE_ASKS_PER_POLL of
|
|
232
|
+
* the stale ones per measure, the never asked first then the oldest.
|
|
233
|
+
* The rest keep their last answer, or unknown until their turn */
|
|
234
|
+
async function gitFacts(paths: string[]): Promise<{ release: Release; commitAt: number | null; dirty: number | null }[]> {
|
|
235
|
+
const stamps = new Map(paths.map((p) => [p, gitStamp(p)]))
|
|
236
|
+
const stale = paths.filter((p) => {
|
|
237
|
+
const k = releases.get(p)
|
|
238
|
+
if (k == null) return true
|
|
239
|
+
const s = stamps.get(p)!
|
|
240
|
+
return s != null ? k.stamp !== s : now() - k.at >= RELEASE_TTL_MS
|
|
241
|
+
})
|
|
242
|
+
stale.sort((a, b) => (releases.get(a)?.at ?? -1) - (releases.get(b)?.at ?? -1))
|
|
243
|
+
await Promise.all(stale.slice(0, RELEASE_ASKS_PER_POLL).map(async (p) => {
|
|
244
|
+
const g = await askGit(p)
|
|
245
|
+
releases.set(p, { stamp: stamps.get(p)!, at: now(), ...g })
|
|
246
|
+
}))
|
|
247
|
+
return paths.map((p) => ({ release: releases.get(p)?.release ?? null, commitAt: releases.get(p)?.commitAt ?? null, dirty: releases.get(p)?.dirty ?? null }))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function measure(): Promise<ProjectsAnswer> {
|
|
251
|
+
const t0 = performance.now()
|
|
252
|
+
const house = houseRepos(d.root, userHome)
|
|
253
|
+
home = house.home
|
|
254
|
+
if (house.index == null) warnOnce('house', `WARN project gutter: ${home} includes no house wiki index, so no sibling shares one and the gutter is empty`)
|
|
255
|
+
repos = house.repos
|
|
256
|
+
const [open, cwds, facts] = await Promise.all([vscodeFolders(), sessionCwds(), gitFacts(repos.map((r) => r.path))])
|
|
257
|
+
const transcripts = lastSessionAt(transcriptRoots(), repos.map((r) => r.path))
|
|
258
|
+
const counts = cwds == null ? null : countSessions(cwds, repos.map((r) => r.path))
|
|
259
|
+
const projects = repos.map((r, i): ProjectRow => {
|
|
260
|
+
const config = readText(join(r.path, 'repo.config.ts'))
|
|
261
|
+
const pkg = readText(join(r.path, 'package.json'))
|
|
262
|
+
const asks = openAsks(join(r.path, asksDirOf(config, r.path)))
|
|
263
|
+
// the README is read only when package.json says nothing
|
|
264
|
+
const description = descriptionOf(pkg, '') ?? descriptionOf('', readText(join(r.path, 'README.md')))
|
|
265
|
+
return {
|
|
266
|
+
name: r.name,
|
|
267
|
+
path: r.path,
|
|
268
|
+
icon: r.icon == null ? null : `/api/projects/icon/${encodeURIComponent(r.name)}?v=${iconStamp(join(r.path, r.icon))}`,
|
|
269
|
+
current: r.path === home,
|
|
270
|
+
vscode: open == null ? null : open.some((f) => f === r.path),
|
|
271
|
+
sessions: counts?.get(r.path) ?? null,
|
|
272
|
+
asks: asks.length,
|
|
273
|
+
askTitles: asks,
|
|
274
|
+
description,
|
|
275
|
+
version: versionOf(pkg),
|
|
276
|
+
branch: branchOf(readText(join(r.path, '.git', 'HEAD'))),
|
|
277
|
+
dirty: facts[i]!.dirty,
|
|
278
|
+
gate: gateOfRepo(r.path),
|
|
279
|
+
release: facts[i]!.release,
|
|
280
|
+
live: liveUrlOf(config),
|
|
281
|
+
activity: lastActivity(facts[i]!.commitAt, lastRunAt(r.path), transcripts.get(r.path)),
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
return { projects: inOrder(projects, parseOrder(readText(orderFile))), ms: Math.round(performance.now() - t0), at: now() }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** the last answer while it is younger than POLL_MS, one measure otherwise,
|
|
288
|
+
* shared by every request that arrives while it runs */
|
|
289
|
+
async function answer(): Promise<ProjectsAnswer> {
|
|
290
|
+
if (last != null && now() - last.at < POLL_MS) return last
|
|
291
|
+
inFlight ??= measure().then((a) => {
|
|
292
|
+
last = a
|
|
293
|
+
return a
|
|
294
|
+
}).finally(() => { inFlight = null })
|
|
295
|
+
return inFlight
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const find = (name: string): WiredRepo | undefined => repos.find((r) => r.name === name)
|
|
299
|
+
|
|
300
|
+
/** saves the rail order: the names given, then every discovered repo they
|
|
301
|
+
* leave out. Written whole through a temp file, so a reader never sees
|
|
302
|
+
* half of it */
|
|
303
|
+
function saveOrder(names: string[]): string[] {
|
|
304
|
+
const order = mergeOrder(names, repos.map((r) => r.name))
|
|
305
|
+
mkdirSync(dirname(orderFile), { recursive: true })
|
|
306
|
+
const tmp = `${orderFile}.tmp-${process.pid}`
|
|
307
|
+
writeFileSync(tmp, renderOrder(order))
|
|
308
|
+
renameSync(tmp, orderFile)
|
|
309
|
+
if (last != null) last = { ...last, projects: inOrder(last.projects, order) }
|
|
310
|
+
return order
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** null when the path is not this route's */
|
|
314
|
+
async function handle(req: Request, path: string): Promise<Response | null> {
|
|
315
|
+
if (path === '/api/projects.json') return Response.json(await answer())
|
|
316
|
+
if (path.startsWith('/api/projects/icon/')) {
|
|
317
|
+
if (repos.length === 0) await answer()
|
|
318
|
+
const r = find(decodeURIComponent(path.slice('/api/projects/icon/'.length)))
|
|
319
|
+
if (r?.icon == null) return new Response('no icon', { status: 404 })
|
|
320
|
+
return new Response(Bun.file(join(r.path, r.icon)), {
|
|
321
|
+
headers: { 'content-type': ICON_TYPES[r.icon.split('.').pop()!] ?? 'application/octet-stream', 'cache-control': 'max-age=86400' },
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
if (path === '/api/projects/order' && req.method === 'POST') {
|
|
325
|
+
if (repos.length === 0) await answer()
|
|
326
|
+
const b = (await req.json().catch(() => ({}))) as { names?: unknown }
|
|
327
|
+
if (!Array.isArray(b.names) || !b.names.every((n) => typeof n === 'string')) return new Response('names must be a list of repo names', { status: 400 })
|
|
328
|
+
try {
|
|
329
|
+
return Response.json({ order: saveOrder(b.names as string[]) })
|
|
330
|
+
} catch (e) {
|
|
331
|
+
return new Response(`the order could not be saved to ${orderFile}: ${(e as Error).message}`, { status: 500 })
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (path === '/api/projects/open' && req.method === 'POST') {
|
|
335
|
+
if (repos.length === 0) await answer()
|
|
336
|
+
const b = (await req.json().catch(() => ({}))) as { name?: string }
|
|
337
|
+
const r = find(b.name ?? '')
|
|
338
|
+
if (r == null) return new Response(`not a wired repo: ${b.name ?? ''}`, { status: 404 })
|
|
339
|
+
try {
|
|
340
|
+
;(d.open ?? openInCode)(r.path)
|
|
341
|
+
} catch (e) {
|
|
342
|
+
return new Response((e as Error).message, { status: 400 })
|
|
343
|
+
}
|
|
344
|
+
return Response.json({ ok: true })
|
|
345
|
+
}
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return { handle, answer }
|
|
350
|
+
}
|