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
@@ -1,90 +1,122 @@
1
- // the macOS app `shipkit gui` opens windows through: a bundle built once from
2
- // ShipkitGui.swift with swiftc, carrying the house repo's own icon, so the
3
- // dock and cmd-tab show it and never a second Chrome. Rebuilt only when the
4
- // source or the icon changed (a stamp in the bundle). No compiler, or not
5
- // macOS: the caller falls back to a Chrome app window.
1
+ // the macOS apps shipkit opens web pages in: ONE source, ShipkitGui.swift,
2
+ // built with swiftc into a bundle per page it serves. `shipkit gui` is that
3
+ // app pointed at the home; each global process with a url gets its own, named
4
+ // and iconed after it, in its repo's `.shipkit/wrapper/`. The bundle's
5
+ // Info.plist carries what differs (name, bundle id, start url), so a fix to
6
+ // the window (find, the inspector, zoom) lands in every app at its next
7
+ // build. Rebuilt only when the source, the icon or the start url changed (a
8
+ // stamp in the bundle). No compiler, or not macOS: the caller falls back.
6
9
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
7
10
  import { homedir } from 'os'
8
11
  import { dirname, join } from 'path'
9
12
  import { fileURLToPath } from 'url'
13
+ import { LAYOUT } from '../core/layout.ts'
10
14
 
11
15
  const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.url))
12
16
 
13
17
  export const BUNDLE_ID = 'dev.shipkit.gui'
14
18
  export const guiAppPath = (): string => process.env.SHIPKIT_GUI_APP ?? join(homedir(), 'Applications', 'Shipkit.app')
15
19
 
16
- /** the icon the app wears: the house repo's own mark, largest first. Only a
20
+ /** the icon the app wears: the repo's own mark, largest first. Only a
17
21
  * png can become an icns */
18
- export const APP_ICON_FILES = ['.shipkit/identity/icon.png', 'public/logo.png', 'public/favicon.png']
22
+ export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
19
23
 
20
24
  export function appIconOf(root: string): string | null {
21
25
  for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
22
26
  return null
23
27
  }
24
28
 
