rvlib-shipkit 1.1.1 → 1.3.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.
Files changed (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -8,11 +8,16 @@
8
8
  // the ban — a green line must never teach the wrong rule. The remedy for
9
9
  // pushed history is the owner's decision, never an automatic rewrite; only
10
10
  // an unpushed tip commit may be amended, and the fails say so.
11
+ import { createHash } from 'crypto'
11
12
  import { readFileSync } from 'fs'
12
13
  import { join } from 'path'
13
14
  import { hasAiAttribution, hasDashPunctuation } from '../hooks/commit-message.ts'
14
15
  import { nestedIn } from '../core/nesting.ts'
15
- import { check } from '../check.ts'
16
+ import { gitBin } from '../core/git-bin.ts'
17
+ import { fileStamp } from '../core/file-stamp.ts'
18
+ import { CONFIG_FILE } from '../core/check-pass.ts'
19
+ import { check, type CheckEnv } from '../check.ts'
20
+ import { declaredVisibility } from '../core/repo-facts.ts'
16
21
 
17
22
  // the documented silencer: .claude/settings.json attribution.commit/.pr both
18
23
  // empty — the harness then never writes the trailer or the PR footer
@@ -25,20 +30,39 @@ const attributionSilenced = (root: string): boolean => {
25
30
  }
26
31
  }
27
32
 
