rvlib-shipkit 1.3.0 → 1.5.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 (203) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +12 -2
  7. package/src/check.ts +73 -98
  8. package/src/checks/SK001-package-json-shape.check.ts +26 -4
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  10. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  11. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  12. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  13. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  14. package/src/checks/SK007-github-visibility.check.ts +4 -2
  15. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  16. package/src/checks/SK009-commit-ai-tells.check.ts +94 -74
  17. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  18. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  19. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  20. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  21. package/src/checks/SK014-prose-ste.check.ts +20 -6
  22. package/src/checks/SK015-nested-repo.check.ts +4 -2
  23. package/src/checks/SK016-legacy-check-shapes.check.ts +23 -16
  24. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  25. package/src/checks/SK018-test-files-run.check.ts +6 -2
  26. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  27. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  28. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  29. package/src/checks/SK024-identity-colors.check.ts +5 -3
  30. package/src/checks/SK025-release-drift.check.ts +4 -5
  31. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  32. package/src/checks/SK027-agent-wiki-links.check.ts +34 -9
  33. package/src/checks/SK028-readme.check.ts +10 -2
  34. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  35. package/src/cli/cmd_Asks.ts +5 -5
  36. package/src/cli/cmd_Assets.ts +7 -7
  37. package/src/cli/cmd_Bump.ts +12 -11
  38. package/src/cli/cmd_Check.ts +8 -7
  39. package/src/cli/cmd_Checks.ts +7 -6
  40. package/src/cli/cmd_Ci.ts +12 -11
  41. package/src/cli/cmd_Commands.ts +16 -13
  42. package/src/cli/cmd_ConfigKeys.ts +7 -6
  43. package/src/cli/cmd_Contracts.ts +3 -3
  44. package/src/cli/cmd_CrossRepoActivity.ts +10 -6
  45. package/src/cli/cmd_Fleet.ts +9 -7
  46. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  47. package/src/cli/cmd_Home.ts +23 -17
  48. package/src/cli/cmd_Hooks.ts +6 -5
  49. package/src/cli/cmd_Init.ts +5 -4
  50. package/src/cli/cmd_Keys.ts +8 -7
  51. package/src/cli/cmd_Mv.ts +7 -6
  52. package/src/cli/cmd_Needs.ts +4 -3
  53. package/src/cli/cmd_Overview.ts +4 -3
  54. package/src/cli/cmd_Pb.ts +2 -2
  55. package/src/cli/cmd_Planned.ts +6 -5
  56. package/src/cli/cmd_Prose.ts +5 -5
  57. package/src/cli/cmd_Reflect.ts +7 -7
  58. package/src/cli/cmd_Release.ts +6 -5
  59. package/src/cli/cmd_Releases.ts +5 -4
  60. package/src/cli/cmd_Serve.ts +4 -3
  61. package/src/cli/cmd_Smoke.ts +5 -4
  62. package/src/cli/cmd_Start.ts +7 -6
  63. package/src/cli/cmd_Status.ts +4 -3
  64. package/src/cli/cmd_Tui.ts +3 -2
  65. package/src/cli/cmd_When.ts +71 -0
  66. package/src/cli/cmd_Wiki.ts +4 -4
  67. package/src/cli.ts +7 -2
  68. package/src/collections/checks.ts +2 -0
  69. package/src/collections/collection-types.ts +24 -4
  70. package/src/collections/providers.ts +6 -2
  71. package/src/contracts/apply-contracts.ts +9 -8
  72. package/src/contracts/contract-types.ts +8 -2
  73. package/src/contracts/contracts-engine.ts +34 -13
  74. package/src/contracts/manifests/identity.contract.ts +6 -5
  75. package/src/contracts/manifests/social.contract.ts +4 -2
  76. package/src/contracts/manifests/steam.contract.ts +7 -3
  77. package/src/contracts/web-surface.ts +59 -0
  78. package/src/core/budget.ts +19 -9
  79. package/src/core/canonical-scripts.ts +1 -1
  80. package/src/core/check-runner.ts +63 -25
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +31 -7
  83. package/src/core/layout.ts +2 -0
  84. package/src/core/legacy-shapes.ts +39 -10
  85. package/src/core/motherhome.ts +88 -66
  86. package/src/core/one-way-in-policy.ts +2 -2
  87. package/src/core/outcome-words.ts +18 -2
  88. package/src/core/port-check-cli.ts +52 -0
  89. package/src/core/port-check-file.ts +295 -0
  90. package/src/core/port-check-files.ts +31 -0
  91. package/src/core/retention.ts +0 -14
  92. package/src/core/run-check.ts +14 -14
  93. package/src/core/run-ci.ts +11 -7
  94. package/src/core/run-fix.ts +30 -23
  95. package/src/core/run-fixers.ts +42 -5
  96. package/src/core/runs-matrix.ts +179 -0
  97. package/src/core/runs.ts +242 -0
  98. package/src/core/update-policy.ts +20 -0
  99. package/src/core/when-block.ts +29 -30
  100. package/src/core/when.ts +41 -103
  101. package/src/core/wire.ts +34 -9
  102. package/src/fleet/scan-fleet.ts +20 -4
  103. package/src/fleet/update-fleet.ts +6 -6
  104. package/src/home/activity-shape.ts +4 -1
  105. package/src/home/activity.ts +22 -15
  106. package/src/home/gui-app/ShipkitGui.swift +144 -25
  107. package/src/home/gui-app.ts +93 -30
  108. package/src/home/home-launchd.ts +30 -6
  109. package/src/home/home-routes.ts +14 -2
  110. package/src/home/home-start.ts +33 -5
  111. package/src/home/hubs.ts +5 -5
  112. package/src/home/machine-stores.ts +4 -2
  113. package/src/home/open-app.ts +23 -10
  114. package/src/home/pm-config.ts +15 -11
  115. package/src/home/pm-inputs.ts +76 -0
  116. package/src/home/pm.ts +11 -3
  117. package/src/home/process-rows.ts +23 -6
  118. package/src/home/resources.ts +67 -15
  119. package/src/home/run-daemon.ts +6 -1
  120. package/src/home/run-pm.ts +30 -5
  121. package/src/home/serve-web.ts +2 -2
  122. package/src/home/supervise.ts +24 -1
  123. package/src/home/widgets.ts +32 -3
  124. package/src/hooks/commit-message.ts +2 -3
  125. package/src/hooks/run-hooks.ts +60 -6
  126. package/src/init-repo.ts +6 -6
  127. package/src/ledger/gate-checks.ts +115 -61
  128. package/src/ledger/git-key.ts +5 -1
  129. package/src/ledger/ledger.ts +5 -5
  130. package/src/ledger/store.ts +11 -0
  131. package/src/ledger/toolchain-identity.ts +45 -27
  132. package/src/ledger/world-cache.ts +17 -15
  133. package/src/plan/repo-source.ts +18 -39
  134. package/src/release/run-release.ts +5 -0
  135. package/src/repo-config.ts +36 -56
  136. package/src/rules/rule.ts +2 -2
  137. package/src/scaffold/files/agent-coding.md +1 -1
  138. package/src/scaffold/files/agent-index.md +1 -1
  139. package/src/scaffold/files/verify-skill.md +2 -2
  140. package/src/serve/hub-app/activity-st.ts +5 -4
  141. package/src/serve/hub-app/activity-view.tsx +4 -4
  142. package/src/serve/hub-app/admin-view.tsx +13 -11
  143. package/src/serve/hub-app/check-view.tsx +66 -46
  144. package/src/serve/hub-app/check-words.ts +1 -11
  145. package/src/serve/hub-app/checks-svc.ts +16 -36
  146. package/src/serve/hub-app/col-tables-view.tsx +131 -0
  147. package/src/serve/hub-app/col-tables.ts +115 -0
  148. package/src/serve/hub-app/cols-svc.ts +33 -4
  149. package/src/serve/hub-app/dock-st.ts +3 -2
  150. package/src/serve/hub-app/dock-view.tsx +36 -0
  151. package/src/serve/hub-app/dock.css +7 -1
  152. package/src/serve/hub-app/float-cards.tsx +30 -0
  153. package/src/serve/hub-app/floating.tsx +41 -4
  154. package/src/serve/hub-app/git-st.ts +42 -0
  155. package/src/serve/hub-app/git-view.tsx +61 -0
  156. package/src/serve/hub-app/hash-route.ts +45 -1
  157. package/src/serve/hub-app/header-bar.tsx +62 -72
  158. package/src/serve/hub-app/health-svc.ts +3 -2
  159. package/src/serve/hub-app/home-st.ts +15 -3
  160. package/src/serve/hub-app/home-strip.css +69 -19
  161. package/src/serve/hub-app/home-strip.tsx +192 -118
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +21 -5
  164. package/src/serve/hub-app/hub-types.ts +11 -5
  165. package/src/serve/hub-app/hub.css +88 -44
  166. package/src/serve/hub-app/icons.tsx +15 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +11 -8
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +34 -49
  171. package/src/serve/hub-app/palette-st.ts +5 -5
  172. package/src/serve/hub-app/palette-view.tsx +1 -1
  173. package/src/serve/hub-app/plan-view.tsx +51 -0
  174. package/src/serve/hub-app/planned-st.ts +9 -4
  175. package/src/serve/hub-app/processes-view.tsx +38 -0
  176. package/src/serve/hub-app/project-list.tsx +3 -3
  177. package/src/serve/hub-app/projects-svc.ts +15 -10
  178. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  179. package/src/serve/hub-app/reach.ts +36 -0
  180. package/src/serve/hub-app/readme-svc.ts +3 -2
  181. package/src/serve/hub-app/repo-sections.ts +117 -0
  182. package/src/serve/hub-app/repo-st.ts +47 -10
  183. package/src/serve/hub-app/runs-tabs.ts +1 -1
  184. package/src/serve/hub-app/sidebar.tsx +12 -37
  185. package/src/serve/hub-app/timeline-svc.ts +3 -2
  186. package/src/serve/hub-app/timeline-view.tsx +14 -11
  187. package/src/serve/hub-app/tree-build.ts +149 -155
  188. package/src/serve/hub-app/tree-row.tsx +39 -9
  189. package/src/serve/hub-app/tree-svc.ts +70 -49
  190. package/src/serve/hub-app/when-view.tsx +89 -0
  191. package/src/serve/hub-app/when.css +17 -0
  192. package/src/serve/hub-runtime.ts +3 -16
  193. package/src/serve/project-gutter.ts +1 -2
  194. package/src/serve/run-serve.ts +1 -1
  195. package/src/serve/serve-hub.ts +4 -14
  196. package/src/serve/warm-cell.ts +49 -16
  197. package/src/suite/judge-suites.ts +42 -17
  198. package/src/suite/suite-cli.ts +5 -8
  199. package/src/wiki/lint-md.ts +2 -0
  200. package/src/wiki/scan-md.ts +37 -10
  201. package/src/serve/hub-app/processes-svc.ts +0 -63
  202. package/src/serve/hub-app/repo-sections.tsx +0 -61
  203. package/src/serve/repo-processes.ts +0 -89