25
- export function infoPlist(o: { version: string }): string {
29
+ /** what one app bundle is: the rest comes from the shared source */
30
+ export type AppSpec = {
31
+ /** CFBundleName: the dock, cmd-tab and the app menu say it */
32
+ name: string
33
+ bundleId: string
34
+ /** the page a launch with no url opens; null opens the home */
35
+ url: string | null
36
+ icon: string | null
37
+ /** where the bundle lands */
38
+ app: string
39
+ version: string
40
+ }
41
+
42
+ const xml = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
43
+
44
+ /** plain http to a host that is not local needs its own ATS exception; the
45
+ * local network is allowed to every app */
46
+ function atsOf(url: string | null): string {
47
+ let u: URL | null = null
48
+ try { u = url == null ? null : new URL(url) } catch { /* not a url: the local network only */ }
49
+ const host = u?.protocol === 'http:' ? u.hostname : null
50
+ const local = host == null || host === 'localhost' || /^[\d.]+$/.test(host) || !host.includes('.') || host.endsWith('.local')
51
+ const exception = local ? '' : `<key>NSExceptionDomains</key><dict><key>${xml(host)}</key><dict><key>NSExceptionAllowsInsecureHTTPLoads</key><true/></dict></dict>`
52
+ return `<dict><key>NSAllowsLocalNetworking</key><true/>${exception}</dict>`
53
+ }
54
+
55
+ export function infoPlist(o: Omit<AppSpec, 'app' | 'icon'>): string {
26
56
  return `<?xml version="1.0" encoding="UTF-8"?>
27
57
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
28
58
  <plist version="1.0">
29
59
  <dict>
30
- \t<key>CFBundleName</key><string>shipkit</string>
31
- \t<key>CFBundleDisplayName</key><string>shipkit</string>
32
- \t<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
60
+ \t<key>CFBundleName</key><string>${xml(o.name)}</string>
61
+ \t<key>CFBundleDisplayName</key><string>${xml(o.name)}</string>
62
+ \t<key>CFBundleIdentifier</key><string>${xml(o.bundleId)}</string>
33
63
  \t<key>CFBundleExecutable</key><string>shipkit-gui</string>
34
64
  \t<key>CFBundleIconFile</key><string>AppIcon</string>
35
65
  \t<key>CFBundlePackageType</key><string>APPL</string>
36
- \t<key>CFBundleShortVersionString</key><string>${o.version}</string>
37
- \t<key>CFBundleVersion</key><string>${o.version}</string>
38
- \t<key>LSMinimumSystemVersion</key><string>12.0</string>
66
+ \t<key>CFBundleShortVersionString</key><string>${xml(o.version)}</string>
67
+ \t<key>CFBundleVersion</key><string>${xml(o.version)}</string>
68
+ ${o.url == null ? '' : `\t<key>ShipkitStartURL</key><string>${xml(o.url)}</string>\n`}\t<key>LSMinimumSystemVersion</key><string>12.0</string>
39
69
  \t<key>NSHighResolutionCapable</key><true/>
40
- \t<key>NSAppTransportSecurity</key><dict><key>NSAllowsLocalNetworking</key><true/></dict>
70
+ \t<key>NSAppTransportSecurity</key>${atsOf(o.url)}
41
71
  </dict>
42
72
  </plist>
43
73
  `
44
74
  }
45
75
 
46
- type Run = (cmd: string[]) => { code: number; err: string }
76
+ export type Run = (cmd: string[]) => { code: number; err: string }
47
77
  const runSync: Run = (cmd) => {
48
78
  const p = Bun.spawnSync(cmd, { stdout: 'ignore', stderr: 'pipe' })
49
79
  return { code: p.exitCode ?? 1, err: p.stderr.toString().trim() }
50
80
  }
51
81
 
52
- /** the stamp a built bundle carries: what it was built from */
53
- export function stampOf(source: string, icon: string | null): string {
54
- const h = new Bun.CryptoHasher('sha256').update(source)
82
+ /** the stamp a built bundle carries: what it was built from. The version is
83
+ * left out, so a shipkit release rebuilds nothing */
84
+ export function stampOf(source: string, icon: string | null, identity = ''): string {
85
+ const h = new Bun.CryptoHasher('sha256').update(source).update(identity)
55
86
  if (icon != null) h.update(readFileSync(icon))
56
87
  return h.digest('hex').slice(0, 16)
57
88
  }
58
89
 
59
- /** the app, built or rebuilt when stale. Throws with the step that failed.
90
+ /** one app, built or rebuilt when stale. Throws with the step that failed.
60
91
  * `rebuilt` tells the caller a running copy is now the old one */
61
- export function ensureGuiApp(o: { root: string; version: string; app?: string; run?: Run }): { app: string; rebuilt: boolean } {
62
- const app = o.app ?? guiAppPath()
92
+ export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): { app: string; rebuilt: boolean } {
93
+ const { app, icon } = spec
63
94
  const run = o.run ?? runSync
64
- const icon = appIconOf(o.root)
65
- const stamp = stampOf(readFileSync(SOURCE, 'utf8'), icon)
95
+ const source = o.source ?? SOURCE
96
+ const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url]))
66
97
  const stampFile = join(app, 'Contents', 'Resources', 'stamp')
67
98
  if (existsSync(stampFile) && readFileSync(stampFile, 'utf8') === stamp) return { app, rebuilt: false }
68
99
  const tmp = `${app}.building-${process.pid}`
100
+ const fail = (msg: string): never => { rmSync(tmp, { recursive: true, force: true }); throw new Error(msg) }
69
101
  rmSync(tmp, { recursive: true, force: true })
70
102
  const res = join(tmp, 'Contents', 'Resources')
71
103
  mkdirSync(join(tmp, 'Contents', 'MacOS'), { recursive: true })
72
104
  mkdirSync(res, { recursive: true })
73
- writeFileSync(join(tmp, 'Contents', 'Info.plist'), infoPlist({ version: o.version }))
74
- const built = run(['swiftc', '-O', '-o', join(tmp, 'Contents', 'MacOS', 'shipkit-gui'), SOURCE])
75
- if (built.code !== 0) { rmSync(tmp, { recursive: true, force: true }); throw new Error(`swiftc could not build the gui app: ${built.err.slice(0, 400)}`) }
105
+ writeFileSync(join(tmp, 'Contents', 'Info.plist'), infoPlist(spec))
106
+ const built = run(['swiftc', '-O', '-o', join(tmp, 'Contents', 'MacOS', 'shipkit-gui'), source])
107
+ if (built.code !== 0) fail(`swiftc could not build ${spec.name}.app: ${built.err.slice(0, 400)}`)
76
108
  if (icon != null) {
77
109
  const set = join(res, 'AppIcon.iconset')
78
110
  mkdirSync(set, { recursive: true })
79
111
  for (const n of [16, 32, 128, 256, 512]) {
80
112
  for (const [scale, px] of [['', n], ['@2x', n * 2]] as const) {
81
- const r = run(['sips', '-z', String(px), String(px), icon, '--out', join(set, `icon_${n}x${n}${scale}.png`)])
82
- if (r.code !== 0) { rmSync(tmp, { recursive: true, force: true }); throw new Error(`sips could not size the icon ${icon}: ${r.err.slice(0, 200)}`) }
113
+ const r = run(['sips', '-s', 'format', 'png', '-z', String(px), String(px), icon, '--out', join(set, `icon_${n}x${n}${scale}.png`)])
114
+ if (r.code !== 0) fail(`sips could not size the icon ${icon}: ${r.err.slice(0, 200)}`)
83
115
  }
84
116
  }
85
117
  const r = run(['iconutil', '-c', 'icns', set, '-o', join(res, 'AppIcon.icns')])
86
118
  rmSync(set, { recursive: true, force: true })
87
- if (r.code !== 0) { rmSync(tmp, { recursive: true, force: true }); throw new Error(`iconutil could not build the icon: ${r.err.slice(0, 200)}`) }
119
+ if (r.code !== 0) fail(`iconutil could not build the icon: ${r.err.slice(0, 200)}`)
88
120
  }