33
+ /** what the answer depends on: the declared visibility, and for a public
34
+ * repo the commit HEAD names, every ref name and the silencer. So a commit,
35
+ * a fetch or a new tag asks again, and nothing else does */
36
+ export function historyKey(env: CheckEnv): string {
37
+ const declared = declaredVisibility(env.config)
38
+ const nested = nestedIn(env.root)
39
+ if (nested != null) return `nested in ${nested}`
40
+ if (declared !== 'public') return `declared ${declared}`
41
+ const git = (args: string[]): string => {
42
+ const p = Bun.spawnSync([gitBin(), ...args], { cwd: env.root, stdout: 'pipe', stderr: 'pipe' })
43
+ return p.exitCode === 0 ? p.stdout.toString() : `exit ${p.exitCode}`
44
+ }
45
+ const refs = git(['for-each-ref', '--format=%(refname) %(objectname)', 'refs/tags', 'refs/heads', 'refs/remotes'])
46
+ const head = git(['rev-parse', 'HEAD'])
47
+ const digest = createHash('sha256').update(head).update('\0').update(refs).digest('hex').slice(0, 16)
48
+ return `public|refs=${digest}|settings=${fileStamp(join(env.root, '.claude', 'settings.json'))}`
49
+ }
50
+
28
51
  export const commitAiTells = check({
29
52
  id: 'SK009',
30
53
  blocking: true,
31
54
  title: 'commit ai tells',
32
55
  description: 'the history of a public repo carries no AI attribution and no dash punctuation',
33
56
  owner: 'identity/how-we-commit.md',
34
- // it scans the full `git log` and every ref name. History and refs are not
35
- // work-tree content, so nothing a scan can produce describes this answer
36
- never: 'reads the full commit history and every ref name, neither of which is work-tree content',
57
+ // it scans the full `git log` and every ref name, which no tree id
58
+ // describes, so the refs themselves are the key
59
+ inputs: [CONFIG_FILE],
60
+ cacheKey: historyKey,
37
61
  run: async (c) => {
38
62
  const nested = nestedIn(c.root)
39
63
  if (nested != null)
40
64
  return void c.lines.push({ kind: 'skip', text: `commit history not scanned, nested in ${nested}: that history belongs to the parent repo` })
41
- const declared = c.config.visibility ?? 'private'
65
+ const declared = declaredVisibility(c.config)
42
66
  if (declared !== 'public')
43
67
  return c.ok(`declared ${declared} — the no-AI-attribution ban still applies from commit one (identity/how-we-commit.md); the full-history scan arms at visibility: 'public'`)
44
68
  // %x1f separates hash from body, %x1e terminates the record — commit
@@ -15,6 +15,7 @@
15
15
  import { isAbsolute, join } from 'path'
16
16
  import { inferBin } from '../core/canonical-scripts.ts'
17
17
  import { denyRules, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
18
+ import { rootPublishesNpm } from '../core/repo-facts.ts'
18
19
  import type { RepoConfig } from '../repo-config.ts'
19
20
  import { check, type CheckCtx, type FixResult } from '../check.ts'
20
21
 
@@ -95,8 +96,8 @@ async function guardScriptMissing(d: Disk, command: string): Promise<string | nu
95
96
  return null // a form this rule cannot resolve is not a form it may condemn
96
97
  }
97
98
 
98
- /** the guard the fixer writes: the cli path the scripts run when a guard sits
99
- * beside it (a repo's own `src/cli.ts` matches the same shape), else the
99
+ /** the guard the fixer writes: the shipkit cli path the scripts run when a
100
+ * guard sits beside it, else the
100
101
  * repo's own dependency, else the copy of shipkit doing the fixing, which is
101
102
  * the one the launcher falls back to in a repo that has none */
102
103
  export async function guardCommandFor(d: Disk): Promise<string> {
@@ -109,6 +110,11 @@ export async function guardCommandFor(d: Disk): Promise<string> {
109
110
  return guardCommand(join(import.meta.dir, '..', 'cli.ts'))
110
111
  }
111
112
 
113
+ /** a package published to npm is worked on by strangers: this file binds
114
+ * their agents, and a deny list with a hook would tie them to a toolkit they
115
+ * never installed */
116
+ const PUBLIC_SKIP = `the root unit publishes to npm: ${SETTINGS_PATH} belongs to every contributor, so it is never written`
117
+
112
118
  export const oneWayInPolicy = check({
113
119
  id: 'SK013',
114
120
  blocking: true,
@@ -119,6 +125,7 @@ export const oneWayInPolicy = check({
119
125
  // the declared guard script resolves. A guard at an absolute path lives
120
126
  // outside the repo, so an answer that probed one is never stored
121
127
  run: async (c) => {
128
+ if (rootPublishesNpm(c.config)) return c.ok(PUBLIC_SKIP)
122
129
  const d = c
123
130
  const want = denyRules(exemptSlots(c.config))
124
131
  if (want.length === 0) return c.ok('every governed slot is exempt (repo.config oneWayIn/defaults) — nothing to deny')
@@ -165,6 +172,7 @@ export const oneWayInPolicy = check({
165
172
  // a repo's own allow list and its own hooks, and clobbering those to fix a
166
173
  // deny list would cost more than the finding it repairs
167
174
  fix: async (c): Promise<FixResult> => {
175
+ if (rootPublishesNpm(c.config)) return { changed: false, summary: PUBLIC_SKIP }
168
176
  const d = c
169
177
  const want = denyRules(exemptSlots(c.config))
170
178
  if (want.length === 0) return { changed: false, summary: 'every governed slot is exempt — nothing to write' }
@@ -3,6 +3,7 @@
3
3
  // sites that write history refuse on the disk fact either way (core/nesting.ts),
4
4
  // so this rule is about the declaration telling the truth to the next reader.
5
5
  import { nestedIn } from '../core/nesting.ts'
6
+ import { CONFIG_FILE } from '../core/check-pass.ts'
6
7
  import { check } from '../check.ts'
7
8
 
8
9
  export const nestedRepo = check({
@@ -11,7 +12,10 @@ export const nestedRepo = check({
11
12
  title: 'nested repo',
12
13
  description: 'a config inside a larger work tree declares `nested: true`, and only there',
13
14
  owner: 'build/shipkit/config-keys.md',
14
- never: 'reads where the git work tree root sits, which no content inside the repo describes',
15
+ // the declaration, and where the git work tree root sits, which no tree
16
+ // id describes, so its path is the rest of the key
17
+ inputs: [CONFIG_FILE],
18
+ cacheKey: (env) => `nested=${env.config.nested === true}|top=${nestedIn(env.root) ?? 'root'}`,
15
19
  run: (c) => {
16
20
  const top = nestedIn(c.root)
17
21
  const declared = c.config.nested === true
@@ -11,6 +11,7 @@ import { basename } from 'path'
11
11
  import { check } from '../check.ts'
12
12
  import type { CheckCtx } from '../core/check-ctx.ts'
13
13
  import { checkFloorReader, resolveFloor } from '../core/floor-resolve.ts'
14
+ import { rootPublishesNpm } from '../core/repo-facts.ts'
14
15
  import { whenOfTask } from '../core/when.ts'
15
16
  import { hubProblemMatcher } from '../problems/problem-line.ts'
16
17
  import { indentAt, parseSpans, plain, removalSpan, type Node } from '../scaffold/jsonc-spans.ts'
@@ -165,6 +166,10 @@ function ownTasks(c: CheckCtx): { text: string } | 'none' | 'linked' {
165
166
  return text == null ? 'none' : { text }
166
167
  }
167
168
 
169
+ /** a package published to npm is worked on by strangers, whose editor
170
+ * runs these tasks */
171
+ const PUBLIC_SKIP = `the root unit publishes to npm: ${TASKS} belongs to every contributor, so it is never rewritten`
172
+
168
173
  export const vscodeHubTask = check({
169
174
  id: 'SK017',
170
175
  // a stale editor file is behind, not broken: it warns, and the fix pass
@@ -174,6 +179,7 @@ export const vscodeHubTask = check({
174
179
  description: 'the vscode hub task carries the problem matcher and no extra tsc watcher runs',
175
180
  owner: 'build/vscode-tasks.md',
176
181
  run: (c) => {
182
+ if (rootPublishesNpm(c.config)) return c.ok(PUBLIC_SKIP)
177
183
  const own = ownTasks(c)
178
184
  if (own === 'none') return c.ok(`no ${TASKS}, so there is no hub task to wire`)
179
185
  if (own === 'linked') return c.ok(`.vscode/ is a link to a shared dir, left alone`)
@@ -185,6 +191,7 @@ export const vscodeHubTask = check({
185
191
  for (const ch of r.changes) c.fail(`${TASKS}: ${ch.replace(/^removed/, 'should remove').replace(/^dropped/, 'should drop').replace(/^wrote/, 'should write').replace(/^folded/, 'should fold')}`)
186
192
  },
187
193
  fix: (c) => {
194
+ if (rootPublishesNpm(c.config)) return { changed: false, summary: PUBLIC_SKIP }
188
195
  const own = ownTasks(c)
189
196
  if (typeof own === 'string') return { changed: false, summary: `no ${TASKS} to repair` }
190
197
  const r = repairTasks(own.text, { typecheckOn: typecheckOn(c) })
@@ -0,0 +1,47 @@
1
+ // SK022 machine runs the pin (build/shipkit.md): on a machine with a house
2
+ // every shipkit runs the house copy, whatever a repo installed, so a repo's
3
+ // rvlib-shipkit pin is a minimum. It warns when the repo asks for a version
4
+ // newer than the one this process runs: a rule or an api that pin relies on
5
+ // is absent here until the house copy catches up.
6
+ import { join } from 'path'
7
+ import { LIB_VERSION } from '../core/lib-version.ts'
8
+ import { fileStamp } from '../core/file-stamp.ts'
9
+ import { check } from '../check.ts'
10
+
11
+ type Pkg = { version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
12
+
13
+ const NAME = 'rvlib-shipkit'
14
+
15
+ /** the lowest version a semver range admits (`^0.30.1` → 0.30.1), null when
16
+ * it names no version (link:, workspace:*, a tag) */
17
+ export function pinFloor(spec: string): string | null {
18
+ const m = /^\s*(?:\^|~|>=|=|v)?\s*(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(spec)
19
+ return m?.[1] ?? null
20
+ }
21
+
22
+ export const machineRunsThePin = check({
23
+ id: 'SK022',
24
+ blocking: false,
25
+ title: 'machine runs the pin',
26
+ description: 'the shipkit this machine runs is at least the version this repo pins',
27
+ owner: 'build/shipkit.md',
28
+ // the pin, the install (gitignored) and the version this machine runs,
29
+ // which is no file of the repo
30
+ inputs: ['package.json'],
31
+ cacheKey: (env) => `${LIB_VERSION}|installed=${fileStamp(join(env.root, 'node_modules', NAME, 'package.json'))}`,
32
+ run: (c) => {
33
+ const pkg = c.json<Pkg>('package.json')
34
+ const spec = pkg?.devDependencies?.[NAME] ?? pkg?.dependencies?.[NAME]
35
+ if (spec == null) return c.ok(`no ${NAME} pin, this machine runs ${LIB_VERSION}`)
36
+ const wants: Array<{ v: string; from: string }> = []
37
+ const floor = pinFloor(spec)
38
+ if (floor != null) wants.push({ v: floor, from: `package.json pins ${spec}` })
39
+ const installed = c.json<Pkg>(`node_modules/${NAME}/package.json`)?.version
40
+ if (installed != null) wants.push({ v: installed, from: `node_modules has ${installed}` })
41
+ const ahead = wants.filter((w) => Bun.semver.order(w.v, LIB_VERSION) > 0)
42
+ if (ahead.length === 0) return c.ok(`${NAME} ${spec}: this machine runs ${LIB_VERSION}, at least that`)
43
+ c.warn(`${NAME}: ${ahead.map((w) => w.from).join(', ')}, newer than the ${LIB_VERSION} this machine runs. Update the house copy (git pull in the repo that hosts the house wiki), or run this repo's own install: bun node_modules/${NAME}/src/cli.ts`)
44
+ },
45
+ })
46
+
47
+ export default machineRunsThePin
@@ -0,0 +1,54 @@
1
+ // SK024 identity colors (core/identity.ts owns the derivation): repo.config.ts
2
+ // declares `identity: { colorLight, colorDark }`, two #rrggbb hex, so every
3
+ // surface that shows the repo (the home page header, its rail tile) draws it
4
+ // in its own colour. The fix derives them from the repo icon, or from a hue
5
+ // hashed from the name, and writes that one key, every other byte kept.
6
+ import { join } from 'path'
7
+ import { check } from '../check.ts'
8
+ import { fileStamp } from '../core/file-stamp.ts'
9
+ import { configName, CONFIG_FILE, deriveIdentity, identityRefusal, INSTALLED_CONFIG_SRC } from '../core/identity.ts'
10
+ import { declaresIdentity, identityOfText, identityProblem, setIdentityText } from '../core/identity-color.ts'
11
+
12
+
13
+ const FIX = 'shipkit check --fix SK024 derives them from the repo icon, or from its name'
14
+
15
+ export const identityColors = check({
16
+ id: 'SK024',
17
+ blocking: false,
18
+ title: 'identity colors',
19
+ description: 'repo.config.ts declares identity colours: colorLight and colorDark, two hex',
20
+ owner: 'build/shipkit/built-in-checks.md',
21
+ // the config, and the installed lib that says whether it knows the key
22
+ inputs: [CONFIG_FILE],
23
+ cacheKey: (env) => `installed=${fileStamp(join(env.root, INSTALLED_CONFIG_SRC))}`,
24
+ run: (c) => {
25
+ const text = c.read(CONFIG_FILE)
26
+ if (text == null) return c.note(`no ${CONFIG_FILE}`)
27
+ const declared = declaresIdentity(text)
28
+ const id = identityOfText(text)
29
+ const why = id == null ? (declared ? 'identity is declared but its two colours cannot be read as #rrggbb hex' : 'no identity colours') : identityProblem(id)
30
+ const refused = why == null ? null : identityRefusal(c.read(INSTALLED_CONFIG_SRC))
31
+ if (refused != null) return c.note(`${CONFIG_FILE}: ${why}, and ${refused}`)
32
+ if (why != null) return c.warn(`${CONFIG_FILE}: ${why}`, FIX)
33
+ c.ok(`identity ${id!.colorLight} on light, ${id!.colorDark} on dark`)
34
+ },
35
+ fix: async (c) => {
36
+ const text = c.read(CONFIG_FILE)
37
+ if (text == null) return { changed: false, summary: `no ${CONFIG_FILE}` }
38
+ const have = identityOfText(text)
39
+ if (have != null && identityProblem(have) == null) return { changed: false, summary: 'identity colours already declared' }
40
+ const refused = identityRefusal(c.read(INSTALLED_CONFIG_SRC))
41
+ if (refused != null) return { changed: false, summary: refused }
42
+ const { id, from } = await deriveIdentity(c.root, configName(text, c.root), (rel) => c.bytes(rel))
43
+ let next: string
44
+ try {
45
+ next = setIdentityText(text, id)
46
+ } catch (e) {
47
+ return { changed: false, summary: (e as Error).message }
48
+ }
49
+ c.write(CONFIG_FILE, next)
50
+ return { changed: true, summary: `identity ${id.colorLight} / ${id.colorDark}, from ${from}` }
51
+ },
52
+ })
53
+
54
+ export default identityColors
@@ -0,0 +1,58 @@
1
+ // SK025 release drift (build/shipkit/built-in-checks.md): every unit this repo
2
+ // releases matches what npm serves and what its push remote holds, the same
3
+ // survey `shipkit releases` prints. It replaces a drift copy per page. A repo
4
+ // that releases nothing (a private root with no release config and no units)
5
+ // skips it. A world probe: the registry and the remote move on a publish this
6
+ // tree cannot see, so a clean answer keeps for a day, keyed on every unit's
7
+ // version so a local bump asks again at once.
8
+ import { check, type CheckCtx } from '../check.ts'
9
+ import { parseWireOutput } from '../core/wire.ts'
10
+ import type { RepoConfig } from '../repo-config.ts'
11
+ import { attentionLines, collectReports, type UnitReport } from '../release/run-releases.ts'
12
+ import { resolveUnits, type ReleaseUnit } from '../release/units.ts'
13
+
14
+ /** the units this repo releases: every declared one, and the root only when
15
+ * it publishes to npm or the repo declares a release config */
16
+ export function releasedUnits(root: string, config: RepoConfig): ReleaseUnit[] {
17
+ return resolveUnits(root, config).units.filter((u) => u.id !== 'root' || u.targets.npm || config.release != null)
18
+ }
19
+
20
+ /** the survey as check lines: one ok per unit in step, else its attention lines */
21
+ export function reportDrift(c: CheckCtx, reports: UnitReport[]): void {
22
+ for (const r of reports) {
23
+ const lines = attentionLines(r)
24
+ if (lines.length === 0) {
25
+ // "in sync with npm" only for a unit that HAS an npm target and whose
26
+ // registry read succeeded (attentionLines warns when it did not)
27
+ c.ok(r.npmTarget ? `${r.name}@${r.version} in sync with npm` : `${r.name}@${r.version}, no npm target, tag state only`)
28
+ continue
29
+ }
30
+ for (const l of parseWireOutput(lines.join('\n'))) {
31
+ if (l.kind === 'warn' || l.kind === 'fail') c.warn(l.text)
32
+ else if (l.kind === 'ok') c.ok(l.text)
33
+ else c.note(l.text)
34
+ }
35
+ }
36
+ }
37
+
38
+ export const releaseDrift = check({
39
+ id: 'SK025',
40
+ blocking: false,
41
+ title: 'release drift',
42
+ description: 'every unit this repo releases matches npm and its push remote',
43
+ owner: 'build/shipkit/built-in-checks.md',
44
+ when: 'health',
45
+ ttl: '20h',
46
+ every: (last) => (last?.status === 'pass' ? ['20h', '28h'] : '4h'),
47
+ planOff: (env) => (releasedUnits(env.root, env.config).length === 0 ? 'this repo releases nothing' : null),
48
+ cacheKey: (env) => releasedUnits(env.root, env.config).map((u) => `${u.id}@${u.version}`).join(','),
49
+ run: async (c) => {
50
+ const ids = new Set(releasedUnits(c.root, c.config).map((u) => u.id))
51
+ if (ids.size === 0) return void c.lines.push({ kind: 'skip', text: 'this repo releases nothing: no npm root, no release config, no units' })
52
+ const { reports, errors } = await collectReports(c.root, c.config)
53
+ for (const e of errors) c.warn(e)
54
+ reportDrift(c, reports.filter((r) => ids.has(r.id)))
55
+ },
56
+ })
57
+
58
+ export default releaseDrift
@@ -0,0 +1,44 @@
1
+ // SK026 asks auto close (build/shipkit/built-in-checks.md): probe every open
2
+ // ask's own `check:` and archive the ones that pass, so an act done in the
3
+ // world leaves the queue without anyone editing a file. An open queue is no
4
+ // warning: the hub and `shipkit needs` show it. A repo with no queue skips.
5
+ import { existsSync } from 'fs'
6
+ import { join } from 'path'
7
+ import { checkAsks, listAsks } from '../asks/asks-queue.ts'
8
+ import { check } from '../check.ts'
9
+ import { LAYOUT } from '../core/layout.ts'
10
+
11
+ export const asksAutoclose = check({
12
+ id: 'SK026',
13
+ blocking: false,
14
+ title: 'asks auto close',
15
+ description: 'no open ask whose own probe passes stays in the queue',
16
+ owner: 'build/shipkit/built-in-checks.md',
17
+ when: 'health',
18
+ never: "runs each open ask's own probe, a shell command that reads the world",
19
+ every: ['20h', '28h'],
20
+ // it only probes an ask that carries one, and archives one marked done
21
+ planOff: (env) => {
22
+ const dir = join(env.root, env.config.asks?.dir ?? LAYOUT.asks)
23
+ if (!existsSync(dir)) return 'no asks queue'
24
+ return listAsks(dir).some((a) => a.check != null || a.status === 'done') ? null : 'no open ask carries a probe'
25
+ },
26
+ run: async (c) => {
27
+ const dir = join(c.root, c.config.asks?.dir ?? LAYOUT.asks)
28
+ if (!existsSync(dir)) return void c.lines.push({ kind: 'skip', text: 'no asks queue in this repo' })
29
+ // archiving RENAMES files, and the release path runs the checks between
30
+ // its clean tree guard and `npm publish`: frozen means report only
31
+ if (c.frozen) return void c.lines.push({ kind: 'skip', text: 'asks not probed: a frozen run archives nothing' })
32
+ const events: string[] = []
33
+ await checkAsks(c.root, dir, (l) => events.push(l))
34
+ // `closed + archived <file>` / `archived <file>` are the queue's frozen stdout contract
35
+ for (const l of events) {
36
+ if (l.startsWith('closed') || l.startsWith('archived')) c.ok(`ask ${l}`)
37
+ // the queue could not run (another agent holds the write lease)
38
+ else if (l.startsWith('WARN')) c.warn(l.replace(/^WARN\s*/, ''))
39
+ }
40
+ if (!c.lines.some((l) => l.kind === 'ok')) c.ok('no open ask has a probe that passes')
41
+ },
42
+ })
43
+
44
+ export default asksAutoclose
@@ -0,0 +1,28 @@
1
+ // SK027 agent wiki links (build/shipkit/built-in-checks.md): a repo's agent/
2
+ // IS an md wiki (entry index.md, the CLAUDE.md include), so an orphan page or
3
+ // a dangling ref there fails. A cross wiki pointer (into a house wiki) is
4
+ // backticked, never a bare → or @, which reads as an agent local link and
5
+ // dangles. A repo without agent/index.md skips.
6
+ import { existsSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { check } from '../check.ts'
9
+ import { lintMd } from '../wiki/lint-md.ts'
10
+
11
+ export const agentWikiLinks = check({
12
+ id: 'SK027',
13
+ blocking: true,
14
+ title: 'agent wiki links',
15
+ description: 'every agent/ page is linked from somewhere, and every agent/ link resolves',
16
+ owner: 'meta/agent-folder.md',
17
+ // the md lint walks agent/ on disk rather than through `c`: agent/ is all it reads
18
+ inputs: ['agent'],
19
+ run: (c) => {
20
+ if (!existsSync(join(c.root, 'agent/index.md'))) return void c.lines.push({ kind: 'skip', text: 'no agent/ wiki in this repo' })
21
+ const lint = lintMd({ root: join(c.root, 'agent'), index: 'index.md' })
22
+ for (const f of lint.orphans) c.fail(`agent/${f}: orphan, unreachable from agent/index.md: link it from a page or delete it`)
23
+ for (const d of lint.dangling) c.fail(`agent/${d.from}: dangling ref → ${d.token}, target not found (backtick a cross wiki ref; fix or delete the pointer)`)
24
+ if (!c.failed) c.ok('agent/ has no orphans and no dangling refs')
25
+ },
26
+ })
27
+
28
+ export default agentWikiLinks
@@ -0,0 +1,79 @@
1
+ // SK028 readme (build/shipkit/built-in-checks.md): the README.md a repo's hub
2
+ // page shows exists and carries what makes the repo recognizable at a glance.
3
+ // A list of named requirements, each reporting its own line, so a new
4
+ // mandatory item is one more entry. Read through `c` only, so the answer is
5
+ // keyed on README.md and the images it points to, never on a clock.
6
+ import { posix } from 'path'
7
+ import { check } from '../check.ts'
8
+ import type { CheckCtx } from '../core/check-ctx.ts'
9
+
10
+ const README = 'README.md'
11
+
12
+ /** every image source the README shows: markdown `![..](src)` and html
13
+ * `<img src>`, code fences and inline code left out, since a sample there
14
+ * shows nothing */
15
+ export const imageRefs = (md: string): string[] => {
16
+ const prose = md.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '').replace(/`[^`\n]*`/g, '')
17
+ const refs: { at: number; src: string }[] = []
18
+ for (const m of prose.matchAll(/!\[[^\]]*\]\(\s*<?([^\s)>]+)>?(?:\s+["'(][^)]*)?\)/g)) refs.push({ at: m.index, src: m[1]! })
19
+ for (const m of prose.matchAll(/<img\b[^>]*?\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi)) refs.push({ at: m.index, src: (m[1] ?? m[2] ?? m[3])! })
20
+ return refs.sort((a, b) => a.at - b.at).map((r) => r.src)
21
+ }
22
+
23
+ /** a status badge (ci, version, license) shows the repo's state, never what it looks like */
24
+ const isBadge = (src: string): boolean => /(^|[/.])(shields\.io|badgen\.net)\//i.test(src) || /badge[^/]*\.svg([?#]|$)/i.test(src)
25
+
26
+ /** where an image source lands: an https url, a repo file, or nowhere with why */
27
+ const resolveImage = (c: CheckCtx, src: string): { ok: string } | { bad: string } => {
28
+ if (isBadge(src)) return { bad: `${src.slice(0, 60)} is a badge` }
29
+ if (/^https:\/\//i.test(src)) return { ok: src }
30
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith('//')) return { bad: `${src.slice(0, 60)} is neither an https url nor a repo file` }
31
+ let rel: string
32
+ try {
33
+ rel = posix.normalize(decodeURI(src.replace(/[?#].*$/, '')).replace(/^\/+/, ''))
34
+ } catch {
35
+ return { bad: `${src} is not a readable path` }
36
+ }
37
+ if (rel === '.' || rel.startsWith('../')) return { bad: `${src} points out of the repo` }
38
+ return c.exists(rel, 'file') ? { ok: rel } : { bad: `${rel} is missing` }
39
+ }
40
+
41
+ type Finding = { ok: string } | { warn: string; fix: string }
42
+ type Requirement = { name: string; test: (c: CheckCtx, md: string) => Finding }
43
+
44
+ /** what every README carries, in order. Add an entry to make one more item mandatory */
45
+ const REQUIREMENTS: Requirement[] = [
46
+ {
47
+ name: 'screenshot',
48
+ test: (c, md) => {
49
+ const found = imageRefs(md).map((src) => resolveImage(c, src))
50
+ const shown = found.find((f): f is { ok: string } => 'ok' in f)
51
+ if (shown != null) return { ok: `${README} shows a screenshot: ${shown.ok}` }
52
+ const why = found.map((f) => (f as { bad: string }).bad)
53
+ return {
54
+ warn: `${README}: screenshot: ${why.length === 0 ? 'no image at all' : `no image resolves (${why.join(', ')})`}`,
55
+ fix: 'add a screenshot to README.md: ![what it looks like](docs/screenshot.png), a file in the repo, or an https url',
56
+ }
57
+ },
58
+ },
59
+ ]
60
+
61
+ export const readme = check({
62
+ id: 'SK028',
63
+ blocking: false,
64
+ title: 'readme',
65
+ description: 'README.md exists and shows at least one screenshot',
66
+ owner: 'build/shipkit/built-in-checks.md',
67
+ run: (c) => {
68
+ const md = c.read(README)
69
+ if (md == null) return c.warn(`${README}: exists: no ${README} at the repo root`, 'write README.md: what the repo is, and a screenshot of it')
70
+ c.ok(`${README} exists`)
71
+ for (const r of REQUIREMENTS) {
72
+ const f = r.test(c, md)
73
+ if ('ok' in f) c.ok(f.ok)
74
+ else c.warn(f.warn, f.fix)
75
+ }
76
+ },
77
+ })
78
+
79
+ export default readme
@@ -99,7 +99,7 @@ export class AssetsCheckCommand extends Command {
99
99
 
100
100
  static override usage = Command.Usage({
101
101
  category: 'Assets',
102
- description: 'kit loads, kinds valid, cflow workflows load, drafts present, backend reachable, identity outputs present',
102
+ description: 'kit loads, kinds valid, cflow workflows load, drafts present, identity outputs present',
103
103
  examples: [['health-check the asset pipeline', 'shipkit assets check']],
104
104
  })
105
105
 
@@ -2,6 +2,9 @@
2
2
  // autofixes then re-lints. Thin clipanion surface; logic in
3
3
  // core/run-check.ts and core/run-fix.ts. ci NEVER fixes.
4
4
  import { Command, Option } from 'clipanion'
5
+ import { runViaHub } from '../home/via-hub.ts'
6
+ import { readJobOpts, recordExit } from '../core/jobs.ts'
7
+ import { runJob } from '../core/job-run.ts'
5
8
  import { runCheck } from '../core/run-check.ts'
6
9
  import { maybeSelfUpdateAndRestart } from '../core/self-update.ts'
7
10
  import { runFix } from '../core/run-fix.ts'
@@ -27,8 +30,25 @@ export class CheckCommand extends Command {
27
30
  fix = Option.Boolean('--fix', false, { description: 'apply check autofixes (every fixable check, or only the listed ids), then re-run them' })
28
31
  frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for the self-maintaining checks (SK005 never touches package.json)' })
29
32
  fixIds = Option.Rest()
33
+ job = Option.String('--job', { hidden: true })
30
34
 
31
35
  async execute(): Promise<number> {
36
+ // a gate job the daemon started: its options are in the job folder
37
+ if (this.job != null) {
38
+ const root = process.cwd()
39
+ const job = readJobOpts(root, this.job)
40
+ let code = 1
41
+ if (job == null) this.context.stderr.write(`FAIL: gate job ${this.job} has no options in its folder\n`)
42
+ else {
43
+ try {
44
+ code = await runJob(root, this.job, 'check', job, (l) => { this.context.stdout.write(`${l}\n`) })
45
+ } catch (e) {
46
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
47
+ }
48
+ }
49
+ recordExit(root, this.job, code)
50
+ return code
51
+ }
32
52
  // a stale toolkit updates itself and restarts this exact invocation
33
53
  // (loop-guarded, skipped under CI/frozen/source consumption)
34
54
  await maybeSelfUpdateAndRestart(process.cwd(), { fresh: this.fresh })
@@ -43,7 +63,12 @@ export class CheckCommand extends Command {
43
63
  }
44
64
  if (this.fix)
45
65
  return await runFix(process.cwd(), { ids: this.fixIds, print: (l) => this.context.stdout.write(`${l}\n`) })
46
- return await runCheck(process.cwd(), { json: this.json, fresh: this.fresh, frozen: this.frozen, print: (l) => this.context.stdout.write(`${l}\n`) })
66
+ const opts = { json: this.json, fresh: this.fresh, frozen: this.frozen }
67
+ const print = (l: string): void => { this.context.stdout.write(`${l}\n`) }
68
+ // on a machine with the home, the repo's hub runs it (home/via-hub.ts)
69
+ const via = await runViaHub(process.cwd(), 'check', opts, { print, printErr: (l) => this.context.stderr.write(`${l}\n`) })
70
+ if (via != null) return via
71
+ return await runCheck(process.cwd(), { ...opts, print })
47
72
  } catch (e) {
48
73
  this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
49
74
  return 1
package/src/cli/cmd_Ci.ts CHANGED
@@ -2,8 +2,10 @@
2
2
  // core/run-ci.ts.
3
3
  import { Command, Option } from 'clipanion'
4
4
  import { join } from 'path'
5
- import { recordExit, startDetached } from '../core/jobs.ts'
5
+ import { readJobOpts, recordExit, startDetached } from '../core/jobs.ts'
6
+ import { runJob } from '../core/job-run.ts'
6
7
  import { runCi } from '../core/run-ci.ts'
8
+ import { runViaHub } from '../home/via-hub.ts'
7
9
  import { maybeSelfUpdateAndRestart } from '../core/self-update.ts'
8
10
 
9
11
  export class CiCommand extends Command {
@@ -22,6 +24,7 @@ export class CiCommand extends Command {
22
24
  ['run everything, take nothing from the cache', 'shipkit ci --fresh'],
23
25
  ['include an opt in check', 'shipkit ci --with up:itch'],
24
26
  ['in the background, read it back with shipkit status', 'shipkit ci --detach'],
27
+ ['every line, the ones nobody acts on included', 'shipkit ci --verbose'],
25
28
  ],
26
29
  })
27
30
 
@@ -36,6 +39,8 @@ export class CiCommand extends Command {
36
39
  fresh = Option.Boolean('--fresh', false, { description: 'run every check: no answer comes from the cache, and what this run finds is stored fresh' })
37
40
  full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
38
41
 
42
+ verbose = Option.Boolean('--verbose', false, { description: 'print every line, the ones nobody acts on included: a clean fix pass, the machine pool, the test tier, the cache declarations, the checks not in this run, the cache counts' })
43
+
39
44
  detach = Option.Boolean('--detach', false, { description: 'start this gate in the background and return at once with a run id. `shipkit status <id>` prints it. The gate is an ordinary one: same cache, same run record' })
40
45
  job = Option.String('--job', { hidden: true })
41
46
 
@@ -46,7 +51,13 @@ export class CiCommand extends Command {
46
51
  for (const l of d.lines) this.context.stdout.write(`${l}\n`)
47
52
  return 0
48
53
  }
49
- const code = await this.gate(root)
54
+ const job = this.job != null ? readJobOpts(root, this.job) : null
55
+ let code = 1
56
+ try {
57
+ code = this.job != null && job != null ? await runJob(root, this.job, 'ci', job, (l) => { this.context.stdout.write(`${l}\n`) }) : await this.gate(root)
58
+ } catch (e) {
59
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
60
+ }
50
61
  if (this.job != null) recordExit(root, this.job, code)
51
62
  return code
52
63
  }
@@ -64,6 +75,7 @@ export class CiCommand extends Command {
64
75
  ...this.only.flatMap((v) => ['--only', v]),
65
76
  ...(this.fresh ? ['--fresh'] : []),
66
77
  ...(this.full ? ['--full'] : []),
78
+ ...(this.verbose ? ['--verbose'] : []),
67
79
  ]
68
80
  }
69
81
 
@@ -72,7 +84,7 @@ export class CiCommand extends Command {
72
84
  // (loop-guarded, skipped under CI/frozen/source consumption)
73
85
  await maybeSelfUpdateAndRestart(root, { fresh: this.fresh })
74
86
  try {
75
- return await runCi(root, {
87
+ const opts = {
76
88
  bail: this.bail,
77
89
  json: this.json,
78
90
  fix: this.fix,
@@ -84,8 +96,14 @@ export class CiCommand extends Command {
84
96
  onlyIds: this.only,
85
97
  fresh: this.fresh,
86
98
  ...(this.full ? { tests: 'full' as const } : {}),
87
- print: (l) => this.context.stdout.write(`${l}\n`),
88
- })
99
+ ...(this.verbose ? { verbose: true } : {}),
100
+ }
101
+ const print = (l: string): void => { this.context.stdout.write(`${l}\n`) }
102
+ // on a machine with the home, the repo's hub runs it (home/via-hub.ts)
103
+ // a job is already the run, wherever it was started from
104
+ const via = this.job != null ? null : await runViaHub(root, 'ci', opts, { print, printErr: (l) => this.context.stderr.write(`${l}\n`) })
105
+ if (via != null) return via
106
+ return await runCi(root, { ...opts, print })
89
107
  } catch (e) {
90
108
  this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
91
109
  return 1