package/src/core/wire.ts CHANGED
@@ -3,22 +3,47 @@
3
3
  // exit 0/1. FROZEN: consumers grep startsWith('FAIL'/'WARN'/'skip:') — a
4
4
  // prefix change is a breaking change of the whole toolkit.
5
5
 
6
- export type Line = { kind: 'ok' | 'warn' | 'fail' | 'note' | 'fix-warn' | 'fix-fail' | 'skip'; text: string }
6
+ import { CACHED, costShort, RAN, spanShort } from './outcome-words.ts'
7
+
8
+ export { spanShort }
9
+
10
+ /** when a remembered world answer was measured, and until when it keeps
11
+ * (null: until its declaration changes). Kept as data, never as text, and
12
+ * rendered against the reader's clock: a time frozen into the line reads
13
+ * as a future expiry long after it passed */
14
+ export type Stamp = { at: number; until: number | null; cached?: boolean; ranMs?: number }
15
+
16
+ export type Line = { kind: 'ok' | 'warn' | 'fail' | 'note' | 'fix-warn' | 'fix-fail' | 'skip'; text: string; stamp?: Stamp }
17
+
18
+ /** `cached, 3h old, kept 57m more` for a replay, `ran 0.4s, kept 1h more`
19
+ * for an answer this run measured, `ran 1d ago, expired 1d ago` for one
20
+ * read back later */
21
+ export function stampWords(s: Stamp, now: number): string {
22
+ const age = now - s.at
23
+ const keeps = s.until == null ? 'kept until its declaration changes' : s.until > now ? `kept ${spanShort(s.until - now)} more` : `expired ${spanShort(now - s.until)} ago`
24
+ if (s.cached === true) return `${CACHED}, ${spanShort(age)} old, ${keeps}`
25
+ if (s.ranMs != null) return `${RAN} ${costShort(s.ranMs)}, ${keeps}`
26
+ return `${age < 1000 ? `${RAN} just now` : `${RAN} ${spanShort(age)} ago`}, ${keeps}`
27
+ }
28
+
29
+ /** a line's text as a reader sees it now, its stamp rendered */
30
+ export const lineText = (l: Line, now: number = Date.now()): string => (l.stamp == null ? l.text : `${l.text} (${stampWords(l.stamp, now)})`)
7
31
 
