rvlib-shipkit 1.0.1 → 1.1.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 +57 -0
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +30 -4
- package/src/check.ts +6 -0
- package/src/checks/SK005-rvlib-versions.check.ts +18 -3
- package/src/checks/SK021-github-login.check.ts +123 -0
- package/src/cli/cmd_Asks.ts +1 -1
- package/src/cli/cmd_Check.ts +1 -1
- package/src/cli/cmd_Ci.ts +1 -1
- package/src/cli/cmd_Fleet.ts +5 -57
- package/src/cli.ts +2 -3
- package/src/collections/checks.ts +12 -2
- package/src/collections/collection-types.ts +34 -18
- package/src/collections/comfy.ts +1 -0
- package/src/collections/posts.ts +1 -0
- package/src/collections/providers.ts +71 -43
- package/src/collections/reflections.ts +1 -0
- package/src/collections/state-rules.ts +29 -13
- package/src/collections/tests.ts +1 -0
- package/src/collections/wikis.ts +1 -0
- package/src/core/budget.ts +60 -0
- package/src/core/check-ctx.ts +2 -1
- package/src/core/check-runner.ts +43 -6
- package/src/core/fd-table.ts +37 -0
- package/src/core/git-root.ts +26 -2
- package/src/core/lib-version.ts +16 -0
- package/src/core/migrate-layout.ts +2 -1
- package/src/core/motherhome.ts +2 -4
- package/src/core/package-manager.ts +4 -2
- package/src/core/raw-access.ts +11 -3
- package/src/core/reexec.ts +26 -0
- package/src/core/run-check.ts +36 -16
- package/src/core/run-ci.ts +29 -24
- package/src/core/self-update.ts +59 -24
- package/src/core/update-policy.ts +25 -0
- package/src/fleet/scan-fleet.ts +4 -15
- package/src/fleet/update-fleet.ts +85 -0
- package/src/ledger/gate-checks.ts +19 -5
- package/src/ledger/git-key.ts +10 -5
- package/src/ledger/git-scan.ts +29 -17
- package/src/ledger/ledger.ts +14 -6
- package/src/ledger/repo-view.ts +8 -6
- package/src/ledger/run-log.ts +10 -4
- package/src/ledger/run-timeline.ts +21 -9
- package/src/problems/problem-board.ts +13 -2
- package/src/problems/problem-line.ts +9 -0
- package/src/repo-config.ts +22 -1
- package/src/serve/hub-app/check-view.tsx +12 -11
- package/src/serve/hub-app/cols-svc.ts +16 -9
- package/src/serve/hub-app/dur.ts +10 -1
- package/src/serve/hub-app/header-bar.tsx +11 -0
- package/src/serve/hub-app/help.tsx +1 -0
- package/src/serve/hub-app/hub-st.ts +3 -4
- package/src/serve/hub-app/hub-types.ts +5 -0
- package/src/serve/hub-app/hub.css +3 -0
- package/src/serve/hub-app/timeline-view.tsx +4 -4
- package/src/serve/hub-app/tree-build.ts +1 -2
- package/src/serve/hub-problems.ts +35 -0
- package/src/serve/project-gutter.ts +3 -2
- package/src/serve/run-serve.ts +51 -12
- package/src/serve/serve-hub.ts +10 -3
- package/src/serve/stale-restart.ts +77 -0
- package/src/suite/suite-cli.ts +14 -10
- package/src/ts/source-graph.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
|
1
|
+
## 1.1.1
|
|
2
|
+
|
|
3
|
+
- **The project rail reads `.shipkit/identity/icon.png` first.** A repo with no public surface has no favicon, so the rail showed its initial. `ICON_FILES` now starts with `.shipkit/identity/icon.png`, ahead of `public/favicon.png`, so a repo can carry a rail icon without shipping a public folder.
|
|
4
|
+
|
|
5
|
+
## 1.1.0
|
|
6
|
+
|
|
7
|
+
- **`shipkit ask` scaffolds a walkthrough, not a one line body.** A new ask used to carry `**Ask**: <title>` and nothing a human could follow. A step ask (every kind but `decision`) now starts with numbered `## N.` sections, each opening with a bold link slot and a `field | value` table, and ends with a `Tell the agent "done"` section. A `decision` ask starts with numbered options, the recommended one first. An HTML comment, invisible in the hub, states the shape, and `shipkit ask` reminds the author to fill every `{{placeholder}}` (braces, since the hub markdown hides an unknown `<tag>`). The `**Why**` line and `--why` are unchanged.
|
|
8
|
+
|
|
9
|
+
- **A gate asks git less about things the disk already answers.** `probeGitRoot` answers a plain repository root (a `.git` directory at the root, no locating variable, no relocating or including config, owned by the caller) from the disk, and leaves every other case to `git rev-parse`. The layout migration reads `core.hooksPath` only when the moved hooks folder exists and the old one does not. `detectPackageManager` asks git which locks it tracks only when a lock file is present, and its `tracked` argument is optional.
|
|
10
|
+
|
|
11
|
+
- **The suite runner's own tests hold on a loaded machine.** Their burner fixtures spun on the wall clock, so beside another gate they burned a fraction of the CPU they stand for (0.2s instead of 0.7s), came in under the 300ms budget, and four full tier tests of the runner failed while the runner was right. The fixtures now spin until the process has burned its CPU, and a test stops one with SIGSTOP mid spin to prove it still burns it.
|
|
12
|
+
|
|
13
|
+
- **`c.list` sees the files of a nested repo.** In a `repo.config.ts` folder inside a larger git work tree, the file listing ran `git ls-tree` on the folder's own subtree from inside the folder, and git filtered that listing by the folder's path, which the subtree does not contain: every glob listed nothing, so a glob check there passed on an empty tree. The listing passes `--full-tree`.
|
|
14
|
+
|
|
15
|
+
- **A gate reads each toolkit source file once while it decides what can be cached.** Every undeclared check's import closure was walked and scanned for raw disk access with its own reads, and the closures share most of their files: 300 reads of 58 files per gate. `rawAccessInClosure` takes a per pass `texts` map and `importClosure` a `read` function, so one pass reads each file once. `composeChecks` 10.8ms to 6.8ms per gate.
|
|
16
|
+
|
|
17
|
+
- **A check's own code is hashed in process.** `hashFiles` spawned `git hash-object --stdin-paths` on every gate for the import closures of the checks it runs. It now hashes the bytes on disk with the same blob id the read recorder uses (`blobOid`), which equals git's for any file no clean filter rewrites and is only stricter where one does: a filter-only change is a miss, never a false hit. A warm narrowed gate now spawns no git at all. Stored answers keyed on a filtered file re-measure once.
|
|
18
|
+
|
|
19
|
+
- **A scan that asks for no path writes no tree.** A run whose checks all key on their reads declares no input, and the scan still ran `git write-tree` for a tree id nothing reads. It validates the repo and returns an empty result.
|
|
20
|
+
|
|
21
|
+
- **SK021 github login: a lost gh login is red.** `shipkit init` and `start` create a private repo with the gh login, and every push, release and SK007 use it, yet nothing probed it. The new built-in SK021 asks `gh auth status --hostname github.com --active --json hosts` (the text answer on a gh without `--json`; neither carries the token) in the health pass, cached 1h. Logged out, a refused token, no `repo` scope or no gh FAILs with `cred gh/github/login missing: …` and its fix (`gh auth login`, `gh auth refresh -h github.com -s repo`). That line makes `gh/github/login` a built-in `core` credential on the secrets page (`GH_LOGIN_CRED`), red there and red in `shipkit needs`, listed once SK021 has run in the repo. The check never blocks a gate. github.com unreachable is an unmeasured WARN, and a token with no oauth scopes (fine grained) WARNs. `SHIPKIT_NO_GITHUB` skips it.
|
|
22
|
+
|
|
23
|
+
- **`c.tool` reads the PATH as it stands now.** `Bun.which` alone searched the PATH the process started with, so a PATH set later (a test seam, a wrapper) never reached it while `c.exec` did.
|
|
24
|
+
|
|
25
|
+
- **A 0.x minor is a breaking jump.** `isBreaking` now draws the line npm's caret draws: a changed major, or on a `0.x` package a changed minor (`^0.2.0` means `<0.3.0`). SK005, the startup self-update and `fleet --update` WARN on such a jump and leave package.json alone, and `shipkit fleet --update --major` takes it. A `0.x` patch still updates by itself. The `--major` help says so.
|
|
26
|
+
|
|
27
|
+
- **A hub under `bun --watch` no longer loses every spawn to EBADF after a day of saves.** bun 1.4.2 `--watch` keeps the previous image's watch fds on each in place restart (about 160 per restart on a large repo), and macOS `posix_spawn` refuses any fd at or past 10240, so once the table filled, every git call failed with `EBADF: bad file descriptor, posix_spawn`. `process.execve` carries none of them, so `StaleRestart` now also restarts the hub in place when its file table passes 4096 open files (`fdTableLeak`, `openFdCount`, `FD_LEAK_LIMIT` in `core/fd-table.ts`), at boot and every 2s, once no run is in flight. A table still full within a minute of such a restart is said as a hub problem instead of looping. **Type change**: `StaleRestartOpts.restart` receives the reason as `why`, and `leak` is a new optional option.
|
|
28
|
+
|
|
29
|
+
- **The run record is written through the handle that already holds it.** A gate opened the record once for its live row and again for its final write, and each close checkpoints the WAL. The final write now reuses the live row's handle with the normal busy timeout. About 8ms to 3.5ms per gate.
|
|
30
|
+
|
|
31
|
+
- **A replay scans the whole tree only when a glob needs it.** Validating a stored read set took a whole tree git scan on every replay and every store, and only a `c.list` glob ever reads that listing: file content, existence and directory entries are checked on disk. The view is now taken on the first glob that asks for it, the same snapshot as before. A gate whose checks list nothing makes no `git add` at all.
|
|
32
|
+
|
|
33
|
+
- **A narrowed gate scans only what it runs.** `shipkit ci --only <check>` hashed and git-scanned the inputs and code of every check in the repo, then used one of them. The ledger is now opened on the ids the run executes (`passScope` in `core/run-check.ts`, the same selection the runner makes, built once by `selectionOf`), and `KeyLayer.open` takes a `scope`: a check outside it gets no key, with the reason on the line. The keys of the checks in scope are unchanged. A warm narrowed gate went from 5 git calls to 3.
|
|
34
|
+
|
|
35
|
+
- **The file listing no longer asks git what it ignores.** A whole tree scan read `git ls-files --others -i` and never used the answer: it only feeds force-adds and the excluded list, and `git add -- .` leaves ignored files out by itself. `scanPaths` takes `{ treeOnly: true }`, which `RepoView` passes, and refuses it for anything but the whole tree. One git call less per gate that lists files.
|
|
36
|
+
|
|
37
|
+
- **A suite run cleans up whatever its tests forget in the temp dir.** The suite runner hands `bun test` a `TMPDIR` of the run's own, deleted when the run ends. Suites that left their fixtures behind had put 90k dirs in the machine temp dir, and bun reads that whole listing the first time a process imports a file from a fixture there, 50 to 110ms per test process.
|
|
38
|
+
|
|
39
|
+
- **No automatic update crosses a major.** SK005 moved `rvlib-pb-mobx` 1.0.0 → 2.0.0 in an unfrozen `ci`, while `fleet --update` refused the same jump without `--major`. SK005, the startup self-update and `fleet --update` now share one policy (`isBreaking`, `breakingAdvice`, `ALLOW_MAJOR_ENV` in `core/update-policy.ts`; `isBreaking` moved there from `fleet/scan-fleet.ts`): a changed major WARNs `<lib> <v> available (breaking): run \`shipkit fleet --update --major --only <repo>\`` and leaves package.json alone, a minor beside it still updates, and `SHIPKIT_ALLOW_MAJOR=1`, which `fleet --update --major` sets, is the one way through. **Type change**: `planSelfUpdate` takes `allowMajor` and `repo`.
|
|
40
|
+
|
|
41
|
+
- **`fleet --update` performs the update.** It ran the repo's `check --fix SK005`, which every binary refuses (SK005 has no autofix), and a plain `check` would have replayed SK005's clean pass cached for 24h. It now runs `check --fresh` with the re-exec guard stripped, and judges by the versions node_modules carries after the run rather than by a printed line. A breaking dep no longer holds back a minor one in the same repo. The loop lives in `fleet/update-fleet.ts` (`updateFleet`).
|
|
42
|
+
|
|
43
|
+
- **The first `check --fresh` after a release no longer exits 1.** The startup self-update decided on its 24h cached latest, then SK005 updated `node_modules/rvlib-shipkit` under the running process, and a check file loaded after the swap met modules of two versions ("layoutSummary export not found"). A fresh run now makes the startup self-update ask the registry, so it updates and restarts before any check loads (`maybeSelfUpdateAndRestart(root, { fresh })`, `latestCached`). SK005 never updates the copy that is running it (`isRunningCopy`): it stores the answer for the next startup (`rememberLatest`) and WARNs. `SHIPKIT_SELF_UPDATE_CACHE` overrides the cache file.
|
|
44
|
+
|
|
45
|
+
- **A version bump restarts a hub under `bun --watch`.** The hub read its own version from package.json with fs, which keeps the file out of the module graph the watch follows, so a bump (which changes nothing but the version) left the hub running and its tree showed a red row asking for a restart. `LIB_VERSION` in `core/lib-version.ts` is a json module import now, and the cli `--version` and the motherhome row read it too. `diskLibVersion()` reads the file at call time.
|
|
46
|
+
|
|
47
|
+
- **A plain `shipkit serve` whose lib moved under it restarts itself.** It compares its boot version with the package.json on disk every 2s and, once no run is in flight, re-executes in place: same pid, argv and port (`StaleRestart` in `serve/stale-restart.ts`, `reexecSelf` and `reexecBlocker` in `core/reexec.ts`, which the self-update restart also uses). A restart that cannot happen is said in the page header and on the problems frame, never as a tree row.
|
|
48
|
+
|
|
49
|
+
- **No `health` kind.** `RESOURCE_TYPES` and `TYPE_MARK` lose `health`, the `health/` virtual folder is gone, and `repoHealthCollection` is `contractsCollection` (id `contracts`, rows under `contracts/`, ids `contract:` and `contractwarn:`). A check with no file of its own is filed under `checks/<id>`. `buildCollections` takes no version options. **Type change**: `Collection` requires `kind`, the kind of its rows, and a collection whose listing throws is one red row of that kind at `<collection>/collector` (`brokenRow`).
|
|
50
|
+
|
|
51
|
+
- **The hub task's problem frame counts the hub's own problems.** A collection the hub could not list and a restart it could not do (`HubProblems` in `serve/hub-problems.ts`) print on the frame at once as `repo.config.ts:1:1: error hub: <text>`, which the existing matcher reads, so `shipkit problems: end, N error(s)` agrees with the page. `ProblemBoard.setHub`, `hubProblemLine` and `HUB_ANCHOR` carry it; `/api/resources.json` sends the list as `hub` and the header shows one chip per problem. `projectResources` and `projectNeeds` take an optional per collection callback. A page that cannot reach its server says so in the header instead of a fake tree row.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
- **A check that passes over its time budget warns.** Every check has a budget, what a healthy run takes, distinct from its `timeoutMs` ceiling: 2000ms for an in process body, 10000ms for a build step, a `cmd` check or a world probe (`IN_PROCESS_BUDGET_MS`, `SUBPROCESS_BUDGET_MS` in `core/budget.ts`). A pass over it prints `WARN <id> passed in 35.2s, over its 10s budget: see the tests page for where the time goes`, reads yellow in the hub, and lands in the problems pane as a warning; it never blocks and never fails the gate. It is judged on the wall time of a run that measured; a build step at the full tier carries no budget (it runs every slow suite on purpose, under its own cache key), and a check doing the same work at both tiers is judged at both. The line says `under load N on C cores` when the 1 minute load reached the core count. A cached answer keeps the warn it earned, and a slow pass is still remembered as long as a quick one. A check file declares `budgetMs`, and `repo.config.ts` `budgets: { <id>: ms }` wins over it, the way `when` does; an id there naming no check fails the run, like a `when` entry. The check page's timings row shows the budget, durations are toned against it, and "slowest checks" flags `over its budget` when the mean of a check's quick tier runs passes it. A check that only ran slow reads yellow but asks nothing of `shipkit needs`, and a probe that only ran slow still proves its secret (`isBudgetLine`). `CheckRow.budgetMs` and `RunnableCheck.budgetMs` carry the resolved value, `CheckRun` gains `unknownBudgets`, and `durationTone` takes the budget as a third argument. The run summary no longer says `all N check(s) green` when a check warned: it says `all N check(s) passed, M with a warning: <ids>`.
|
|
55
|
+
|
|
56
|
+
- **A secret declares how much breaks without it: `creds[].level`.** `core` (the toolkit's own features stop: a push, creating a repo, a release) is red when it is missing or its probe fails, and a missing one reaches `shipkit needs` as red (a failed probe keeps a yellow needs line: its own check row carries the failure). `used`, the default (a feature the repo uses today), is orange with a yellow needs line that never pings. `minor` (proactive, nothing uses it yet) is dim, says `minor` in its tree slot and its sentence, and stays out of `shipkit needs`. Before, every missing secret was red and every failed probe was red on the tree, so a key for a service nobody uses yet shouted as loud as the one every release needs. `secretState` takes the level, `SecretLevel` and `DEFAULT_SECRET_LEVEL` are exported from `collections/state-rules.ts`. Present and probed green stays green at every level.
|
|
57
|
+
|
|
1
58
|
## 1.0.1
|
|
2
59
|
|
|
3
60
|
- **A layout move lands once, and its commit says what it moved.** A tracked root `STATUS.md` that named an old folder was dropped and then written back by the text sweep in the same pass, so the move commit kept it, and the next run landed a second commit, titled as a move, that moved nothing and reported `moved under .shipkit/`. A legacy generated file is now only dropped, never swept. The commit title is built from what the pass did (`chore(machine): move asks/ and .rv-identity/ under .shipkit/, and drop the root STATUS.md, which shipkit writes under .shipkit/`), so a pass that moves no folder never carries the move title, and the fix pass and SK004 print the same words (`layoutSummary` in `core/migrate-layout.ts`, which replaces `MIGRATION_MESSAGE`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"funding": "https://github.com/sponsors/rvion",
|
|
5
5
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
6
6
|
"license": "MIT",
|
package/src/asks/asks-queue.ts
CHANGED
|
@@ -88,6 +88,32 @@ function checkAsksBody(dir: string, print: (line: string) => void): { closed: nu
|
|
|
88
88
|
return { closed }
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// the hub renders ask markdown, so a new ask starts as the page the human
|
|
92
|
+
// follows: one section per step, a link that opens the exact page, values
|
|
93
|
+
// to copy. Every {{placeholder}} is the author's to fill (braces, since markdown hides an unknown <tag>) before the ask ships
|
|
94
|
+
const STEPS_BODY = `<!-- one "## N." section per step, in order. Each opens with ONE bold link you have seen work, ids filled in; with none, link the nearest known page and name the one click from there. Values in the table, buttons in bold, a why line only where skipping breaks something. -->
|
|
95
|
+
|
|
96
|
+
## 1. {{verb}} {{thing}}
|
|
97
|
+
|
|
98
|
+
Open **[{{page name}}]({{url, ids filled in}})**, then click **{{button}}**.
|
|
99
|
+
|
|
100
|
+
| field | value |
|
|
101
|
+
|---|---|
|
|
102
|
+
| {{field}} | \`{{value}}\` |
|
|
103
|
+
|
|
104
|
+
## 2. Tell the agent "done"
|
|
105
|
+
|
|
106
|
+
{{what the agent does next, and what the human clicks during it}}
|
|
107
|
+
`
|
|
108
|
+
|
|
109
|
+
const DECISION_BODY = `<!-- a decision: numbered options, the recommended one first, each with what it costs and what it changes. -->
|
|
110
|
+
|
|
111
|
+
1. **{{option}} (recommended)**: {{what it changes, what it costs}}
|
|
112
|
+
2. **{{option}}**: {{what it changes, what it costs}}
|
|
113
|
+
|
|
114
|
+
Reply with the number.
|
|
115
|
+
`
|
|
116
|
+
|
|
91
117
|
export function createAsk(dir: string, title: string, opts?: CreateOpts): string {
|
|
92
118
|
mkdirSync(dir, { recursive: true })
|
|
93
119
|
// max numeric prefix across BOTH live and archived asks — numbers are never reused
|
|
@@ -102,6 +128,7 @@ export function createAsk(dir: string, title: string, opts?: CreateOpts): string
|
|
|
102
128
|
// "TODO" that never gets filled (the ask itself is the record).
|
|
103
129
|
const why = opts?.why?.trim()
|
|
104
130
|
const whyBlock = why != null && why !== '' ? `**Why**: ${why}\n\n` : ''
|
|
131
|
+
const doneWhen = check != null ? `\`${check}\` exits 0` : 'status flipped to done'
|
|
105
132
|
writeFileSync(join(dir, file), `---
|
|
106
133
|
status: open
|
|
107
134
|
kind: ${opts?.kind ?? 'action'}
|
|
@@ -111,11 +138,10 @@ blocks: ${opts?.blocks ?? '-'}${check != null ? `\ncheck: ${check}` : ''}
|
|
|
111
138
|
|
|
112
139
|
# ${title}
|
|
113
140
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
${whyBlock}**Answer**: _(fill in / check off, or just do it — the check probe auto-closes when it passes)_
|
|
141
|
+
${whyBlock}${opts?.kind === 'decision' ? DECISION_BODY : STEPS_BODY}
|
|
142
|
+
**Answer**: _(fill in / check off, or just do it — the check probe auto-closes when it passes)_
|
|
117
143
|
|
|
118
|
-
**Done when**: ${
|
|
144
|
+
**Done when**: ${doneWhen}
|
|
119
145
|
`)
|
|
120
146
|
return file
|
|
121
147
|
}
|
package/src/check.ts
CHANGED
|
@@ -122,6 +122,12 @@ export type CheckDef = {
|
|
|
122
122
|
/** wall clock ceiling in ms. A `cmd` check is killed with its whole
|
|
123
123
|
* process tree at the ceiling. */
|
|
124
124
|
timeoutMs?: number
|
|
125
|
+
/** what a healthy run takes, in ms, distinct from the ceiling: a run that
|
|
126
|
+
* passes over it prints a WARN and reads yellow, and never blocks.
|
|
127
|
+
* Judged on the wall time of a run that measured, at the quick tier.
|
|
128
|
+
* Default 2000 for an in process body, 10000 for a build step, a `cmd`
|
|
129
|
+
* or a world probe (core/budget.ts). The repo's `budgets` wins over it. */
|
|
130
|
+
budgetMs?: number
|
|
125
131
|
/** a label several checks share, so `--skip <group>` parks all of them
|
|
126
132
|
* and `--only <group>` runs all of them. */
|
|
127
133
|
group?: string
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
// Per rvlib-* dep:
|
|
11
11
|
// link: → the symlink must resolve (a broken link dies cryptically at
|
|
12
12
|
// import time otherwise) — nothing to update, code is live
|
|
13
|
-
// semver → installed vs npm latest;
|
|
13
|
+
// semver → installed vs npm latest; a BREAKING jump (a new major, or a
|
|
14
|
+
// new 0.x minor) is never taken by itself
|
|
15
|
+
// (src/core/update-policy.ts: WARN naming `fleet --update --major`,
|
|
16
|
+
// package.json untouched); the rvlib-shipkit tree running this
|
|
17
|
+
// process is never swapped mid run, the startup self-update owns
|
|
18
|
+
// it (src/core/self-update.ts); NEWER LIVE → this lint updates the
|
|
14
19
|
// dep itself, loudly, with the manager the repo's own lock files
|
|
15
20
|
// name (src/core/package-manager.ts), guarded:
|
|
16
21
|
// - SHIPKIT_FROZEN=1 (`check --frozen`, `ci --frozen`) → WARN,
|
|
@@ -39,8 +44,10 @@
|
|
|
39
44
|
// are NOT scanned: keeping a published lib's peer ranges consistent after
|
|
40
45
|
// a bump stays the agent's job.
|
|
41
46
|
import { existsSync, readFileSync } from 'fs'
|
|
42
|
-
import { join } from 'path'
|
|
47
|
+
import { basename, join } from 'path'
|
|
43
48
|
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
49
|
+
import { allowMajor, breakingAdvice, isBreaking } from '../core/update-policy.ts'
|
|
50
|
+
import { isRunningCopy, rememberLatest } from '../core/self-update.ts'
|
|
44
51
|
import { mutateLease, withMutateLease } from '../core/locked-write.ts'
|
|
45
52
|
import { detectPackageManager, isRefusal, LOCK_FILES } from '../core/package-manager.ts'
|
|
46
53
|
import { check, type CheckEnv } from '../check.ts'
|
|
@@ -107,7 +114,15 @@ export const rvlibVersions = check({
|
|
|
107
114
|
const cmp = Bun.semver.order(latest, installed)
|
|
108
115
|
if (cmp === 0) c.ok(`${name} ${installed} (latest)`)
|
|
109
116
|
else if (cmp < 0) c.ok(`${name} ${installed} ahead of npm latest ${latest} (prerelease or dist-tag lag) — not touching`)
|
|
110
|
-
else
|
|
117
|
+
else if (isBreaking(installed, latest) && !allowMajor()) c.warn(breakingAdvice(name, latest, basename(c.root)))
|
|
118
|
+
else if (name === 'rvlib-shipkit' && isRunningCopy(c.root)) {
|
|
119
|
+
// swapping this tree under the running process is how a run
|
|
120
|
+
// meets modules of two versions. The startup self-update owns
|
|
121
|
+
// it: handing it the answer makes the next run update and
|
|
122
|
+
// restart before any check loads
|
|
123
|
+
rememberLatest(latest)
|
|
124
|
+
c.warn(`${name} ${installed} → ${latest} is the toolkit running this check, so it is not swapped mid run: the next shipkit run updates it and restarts before any check`)
|
|
125
|
+
} else stale.push({ name, installed, latest })
|
|
111
126
|
}
|
|
112
127
|
if (stale.length === 0) return
|
|
113
128
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SK021: the gh login the toolkit itself runs on: `init` and `start` create
|
|
2
|
+
// a PRIVATE repo with it, every push and release goes through it, SK007 reads
|
|
3
|
+
// visibility with it. gh keeps that login outside any secrets manager, so this
|
|
4
|
+
// probe is the only place a lost one shows. It is a `core` credential: red on
|
|
5
|
+
// the secrets page and in `shipkit needs` (collections/providers.ts reads the
|
|
6
|
+
// `cred <name> missing` line), while the check itself is about the world, so
|
|
7
|
+
// it never blocks a gate and runs in the health pass only.
|
|
8
|
+
//
|
|
9
|
+
// A private repo needs the classic `repo` scope. The token itself is never
|
|
10
|
+
// asked for: `gh auth status --json` carries no token field, and the text
|
|
11
|
+
// answer masks it. github.com unreachable is `unreachable`, a WARN that
|
|
12
|
+
// measured nothing, never a lost login.
|
|
13
|
+
import { carveGhJson, firstGhLine } from '../git-bootstrap.ts'
|
|
14
|
+
import { check } from '../check.ts'
|
|
15
|
+
|
|
16
|
+
const HOST = 'github.com'
|
|
17
|
+
|
|
18
|
+
/** the secrets page row this check proves: a built-in `core` credential
|
|
19
|
+
* every repo has, shown once SK021 has run there */
|
|
20
|
+
export const GH_LOGIN_CRED = {
|
|
21
|
+
name: 'gh/github/login',
|
|
22
|
+
service: 'gh login',
|
|
23
|
+
level: 'core' as const,
|
|
24
|
+
probedBy: 'SK021',
|
|
25
|
+
mintUrl: 'https://cli.github.com/manual/gh_auth_login',
|
|
26
|
+
/** what the row offers to copy when no failing line names a better fix */
|
|
27
|
+
command: 'gh auth login',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** what gh said about the github.com login, whichever output shape it used */
|
|
31
|
+
export type GhLogin =
|
|
32
|
+
| { state: 'none' }
|
|
33
|
+
| { state: 'ok'; login: string; scopes: string[] | null }
|
|
34
|
+
| { state: 'refused'; source: string; why: string }
|
|
35
|
+
| { state: 'unreachable'; why: string }
|
|
36
|
+
| { state: 'unreadable'; why: string }
|
|
37
|
+
|
|
38
|
+
const NETWORK = /dial tcp|no such host|connection refused|network is unreachable|i\/o timeout|timeout|tls handshake|connection reset|\bEOF\b/i
|
|
39
|
+
|
|
40
|
+
type HostEntry = { state?: string; login?: string; scopes?: string; error?: string; tokenSource?: string; active?: boolean }
|
|
41
|
+
|
|
42
|
+
const scopesOf = (raw: string | undefined): string[] | null => {
|
|
43
|
+
const list = (raw ?? '').split(',').map((s) => s.trim().replace(/^'|'$/g, '')).filter((s) => s !== '')
|
|
44
|
+
return list.length === 0 ? null : list
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `gh auth status --json hosts`, the shape gh 2.7x and later print */
|
|
48
|
+
export function readGhJson(out: string): GhLogin {
|
|
49
|
+
const parsed = carveGhJson<{ hosts?: Record<string, HostEntry[]> }>(out)
|
|
50
|
+
if (parsed?.hosts == null) return { state: 'unreadable', why: firstGhLine(out) }
|
|
51
|
+
const entries = parsed.hosts[HOST] ?? []
|
|
52
|
+
const e = entries.find((x) => x.active === true) ?? entries[0]
|
|
53
|
+
if (e == null) return { state: 'none' }
|
|
54
|
+
if (e.state === 'success') return { state: 'ok', login: e.login ?? '?', scopes: scopesOf(e.scopes) }
|
|
55
|
+
const why = firstGhLine(e.error ?? e.state ?? 'unknown')
|
|
56
|
+
if (e.state === 'timeout' || NETWORK.test(why)) return { state: 'unreachable', why }
|
|
57
|
+
// `non-200 OK status code: 401 Unauthorized body: "{...}"` → `401 Unauthorized, Bad credentials`
|
|
58
|
+
const status = why.match(/\b(\d{3} [A-Za-z][A-Za-z ]*?)(?: body:|$)/)?.[1]
|
|
59
|
+
const message = why.match(/message\W+([^"\\]+)/)?.[1]
|
|
60
|
+
return { state: 'refused', source: e.tokenSource ?? 'its token', why: status == null ? why : [status, message].filter((s) => s != null).join(', ') }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** the plain text answer, for a gh too old to print json */
|
|
64
|
+
export function readGhText(out: string): GhLogin {
|
|
65
|
+
if (/not logged in/i.test(out)) return { state: 'none' }
|
|
66
|
+
const failed = out.match(/Failed to log in to \S+ (?:using token \(([^)]+)\)|account \S+ \(([^)]+)\))/)
|
|
67
|
+
if (failed != null) {
|
|
68
|
+
const why = out.split('\n').map((l) => l.trim().replace(/^- /, '')).find((l) => /invalid|expired|revoked|401/i.test(l)) ?? 'the token was refused'
|
|
69
|
+
if (NETWORK.test(out)) return { state: 'unreachable', why: firstGhLine(why) }
|
|
70
|
+
return { state: 'refused', source: failed[1] ?? failed[2] ?? 'its token', why }
|
|
71
|
+
}
|
|
72
|
+
if (NETWORK.test(out) && !/Logged in to/.test(out)) return { state: 'unreachable', why: firstGhLine(out) }
|
|
73
|
+
const login = out.match(/Logged in to \S+ (?:account|as) (\S+)/)
|
|
74
|
+
if (login == null) return { state: 'unreadable', why: firstGhLine(out) }
|
|
75
|
+
const scopes = out.match(/Token scopes: (.*)$/m)
|
|
76
|
+
return { state: 'ok', login: login[1]!, scopes: scopesOf(scopes?.[1]) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const githubLogin = check({
|
|
80
|
+
id: 'SK021',
|
|
81
|
+
// about the world, never this repo: a lost login warns in a gate and is
|
|
82
|
+
// red on the secrets page and in needs, through the core credential row
|
|
83
|
+
blocking: false,
|
|
84
|
+
title: 'github login',
|
|
85
|
+
description: 'gh is logged in to github.com with the repo scope shipkit creates repos with',
|
|
86
|
+
owner: 'build/shipkit/built-in-checks.md',
|
|
87
|
+
when: 'health',
|
|
88
|
+
ttl: '1h',
|
|
89
|
+
run: async (c) => {
|
|
90
|
+
const cred = GH_LOGIN_CRED.name
|
|
91
|
+
// the switch that keeps every github step off (git-bootstrap.ts), which every test suite sets
|
|
92
|
+
if ((process.env['SHIPKIT_NO_GITHUB'] ?? '') !== '') return void c.lines.push({ kind: 'skip', text: 'gh login not probed, SHIPKIT_NO_GITHUB is set' })
|
|
93
|
+
const gh = c.tool('gh')
|
|
94
|
+
if (gh == null)
|
|
95
|
+
return c.fail(`cred ${cred} missing: gh is not installed, so shipkit cannot create a repo, push or release`, 'install gh (https://cli.github.com), then run `gh auth login`')
|
|
96
|
+
|
|
97
|
+
let r = await c.exec([gh, 'auth', 'status', '--hostname', HOST, '--active', '--json', 'hosts'], { cwd: c.root, timeoutMs: 20_000 })
|
|
98
|
+
let seen: GhLogin
|
|
99
|
+
if (r.code !== 0 && /unknown flag/i.test(r.out)) {
|
|
100
|
+
r = await c.exec([gh, 'auth', 'status', '--hostname', HOST], { cwd: c.root, timeoutMs: 20_000 })
|
|
101
|
+
seen = readGhText(r.out)
|
|
102
|
+
} else seen = r.timedOut ? { state: 'unreachable', why: 'gh auth status timed out' } : readGhJson(r.out)
|
|
103
|
+
|
|
104
|
+
switch (seen.state) {
|
|
105
|
+
case 'none':
|
|
106
|
+
return c.fail(`cred ${cred} missing: gh is not logged in to ${HOST}, so shipkit cannot create a repo, push or release`, 'gh auth login')
|
|
107
|
+
case 'refused':
|
|
108
|
+
return c.fail(`cred ${cred} missing: the gh login from ${seen.source} was refused by ${HOST} (${seen.why.slice(0, 120)})`, 'gh auth login')
|
|
109
|
+
case 'unreachable':
|
|
110
|
+
return c.unreachable(`gh login unverified: ${HOST} unreachable (${seen.why.slice(0, 120)})`)
|
|
111
|
+
case 'unreadable':
|
|
112
|
+
return c.unreachable(`gh login unverified: gh answered something unreadable (${seen.why})`)
|
|
113
|
+
case 'ok':
|
|
114
|
+
if (seen.scopes == null)
|
|
115
|
+
return c.warn(`gh is logged in to ${HOST} as ${seen.login}, but its token lists no oauth scopes (a fine grained or app token), so creating a private repo is unverified`, 'gh auth login')
|
|
116
|
+
if (!seen.scopes.includes('repo'))
|
|
117
|
+
return c.fail(`cred ${cred} missing: the gh login ${seen.login} lacks the repo scope (it has ${seen.scopes.join(', ')}), so shipkit cannot create a private repo`, `gh auth refresh -h ${HOST} -s repo`)
|
|
118
|
+
return c.ok(`gh is logged in to ${HOST} as ${seen.login}, with the repo scope`)
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
export default githubLogin
|
package/src/cli/cmd_Asks.ts
CHANGED
|
@@ -75,7 +75,7 @@ export class AskNewCommand extends Command {
|
|
|
75
75
|
try {
|
|
76
76
|
const dir = await asksDir(process.cwd())
|
|
77
77
|
const file = createAsk(dir, this.title, { kind: this.kind, blocks: this.blocks, check: this.check, why: this.why })
|
|
78
|
-
this.context.stdout.write(`created ${dir}/${file}\n`)
|
|
78
|
+
this.context.stdout.write(`created ${dir}/${file}\nfill every {{placeholder}} in it: the hub shows the body as written\n`)
|
|
79
79
|
return 0
|
|
80
80
|
} catch (e) {
|
|
81
81
|
this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
|
package/src/cli/cmd_Check.ts
CHANGED
|
@@ -31,7 +31,7 @@ export class CheckCommand extends Command {
|
|
|
31
31
|
async execute(): Promise<number> {
|
|
32
32
|
// a stale toolkit updates itself and restarts this exact invocation
|
|
33
33
|
// (loop-guarded, skipped under CI/frozen/source consumption)
|
|
34
|
-
await maybeSelfUpdateAndRestart(process.cwd())
|
|
34
|
+
await maybeSelfUpdateAndRestart(process.cwd(), { fresh: this.fresh })
|
|
35
35
|
try {
|
|
36
36
|
if (this.fixIds.length > 0 && !this.fix) {
|
|
37
37
|
this.context.stderr.write(`FAIL: check ids need --fix (shipkit check --fix ${this.fixIds.join(' ')})\n`)
|
package/src/cli/cmd_Ci.ts
CHANGED
|
@@ -70,7 +70,7 @@ export class CiCommand extends Command {
|
|
|
70
70
|
private async gate(root: string): Promise<number> {
|
|
71
71
|
// a stale toolkit updates itself and restarts this exact invocation
|
|
72
72
|
// (loop-guarded, skipped under CI/frozen/source consumption)
|
|
73
|
-
await maybeSelfUpdateAndRestart(root)
|
|
73
|
+
await maybeSelfUpdateAndRestart(root, { fresh: this.fresh })
|
|
74
74
|
try {
|
|
75
75
|
return await runCi(root, {
|
|
76
76
|
bail: this.bail,
|
package/src/cli/cmd_Fleet.ts
CHANGED
|
@@ -3,19 +3,9 @@
|
|
|
3
3
|
// each repo's OWN shipkit, so rule SK005 performs the update with the lease,
|
|
4
4
|
// the dirty-tree guard and the frozen guard it already owns.
|
|
5
5
|
import { Command, Option } from 'clipanion'
|
|
6
|
-
import { existsSync } from 'fs'
|
|
7
6
|
import { join } from 'path'
|
|
8
|
-
import { npmLatest, resolveLatest, scanFleet,
|
|
9
|
-
import {
|
|
10
|
-
import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
|
|
11
|
-
|
|
12
|
-
const short = (name: string): string => name.replace(/^rvlib-/, '')
|
|
13
|
-
// the hand-run fallback speaks the manager THAT repo installs with: telling
|
|
14
|
-
// an npm consumer to run bun leaves its package-lock.json behind
|
|
15
|
-
const manualUpdate = (r: FleetRepo): string => {
|
|
16
|
-
const pm = detectPackageManager(r.dir)
|
|
17
|
-
return isRefusal(pm) ? `<its package manager> update ${r.behind.map((d) => d.name).join(' ')} (${pm.refused})` : pm.update(r.behind.map((d) => d.name)).join(' ')
|
|
18
|
-
}
|
|
7
|
+
import { npmLatest, resolveLatest, scanFleet, type FleetRepo } from '../fleet/scan-fleet.ts'
|
|
8
|
+
import { short, updateFleet } from '../fleet/update-fleet.ts'
|
|
19
9
|
|
|
20
10
|
export class FleetCommand extends Command {
|
|
21
11
|
static override paths = [['fleet']]
|
|
@@ -36,7 +26,7 @@ export class FleetCommand extends Command {
|
|
|
36
26
|
update = Option.Boolean('--update', false, { description: "run each behind repo's own `shipkit check`, which is where rule SK005 performs the guarded update" })
|
|
37
27
|
only = Option.Array('--only', { description: 'limit to these repo directory names (repeatable)' })
|
|
38
28
|
offline = Option.Boolean('--offline', false, { description: 'skip the registry: report what is installed, never what is current' })
|
|
39
|
-
major = Option.Boolean('--major', false, { description: 'let --update cross a breaking boundary too. Default off: a major (or a 0.x minor) is a porting job, not a bump' })
|
|
29
|
+
major = Option.Boolean('--major', false, { description: 'let --update cross a breaking boundary too. Default off: a major (or a 0.x minor, as npm caret reads it) is a porting job, not a bump, and no automatic update path takes one' })
|
|
40
30
|
json = Option.Boolean('--json', false, { description: 'machine-readable FleetRepo[]' })
|
|
41
31
|
|
|
42
32
|
async execute(): Promise<number> {
|
|
@@ -69,7 +59,8 @@ export class FleetCommand extends Command {
|
|
|
69
59
|
w(`WARN ${behind.length}/${repos.length} repo(s) behind — rerun with --update, which runs each repo's own gate so SK005 does the write`)
|
|
70
60
|
return 0
|
|
71
61
|
}
|
|
72
|
-
|
|
62
|
+
await updateFleet(behind, { major: this.major, print: w })
|
|
63
|
+
return 0
|
|
73
64
|
}
|
|
74
65
|
|
|
75
66
|
private row(r: FleetRepo): string {
|
|
@@ -83,47 +74,4 @@ export class FleetCommand extends Command {
|
|
|
83
74
|
const flag = r.linked ? 'source' : r.breaking.length > 0 ? `${r.breaking.length} BREAKING` : r.behind.length > 0 ? `${r.behind.length} behind` : 'current'
|
|
84
75
|
return ` ${r.name.padEnd(24)} ${flag.padEnd(10)} ${cells.join(', ')}`
|
|
85
76
|
}
|
|
86
|
-
|
|
87
|
-
/** the update is NOT performed here. Each repo runs its own binary, which
|
|
88
|
-
* is the only copy whose SK005 matches the rules that repo is pinned to,
|
|
89
|
-
* and the only one holding that repo's mutate lease. */
|
|
90
|
-
private async runUpdates(behind: FleetRepo[], w: (l: string) => void): Promise<number> {
|
|
91
|
-
let failed = 0
|
|
92
|
-
for (const r of behind) {
|
|
93
|
-
if (r.breaking.length > 0 && !this.major) {
|
|
94
|
-
w(`WARN ${r.name}: ${r.breaking.map((d) => `${short(d.name)} ${d.installed} → ${d.latest}`).join(', ')} crosses a breaking boundary — that is a porting job. Rerun with --major once somebody owns it`)
|
|
95
|
-
failed++
|
|
96
|
-
continue
|
|
97
|
-
}
|
|
98
|
-
const refused = updateRefusal(r)
|
|
99
|
-
if (refused != null) {
|
|
100
|
-
w(`WARN ${r.name}: not updating: ${refused}`)
|
|
101
|
-
failed++
|
|
102
|
-
continue
|
|
103
|
-
}
|
|
104
|
-
const cli = join(r.dir, 'node_modules', 'rvlib-shipkit', 'src', 'cli.ts')
|
|
105
|
-
if (!existsSync(cli)) {
|
|
106
|
-
w(`WARN ${r.name}: no shipkit installed, so it cannot update itself — run: cd ${r.dir} && ${manualUpdate(r)}`)
|
|
107
|
-
failed++
|
|
108
|
-
continue
|
|
109
|
-
}
|
|
110
|
-
// `check --fix SK005` is the narrow form: a bare rule id is refused,
|
|
111
|
-
// and a full `check` pays for every world probe to reach one rule
|
|
112
|
-
const res = await spawnWithTimeout(['bun', cli, 'check', '--fix', 'SK005'], { cwd: r.dir, timeoutMs: 180_000 })
|
|
113
|
-
// judge by what SK005 SAID, never by the exit code: that code covers
|
|
114
|
-
// every rule in that repo, and a repo can be red for a reason this
|
|
115
|
-
// command neither caused nor owns
|
|
116
|
-
const line = res.out.split('\n').find((l) => /\brvlib-/.test(l) && (l.startsWith('ok:') || l.startsWith('WARN') || l.startsWith('FAIL')))
|
|
117
|
-
if (line?.startsWith('ok:') === true) w(`ok: ${r.name} — ${line.slice(4)}`)
|
|
118
|
-
else {
|
|
119
|
-
failed++
|
|
120
|
-
w(`WARN ${r.name} did not move — ${line ?? `check exited ${res.code} with no rvlib line`}`)
|
|
121
|
-
w(` fix: cd ${r.dir} && ${manualUpdate(r)}`)
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
// a repo whose gate refused (dirty tree, frozen, a lease held elsewhere)
|
|
125
|
-
// is a REPORT, not a failure of this command: SK005 defers on purpose
|
|
126
|
-
w(failed === 0 ? `ok: ${behind.length} repo(s) updated through their own gate` : `WARN ${failed}/${behind.length} repo(s) did not update — each line above says why`)
|
|
127
|
-
return 0
|
|
128
|
-
}
|
|
129
77
|
}
|
package/src/cli.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// shipkit — clipanion entry: one command FAMILY per file in cli/ (cmd_<Name>.ts),
|
|
3
3
|
// logic in plain modules the commands call. runExit sets the exit code
|
|
4
4
|
// without force-exiting, so serve commands keep the process alive.
|
|
5
|
-
import {
|
|
5
|
+
import { LIB_VERSION } from './core/lib-version.ts'
|
|
6
6
|
import { Builtins, Cli } from 'clipanion'
|
|
7
7
|
import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
|
|
8
8
|
import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand } from './cli/cmd_Assets.ts'
|
|
@@ -31,7 +31,6 @@ import { StartCommand } from './cli/cmd_Start.ts'
|
|
|
31
31
|
import { TuiCommand } from './cli/cmd_Tui.ts'
|
|
32
32
|
import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
|
|
33
33
|
|
|
34
|
-
const version = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
|
|
35
34
|
|
|
36
35
|
/** the registry, built but NOT run: a test drives the real binary in process
|
|
37
36
|
* instead of paying a cold bun start per assertion (~250ms each), and the
|
|
@@ -40,7 +39,7 @@ export function buildCli(): Cli {
|
|
|
40
39
|
const cli = new Cli({
|
|
41
40
|
binaryLabel: 'shipkit — repo toolkit',
|
|
42
41
|
binaryName: 'shipkit',
|
|
43
|
-
binaryVersion:
|
|
42
|
+
binaryVersion: LIB_VERSION,
|
|
44
43
|
enableCapture: true, // console output pipes into the command streams (testable)
|
|
45
44
|
enableColors: true,
|
|
46
45
|
})
|
|
@@ -26,6 +26,7 @@ import { NEGATIVE_TTL_CAP_MS } from '../ledger/world-cache.ts'
|
|
|
26
26
|
import type { RepoConfig } from '../repo-config.ts'
|
|
27
27
|
import type { Collection, Instance, NeedSeverity, ResourceState, CheckFacts, RecentRun } from './collection-types.ts'
|
|
28
28
|
import { checkVerdictWords, plural } from '../core/outcome-words.ts'
|
|
29
|
+
import { isBudgetLine } from '../core/budget.ts'
|
|
29
30
|
|
|
30
31
|
/** what a check is killed at when it names no `timeoutMs`: a build step runs a
|
|
31
32
|
* whole tool chain, everything else answers one question */
|
|
@@ -61,6 +62,7 @@ export function checksCollection(root: string, c: RepoConfig, o: ChecksOpts = {}
|
|
|
61
62
|
return {
|
|
62
63
|
id: 'checks',
|
|
63
64
|
title: 'gate checks',
|
|
65
|
+
kind: 'check',
|
|
64
66
|
media: 'health',
|
|
65
67
|
list: async () => {
|
|
66
68
|
const rows = await gateInventory(root, c)
|
|
@@ -129,6 +131,7 @@ function instanceOf(root: string, c: RepoConfig, row: CheckRow, stat: CheckStat
|
|
|
129
131
|
never: row.decl.never ?? undefined,
|
|
130
132
|
reads: row.decl.reads === true ? true : undefined,
|
|
131
133
|
timeoutMs: row.timeoutMs ?? (row.build ? BUILD_TIMEOUT_MS : CHECK_TIMEOUT_MS),
|
|
134
|
+
budgetMs: row.budgetMs,
|
|
132
135
|
status,
|
|
133
136
|
replayed,
|
|
134
137
|
lastMs: stat?.last == null || replayed ? null : stat.last.ms,
|
|
@@ -231,16 +234,23 @@ function stateOf(f: CheckFacts): ResourceState {
|
|
|
231
234
|
* notification at night. */
|
|
232
235
|
function severityOf(f: CheckFacts): NeedSeverity | undefined {
|
|
233
236
|
if (f.status === 'fail') return f.advisory ? 'yellow' : 'red'
|
|
234
|
-
|
|
237
|
+
// a check that only ran over its time budget is work for whoever makes it
|
|
238
|
+
// faster, never a line asking for his hands: yellow on the tree, no feed
|
|
239
|
+
if (f.status === 'warn') return onlySlow(f) ? undefined : 'yellow'
|
|
235
240
|
return undefined
|
|
236
241
|
}
|
|
237
242
|
|
|
243
|
+
const onlySlow = (f: CheckFacts): boolean => {
|
|
244
|
+
const warns = f.lastLines.filter((l) => l.startsWith('WARN '))
|
|
245
|
+
return warns.length > 0 && warns.every((l) => isBudgetLine(l.slice('WARN '.length)))
|
|
246
|
+
}
|
|
247
|
+
|
|
238
248
|
/** where the check lives on the tree. A check that IS a file in this repo, or
|
|
239
249
|
* whose owner page is one, sits beside everything else in that folder, which
|
|
240
250
|
* is the whole payoff of pathing resources. A build step is a command, so it
|
|
241
251
|
* has no file and says so under a virtual root. */
|
|
242
252
|
function place(root: string, c: RepoConfig, row: CheckRow): Pick<Instance, 'path' | 'virtual'> {
|
|
243
|
-
const virtual = { path: `
|
|
253
|
+
const virtual = { path: `checks/${row.decl.id}`, virtual: true as const }
|
|
244
254
|
if (row.owner == null || ownerIsCommand(row)) return virtual
|
|
245
255
|
const rel = repoRelative(root, row.owner) ?? repoRelative(root, `${c.docsBase ?? ''}${row.owner}`)
|
|
246
256
|
return rel == null ? virtual : { path: rel }
|
|
@@ -26,9 +26,10 @@ export const testId = (file: string): string => `test:${file}`
|
|
|
26
26
|
export const NO_CHECK = 'in no check'
|
|
27
27
|
|
|
28
28
|
/** the resource KIND, never the collection it came from: one folder holds
|
|
29
|
-
* several kinds, which is what makes the path tree worth having.
|
|
30
|
-
*
|
|
31
|
-
|
|
29
|
+
* several kinds, which is what makes the path tree worth having. There is no
|
|
30
|
+
* catch all kind: a row belongs to an owner, and what belongs to the hub
|
|
31
|
+
* itself is its status (serve/hub-problems.ts), never a row */
|
|
32
|
+
export const RESOURCE_TYPES = ['ask', 'secret', 'asset', 'post', 'workflow', 'wiki', 'contract', 'check', 'test', 'reflection'] as const
|
|
32
33
|
|
|
33
34
|
export type ResourceType = (typeof RESOURCE_TYPES)[number]
|
|
34
35
|
|
|
@@ -97,6 +98,8 @@ export type CheckFacts = {
|
|
|
97
98
|
/** where it is killed: a check creeping toward its ceiling is visible
|
|
98
99
|
* before it trips */
|
|
99
100
|
timeoutMs: number
|
|
101
|
+
/** what a healthy run takes (core/budget.ts): a pass over it warns */
|
|
102
|
+
budgetMs: number
|
|
100
103
|
status: 'pass' | 'warn' | 'fail' | 'skip' | null
|
|
101
104
|
replayed: boolean
|
|
102
105
|
/** what the last run cost, ms. null on a replay: it measured nothing */
|
|
@@ -248,6 +251,9 @@ export type Instance = {
|
|
|
248
251
|
export type Collection = {
|
|
249
252
|
id: string
|
|
250
253
|
title: string
|
|
254
|
+
/** the kind of the rows it lists, which is also the kind of the one row
|
|
255
|
+
* that says its listing broke */
|
|
256
|
+
kind: ResourceType
|
|
251
257
|
media: 'image' | 'audio' | 'text' | 'data' | 'pointer' | 'health'
|
|
252
258
|
/** wire tone when any needing instance is only yellow */
|
|
253
259
|
list: () => Promise<Instance[]>
|
|
@@ -263,7 +269,7 @@ export type Resource = Instance & { collection: string; title: string; state: Re
|
|
|
263
269
|
|
|
264
270
|
/** PROACTIVE kinds: work waiting on a human, which is not the same thing as
|
|
265
271
|
* something being broken. Red is reserved for broken (a blocking check that
|
|
266
|
-
* failed, a secret
|
|
272
|
+
* failed, a `core` secret missing or refused, a collector that threw),
|
|
267
273
|
* so these read as taste pending on the tree. Their `severity` is untouched,
|
|
268
274
|
* because that is the axis `shipkit needs` and the once ever ping read */
|
|
269
275
|
const PROACTIVE: ReadonlySet<ResourceType> = new Set<ResourceType>(['ask'])
|
|
@@ -277,25 +283,33 @@ export async function listResources(c: Collection): Promise<Resource[]> {
|
|
|
277
283
|
return (await c.list()).map((i) => ({ ...i, collection: c.id, title: i.label ?? i.text, state: stateOf(i) }))
|
|
278
284
|
}
|
|
279
285
|
|
|
286
|
+
/** the ONE row a collection whose listing threw is reduced to: its own kind,
|
|
287
|
+
* filed in its own folder, red */
|
|
288
|
+
export const brokenRow = (c: Collection, e: unknown): Instance => ({
|
|
289
|
+
id: `${c.id}:broken`,
|
|
290
|
+
type: c.kind,
|
|
291
|
+
path: `${c.id}/collector`,
|
|
292
|
+
virtual: true,
|
|
293
|
+
text: `collector broke: ${(e as Error).message}`,
|
|
294
|
+
severity: 'red',
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
/** told, per collection, whether its listing broke (the message) or not
|
|
298
|
+
* (null), so the hub can carry it in its own status */
|
|
299
|
+
export type OnCollector = (collection: string, error: string | null) => void
|
|
300
|
+
|
|
280
301
|
/** every resource of every collection, failure-isolated per collection: a
|
|
281
302
|
* broken source reports itself as ONE red row, never blanks the tree */
|
|
282
|
-
export async function projectResources(collections: Collection[]): Promise<Resource[]> {
|
|
303
|
+
export async function projectResources(collections: Collection[], onCollector?: OnCollector): Promise<Resource[]> {
|
|
283
304
|
const out: Resource[] = []
|
|
284
305
|
for (const c of collections) {
|
|
285
306
|
try {
|
|
286
307
|
out.push(...(await listResources(c)))
|
|
308
|
+
onCollector?.(c.id, null)
|
|
287
309
|
} catch (e) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
type: 'health',
|
|
292
|
-
path: `health/${c.id}-collector`,
|
|
293
|
-
virtual: true,
|
|
294
|
-
title: `collector broke: ${(e as Error).message}`,
|
|
295
|
-
text: `collector broke: ${(e as Error).message}`,
|
|
296
|
-
severity: 'red',
|
|
297
|
-
state: 'red',
|
|
298
|
-
})
|
|
310
|
+
const row = brokenRow(c, e)
|
|
311
|
+
out.push({ ...row, collection: c.id, title: row.text, state: 'red' })
|
|
312
|
+
onCollector?.(c.id, (e as Error).message)
|
|
299
313
|
}
|
|
300
314
|
}
|
|
301
315
|
return out
|
|
@@ -303,13 +317,15 @@ export async function projectResources(collections: Collection[]): Promise<Resou
|
|
|
303
317
|
|
|
304
318
|
/** the flat needs projection — failure-isolated per collection: a broken
|
|
305
319
|
* source reports itself as ONE red item, never blanks the feed */
|
|
306
|
-
export async function projectNeeds(collections: Collection[]): Promise<NeedItem[]> {
|
|
320
|
+
export async function projectNeeds(collections: Collection[], onCollector?: OnCollector): Promise<NeedItem[]> {
|
|
307
321
|
const out: NeedItem[] = []
|
|
308
322
|
for (const c of collections) {
|
|
309
323
|
try {
|
|
310
324
|
for (const i of await c.list()) if (i.severity != null) out.push({ ...i, collection: c.id })
|
|
325
|
+
onCollector?.(c.id, null)
|
|
311
326
|
} catch (e) {
|
|
312
|
-
out.push({
|
|
327
|
+
out.push({ ...brokenRow(c, e), collection: c.id })
|
|
328
|
+
onCollector?.(c.id, (e as Error).message)
|
|
313
329
|
}
|
|
314
330
|
}
|
|
315
331
|
return out
|