rvlib-shipkit 0.38.1 → 1.0.1
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 +379 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/derived.ts +1 -1
- 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} +49 -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 +16 -3
- 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 +257 -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 +616 -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 +88 -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 +152 -189
- package/src/core/run-fix.ts +99 -0
- package/src/core/run-fixers.ts +64 -33
- package/src/core/self-update.ts +11 -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/fleet/scan-fleet.ts +16 -0
- package/src/hooks/leak-guard.ts +20 -9
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +38 -26
- 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 +157 -88
- 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 +9 -4
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +209 -15
- 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/scan-md.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,449 @@
|
|
|
1
|
+
// THE discovery of the house repos on this disk (houseRepos), and what is
|
|
2
|
+
// read of each one: which folders VS Code has open, which Claude Code
|
|
3
|
+
// sessions run in which repo, its open asks, its distance to the last
|
|
4
|
+
// release, its declared live url, its run record and its hub port. The
|
|
5
|
+
// project gutter and the live strip both list repos through houseRepos, so
|
|
6
|
+
// the two surfaces show the same repos by construction. The parsers read
|
|
7
|
+
// text they are handed, so sample output pins them; project-gutter-route.ts
|
|
8
|
+
// and other-repos.ts own the spawns, the reads and the caches.
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
10
|
+
import { basename, dirname, join, resolve, sep } from 'path'
|
|
11
|
+
import { fileURLToPath } from 'url'
|
|
12
|
+
import { gitRoot } from '../core/git-root.ts'
|
|
13
|
+
import { DB_FILE } from '../ledger/db.ts'
|
|
14
|
+
import { defaultHubPort } from './hub-port.ts'
|
|
15
|
+
import { LAYOUT } from '../core/layout.ts'
|
|
16
|
+
|
|
17
|
+
export type WiredRepo = { name: string; path: string; icon: string | null }
|
|
18
|
+
|
|
19
|
+
/** the files a repo carries its includes in */
|
|
20
|
+
const CLAUDE_FILES = ['CLAUDE.md', 'CLAUDE.local.md']
|
|
21
|
+
|
|
22
|
+
/** the absolute md file of every `@<path>.md` include line a repo's CLAUDE
|
|
23
|
+
* files carry, in order. A sentence that mentions a path is no include */
|
|
24
|
+
export function includeTargets(repo: string, home: string): string[] {
|
|
25
|
+
const out: string[] = []
|
|
26
|
+
for (const f of CLAUDE_FILES) {
|
|
27
|
+
let text: string
|
|
28
|
+
try {
|
|
29
|
+
text = readFileSync(join(repo, f), 'utf8')
|
|
30
|
+
} catch {
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
for (const line of text.split('\n')) {
|
|
34
|
+
const raw = /^@(\S+\.md)$/.exec(line.trim())?.[1]
|
|
35
|
+
if (raw != null) out.push(raw.startsWith('~/') ? join(home, raw.slice(2)) : resolve(repo, raw))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** the house wiki index this repo includes: the first include outside the
|
|
42
|
+
* repo, else (the repo that hosts the house) the first one that exists and
|
|
43
|
+
* is not another CLAUDE file */
|
|
44
|
+
export function houseIndex(repo: string, home: string): string | null {
|
|
45
|
+
const targets = includeTargets(repo, home).filter((t) => existsSync(t))
|
|
46
|
+
return targets.find((t) => !inside(t, repo)) ?? targets.find((t) => !basename(t).startsWith('CLAUDE')) ?? null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** a repo's own icon, first hit wins. The identity kit writes the first one */
|
|
50
|
+
export const ICON_FILES = ['public/favicon.png', 'public/favicon.svg', 'public/favicon.ico', 'favicon.ico', 'public/logo.png']
|
|
51
|
+
|
|
52
|
+
export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
|
|
53
|
+
|
|
54
|
+
/** every direct child of `parent` that includes `index`, sorted by name */
|
|
55
|
+
export function wiredRepos(parent: string, index: string, home: string): WiredRepo[] {
|
|
56
|
+
let names: string[]
|
|
57
|
+
try {
|
|
58
|
+
names = readdirSync(parent)
|
|
59
|
+
} catch {
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
const out: WiredRepo[] = []
|
|
63
|
+
for (const name of names.sort()) {
|
|
64
|
+
if (name.startsWith('.')) continue
|
|
65
|
+
const path = join(parent, name)
|
|
66
|
+
if (!includeTargets(path, home).includes(index)) continue
|
|
67
|
+
out.push({ name, path, icon: ICON_FILES.find((f) => existsSync(join(path, f))) ?? null })
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** the house repos around the repo at `root`: its main checkout (`home`),
|
|
73
|
+
* the house index its own includes name, and every sibling that includes the
|
|
74
|
+
* same index, `home` among them. No index = no repos */
|
|
75
|
+
export function houseRepos(root: string, userHome: string): { home: string; index: string | null; repos: WiredRepo[] } {
|
|
76
|
+
const home = homeRepo(root)
|
|
77
|
+
const index = houseIndex(home, userHome)
|
|
78
|
+
return { home, index, repos: index == null ? [] : wiredRepos(dirname(home), index, userHome) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** the checkout a linked worktree belongs to, so a hub started in a worktree
|
|
82
|
+
* still lists its siblings and marks its own repo */
|
|
83
|
+
export function homeRepo(root: string): string {
|
|
84
|
+
const g = gitRoot(root)
|
|
85
|
+
if (g == null || basename(g.commonDir) !== '.git') return root
|
|
86
|
+
return dirname(g.commonDir)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** the version stamp an icon url carries, so the browser caches it until the
|
|
90
|
+
* file changes */
|
|
91
|
+
export const iconStamp = (file: string): number => {
|
|
92
|
+
try {
|
|
93
|
+
return Math.floor(statSync(file).mtimeMs)
|
|
94
|
+
} catch {
|
|
95
|
+
return 0
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── VS Code ───────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** where VS Code keeps its window state, or null on a platform it has none
|
|
102
|
+
* we know of */
|
|
103
|
+
export function vscodeStoragePath(platform: string, home: string): string | null {
|
|
104
|
+
if (platform === 'darwin') return join(home, 'Library/Application Support/Code/User/globalStorage/storage.json')
|
|
105
|
+
if (platform === 'linux') return join(home, '.config/Code/User/globalStorage/storage.json')
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type Win = { folder?: unknown }
|
|
110
|
+
|
|
111
|
+
/** the folders VS Code has open, as absolute paths. A workspace file window
|
|
112
|
+
* or a remote folder carries no local folder and is skipped */
|
|
113
|
+
export function openFolders(storageJson: string): string[] {
|
|
114
|
+
const j = JSON.parse(storageJson) as { windowsState?: { lastActiveWindow?: Win; openedWindows?: Win[] } }
|
|
115
|
+
const ws = j.windowsState
|
|
116
|
+
const wins: Win[] = [...(ws?.openedWindows ?? []), ...(ws?.lastActiveWindow != null ? [ws.lastActiveWindow] : [])]
|
|
117
|
+
const out = new Set<string>()
|
|
118
|
+
for (const w of wins) {
|
|
119
|
+
if (typeof w.folder !== 'string' || !w.folder.startsWith('file://')) continue
|
|
120
|
+
try {
|
|
121
|
+
out.add(fileURLToPath(w.folder).replace(/\/+$/, ''))
|
|
122
|
+
} catch {
|
|
123
|
+
// a malformed uri names no folder
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [...out]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** true when `p` is `dir` or sits inside it */
|
|
130
|
+
export const inside = (p: string, dir: string): boolean => p === dir || p.startsWith(dir + sep)
|
|
131
|
+
|
|
132
|
+
// ── Claude Code sessions ──────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/** the Claude Code pids in `ps -axo pid=,comm=`: a command named exactly
|
|
135
|
+
* `claude`, bare or as a path. BSD pgrep would be cheaper, but it hides its
|
|
136
|
+
* own ancestors, so a hub started from a session could never count it */
|
|
137
|
+
export function parseClaudePids(ps: string): number[] {
|
|
138
|
+
const out: number[] = []
|
|
139
|
+
for (const l of ps.split('\n')) {
|
|
140
|
+
const m = /^\s*(\d+)\s+(.+?)\s*$/.exec(l)
|
|
141
|
+
if (m != null && basename(m[2]!) === 'claude') out.push(Number(m[1]))
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `lsof -a -p <pids> -d cwd -Fpn`: a `p<pid>` line, then `f` and `n<path>`
|
|
147
|
+
* lines for that process */
|
|
148
|
+
export function parseLsofCwd(out: string): Map<number, string> {
|
|
149
|
+
const cwd = new Map<number, string>()
|
|
150
|
+
let pid: number | null = null
|
|
151
|
+
for (const l of out.split('\n')) {
|
|
152
|
+
if (l.startsWith('p')) pid = Number(l.slice(1))
|
|
153
|
+
else if (l.startsWith('n') && pid != null && Number.isFinite(pid)) cwd.set(pid, l.slice(1))
|
|
154
|
+
}
|
|
155
|
+
return cwd
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** sessions per repo path: a cwd counts for the repo it sits in */
|
|
159
|
+
export function countSessions(cwds: Iterable<string>, repos: string[]): Map<string, number> {
|
|
160
|
+
const n = new Map<string, number>(repos.map((r) => [r, 0]))
|
|
161
|
+
for (const c of cwds) {
|
|
162
|
+
const r = repos.find((p) => inside(c, p))
|
|
163
|
+
if (r != null) n.set(r, n.get(r)! + 1)
|
|
164
|
+
}
|
|
165
|
+
return n
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── asks, release, live ───────────────────────────────────────────────────
|
|
169
|
+
// a repo's config is READ as text, never imported: importing runs that
|
|
170
|
+
// repo's code in the hub process, on every poll
|
|
171
|
+
|
|
172
|
+
/** the body of `<key>: { … }` in a config source, line comments dropped, or
|
|
173
|
+
* null when the key declares no object */
|
|
174
|
+
export function configBlock(src: string, key: string): string | null {
|
|
175
|
+
const clean = src.replace(/(^|\s)\/\/.*$/gm, '$1')
|
|
176
|
+
const m = new RegExp(`\\b${key}\\s*:\\s*\\{`).exec(clean)
|
|
177
|
+
if (m == null) return null
|
|
178
|
+
let depth = 1
|
|
179
|
+
for (let i = m.index + m[0].length; i < clean.length; i++) {
|
|
180
|
+
if (clean[i] === '{') depth++
|
|
181
|
+
else if (clean[i] === '}' && --depth === 0) return clean.slice(m.index + m[0].length, i)
|
|
182
|
+
}
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const stringKey = (block: string | null, key: string): string | null =>
|
|
187
|
+
block == null ? null : (new RegExp(`\\b${key}\\s*:\\s*['"\`]([^'"\`]+)['"\`]`).exec(block)?.[1] ?? null)
|
|
188
|
+
|
|
189
|
+
/** `asks: { dir }`, the queue's folder, `.shipkit/asks` when the config names
|
|
190
|
+
* none. A repo that has not run the new shipkit yet still keeps its queue in
|
|
191
|
+
* `asks/` (this read dies once `shipkit fleet` shows no repo on the old layout) */
|
|
192
|
+
export const asksDirOf = (configSrc: string, repo?: string): string =>
|
|
193
|
+
stringKey(configBlock(configSrc, 'asks'), 'dir') ?? (repo != null && !existsSync(join(repo, LAYOUT.asks)) && existsSync(join(repo, 'asks')) ? 'asks' : LAYOUT.asks)
|
|
194
|
+
|
|
195
|
+
/** `smoke: { base }`, the one deployed url a repo declares in a machine
|
|
196
|
+
* readable place, or null */
|
|
197
|
+
export function liveUrlOf(configSrc: string): string | null {
|
|
198
|
+
const u = stringKey(configBlock(configSrc, 'smoke'), 'base')
|
|
199
|
+
return u != null && /^https?:\/\//.test(u) ? u : null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** an ask file is open until its frontmatter says `status: done` */
|
|
203
|
+
export const isOpenAsk = (md: string): boolean => !/^status:\s*done\b/m.test(md)
|
|
204
|
+
|
|
205
|
+
/** the open asks in a queue folder: its top level md files, the done/
|
|
206
|
+
* archive and every other folder left out. Read only: a missing folder is 0 */
|
|
207
|
+
export const openAskCount = (dir: string): number => openAsks(dir).length
|
|
208
|
+
|
|
209
|
+
/** an ask's title: its first heading, else its first line of prose, the
|
|
210
|
+
* frontmatter skipped */
|
|
211
|
+
export function askTitle(md: string): string | null {
|
|
212
|
+
const body = md.replace(/^---\n[\s\S]*?\n---\n?/, '')
|
|
213
|
+
const lines = body.split('\n').map((l) => l.trim()).filter((l) => l !== '')
|
|
214
|
+
const heading = lines.find((l) => /^#{1,6}\s+\S/.test(l))
|
|
215
|
+
const title = heading?.replace(/^#{1,6}\s+/, '') ?? lines[0]
|
|
216
|
+
return title == null || title === '' ? null : title
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** the titles of the open asks in a queue folder, by file name order, the
|
|
220
|
+
* file name when an ask has no text */
|
|
221
|
+
export function openAsks(dir: string): string[] {
|
|
222
|
+
let files: string[]
|
|
223
|
+
try {
|
|
224
|
+
files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort()
|
|
225
|
+
} catch {
|
|
226
|
+
return []
|
|
227
|
+
}
|
|
228
|
+
const out: string[] = []
|
|
229
|
+
for (const f of files) {
|
|
230
|
+
try {
|
|
231
|
+
const md = readFileSync(join(dir, f), 'utf8')
|
|
232
|
+
if (isOpenAsk(md)) out.push(askTitle(md) ?? f.replace(/\.md$/, ''))
|
|
233
|
+
} catch {
|
|
234
|
+
// a folder named like an md file, or a file gone mid read
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── the card facts: description, version, branch, last gate ─────────────
|
|
241
|
+
|
|
242
|
+
/** a one line summary cut at `max` characters, on a word when it can */
|
|
243
|
+
export const clip = (text: string, max = 90): string => {
|
|
244
|
+
const t = text.replace(/\s+/g, ' ').trim()
|
|
245
|
+
if (t.length <= max) return t
|
|
246
|
+
const cut = t.slice(0, max - 1)
|
|
247
|
+
const at = cut.lastIndexOf(' ')
|
|
248
|
+
return `${(at > max / 2 ? cut.slice(0, at) : cut).replace(/[\s,;:.]+$/, '')}…`
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** what the repo says it is: package.json `description`, else the first line
|
|
252
|
+
* of prose in its README after the title (badges, headings, html,
|
|
253
|
+
* blockquotes and a bare url skipped, a markdown link kept as its text) */
|
|
254
|
+
export function descriptionOf(packageJson: string, readme: string): string | null {
|
|
255
|
+
try {
|
|
256
|
+
const d = (JSON.parse(packageJson) as { description?: unknown }).description
|
|
257
|
+
if (typeof d === 'string' && d.trim() !== '') return clip(d)
|
|
258
|
+
} catch {
|
|
259
|
+
// no package.json, or not json: the README answers
|
|
260
|
+
}
|
|
261
|
+
const body = readme.replace(/^---\n[\s\S]*?\n---\n?/, '').replace(/<!--[\s\S]*?-->/g, '')
|
|
262
|
+
for (const raw of body.split('\n')) {
|
|
263
|
+
const l = raw.trim()
|
|
264
|
+
if (l === '' || /^(#|>|<|!\[|\[!\[|```|---|===|\|)/.test(l) || /^<?https?:\/\/\S+>?$/.test(l)) continue
|
|
265
|
+
return clip(l.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\*\*|__|`/g, ''))
|
|
266
|
+
}
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** package.json `version`, or null */
|
|
271
|
+
export function versionOf(packageJson: string): string | null {
|
|
272
|
+
try {
|
|
273
|
+
const v = (JSON.parse(packageJson) as { version?: unknown }).version
|
|
274
|
+
return typeof v === 'string' && v.trim() !== '' ? v.trim() : null
|
|
275
|
+
} catch {
|
|
276
|
+
return null
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** the branch `.git/HEAD` names, or `detached <sha>` for a detached head */
|
|
281
|
+
export function branchOf(head: string): string | null {
|
|
282
|
+
const t = head.trim()
|
|
283
|
+
const ref = /^ref: refs\/heads\/(.+)$/.exec(t)?.[1]
|
|
284
|
+
if (ref != null) return ref
|
|
285
|
+
return /^[0-9a-f]{7,40}$/.test(t) ? `detached ${t.slice(0, 7)}` : null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** `git status --porcelain`: one line per changed or untracked path */
|
|
289
|
+
export const dirtyCount = (porcelain: string): number => porcelain.split('\n').filter((l) => l.trim() !== '').length
|
|
290
|
+
|
|
291
|
+
/** the last gate as its record holds it: green, or the checks whose last
|
|
292
|
+
* outcome failed. No row at all = unknown */
|
|
293
|
+
export type Gate = 'green' | { reds: string[] } | null
|
|
294
|
+
export function gateOf(rows: { id: string; status: string }[]): Gate {
|
|
295
|
+
if (rows.length === 0) return null
|
|
296
|
+
const reds = rows.filter((r) => r.status === 'fail').map((r) => r.id).sort()
|
|
297
|
+
return reds.length === 0 ? 'green' : { reds }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** the tag shapes a release leaves: `v1.2.3`, `name@1.2.3` (the shipkit
|
|
301
|
+
* defaults) and a bare `1.2.3`. A date or a merge marker is no release */
|
|
302
|
+
export const RELEASE_TAGS = ['v[0-9]*', '*@[0-9]*', '[0-9]*.[0-9]*']
|
|
303
|
+
|
|
304
|
+
/** `git describe --tags --long`: `<tag>-<commits since>-g<sha>` */
|
|
305
|
+
export function parseDescribe(out: string): { tag: string; ahead: number } | null {
|
|
306
|
+
const m = /^(.+)-(\d+)-g[0-9a-f]+$/.exec(out.trim())
|
|
307
|
+
return m == null ? null : { tag: m[1]!, ahead: Number(m[2]) }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** what moves when a commit or a tag lands, read with a few stats, so the
|
|
311
|
+
* release distance is asked of git only when it can have changed. null for
|
|
312
|
+
* a checkout whose `.git` is not a folder */
|
|
313
|
+
export function gitStamp(repo: string): string | null {
|
|
314
|
+
const git = join(repo, '.git')
|
|
315
|
+
const mt = (p: string): number => {
|
|
316
|
+
try {
|
|
317
|
+
return statSync(join(git, p)).mtimeMs
|
|
318
|
+
} catch {
|
|
319
|
+
return 0
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
if (!statSync(git).isDirectory()) return null
|
|
324
|
+
const head = readFileSync(join(git, 'HEAD'), 'utf8').trim()
|
|
325
|
+
const ref = /^ref: (.+)$/.exec(head)?.[1]
|
|
326
|
+
// the index moves on every add, commit, stash and checkout, which is
|
|
327
|
+
// when the uncommitted count is asked again
|
|
328
|
+
return [head, ref == null ? 0 : mt(ref), mt('packed-refs'), mt('refs/tags'), mt('index')].join('|')
|
|
329
|
+
} catch {
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── last activity ─────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/** the newest of the stamps that exist, or null when none does */
|
|
337
|
+
export const lastActivity = (...stamps: (number | null | undefined)[]): number | null => {
|
|
338
|
+
const known = stamps.filter((s): s is number => s != null && Number.isFinite(s) && s > 0)
|
|
339
|
+
return known.length === 0 ? null : Math.max(...known)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const mtimeOf = (file: string): number | null => {
|
|
343
|
+
try {
|
|
344
|
+
return statSync(file).mtimeMs
|
|
345
|
+
} catch {
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** when the repo's own run record was last written: every gate and health
|
|
351
|
+
* run writes it, so its mtime is the last shipkit run */
|
|
352
|
+
export const lastRunAt = (repo: string): number | null => {
|
|
353
|
+
const record = recordOf(repo)
|
|
354
|
+
return record == null ? null : lastActivity(mtimeOf(record), mtimeOf(`${record}-wal`))
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** where a repo's run record lives, without asking git: a `.git` directory
|
|
358
|
+
* holds it. A `.git` FILE (a linked worktree) is left out: its record sits
|
|
359
|
+
* with the main checkout, which is on the list itself */
|
|
360
|
+
export function recordOf(dir: string): string | null {
|
|
361
|
+
const git = join(dir, '.git')
|
|
362
|
+
try {
|
|
363
|
+
if (!statSync(git).isDirectory()) return null
|
|
364
|
+
} catch {
|
|
365
|
+
return null
|
|
366
|
+
}
|
|
367
|
+
return join(git, 'shipkit', DB_FILE)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** what the record's files look like from outside: the db and its write
|
|
371
|
+
* ahead log, since a commit in WAL mode moves only the log */
|
|
372
|
+
export const recordStamp = (path: string): string => {
|
|
373
|
+
const one = (p: string): string => {
|
|
374
|
+
try {
|
|
375
|
+
const s = statSync(p)
|
|
376
|
+
return `${s.mtimeMs}:${s.size}`
|
|
377
|
+
} catch {
|
|
378
|
+
return '-'
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return `${one(path)}|${one(`${path}-wal`)}`
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** the port a repo's hub listens on: `serve.port` in its repo.config.ts when
|
|
385
|
+
* it names one, else the port its name hashes to, as `shipkit serve` picks */
|
|
386
|
+
export function hubPortOf(dir: string): number {
|
|
387
|
+
let src = ''
|
|
388
|
+
try {
|
|
389
|
+
src = readFileSync(join(dir, 'repo.config.ts'), 'utf8')
|
|
390
|
+
} catch {
|
|
391
|
+
// no config: the directory name answers
|
|
392
|
+
}
|
|
393
|
+
const port = /\bport\s*:\s*(\d{2,5})/.exec(configBlock(src, 'serve') ?? '')?.[1]
|
|
394
|
+
if (port != null) return Number(port)
|
|
395
|
+
const name = /\bname\s*:\s*['"]([^'"]+)['"]/.exec(src)?.[1]
|
|
396
|
+
return defaultHubPort(name ?? basename(dir))
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** the folder name Claude Code keeps a directory's transcripts under */
|
|
400
|
+
export const transcriptSlug = (dir: string): string => dir.replace(/[^a-zA-Z0-9]/g, '-')
|
|
401
|
+
|
|
402
|
+
/** the newest transcript mtime per repo, from every `<root>/projects/<slug>/`:
|
|
403
|
+
* the repo's own slug, or one under a dot folder of it (`<slug>--claude-…`).
|
|
404
|
+
* A plain subfolder slug is not claimed, since `repo-sub` also reads as a
|
|
405
|
+
* sibling named `repo-sub` */
|
|
406
|
+
export function lastSessionAt(projectRoots: string[], repos: string[]): Map<string, number> {
|
|
407
|
+
const bySlug = new Map(repos.map((r) => [transcriptSlug(r), r]))
|
|
408
|
+
const out = new Map<string, number>()
|
|
409
|
+
for (const root of projectRoots) {
|
|
410
|
+
let slugs: string[]
|
|
411
|
+
try {
|
|
412
|
+
slugs = readdirSync(root)
|
|
413
|
+
} catch {
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
for (const slug of slugs) {
|
|
417
|
+
const repo = bySlug.get(slug) ?? [...bySlug.entries()].find(([s]) => slug.startsWith(`${s}--`))?.[1]
|
|
418
|
+
if (repo == null) continue
|
|
419
|
+
let files: string[]
|
|
420
|
+
try {
|
|
421
|
+
files = readdirSync(join(root, slug)).filter((f) => f.endsWith('.jsonl'))
|
|
422
|
+
} catch {
|
|
423
|
+
continue
|
|
424
|
+
}
|
|
425
|
+
const newest = lastActivity(out.get(repo), ...files.map((f) => mtimeOf(join(root, slug, f))))
|
|
426
|
+
if (newest != null) out.set(repo, newest)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return out
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── the rail order ────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
/** the order file: one repo name per line, `#` lines and blanks ignored,
|
|
435
|
+
* the first mention of a name wins */
|
|
436
|
+
export const parseOrder = (text: string): string[] => [...new Set(text.split('\n').map((l) => l.trim()).filter((l) => l !== '' && !l.startsWith('#')))]
|
|
437
|
+
|
|
438
|
+
export const ORDER_HEADER = '# the project rail order of every shipkit hub on this machine: one repo name per line, top first'
|
|
439
|
+
|
|
440
|
+
export const renderOrder = (names: string[]): string => `${ORDER_HEADER}\n${names.join('\n')}\n`
|
|
441
|
+
|
|
442
|
+
/** the rail: the saved order for the repos that still exist, then every repo
|
|
443
|
+
* the file does not name, in discovery order. A vanished name drops out */
|
|
444
|
+
export function mergeOrder(saved: string[], discovered: string[]): string[] {
|
|
445
|
+
const here = new Set(discovered)
|
|
446
|
+
const kept = saved.filter((n) => here.has(n))
|
|
447
|
+
const known = new Set(kept)
|
|
448
|
+
return [...kept, ...discovered.filter((n) => !known.has(n))]
|
|
449
|
+
}
|