8
32
  const green = (s: string): string => `\x1b[32m${s}\x1b[0m`
9
33
  const red = (s: string): string => `\x1b[31m${s}\x1b[0m`
10
34
  const yellow = (s: string): string => `\x1b[33m${s}\x1b[0m`
11
35
 
12
- export function formatLine(l: Line, color: boolean): string {
36
+ export function formatLine(l: Line, color: boolean, now: number = Date.now()): string {
13
37
  const c = (f: (s: string) => string, s: string) => (color ? f(s) : s)
38
+ const text = lineText(l, now)
14
39
  switch (l.kind) {
15
- case 'ok': return `${c(green, 'ok:')} ${l.text}`
16
- case 'warn': return c(yellow, `WARN ${l.text}`)
17
- case 'fail': return c(red, `FAIL: ${l.text}`)
18
- case 'fix-warn': return ` ${c(yellow, `fix: ${l.text}`)}`
19
- case 'fix-fail': return ` ${c(red, `fix: ${l.text}`)}`
20
- case 'skip': return `skip: ${l.text}`
21
- case 'note': return l.text
40
+ case 'ok': return `${c(green, 'ok:')} ${text}`
41
+ case 'warn': return c(yellow, `WARN ${text}`)
42
+ case 'fail': return c(red, `FAIL: ${text}`)
43
+ case 'fix-warn': return ` ${c(yellow, `fix: ${text}`)}`
44
+ case 'fix-fail': return ` ${c(red, `fix: ${text}`)}`
45
+ case 'skip': return `skip: ${text}`
46
+ case 'note': return text
22
47
  }
23
48
  }
24
49
 
@@ -14,7 +14,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
14
14
  import { join } from 'path'
15
15
  import { isArchived } from '../core/archived.ts'
16
16
  import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
17
- import { isBreaking } from '../core/update-policy.ts'
17
+ import { isBreaking, isLinkedInstall, pinFloor } from '../core/update-policy.ts'
18
18
 
19
19
  export type Pkg = { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
20
20
 
@@ -24,6 +24,11 @@ export type FleetDep = {
24
24
  pin: string
25
25
  /** what node_modules actually carries, null when nothing is installed */
26
26
  installed: string | null
27
+ /** node_modules/<name> is a `bun link` symlink to a source tree */
28
+ linkedInstall: boolean
29
+ /** the version freshness is judged by: the pin's floor under a link
30
+ * (a fresh clone installs the pin, never the source), else `installed` */
31
+ at: string | null
27
32
  /** npm latest, null when the registry was not consulted or not reachable */
28
33
  latest: string | null
29
34
  }
@@ -54,6 +59,13 @@ const readPkg = (dir: string): Pkg | null => {
54
59
 
55
60
  export const installedVersion = (dir: string, name: string): string | null => readPkg(join(dir, 'node_modules', name))?.version ?? null
56
61
 
62
+ /** what `FleetDep.at` reads now, for a verdict after an update ran */
63
+ export const judgedVersion = (dir: string, name: string): string | null => {
64
+ if (!isLinkedInstall(dir, name)) return installedVersion(dir, name)
65
+ const pkg = readPkg(dir)
66
+ return pinFloor(pkg?.dependencies?.[name] ?? pkg?.devDependencies?.[name] ?? '')
67
+ }
68
+
57
69
  /** semver-ish compare, enough to answer "is this one behind that one". A
58
70
  * prerelease tag sorts before its release, which is the safe direction: it
59
71
  * reports behind rather than pretending parity. */
@@ -89,7 +101,11 @@ export function scanFleet(root: string, o: { prefix?: string } = {}): FleetRepo[
89
101
  const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
90
102
  const names = Object.keys(all).filter((n) => n.startsWith(prefix))
91
103
  if (names.length === 0) continue
92
- const deps: FleetDep[] = names.sort().map((name) => ({ name, pin: all[name]!, installed: installedVersion(dir, name), latest: null }))
104
+ const deps: FleetDep[] = names.sort().map((name) => {
105
+ const installed = installedVersion(dir, name)
106
+ const linkedInstall = isLinkedInstall(dir, name)
107
+ return { name, pin: all[name]!, installed, linkedInstall, at: linkedInstall ? pinFloor(all[name]!) : installed, latest: null }
108
+ })
93
109
  out.push({
94
110
  name: e,
95
111
  dir,
@@ -120,8 +136,8 @@ export async function resolveLatest(repos: FleetRepo[], fetchLatest: (name: stri
120
136
  )
121
137
  for (const r of repos) {
122
138
  for (const d of r.deps) d.latest = latest.get(d.name) ?? null
123
- r.behind = r.deps.filter((d) => d.installed != null && d.latest != null && isBehind(d.installed, d.latest))
124
- r.breaking = r.behind.filter((d) => isBreaking(d.installed!, d.latest!))
139
+ r.behind = r.deps.filter((d) => d.at != null && d.latest != null && isBehind(d.at, d.latest))
140
+ r.breaking = r.behind.filter((d) => isBreaking(d.at!, d.latest!))
125
141
  }
126
142
  return latest
127
143
  }
@@ -7,7 +7,7 @@ import { join } from 'path'
7
7
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
8
8
  import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
9
9
  import { ALLOW_MAJOR_ENV, breakingAdvice } from '../core/update-policy.ts'
10
- import { installedVersion, isBehind, updateRefusal, type FleetRepo } from './scan-fleet.ts'
10
+ import { isBehind, judgedVersion, updateRefusal, type FleetRepo } from './scan-fleet.ts'
11
11
 
12
12
 
13
13
  export const short = (name: string): string => name.replace(/^rvlib-/, '')
@@ -64,15 +64,15 @@ export async function updateFleet(behind: FleetRepo[], o: FleetUpdateOpts): Prom
64
64
  const env: Record<string, string> = Object.fromEntries(Object.entries(process.env).filter((e): e is [string, string] => e[1] != null && e[0] !== 'SHIPKIT_SELF_UPDATED' && e[0] !== ALLOW_MAJOR_ENV))
65
65
  if (o.major) env[ALLOW_MAJOR_ENV] = '1'
66
66
  const res = await spawnWithTimeout(['bun', cli, 'check', '--fresh'], { cwd: r.dir, env, timeoutMs: o.timeoutMs ?? 600_000 })
67
- // judge by what node_modules carries now, never by the exit code or a
68
- // printed line: the code covers every rule in that repo, and the line
69
- // format belongs to whatever version that repo runs
67
+ // judge by what node_modules carries now (the pin, for a linked dep),
68
+ // never by the exit code or a printed line: the code covers every rule
69
+ // in that repo, and the line format belongs to whatever version that repo runs
70
70
  const stuck = wanted.filter((d) => {
71
- const now = installedVersion(r.dir, d.name)
71
+ const now = judgedVersion(r.dir, d.name)
72
72
  return now == null || isBehind(now, d.latest!)
73
73
  })
74
74
  if (stuck.length === 0) {
75
- w(`ok: ${r.name} — ${wanted.map((d) => `${short(d.name)} ${d.installed} → ${installedVersion(r.dir, d.name)}`).join(', ')}`)
75
+ w(`ok: ${r.name} — ${wanted.map((d) => `${short(d.name)}${d.linkedInstall ? ' pin' : ''} ${d.at} → ${judgedVersion(r.dir, d.name)}`).join(', ')}`)
76
76
  continue
77
77
  }
78
78
  failed++
@@ -64,8 +64,11 @@ export type StatusCheck = {
64
64
  status: 'fail' | 'warn'
65
65
  /** when that outcome was recorded, epoch ms */
66
66
  at: number | null
67
- /** the first line it printed about the problem, '' when it printed none */
67
+ /** the worst line it printed about the problem, '' when it printed none */
68
68
  line: string
69
+ /** every fail and warn line it printed, the worst weight first: a check
70
+ * reporting three findings lists three */
71
+ lines: string[]
69
72
  }
70
73
 
71
74
  export type ActivityAnswer = { now: number; minutes: number; rows: ActivityRow[]; ms: number }
@@ -11,7 +11,7 @@
11
11
  import { gitBin } from '../core/git-bin.ts'
12
12
  import { readLastStatusReadonly } from '../ledger/run-log.ts'
13
13
  import { checkStateOf, recordOf } from '../serve/project-gutter.ts'
14
- import type { Line } from '../core/wire.ts'
14
+ import { lineText, type Line } from '../core/wire.ts'
15
15
  import { MAX_COMMITS, MAX_FILES, type ActivityAnswer, type ActivityCommit, type ActivityRepo, type ActivityRow, type FileKind, type StatusCheck } from './activity-shape.ts'
16
16
 
17
17
  export * from './activity-shape.ts'
@@ -93,26 +93,32 @@ export type Declared = (name: string) => ReadonlySet<string> | null
93
93
  /** each declared check's title by id; null = not known, the id stands in */
94
94
  export type Titles = (name: string) => ReadonlyMap<string, string> | null
95
95
 
96
- /** the line a check printed about its problem: its first line of the same
97
- * weight (a fail for a failure, a warning for a warning), else its first
98
- * line that is not a pass, without the id its line leads with */
99
- export function findingOf(lines: string, status: 'fail' | 'warn', id: string): string {
96
+ /** every line a check printed about its problems: its fail and warn lines,
97
+ * those of its own weight first (a fail for a failure, a warning for a
98
+ * warning), else its first line that is not a pass, each without the id it
99
+ * leads with */
100
+ export function findingsOf(lines: string, status: 'fail' | 'warn', id: string): string[] {
100
101
  let parsed: Line[]
101
102
  try {
102
103
  parsed = JSON.parse(lines) as Line[]
103
104
  } catch {
104
- return ''
105
+ return []
105
106
  }
106
- if (!Array.isArray(parsed)) return ''
107
- const kinds = status === 'fail' ? ['fail', 'fix-fail'] : ['warn', 'fix-warn']
108
- const hit = parsed.find((l) => kinds.includes(l.kind)) ?? parsed.find((l) => l.kind !== 'ok' && l.kind !== 'skip')
109
- if (hit == null) return ''
110
- const t = hit.text.trim()
111
- // `[ID] text`, and a runner line's `ID — text`: the list names the id already
112
- for (const lead of [`[${id}]`, `${id} —`]) if (t.startsWith(lead)) return t.slice(lead.length).trim()
113
- return t
107
+ if (!Array.isArray(parsed)) return []
108
+ const of = (k: Line['kind']): Line[] => parsed.filter((l) => l.kind === k)
109
+ let hits = status === 'fail' ? [...of('fail'), ...of('warn')] : [...of('warn'), ...of('fail')]
110
+ if (hits.length === 0) hits = parsed.filter((l) => l.kind !== 'ok' && l.kind !== 'skip').slice(0, 1)
111
+ return hits.map((l) => {
112
+ const t = lineText(l).trim()
113
+ // `[ID] text`, and a runner line's `ID — text`: the list names the id already
114
+ for (const lead of [`[${id}]`, `${id} —`]) if (t.startsWith(lead)) return t.slice(lead.length).trim()
115
+ return t
116
+ })
114
117
  }
115
118
 
119
+ /** the worst of them, '' when it printed none */
120
+ export const findingOf = (lines: string, status: 'fail' | 'warn', id: string): string => findingsOf(lines, status, id)[0] ?? ''
121
+
116
122
  /** a repo's checks as its run record last saw them, counted over what it
117
123
  * declares, and the failing and warning ones with what each said */
118
124
  export function statusOf(repo: ActivityRepo, declared: Declared | null = null, titles: Titles | null = null): Pick<ActivityRow, 'status' | 'failing'> {
@@ -125,7 +131,8 @@ export function statusOf(repo: ActivityRepo, declared: Declared | null = null, t
125
131
  const named = titles?.(repo.name) ?? null
126
132
  const one = (id: string, st: 'fail' | 'warn'): StatusCheck => {
127
133
  const r = byId.get(id)
128
- return { id, title: named?.get(id) ?? id, status: st, at: status.since[id] ?? null, line: r == null ? '' : findingOf(r.lines, st, id) }
134
+ const lines = r == null ? [] : findingsOf(r.lines, st, id)
135
+ return { id, title: named?.get(id) ?? id, status: st, at: status.since[id] ?? null, line: lines[0] ?? '', lines }
129
136
  }
130
137
  return { status, failing: [...status.reds.map((id) => one(id, 'fail')), ...status.warns.map((id) => one(id, 'warn'))] }
131
138
  }
@@ -1,24 +1,94 @@
1
- // the shipkit window on macOS: its own app, so the dock and cmd-tab show the
2
- // house icon, never a second browser. One WKWebView per window on the home's
3
- // page; every `open -a <app> <url>` opens one more window, cmd-n too. A link
4
- // to another site opens in the default browser. Built once by `shipkit gui`
5
- // (home/gui-app.ts) with swiftc, no Xcode project.
1
+ // one web app window on macOS: its own app, so the dock and cmd-tab show its
2
+ // own icon, never a second browser. The same source is the shipkit window and
3
+ // every per-process app (home/gui-app.ts builds both with swiftc, no Xcode
4
+ // project): the bundle's Info.plist names the app (CFBundleName) and the url
5
+ // a bare launch opens (ShipkitStartURL). One WKWebView per window; every
6
+ // `open -a <app> <url>` opens one more window, cmd-n too. A link to another
7
+ // site opens in the default browser.
6
8
  import AppKit
7
9
  import WebKit
8
10
 
9
- let homeFallback = "http://127.0.0.1:4799/"
11
+ let info = Bundle.main.infoDictionary ?? [:]
12
+ let appName = (info["CFBundleName"] as? String) ?? "shipkit"
13
+ let startURL = URL(string: (info["ShipkitStartURL"] as? String) ?? "") ?? URL(string: "http://127.0.0.1:4799/")!
10
14
 
11
- final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
15
+ /** a url that stays in the app: the machine itself, or the host the app is for */
16
+ func stays(_ url: URL) -> Bool {
17
+ url.host == "127.0.0.1" || url.host == "localhost" || url.host == startURL.host
18
+ }
19
+
20
+ /** a window's content: the web view, and the find bar above it when shown.
21
+ * WKWebView is an NSTextFinderClient, so the platform find bar searches the
22
+ * page; this view is the bar's container */
23
+ final class Pane: NSView, NSTextFinderBarContainer {
24
+ let web: WKWebView
25
+ let finder = NSTextFinder()
26
+
27
+ init(web: WKWebView) {
28
+ self.web = web
29
+ super.init(frame: .zero)
30
+ addSubview(web)
31
+ finder.client = web
32
+ finder.findBarContainer = self
33
+ finder.isIncrementalSearchingEnabled = true
34
+ finder.incrementalSearchingShouldDimContentView = false
35
+ }
36
+
37
+ required init?(coder: NSCoder) { fatalError("not from a nib") }
38
+
39
+ override var isFlipped: Bool { true }
40
+
41
+ var findBarView: NSView? {
42
+ didSet {
43
+ oldValue?.removeFromSuperview()
44
+ if let bar = findBarView { addSubview(bar) }
45
+ needsLayout = true
46
+ }
47
+ }
48
+
49
+ var isFindBarVisible = false {
50
+ didSet {
51
+ findBarView?.isHidden = !isFindBarVisible
52
+ needsLayout = true
53
+ }
54
+ }
55
+
56
+ func findBarViewDidChangeHeight() { needsLayout = true }
57
+
58
+ func contentView() -> NSView? { web }
59
+
60
+ override func layout() {
61
+ super.layout()
62
+ var top: CGFloat = 0
63
+ if let bar = findBarView, isFindBarVisible {
64
+ let h = bar.frame.height
65
+ bar.frame = NSRect(x: 0, y: 0, width: bounds.width, height: h)
66
+ top = h
67
+ }
68
+ web.frame = NSRect(x: 0, y: top, width: bounds.width, height: max(0, bounds.height - top))
69
+ }
70
+ }
71
+
72
+ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate, NSMenuItemValidation {
12
73
  var windows: [NSWindow] = []
13
74
  var opened = false
14
75
 
15
76
  func applicationDidFinishLaunching(_ note: Notification) {
16
77
  buildMenu()
78
+ // esc closes the find bar of the key window, and reaches the page otherwise
79
+ NSEvent.addLocalMonitorForEvents(matching: .keyDown) { e in
80
+ if e.keyCode == 53, let pane = self.keyPane(), pane.isFindBarVisible {
81
+ pane.finder.performAction(.hideFindInterface)
82
+ pane.window?.makeFirstResponder(pane.web)
83
+ return nil
84
+ }
85
+ return e
86
+ }
17
87
  // an open event may deliver the url right after launch: wait for it
18
88
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
19
89
  if !self.opened {
20
90
  let arg = CommandLine.arguments.dropFirst().first { $0.hasPrefix("http") }
21
- self.open(URL(string: arg ?? homeFallback)!)
91
+ self.open(arg.flatMap { URL(string: $0) } ?? startURL)
22
92
  }
23
93
  }
24
94
  }
@@ -30,26 +100,27 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
30
100
  func applicationShouldTerminateAfterLastWindowClosed(_ app: NSApplication) -> Bool { true }
31
101
 
32
102
  func applicationShouldHandleReopen(_ app: NSApplication, hasVisibleWindows: Bool) -> Bool {
33
- if !hasVisibleWindows { open(URL(string: homeFallback)!) }
103
+ if !hasVisibleWindows { open(startURL) }
34
104
  return true
35
105
  }
36
106
 
107
+ func keyPane() -> Pane? { NSApp.keyWindow?.contentView as? Pane }
108
+
37
109
  @objc func newWindow(_ sender: Any?) {
38
- let current = (NSApp.keyWindow?.contentView as? WKWebView)?.url
39
- open(current ?? URL(string: homeFallback)!)
110
+ open(keyPane()?.web.url ?? startURL)
40
111
  }
41
112
 
42
113
  @objc func reload(_ sender: Any?) {
43
- (NSApp.keyWindow?.contentView as? WKWebView)?.reload()
114
+ keyPane()?.web.reload()
44
115
  }
45
116
 
46
- // the zoom is one value for every window, kept across restarts
117
+ // the zoom is one value for every window of the app, kept across restarts
47
118
  var zoom: CGFloat {
48
119
  get { let z = UserDefaults.standard.double(forKey: "pageZoom"); return z > 0 ? CGFloat(z) : 1 }
49
120
  set {
50
121
  let z = min(3, max(0.3, newValue))
51
122
  UserDefaults.standard.set(Double(z), forKey: "pageZoom")
52
- for w in windows { (w.contentView as? WKWebView)?.pageZoom = z }
123
+ for w in windows { (w.contentView as? Pane)?.web.pageZoom = z }
53
124
  }
54
125
  }
55
126
 
@@ -57,19 +128,51 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
57
128
  @objc func zoomOut(_ sender: Any?) { zoom = zoom / 1.1 }
58
129
  @objc func zoomReset(_ sender: Any?) { zoom = 1 }
59
130
 
131
+ // the menu item's tag is the NSTextFinder.Action it performs
132
+ @objc func findAction(_ sender: NSMenuItem) {
133
+ guard let pane = keyPane(), let action = NSTextFinder.Action(rawValue: sender.tag) else { return }
134
+ pane.finder.performAction(action)
135
+ }
136
+
137
+ // no public api opens the inspector: `_inspector` then `show` is the
138
+ // WebKit call the context menu's Inspect Element makes. Where it is gone,
139
+ // say where the inspector still is
140
+ @objc func devTools(_ sender: Any?) {
141
+ guard let web = keyPane()?.web else { return }
142
+ let get = NSSelectorFromString("_inspector"), show = NSSelectorFromString("show")
143
+ if web.responds(to: get), let inspector = web.perform(get)?.takeUnretainedValue() as? NSObject, inspector.responds(to: show) {
144
+ inspector.perform(show)
145
+ return
146
+ }
147
+ let alert = NSAlert()
148
+ alert.messageText = "This WebKit has no way to open the inspector from a menu"
149
+ alert.informativeText = "Right click the page and pick Inspect Element."
150
+ alert.runModal()
151
+ }
152
+
153
+ func validateMenuItem(_ item: NSMenuItem) -> Bool {
154
+ if item.action == #selector(findAction(_:)) {
155
+ guard let pane = keyPane(), let action = NSTextFinder.Action(rawValue: item.tag) else { return false }
156
+ return pane.finder.validateAction(action)
157
+ }
158
+ if item.action == #selector(devTools(_:)) || item.action == #selector(reload(_:)) { return keyPane() != nil }
159
+ return true
160
+ }
161
+
60
162
  func open(_ url: URL) {
61
163
  opened = true
62
164
  let config = WKWebViewConfiguration()
63
165
  config.preferences.setValue(true, forKey: "developerExtrasEnabled")
64
166
  let web = WKWebView(frame: .zero, configuration: config)
167
+ if #available(macOS 13.3, *) { web.isInspectable = true }
65
168
  web.navigationDelegate = self
66
169
  web.uiDelegate = self
67
170
  web.allowsBackForwardNavigationGestures = true
68
171
  web.pageZoom = zoom
69
172
  let frame = NSRect(x: 0, y: 0, width: 1400, height: 900)
70
173
  let win = NSWindow(contentRect: frame, styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
71
- win.contentView = web
72
- win.title = "shipkit"
174
+ win.contentView = Pane(web: web)
175
+ win.title = appName
73
176
  win.delegate = self
74
177
  win.isReleasedWhenClosed = false
75
178
  win.setFrameAutosaveName("shipkit-window")
@@ -77,6 +180,7 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
77
180
  windows.append(win)
78
181
  web.load(URLRequest(url: url))
79
182
  win.makeKeyAndOrderFront(nil)
183
+ win.makeFirstResponder(web)
80
184
  NSApp.activate(ignoringOtherApps: true)
81
185
  }
82
186
 
@@ -84,35 +188,40 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
84
188
  windows.removeAll { $0 === note.object as? NSWindow }
85
189
  }
86
190
 
87
- // the page title names the window, so cmd-tab and the window menu tell repos apart
191
+ // the page title names the window, so cmd-tab and the window menu tell pages apart
88
192
  func webView(_ web: WKWebView, didFinish nav: WKNavigation!) {
89
- web.window?.title = (web.title?.isEmpty == false) ? web.title! : "shipkit"
193
+ web.window?.title = (web.title?.isEmpty == false) ? web.title! : appName
90
194
  }
91
195
 
92
- // same host: stay in the window; anything else: the default browser
196
+ // the app's own host: stay in the window; anything else: the default browser
93
197
  func webView(_ web: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
94
198
  guard let url = action.request.url, action.navigationType == .linkActivated else { return decisionHandler(.allow) }
95
- let local = url.host == "127.0.0.1" || url.host == "localhost"
96
- if local { return decisionHandler(.allow) }
199
+ if stays(url) { return decisionHandler(.allow) }
97
200
  NSWorkspace.shared.open(url)
98
201
  decisionHandler(.cancel)
99
202
  }
100
203
 
101
- // window.open and target=_blank: one more shipkit window for a local page
204
+ // window.open and target=_blank: one more window of this app for its own pages
102
205
  func webView(_ web: WKWebView, createWebViewWith config: WKWebViewConfiguration, for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
103
206
  if let url = action.request.url {
104
- if url.host == "127.0.0.1" || url.host == "localhost" { open(url) } else { NSWorkspace.shared.open(url) }
207
+ if stays(url) { open(url) } else { NSWorkspace.shared.open(url) }
105
208
  }
106
209
  return nil
107
210
  }
108
211
 
212
+ func item(_ menu: NSMenu, _ title: String, _ action: Selector, _ key: String, _ mods: NSEvent.ModifierFlags = [.command], tag: Int = 0) {
213
+ let i = menu.addItem(withTitle: title, action: action, keyEquivalent: key)
214
+ i.keyEquivalentModifierMask = mods
215
+ i.tag = tag
216
+ }
217
+
109
218
  func buildMenu() {
110
219
  let main = NSMenu()
111
220
  let appItem = NSMenuItem()
112
221
  main.addItem(appItem)
113
222
  let appMenu = NSMenu()
114
- appMenu.addItem(withTitle: "Hide shipkit", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
115
- appMenu.addItem(withTitle: "Quit shipkit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
223
+ appMenu.addItem(withTitle: "Hide \(appName)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
224
+ appMenu.addItem(withTitle: "Quit \(appName)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
116
225
  appItem.submenu = appMenu
117
226
  let fileItem = NSMenuItem()
118
227
  main.addItem(fileItem)
@@ -130,6 +239,14 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
130
239
  edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
131
240
  edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
132
241
  edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
242
+ edit.addItem(.separator())
243
+ let findItem = edit.addItem(withTitle: "Find", action: nil, keyEquivalent: "")
244
+ let find = NSMenu(title: "Find")
245
+ item(find, "Find…", #selector(findAction(_:)), "f", tag: NSTextFinder.Action.showFindInterface.rawValue)
246
+ item(find, "Find Next", #selector(findAction(_:)), "g", tag: NSTextFinder.Action.nextMatch.rawValue)
247
+ item(find, "Find Previous", #selector(findAction(_:)), "g", [.command, .shift], tag: NSTextFinder.Action.previousMatch.rawValue)
248
+ item(find, "Hide Find Bar", #selector(findAction(_:)), "", [], tag: NSTextFinder.Action.hideFindInterface.rawValue)
249
+ edit.setSubmenu(find, for: findItem)
133
250
  editItem.submenu = edit
134
251
  let viewItem = NSMenuItem()
135
252
  main.addItem(viewItem)
@@ -141,6 +258,8 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
141
258
  alsoIn.isAlternate = true
142
259
  alsoIn.keyEquivalentModifierMask = [.command]
143
260
  view.addItem(withTitle: "Zoom Out", action: #selector(zoomOut(_:)), keyEquivalent: "-")
261
+ view.addItem(.separator())
262
+ item(view, "Developer Tools", #selector(devTools(_:)), "i", [.command, .option])
144
263
  viewItem.submenu = view
145
264
  let winItem = NSMenuItem()
146
265
  main.addItem(winItem)