89
121
  writeFileSync(join(res, 'stamp'), stamp)
90
122
  mkdirSync(dirname(app), { recursive: true })
@@ -95,5 +127,36 @@ export function ensureGuiApp(o: { root: string; version: string; app?: string; r
95
127
  return { app, rebuilt: true }
96
128
  }
97
129
 
130
+ /** the shipkit app: the shared source on the home, the house repo's icon */
131
+ export function ensureGuiApp(o: { root: string | null; version: string; app?: string; run?: Run }): { app: string; rebuilt: boolean } {
132
+ return ensureApp({ name: 'shipkit', bundleId: BUNDLE_ID, url: null, icon: o.root == null ? null : appIconOf(o.root), app: o.app ?? guiAppPath(), version: o.version }, { run: o.run })
133
+ }
134
+
135
+ /** what a global process gives its app */
136
+ export type WrapperOf = { id: string; label: string; url: string | null; icon: string | null; root: string }
137
+
138
+ /** the image icons sips reads; an emoji or an svg leaves the repo's mark */
139
+ const RASTER = /\.(png|jpe?g|gif|webp|bmp|tiff?)$/i
140
+
141
+ export const wrapperBundleId = (id: string): string => `dev.shipkit.app.${id.replace(/[^A-Za-z0-9-]+/g, '-')}`
142
+ export const wrapperPath = (root: string, id: string): string => join(root, LAYOUT.wrapper, `${id.replace(/[^A-Za-z0-9._-]+/g, '-')}.app`)
143
+
144
+ /** the spec of a process's own app: its label, its declared image icon or
145
+ * else its repo's mark, in its repo's `.shipkit/wrapper/`. Throws for a
146
+ * process with no url: there is no page to wrap */
147
+ export function wrapperSpec(d: WrapperOf, version: string): AppSpec {
148
+ if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
149
+ const declared = d.icon != null && RASTER.test(d.icon) && existsSync(join(d.root, d.icon)) ? join(d.root, d.icon) : null
150
+ return { name: d.label, bundleId: wrapperBundleId(d.id), url: d.url, icon: declared ?? appIconOf(d.root), app: wrapperPath(d.root, d.id), version }
151
+ }
152
+
153
+ export const ensureWrapperApp = (d: WrapperOf, o: { version: string; run?: Run }): { app: string; rebuilt: boolean; bundleId: string } => {
154
+ const spec = wrapperSpec(d, o.version)
155
+ return { ...ensureApp(spec, { run: o.run }), bundleId: spec.bundleId }
156
+ }
157
+
158
+ /** quits a running copy of an app, and launches nothing when none runs */
159
+ export const quitCmd = (bundleId: string): string[] => ['osascript', '-e', `if application id "${bundleId}" is running then quit application id "${bundleId}"`]
160
+
98
161
  /** true when this machine can build the app */
99
162
  export const canBuildGui = (platform = process.platform): boolean => platform === 'darwin' && Bun.which('swiftc') != null && Bun.which('iconutil') != null
@@ -2,7 +2,7 @@
2
2
  // dies. macOS only; launchd is the one supervisor of the one process that must
3
3
  // always run, and pm supervises everything else (the daemon, the web, the
4
4
  // machine resources).
5
- import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
6
6
  import { homedir } from 'os'
7
7
  import { join } from 'path'
8
8
  import { homeDir, pmLog } from './home-paths.ts'
@@ -22,7 +22,10 @@ const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;'
22
22
  * every command, so any edit would restart pm. pm,
23
23
  * the daemon and the web each watch their own code and restart in place on
24
24
  * it (lib-watch.ts). PATH is the installing shell's, since launchd starts
25
- * with almost none and the daemon needs bun, git, ps, lsof and ssh */
25
+ * with almost none and the daemon needs bun, git, ps, lsof and ssh.
26
+ * ProcessType Interactive: with none, launchd throttles the agent's disk
27
+ * reads under load, every child inherits it, and a busy disk left the
28
+ * daemon's boot reads minutes long while its port was already open */
26
29
  export function renderPlist(o: { bun: string; cli: string; root: string; path: string; log: string }): string {
27
30
  const args = [o.bun, o.cli, 'pm'].map((a) => `\t\t<string>${esc(a)}</string>`).join('\n')
28
31
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -42,6 +45,8 @@ ${args}
42
45
  \t\t<key>PATH</key>
43
46
  \t\t<string>${esc(o.path)}</string>
44
47
  \t</dict>
48
+ \t<key>ProcessType</key>
49
+ \t<string>Interactive</string>
45
50
  \t<key>RunAtLoad</key>
46
51
  \t<true/>
47
52
  \t<key>KeepAlive</key>
@@ -55,7 +60,15 @@ ${args}
55
60
  `
56
61
  }
57
62
 
58
- type Launchctl = (args: string[]) => Promise<{ code: number; out: string }>
63
+ /** the house repo the installed pm runs from, read back from its agent: the
64
+ * one answer to "which house" that does not depend on cwd */
65
+ export function installedHouseRoot(file = plistPath()): string | null {
66
+ if (!existsSync(file)) return null
67
+ const m = /<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/.exec(readFileSync(file, 'utf8'))
68
+ return m == null ? null : m[1]!.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
69
+ }
70
+
71
+ type Launchctl =(args: string[]) => Promise<{ code: number; out: string }>
59
72
 
60
73
  const launchctl: Launchctl = async (args) => {
61
74
  const p = Bun.spawn(['launchctl', ...args], { stdout: 'pipe', stderr: 'pipe' })
@@ -65,7 +78,13 @@ const launchctl: Launchctl = async (args) => {
65
78
 
66
79
  const domain = (): string => `gui/${process.getuid?.() ?? 501}`
67
80
 
68
- export async function installHome(o: { root: string; cli: string; print: (l: string) => void; run?: Launchctl }): Promise<number> {
81
+ /** `bootout` returns before launchd lets go of the label, and a bootstrap in
82
+ * that window fails (exit 5, EIO) with the old agent already gone: retried
83
+ * this many times, BOOTSTRAP_STEP_MS apart, before the install fails */
84
+ export const BOOTSTRAP_TRIES = 20
85
+ const BOOTSTRAP_STEP_MS = 250
86
+
87
+ export async function installHome(o: { root: string; cli: string; print: (l: string) => void; run?: Launchctl; sleep?: (ms: number) => Promise<void> }): Promise<number> {
69
88
  if (process.platform !== 'darwin') {
70
89
  o.print(`FAIL: the pm LaunchAgent is macOS only. Run \`shipkit pm\` under your own supervisor on ${process.platform}`)
71
90
  return 1
@@ -81,9 +100,14 @@ export async function installHome(o: { root: string; cli: string; print: (l: str
81
100
  await run(['bootout', `${domain()}/${LABEL}`])
82
101
  await run(['bootout', `${domain()}/${LEGACY_LABEL}`])
83
102
  rmSync(plistPath(homedir(), LEGACY_LABEL), { force: true })
84
- const r = await run(['bootstrap', domain(), file])
103
+ const sleep = o.sleep ?? Bun.sleep
104
+ let r = await run(['bootstrap', domain(), file])
105
+ for (let i = 1; i < BOOTSTRAP_TRIES && r.code !== 0; i++) {
106
+ await sleep(BOOTSTRAP_STEP_MS)
107
+ r = await run(['bootstrap', domain(), file])
108
+ }
85
109
  if (r.code !== 0) {
86
- o.print(`FAIL: launchctl bootstrap refused ${file} (exit ${r.code}): ${r.out}`)
110
+ o.print(`FAIL: launchctl bootstrap refused ${file} (exit ${r.code}, ${BOOTSTRAP_TRIES} tries): ${r.out}. pm is not loaded now: run \`shipkit pm install\` again`)
87
111
  return 1
88
112
  }
89
113
  o.print(`ok: pm runs now and at every login, from ${o.root}, and runs the daemon and the web`)
@@ -16,8 +16,11 @@ import { tailLines } from './supervise.ts'
16
16
  import { Hubs, type WorkerView } from './hubs.ts'
17
17
  import type { Planner, PlanRow } from '../plan/planner.ts'
18
18
  import type { Widgets, WidgetView } from './widgets.ts'
19
- import { ActivityCache, windowMinutes } from './activity.ts'
19
+ import { ActivityCache, repoActivity, windowMinutes } from './activity.ts'
20
20
  import { HouseRuns } from '../serve/house-runs.ts'
21
+
22
+ /** how far back a repo's git page lists its commits */
23
+ export const GIT_PAGE_MS = 7 * 24 * 3600_000
21
24
  import type { HouseView } from '../ledger/house-runs.ts'
22
25
 
23
26
  /** how often the daemon looks at every repo */
@@ -173,13 +176,22 @@ export function homeRoutes(d: HomeDeps) {
173
176
  const q = new URL(req.url).searchParams
174
177
  return Response.json(await activity.get(repos, windowMinutes(q.get('m')), { fresh: q.get('fresh') === '1' }))
175
178
  }
179
+ // one repo's git for its git page: a week of commits, and HEAD's last
180
+ // one past that, whatever window the activity page shows
181
+ if (path === '/api/home/git.json') {
182
+ if (repos.length === 0) await refresh()
183
+ const r = find(new URL(req.url).searchParams.get('repo'))
184
+ if (r == null) return new Response('not a house repo', { status: 404 })
185
+ return Response.json(await repoActivity(r, Date.now() - GIT_PAGE_MS, (n) => d.plan?.declared(n) ?? null, (n) => d.plan?.titles(n) ?? null))
186
+ }
176
187
  if (path === '/api/house-runs.json') {
177
188
  if (repos.length === 0) await refresh()
178
189
  const view: HouseView = { ...house.view(), held: d.plan?.held() ?? [] }
179
190
  return Response.json(view, { headers: { 'cache-control': 'no-store' } })
180
191
  }
181
192
  if (path === '/api/home/widgets.json') {
182
- const out: WidgetsAnswer = { widgets: d.widgets?.views() ?? [], errors: d.widgets?.errors ?? [] }
193
+ if (d.widgets != null && repos.length === 0) await refresh()
194
+ const out: WidgetsAnswer = { widgets: (await d.widgets?.look(repos.map((r) => ({ name: r.name, path: r.path })))) ?? [], errors: d.widgets?.errors ?? [] }
183
195
  return Response.json(out)
184
196
  }
185
197
  if (path === '/api/home.json') {
@@ -6,8 +6,8 @@ import { homedir } from 'os'
6
6
  import { dirname, join, resolve } from 'path'
7
7
  import { gitRoot } from '../core/git-root.ts'
8
8
  import { homeRepo, houseIndex } from '../serve/project-gutter.ts'
9
- import { BUNDLE_ID, canBuildGui, ensureGuiApp } from './gui-app.ts'
10
- import { LABEL, plistPath } from './home-launchd.ts'
9
+ import { BUNDLE_ID, canBuildGui, ensureGuiApp, ensureWrapperApp, quitCmd, type WrapperOf } from './gui-app.ts'
10
+ import { installedHouseRoot, LABEL, plistPath } from './home-launchd.ts'
11
11
  import { childLog, daemonPort, daemonUp, homeDir, homePort, homeUp, pmLog } from './home-paths.ts'
12
12
 
13
13
  /** the pm LaunchAgent is installed: on this machine no hub runs outside the home */
@@ -65,7 +65,11 @@ export function houseRootOf(cwd: string): string | null {
65
65
  return gitRoot(dirname(index))?.top ?? null
66
66
  }
67
67
 
68
- export const CHROME = process.env.CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
68
+ /** the house the shipkit app wears the mark of: the one cwd sits in, else the
69
+ * one the installed pm runs from. Never cwd itself, which carries no mark */
70
+ export const guiRoot = (cwd: string, plist = plistPath()): string | null => houseRootOf(cwd) ?? installedHouseRoot(plist)
71
+
72
+ export const CHROME =process.env.CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
69
73
  /** the window's own Chrome profile: its own dock icon and cookies, apart from the everyday browser */
70
74
  export const guiProfile = (): string => join(homeDir(), 'gui-profile')
71
75
  const GUI_DISK_CACHE = 32 * 1024 * 1024
@@ -87,9 +91,9 @@ export function windowCmd(url: string, o: { chrome?: string; platform?: string }
87
91
  export function openWindow(url: string, o: { root?: string | null; version?: string; warn?: (l: string) => void } = {}): string[] {
88
92
  if (canBuildGui()) {
89
93
  try {
90
- const { app, rebuilt } = ensureGuiApp({ root: o.root ?? houseRootOf(process.cwd()) ?? process.cwd(), version: o.version ?? '0' })
94
+ const { app, rebuilt } = ensureGuiApp({ root: o.root ?? guiRoot(process.cwd()),version: o.version ?? '0' })
91
95
  // a running copy is the old build: it quits, and the new one opens the window
92
- if (rebuilt) Bun.spawnSync(['osascript', '-e', `quit app id "${BUNDLE_ID}"`], { stdout: 'ignore', stderr: 'ignore' })
96
+ if (rebuilt) Bun.spawnSync(quitCmd(BUNDLE_ID), { stdout: 'ignore', stderr: 'ignore' })
93
97
  const cmd = ['open', '-a', app, url]
94
98
  Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'ignore'] })
95
99
  return cmd
@@ -97,6 +101,30 @@ export function openWindow(url: string, o: { root?: string | null; version?: str
97
101
  o.warn?.(`WARN the shipkit app could not be built, so this window is a Chrome one: ${(e as Error).message}`)
98
102
  }
99
103
  }
104
+ return chromeWindow(url)
105
+ }
106
+
107
+ /** a global process's own app: built on first use in its repo's
108
+ * `.shipkit/wrapper/`, then opened with no url, so a running copy comes to
109
+ * the front instead of stacking windows. Where no app can be built, a
110
+ * Chrome app window on its url */
111
+ export function openProcessApp(d: WrapperOf, o: { version: string; warn?: (l: string) => void }): string[] {
112
+ if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
113
+ if (canBuildGui()) {
114
+ try {
115
+ const { app, rebuilt, bundleId } = ensureWrapperApp(d, { version: o.version })
116
+ if (rebuilt) Bun.spawnSync(quitCmd(bundleId), { stdout: 'ignore', stderr: 'ignore' })
117
+ const cmd = ['open', '-a', app]
118
+ Bun.spawnSync(cmd, { stdout: 'ignore', stderr: 'ignore' })
119
+ return cmd
120
+ } catch (e) {
121
+ o.warn?.(`WARN the app for ${d.id} could not be built, so this window is a Chrome one: ${(e as Error).message}`)
122
+ }
123
+ }
124
+ return chromeWindow(d.url)
125
+ }
126
+
127
+ function chromeWindow(url: string): string[] {
100
128
  mkdirSync(guiProfile(), { recursive: true })
101
129
  const cmd = windowCmd(url)
102
130
  // detached: the window outlives the command that opened it
package/src/home/hubs.ts CHANGED
@@ -132,7 +132,7 @@ export class Hubs {
132
132
  const rt = await this.o.load(e.repo, io, { fresh: fresh || e.loads > 0 })
133
133
  // a stop by hand came while it loaded, and wins
134
134
  if (e.held) {
135
- rt.stop({ procs: true })
135
+ rt.stop()
136
136
  this.set(e, 'down', 'stopped by hand')
137
137
  return
138
138
  }
@@ -158,7 +158,7 @@ export class Hubs {
158
158
  const names = new Set(repos.map((r) => r.name))
159
159
  for (const [name, e] of this.entries) {
160
160
  if (names.has(name)) continue
161
- e.rt?.stop({ procs: true })
161
+ e.rt?.stop()
162
162
  this.entries.delete(name)
163
163
  }
164
164
  for (const r of repos) {
@@ -179,7 +179,7 @@ export class Hubs {
179
179
  stop(repo: WiredRepo): WorkerView {
180
180
  const e = this.entry(repo)
181
181
  e.held = true
182
- e.rt?.stop({ procs: true })
182
+ e.rt?.stop()
183
183
  e.rt = null
184
184
  this.set(e, 'down', 'stopped by hand')
185
185
  return this.view(e)
@@ -190,7 +190,7 @@ export class Hubs {
190
190
  const e = this.entry(repo)
191
191
  await e.loading
192
192
  e.held = false
193
- e.rt?.stop({ procs: true })
193
+ e.rt?.stop()
194
194
  e.rt = null
195
195
  await this.schedule(e, true)
196
196
  return this.view(e)
@@ -279,7 +279,7 @@ export class Hubs {
279
279
  return repos.map((r) => this.view(this.entry(r)))
280
280
  }
281
281
 
282
- /** every hub stops, its processes left running for the next home to adopt */
282
+ /** every hub stops */
283
283
  stopAll(): void {
284
284
  for (const e of this.entries.values()) e.rt?.stop()
285
285
  }
@@ -91,8 +91,9 @@ export const BOOT_SWEEP_DELAY_MS = 10_000
91
91
  export type Sweeper = { stop: () => void; once: () => Promise<void> }
92
92
 
93
93
  /** the daemon's one retention sweep: every repo it loads, then the machine,
94
- * on boot and every hour. A store whose sweep throws is one printed line */
95
- export function startSweeps(o: { roots: () => string[]; dir: string; printErr: (l: string) => void; everyMs?: number; bootDelayMs?: number; now?: () => number }): Sweeper {
94
+ * then `also` (the machine's batched writes), on boot and every hour. A
95
+ * store whose sweep throws is one printed line */
96
+ export function startSweeps(o: { roots: () => string[]; dir: string; printErr: (l: string) => void; everyMs?: number; bootDelayMs?: number; now?: () => number; also?: (now: number) => void }): Sweeper {
96
97
  let running = false
97
98
  const once = async (): Promise<void> => {
98
99
  if (running) return
@@ -103,6 +104,7 @@ export function startSweeps(o: { roots: () => string[]; dir: string; printErr: (
103
104
  for (const r of await sweepStores(repoStores(root), now)) if (r.error != null) o.printErr(`WARN retention sweep of ${r.id} in ${root} failed: ${r.error}`)
104
105
  }
105
106
  for (const r of await sweepStores(machineStores(o.dir), now)) if (r.error != null) o.printErr(`WARN retention sweep of ${r.id} failed: ${r.error}`)
107
+ o.also?.(now)
106
108
  } finally {
107
109
  running = false
108
110
  }
@@ -1,16 +1,29 @@
1
- // opens one shipkit app window on a url, in its own short process, so pm
2
- // never loads what building the app needs:
1
+ // opens one shipkit app window, in its own short process, so pm never loads
2
+ // what building an app needs:
3
3
  //
4
- // bun open-app.ts <url>
4
+ // bun open-app.ts <url> one more shipkit window on a url
5
+ // bun open-app.ts --process <json> a global process's own app (WrapperOf)
5
6
  import { LIB_VERSION } from '../core/lib-version.ts'
6
- import { openWindow } from './home-start.ts'
7
+ import type { WrapperOf } from './gui-app.ts'
8
+ import { openProcessApp, openWindow } from './home-start.ts'
7
9
 
8
10
  if (import.meta.main) {
9
- const url = process.argv[2]
10
- if (url == null) {
11
- console.error('usage: bun open-app.ts <url>')
12
- process.exit(2)
11
+ const [first, second] = process.argv.slice(2)
12
+ const warn = (l: string): void => console.error(l)
13
+ try {
14
+ if (first === '--process' && second != null) {
15
+ const d = JSON.parse(second) as WrapperOf
16
+ const cmd = openProcessApp(d, { version: LIB_VERSION, warn })
17
+ console.log(`shipkit: opened ${d.id} as its own app: ${cmd.join(' ')}`)
18
+ } else if (first != null && first !== '--process') {
19
+ const cmd = openWindow(first, { version: LIB_VERSION, warn })
20
+ console.log(`shipkit: opened ${first} as an app window: ${cmd.join(' ')}`)
21
+ } else {
22
+ console.error('usage: bun open-app.ts <url> | --process <json>')
23
+ process.exit(2)
24
+ }
25
+ } catch (e) {
26
+ console.error(`shipkit: could not open the app: ${(e as Error).message}`)
27
+ process.exit(1)
13
28
  }
14
- const cmd = openWindow(url, { version: LIB_VERSION, warn: (l) => console.error(l) })
15
- console.log(`shipkit: opened ${url} as an app window: ${cmd.join(' ')}`)
16
29
  }
@@ -2,18 +2,14 @@
2
2
  // and the global processes every house repo declares in its `.shipkit.ts`
3
3
  // files), read in a short child process so pm itself never imports a repo
4
4
  // file. That graph would make pm large and reload it on every edit there.
5
+ // The answer names the paths it read (`inputs`) and their stamp, so pm runs
6
+ // the child again only once a stat of one of them moved.
5
7
  //
6
- // bun pm-config.ts <root> prints { index, processes, errors } as json on stdout
8
+ // bun pm-config.ts <root> prints a PmConfig as json on stdout
7
9
  import { homedir } from 'os'
8
- import type { GlobalProcessDecl } from './global-units.ts'
10
+ import { houseInputs, stampOf, type PmConfig } from './pm-inputs.ts'
9
11
 
10
- export type PmConfig = {
11
- /** the house wiki index the repo includes, null when it includes none */
12
- index: string | null
13
- processes: GlobalProcessDecl[]
14
- /** the unit files that would not load */
15
- errors: string[]
16
- }
12
+ export type { PmConfig }
17
13
 
18
14
  if (import.meta.main) {
19
15
  const root = process.argv[2]
@@ -22,14 +18,22 @@ if (import.meta.main) {
22
18
  process.exit(2)
23
19
  }
24
20
  const { houseRepos } = await import('../serve/project-gutter.ts')
21
+ const { repoUnitFiles } = await import('../core/discover-checks.ts')
25
22
  const house = houseRepos(root, homedir())
26
- const out: PmConfig = { index: house.index, processes: [], errors: [] }
23
+ const repos = house.index == null ? [] : house.repos
24
+ const files = new Map<string, string[]>()
25
+ const inputs = houseInputs(house.home, house.index, repos.map((r) => r.path), (r, dirs) => {
26
+ const f = repoUnitFiles(r, dirs)
27
+ files.set(r, f)
28
+ return f
29
+ })
30
+ const out: PmConfig = { index: house.index, processes: [], errors: [], inputs, stamp: stampOf(inputs) }
27
31
  if (house.index != null) {
28
32
  // a unit file imports the lib by name: this copy, like the daemon's
29
33
  const { useOneLib } = await import('../core/one-lib.ts')
30
34
  const { scanGlobalUnits } = await import('./global-units.ts')
31
35
  useOneLib()
32
- const u = await scanGlobalUnits(house.repos.map((r) => ({ name: r.name, path: r.path })))
36
+ const u = await scanGlobalUnits(repos.map((r) => ({ name: r.name, path: r.path })), { files: (r) => files.get(r) ?? [] })
33
37
  out.processes = u.processes
34
38
  out.errors = u.errors
35
39
  }
@@ -0,0 +1,76 @@
1
+ // what pm stamps to know whether the house side moved, without running the
2
+ // child that reads it (pm-config.ts): the answer's shape, the paths it
3
+ // depends on, and their stamp. Imports nothing a repo file does, so pm's own
4
+ // graph stays small.
5
+ import { readdirSync, statSync } from 'fs'
6
+ import { dirname, join } from 'path'
7
+ import { UNIT_SUFFIX } from '../core/check-file-names.ts'
8
+ import type { GlobalProcessDecl } from './global-units.ts'
9
+
10
+ export type PmConfig = {
11
+ /** the house wiki index the repo includes, null when it includes none */
12
+ index: string | null
13
+ processes: GlobalProcessDecl[]
14
+ /** the unit files that would not load */
15
+ errors: string[]
16
+ /** every path whose stat can change this answer (houseInputs) */
17
+ inputs: string[]
18
+ /** stampOf(inputs), taken before the unit files were imported */
19
+ stamp: string
20
+ }
21
+
22
+ /** the files a repo carries its includes in. Here, not in project-gutter.ts
23
+ * which reads them, so pm stamps them without loading that graph */
24
+ export const CLAUDE_FILES = ['CLAUDE.md', 'CLAUDE.local.md']
25
+
26
+ /** what the answer depends on, as paths to stat: the dir holding the repos
27
+ * (a repo added or removed), every sibling's CLAUDE files (a repo starting
28
+ * to include the house), the index, each house repo's config (archived),
29
+ * and every dir and unit file the unit walk read. A helper a unit file
30
+ * imports is not in it: an edit there shows once the unit file moves */
31
+ export function houseInputs(home: string, index: string | null, repos: string[], unitFiles: (root: string, dirs: string[]) => string[]): string[] {
32
+ const parent = dirname(home)
33
+ const out = [parent]
34
+ let names: string[] = []
35
+ try { names = readdirSync(parent) } catch { /* the stat of parent says when it appears */ }
36
+ for (const n of names.sort()) if (!n.startsWith('.')) for (const f of CLAUDE_FILES) out.push(join(parent, n, f))
37
+ if (index != null) out.push(index)
38
+ for (const r of repos) {
39
+ out.push(join(r, 'repo.config.ts'))
40
+ const dirs: string[] = []
41
+ const files = unitFiles(r, dirs)
42
+ out.push(...dirs, ...files)
43
+ }
44
+ return out
45
+ }
46
+
47
+ /** a dir's entries that can change the answer: its subdirs and its unit
48
+ * files. A temp file coming and going moves the dir's mtime, not this */
49
+ const listings = new Map<string, { mtime: number; names: string }>()
50
+ function dirNames(dir: string, mtime: number): string {
51
+ const hit = listings.get(dir)
52
+ if (hit?.mtime === mtime) return hit.names
53
+ let names = ''
54
+ try {
55
+ names = readdirSync(dir, { withFileTypes: true })
56
+ .filter((e) => !e.name.startsWith('.') && (e.isDirectory() || e.name.endsWith(UNIT_SUFFIX)))
57
+ .map((e) => e.name)
58
+ .sort()
59
+ .join('/')
60
+ } catch { /* gone between the stat and the read: the next stat says so */ }
61
+ listings.set(dir, { mtime, names })
62
+ return names
63
+ }
64
+
65
+ /** every path as one string: a file by its mtime, a dir by the entries
66
+ * that matter (read again only when its mtime moved), -1 when missing */
67
+ export function stampOf(paths: string[]): string {
68
+ return paths.map((p) => {
69
+ try {
70
+ const st = statSync(p)
71
+ return st.isDirectory() ? dirNames(p, st.mtimeMs) : st.mtimeMs
72
+ } catch {
73
+ return -1
74
+ }
75
+ }).join(',')
76
+ }