rvlib-shipkit 1.5.0 → 1.7.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 (94) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +12 -8
  4. package/src/assets/server/studio-server.ts +14 -2
  5. package/src/check.ts +5 -8
  6. package/src/checks/SK001-package-json-shape.check.ts +5 -4
  7. package/src/checks/SK003-claude-md-shape.check.ts +2 -2
  8. package/src/checks/SK030-cache-audit.check.ts +34 -0
  9. package/src/checks/SK031-trace-audit.check.ts +39 -0
  10. package/src/checks/SK032-ignored-inputs.check.ts +79 -0
  11. package/src/cli/cmd_Assets.ts +10 -6
  12. package/src/cli/cmd_CacheAudit.ts +43 -0
  13. package/src/cli/cmd_CrossRepoActivity.ts +2 -1
  14. package/src/cli/cmd_GlobalProcesses.ts +4 -3
  15. package/src/cli/cmd_Home.ts +3 -2
  16. package/src/cli/cmd_Insights.ts +78 -0
  17. package/src/cli/cmd_Keys.ts +12 -1
  18. package/src/cli/cmd_Overview.ts +1 -1
  19. package/src/cli/cmd_Serve.ts +2 -1
  20. package/src/cli/cmd_TraceAudit.ts +62 -0
  21. package/src/cli.ts +6 -0
  22. package/src/collections/collection-types.ts +3 -0
  23. package/src/collections/providers.ts +5 -3
  24. package/src/core/app-icon.ts +154 -0
  25. package/src/core/audit-report.ts +21 -0
  26. package/src/core/cache-audit.ts +72 -0
  27. package/src/core/canonical-scripts.ts +1 -1
  28. package/src/core/check-ctx.ts +14 -5
  29. package/src/core/check-runner.ts +6 -3
  30. package/src/core/floor-resolve.ts +5 -7
  31. package/src/core/git-root.ts +13 -0
  32. package/src/core/ignored-block.ts +94 -0
  33. package/src/core/ignored-inputs.ts +47 -0
  34. package/src/core/jobs.ts +9 -2
  35. package/src/core/one-lib.ts +59 -4
  36. package/src/core/read-set.ts +97 -9
  37. package/src/core/run-ci.ts +6 -1
  38. package/src/core/run-fixers.ts +8 -2
  39. package/src/core/trace-audit.ts +129 -0
  40. package/src/home/gui-app/ShipkitGui.swift +125 -41
  41. package/src/home/gui-app.ts +24 -27
  42. package/src/home/home-routes.ts +15 -0
  43. package/src/home/home-start.ts +11 -5
  44. package/src/home/insights-shape.ts +124 -0
  45. package/src/home/insights.ts +90 -0
  46. package/src/home/open-app.ts +2 -2
  47. package/src/home/serve-web.ts +3 -3
  48. package/src/home/via-hub.ts +14 -0
  49. package/src/home/widgets.ts +12 -2
  50. package/src/ledger/db.ts +2 -13
  51. package/src/ledger/gate-checks.ts +21 -3
  52. package/src/ledger/git-key.ts +148 -30
  53. package/src/ledger/git-scan.ts +5 -2
  54. package/src/ledger/ledger.ts +131 -16
  55. package/src/ledger/store.ts +14 -0
  56. package/src/ledger/toolchain-identity.ts +210 -25
  57. package/src/problems/extract.ts +3 -0
  58. package/src/repo-config.ts +18 -1
  59. package/src/serve/hub-app/admin-svc.ts +36 -3
  60. package/src/serve/hub-app/admin-view.tsx +33 -1
  61. package/src/serve/hub-app/col-card.tsx +1 -44
  62. package/src/serve/hub-app/col-tables-view.tsx +71 -5
  63. package/src/serve/hub-app/col-tables.ts +27 -0
  64. package/src/serve/hub-app/home-st.ts +13 -6
  65. package/src/serve/hub-app/home-strip.css +7 -9
  66. package/src/serve/hub-app/home-strip.tsx +8 -17
  67. package/src/serve/hub-app/home.html +1 -0
  68. package/src/serve/hub-app/hub-app.tsx +20 -4
  69. package/src/serve/hub-app/hub.css +24 -20
  70. package/src/serve/hub-app/icons.tsx +1 -0
  71. package/src/serve/hub-app/index.html +1 -0
  72. package/src/serve/hub-app/insights-st.ts +111 -0
  73. package/src/serve/hub-app/insights-view.tsx +112 -0
  74. package/src/serve/hub-app/insights.css +24 -0
  75. package/src/serve/hub-app/keys.ts +11 -3
  76. package/src/serve/hub-app/machine-st.ts +23 -0
  77. package/src/serve/hub-app/palette-st.ts +2 -1
  78. package/src/serve/hub-app/processes-view.tsx +16 -9
  79. package/src/serve/hub-app/project-list.css +4 -1
  80. package/src/serve/hub-app/project-list.tsx +9 -5
  81. package/src/serve/hub-app/projects-svc.ts +11 -1
  82. package/src/serve/hub-app/repo-sections.ts +31 -17
  83. package/src/serve/hub-app/repo-st.ts +25 -6
  84. package/src/serve/hub-app/sidebar.tsx +5 -5
  85. package/src/serve/hub-app/tree-build.ts +2 -0
  86. package/src/serve/hub-app/tree-row.tsx +18 -5
  87. package/src/serve/hub-app/tree-svc.ts +27 -7
  88. package/src/serve/hub-runtime.ts +4 -2
  89. package/src/serve/project-gutter-route.ts +22 -1
  90. package/src/shipkit-files.ts +3 -2
  91. package/src/suite/judge-suites.ts +6 -0
  92. package/src/trace/interpose.c +655 -0
  93. package/src/trace/read-trace.ts +485 -0
  94. package/src/trace/tracer.ts +282 -0
@@ -17,55 +17,110 @@ func stays(_ url: URL) -> Bool {
17
17
  url.host == "127.0.0.1" || url.host == "localhost" || url.host == startURL.host
18
18
  }
19
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 {
20
+ /** a window's content: the find bar above the web view when shown. WebKit's
21
+ * public find api searches the page: NSTextFinder never shows its bar for a
22
+ * WKWebView, which is no NSTextFinderClient at runtime */
23
+ final class Pane: NSView, NSSearchFieldDelegate {
24
24
  let web: WKWebView
25
- let finder = NSTextFinder()
25
+ let bar = NSStackView()
26
+ let field = NSSearchField()
27
+ let status = NSTextField(labelWithString: "")
28
+ static let barHeight: CGFloat = 34
26
29
 
27
30
  init(web: WKWebView) {
28
31
  self.web = web
29
32
  super.init(frame: .zero)
30
33
  addSubview(web)
31
- finder.client = web
32
- finder.findBarContainer = self
33
- finder.isIncrementalSearchingEnabled = true
34
- finder.incrementalSearchingShouldDimContentView = false
34
+ field.placeholderString = "find in page"
35
+ field.delegate = self
36
+ field.sendsSearchStringImmediately = true
37
+ status.textColor = .secondaryLabelColor
38
+ let prev = NSButton(title: "‹", target: self, action: #selector(findPrevious))
39
+ let next = NSButton(title: "›", target: self, action: #selector(findNext))
40
+ let done = NSButton(title: "Done", target: self, action: #selector(hideFind))
41
+ prev.toolTip = "previous match (⇧⌘G, ⇧↩)"
42
+ next.toolTip = "next match (⌘G, ↩)"
43
+ for b in [prev, next, done] { b.bezelStyle = .rounded; b.controlSize = .small }
44
+ field.controlSize = .small
45
+ field.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true
46
+ bar.orientation = .horizontal
47
+ bar.spacing = 6
48
+ bar.edgeInsets = NSEdgeInsets(top: 4, left: 8, bottom: 4, right: 8)
49
+ for v in [field, status, prev, next, done] as [NSView] { bar.addArrangedSubview(v) }
50
+ bar.setHuggingPriority(.defaultLow, for: .horizontal)
51
+ bar.isHidden = true
52
+ addSubview(bar)
35
53
  }
36
54
 
37
55
  required init?(coder: NSCoder) { fatalError("not from a nib") }
38
56
 
39
57
  override var isFlipped: Bool { true }
40
58
 
41
- var findBarView: NSView? {
59
+ var isFindBarVisible = false {
42
60
  didSet {
43
- oldValue?.removeFromSuperview()
44
- if let bar = findBarView { addSubview(bar) }
61
+ bar.isHidden = !isFindBarVisible
45
62
  needsLayout = true
46
63
  }
47
64
  }
48
65
 
49
- var isFindBarVisible = false {
50
- didSet {
51
- findBarView?.isHidden = !isFindBarVisible
52
- needsLayout = true
66
+ override func layout() {
67
+ super.layout()
68
+ let top = isFindBarVisible ? Pane.barHeight : 0
69
+ bar.frame = NSRect(x: 0, y: 0, width: bounds.width, height: Pane.barHeight)
70
+ web.frame = NSRect(x: 0, y: top, width: bounds.width, height: max(0, bounds.height - top))
71
+ }
72
+
73
+ @objc func showFind() {
74
+ isFindBarVisible = true
75
+ window?.makeFirstResponder(field)
76
+ field.currentEditor()?.selectAll(nil)
77
+ }
78
+
79
+ @objc func hideFind() {
80
+ isFindBarVisible = false
81
+ window?.makeFirstResponder(web)
82
+ }
83
+
84
+ @objc func findNext() { search(backwards: false) }
85
+ @objc func findPrevious() { search(backwards: true) }
86
+
87
+ /** the next match of the field's text from the page's selection, wrapping;
88
+ * `done` hears whether one was found */
89
+ func search(backwards: Bool, done: ((Bool) -> Void)? = nil) {
90
+ let q = field.stringValue
91
+ if q.isEmpty {
92
+ status.stringValue = ""
93
+ done?(false)
94
+ return
95
+ }
96
+ let c = WKFindConfiguration()
97
+ c.backwards = backwards
98
+ c.wraps = true
99
+ c.caseSensitive = false
100
+ web.find(q, configuration: c) { r in
101
+ self.status.stringValue = r.matchFound ? "" : "no match"
102
+ done?(r.matchFound)
53
103
  }
54
104
  }
55
105
 
56
- func findBarViewDidChangeHeight() { needsLayout = true }
106
+ /** typing searches again from the top of the page */
107
+ func fromTop(done: ((Bool) -> Void)? = nil) {
108
+ web.evaluateJavaScript("getSelection().removeAllRanges()") { _, _ in self.search(backwards: false, done: done) }
109
+ }
57
110
 
58
- func contentView() -> NSView? { web }
111
+ func controlTextDidChange(_ note: Notification) { fromTop() }
59
112
 
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
113
+ // in the field: return is the next match, shift return the previous, esc closes
114
+ func control(_ control: NSControl, textView: NSTextView, doCommandBy sel: Selector) -> Bool {
115
+ if sel == #selector(NSResponder.insertNewline(_:)) {
116
+ search(backwards: NSApp.currentEvent?.modifierFlags.contains(.shift) == true)
117
+ return true
67
118
  }
68
- web.frame = NSRect(x: 0, y: top, width: bounds.width, height: max(0, bounds.height - top))
119
+ if sel == #selector(NSResponder.cancelOperation(_:)) {
120
+ hideFind()
121
+ return true
122
+ }
123
+ return false
69
124
  }
70
125
  }
71
126
 
@@ -74,12 +129,12 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
74
129
  var opened = false
75
130
 
76
131
  func applicationDidFinishLaunching(_ note: Notification) {
132
+ if CommandLine.arguments.contains("--self-test-find") { return selfTestFind() }
77
133
  buildMenu()
78
134
  // esc closes the find bar of the key window, and reaches the page otherwise
79
135
  NSEvent.addLocalMonitorForEvents(matching: .keyDown) { e in
80
136
  if e.keyCode == 53, let pane = self.keyPane(), pane.isFindBarVisible {
81
- pane.finder.performAction(.hideFindInterface)
82
- pane.window?.makeFirstResponder(pane.web)
137
+ pane.hideFind()
83
138
  return nil
84
139
  }
85
140
  return e
@@ -128,11 +183,10 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
128
183
  @objc func zoomOut(_ sender: Any?) { zoom = zoom / 1.1 }
129
184
  @objc func zoomReset(_ sender: Any?) { zoom = 1 }
130
185
 
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
- }
186
+ @objc func showFind(_ sender: Any?) { keyPane()?.showFind() }
187
+ @objc func hideFind(_ sender: Any?) { keyPane()?.hideFind() }
188
+ @objc func findNext(_ sender: Any?) { keyPane()?.findNext() }
189
+ @objc func findPrevious(_ sender: Any?) { keyPane()?.findPrevious() }
136
190
 
137
191
  // no public api opens the inspector: `_inspector` then `show` is the
138
192
  // WebKit call the context menu's Inspect Element makes. Where it is gone,
@@ -151,10 +205,9 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
151
205
  }
152
206
 
153
207
  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
- }
208
+ if item.action == #selector(hideFind(_:)) { return keyPane()?.isFindBarVisible == true }
209
+ if item.action == #selector(findNext(_:)) || item.action == #selector(findPrevious(_:)) { return keyPane()?.field.stringValue.isEmpty == false }
210
+ if item.action == #selector(showFind(_:)) { return keyPane() != nil }
158
211
  if item.action == #selector(devTools(_:)) || item.action == #selector(reload(_:)) { return keyPane() != nil }
159
212
  return true
160
213
  }
@@ -184,12 +237,43 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
184
237
  NSApp.activate(ignoringOtherApps: true)
185
238
  }
186
239
 
240
+ var onLoad: ((WKWebView) -> Void)?
241
+
242
+ // `--self-test-find`: an offscreen window on a page holding a word twice,
243
+ // the find bar opened, a word on the page searched, then one that is not;
244
+ // what happened printed, exit 0 only when the bar shows and finds
245
+ func selfTestFind() {
246
+ let web = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
247
+ web.navigationDelegate = self
248
+ let pane = Pane(web: web)
249
+ let win = NSWindow(contentRect: NSRect(x: -4000, y: -4000, width: 600, height: 400), styleMask: [.titled], backing: .buffered, defer: false)
250
+ win.contentView = pane
251
+ win.orderFrontRegardless()
252
+ windows.append(win)
253
+ onLoad = { _ in
254
+ pane.showFind()
255
+ pane.field.stringValue = "needle"
256
+ pane.fromTop { found in
257
+ pane.field.stringValue = "haystack"
258
+ pane.fromTop { absent in
259
+ print("find-bar-visible=\(pane.isFindBarVisible)")
260
+ print("found=\(found)")
261
+ print("absent-found=\(absent)")
262
+ print("status=\(pane.status.stringValue)")
263
+ exit(pane.isFindBarVisible && found && !absent ? 0 : 1)
264
+ }
265
+ }
266
+ }
267
+ web.loadHTMLString("<p>one needle, two needle</p>", baseURL: nil)
268
+ }
269
+
187
270
  func windowWillClose(_ note: Notification) {
188
271
  windows.removeAll { $0 === note.object as? NSWindow }
189
272
  }
190
273
 
191
274
  // the page title names the window, so cmd-tab and the window menu tell pages apart
192
275
  func webView(_ web: WKWebView, didFinish nav: WKNavigation!) {
276
+ onLoad?(web)
193
277
  web.window?.title = (web.title?.isEmpty == false) ? web.title! : appName
194
278
  }
195
279
 
@@ -242,10 +326,10 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
242
326
  edit.addItem(.separator())
243
327
  let findItem = edit.addItem(withTitle: "Find", action: nil, keyEquivalent: "")
244
328
  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)
329
+ item(find, "Find…", #selector(showFind(_:)), "f")
330
+ item(find, "Find Next", #selector(findNext(_:)), "g")
331
+ item(find, "Find Previous", #selector(findPrevious(_:)), "g", [.command, .shift])
332
+ item(find, "Hide Find Bar", #selector(hideFind(_:)), "", [])
249
333
  edit.setSubmenu(find, for: findItem)
250
334
  editItem.submenu = edit
251
335
  let viewItem = NSMenuItem()
@@ -4,12 +4,15 @@
4
4
  // and iconed after it, in its repo's `.shipkit/wrapper/`. The bundle's
5
5
  // Info.plist carries what differs (name, bundle id, start url), so a fix to
6
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.
7
+ // build. The icon is core/app-icon.ts: the repo's mark, its margin cut, on
8
+ // a plate in the repo's colour. Rebuilt only when the source, the icon, the
9
+ // colours or the start url changed (a stamp in the bundle). No compiler, or not macOS: the caller falls back.
9
10
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
10
11
  import { homedir } from 'os'
11
12
  import { dirname, join } from 'path'
12
13
  import { fileURLToPath } from 'url'
14
+ import { appIconOf, appIconPngs, identityOfRoot } from '../core/app-icon.ts'
15
+ import { identityOfName, type Identity } from '../core/identity-color.ts'
13
16
  import { LAYOUT } from '../core/layout.ts'
14
17
 
15
18
  const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.url))
@@ -17,15 +20,6 @@ const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.u
17
20
  export const BUNDLE_ID = 'dev.shipkit.gui'
18
21
  export const guiAppPath = (): string => process.env.SHIPKIT_GUI_APP ?? join(homedir(), 'Applications', 'Shipkit.app')
19
22
 
20
- /** the icon the app wears: the repo's own mark, largest first. Only a
21
- * png can become an icns */
22
- export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
23
-
24
- export function appIconOf(root: string): string | null {
25
- for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
26
- return null
27
- }
28
-
29
23
  /** what one app bundle is: the rest comes from the shared source */
30
24
  export type AppSpec = {
31
25
  /** CFBundleName: the dock, cmd-tab and the app menu say it */
@@ -34,6 +28,8 @@ export type AppSpec = {
34
28
  /** the page a launch with no url opens; null opens the home */
35
29
  url: string | null
36
30
  icon: string | null
31
+ /** the repo's colours: the plate behind the icon's mark */
32
+ identity: Identity
37
33
  /** where the bundle lands */
38
34
  app: string
39
35
  version: string
@@ -52,7 +48,7 @@ function atsOf(url: string | null): string {
52
48
  return `<dict><key>NSAllowsLocalNetworking</key><true/>${exception}</dict>`
53
49
  }
54
50
 
55
- export function infoPlist(o: Omit<AppSpec, 'app' | 'icon'>): string {
51
+ export function infoPlist(o: Omit<AppSpec, 'app' | 'icon' | 'identity'>): string {
56
52
  return `<?xml version="1.0" encoding="UTF-8"?>
57
53
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
58
54
  <plist version="1.0">
@@ -79,6 +75,9 @@ const runSync: Run = (cmd) => {
79
75
  return { code: p.exitCode ?? 1, err: p.stderr.toString().trim() }
80
76
  }
81
77
 
78
+ /** bumped when the icon drawing changes, so every built app redraws its icon */
79
+ const ICON_DRAWING = 'plate-1'
80
+
82
81
  /** the stamp a built bundle carries: what it was built from. The version is
83
82
  * left out, so a shipkit release rebuilds nothing */
84
83
  export function stampOf(source: string, icon: string | null, identity = ''): string {
@@ -89,11 +88,11 @@ export function stampOf(source: string, icon: string | null, identity = ''): str
89
88
 
90
89
  /** one app, built or rebuilt when stale. Throws with the step that failed.
91
90
  * `rebuilt` tells the caller a running copy is now the old one */
92
- export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): { app: string; rebuilt: boolean } {
91
+ export async function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): Promise<{ app: string; rebuilt: boolean }> {
93
92
  const { app, icon } = spec
94
93
  const run = o.run ?? runSync
95
94
  const source = o.source ?? SOURCE
96
- const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url]))
95
+ const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url, spec.identity, ICON_DRAWING]))
97
96
  const stampFile = join(app, 'Contents', 'Resources', 'stamp')
98
97
  if (existsSync(stampFile) && readFileSync(stampFile, 'utf8') === stamp) return { app, rebuilt: false }
99
98
  const tmp = `${app}.building-${process.pid}`
@@ -108,12 +107,10 @@ export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {})
108
107
  if (icon != null) {
109
108
  const set = join(res, 'AppIcon.iconset')
110
109
  mkdirSync(set, { recursive: true })
111
- for (const n of [16, 32, 128, 256, 512]) {
112
- for (const [scale, px] of [['', n], ['@2x', n * 2]] as const) {
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)}`)
115
- }
116
- }
110
+ const files = [16, 32, 128, 256, 512].flatMap((n) => [[`icon_${n}x${n}.png`, n], [`icon_${n}x${n}@2x.png`, n * 2]] as const)
111
+ const pngs = await appIconPngs(readFileSync(icon), spec.identity, files.map(([, px]) => px))
112
+ .catch((e: Error) => fail(`could not draw the icon from ${icon}: ${e.message}`))
113
+ files.forEach(([file], i) => writeFileSync(join(set, file), pngs[i]!))
117
114
  const r = run(['iconutil', '-c', 'icns', set, '-o', join(res, 'AppIcon.icns')])
118
115
  rmSync(set, { recursive: true, force: true })
119
116
  if (r.code !== 0) fail(`iconutil could not build the icon: ${r.err.slice(0, 200)}`)
@@ -128,15 +125,15 @@ export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {})
128
125
  }
129
126
 
130
127
  /** 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 })
128
+ export function ensureGuiApp(o: { root: string | null; version: string; app?: string; run?: Run }): Promise<{ app: string; rebuilt: boolean }> {
129
+ return ensureApp({ name: 'shipkit', bundleId: BUNDLE_ID, url: null, icon: o.root == null ? null : appIconOf(o.root), identity: o.root == null ? identityOfName('shipkit') : identityOfRoot(o.root), app: o.app ?? guiAppPath(), version: o.version }, { run: o.run })
133
130
  }
134
131
 
135
132
  /** what a global process gives its app */
136
133
  export type WrapperOf = { id: string; label: string; url: string | null; icon: string | null; root: string }
137
134
 
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
135
+ /** the images the icon renderer reads; an emoji or an svg leaves the repo's mark */
136
+ const RASTER = /\.(png|jpe?g|gif|webp)$/i
140
137
 
141
138
  export const wrapperBundleId = (id: string): string => `dev.shipkit.app.${id.replace(/[^A-Za-z0-9-]+/g, '-')}`
142
139
  export const wrapperPath = (root: string, id: string): string => join(root, LAYOUT.wrapper, `${id.replace(/[^A-Za-z0-9._-]+/g, '-')}.app`)
@@ -147,12 +144,12 @@ export const wrapperPath = (root: string, id: string): string => join(root, LAYO
147
144
  export function wrapperSpec(d: WrapperOf, version: string): AppSpec {
148
145
  if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
149
146
  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 }
147
+ return { name: d.label, bundleId: wrapperBundleId(d.id), url: d.url, icon: declared ?? appIconOf(d.root), identity: identityOfRoot(d.root), app: wrapperPath(d.root, d.id), version }
151
148
  }
152
149
 
153
- export const ensureWrapperApp = (d: WrapperOf, o: { version: string; run?: Run }): { app: string; rebuilt: boolean; bundleId: string } => {
150
+ export const ensureWrapperApp = async (d: WrapperOf, o: { version: string; run?: Run }): Promise<{ app: string; rebuilt: boolean; bundleId: string }> => {
154
151
  const spec = wrapperSpec(d, o.version)
155
- return { ...ensureApp(spec, { run: o.run }), bundleId: spec.bundleId }
152
+ return { ...(await ensureApp(spec, { run: o.run })), bundleId: spec.bundleId }
156
153
  }
157
154
 
158
155
  /** quits a running copy of an app, and launches nothing when none runs */
@@ -17,6 +17,7 @@ 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
19
  import { ActivityCache, repoActivity, windowMinutes } from './activity.ts'
20
+ import { devRepos, InsightsCache, type Scope } from './insights.ts'
20
21
  import { HouseRuns } from '../serve/house-runs.ts'
21
22
 
22
23
  /** how far back a repo's git page lists its commits */
@@ -111,6 +112,8 @@ export function homeRoutes(d: HomeDeps) {
111
112
  const modernizing = new Map<string, ModernizeView & { log: string }>()
112
113
  // read only while the activity page asks, never on the tick
113
114
  const activity = new ActivityCache({ declared: (name) => d.plan?.declared(name) ?? null, titles: (name) => d.plan?.titles(name) ?? null })
115
+ // read only while the insights page asks, one answer per scope
116
+ const insights = new InsightsCache()
114
117
  const modernizeLog = (repo: string): string => join(d.dir, 'modernize', `${repo}.log`)
115
118
  // the dock: every loaded repo's recent runs, read only while a page asks
116
119
  const house = new HouseRuns(() => repos)
@@ -176,6 +179,12 @@ export function homeRoutes(d: HomeDeps) {
176
179
  const q = new URL(req.url).searchParams
177
180
  return Response.json(await activity.get(repos, windowMinutes(q.get('m')), { fresh: q.get('fresh') === '1' }))
178
181
  }
182
+ if (path === '/api/home/insights.json') {
183
+ if (repos.length === 0) await refresh()
184
+ const q = new URL(req.url).searchParams
185
+ const scope: Scope = q.get('scope') === 'dev' ? 'dev' : 'house'
186
+ return Response.json(await insights.get(scope, () => (scope === 'dev' ? devRepos() : repos), { fresh: q.get('fresh') === '1' }))
187
+ }
179
188
  // one repo's git for its git page: a week of commits, and HEAD's last
180
189
  // one past that, whatever window the activity page shows
181
190
  if (path === '/api/home/git.json') {
@@ -209,6 +218,12 @@ export function homeRoutes(d: HomeDeps) {
209
218
  if (b.act === 'restart') return Response.json(await d.hubs.restart(r))
210
219
  return new Response(`act must be ensure, stop or restart`, { status: 400 })
211
220
  }
221
+ if (path === '/api/home/widgets/refresh') {
222
+ const b = await body(req)
223
+ const v = await d.widgets?.refresh(String(b.repo), String(b.id))
224
+ if (v == null) return new Response(`no widget ${String(b.id)} in ${String(b.repo)}`, { status: 404 })
225
+ return Response.json(v)
226
+ }
212
227
  if (path === '/api/home/planned/run') {
213
228
  const b = await body(req)
214
229
  if (d.plan == null || !d.plan.ask(String(b.repo), String(b.check))) return new Response(`no planned check ${String(b.check)} in ${String(b.repo)}`, { status: 404 })
@@ -6,7 +6,11 @@ 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, ensureWrapperApp, quitCmd, type WrapperOf } from './gui-app.ts'
9
+ import type { WrapperOf } from './gui-app.ts'
10
+
11
+ // the app builder draws icons with canvas: loaded when a window opens, never
12
+ // at the startup of every command that imports this module
13
+ const gui = (): Promise<typeof import('./gui-app.ts')> => import('./gui-app.ts')
10
14
  import { installedHouseRoot, LABEL, plistPath } from './home-launchd.ts'
11
15
  import { childLog, daemonPort, daemonUp, homeDir, homePort, homeUp, pmLog } from './home-paths.ts'
12
16
 
@@ -88,10 +92,11 @@ export function windowCmd(url: string, o: { chrome?: string; platform?: string }
88
92
  /** one more home window: the native app where it can be built (its own
89
93
  * dock icon, the house mark), else a Chrome app window. `warn` hears why the
90
94
  * native one was not used */
91
- export function openWindow(url: string, o: { root?: string | null; version?: string; warn?: (l: string) => void } = {}): string[] {
95
+ export async function openWindow(url: string, o: { root?: string | null; version?: string; warn?: (l: string) => void } = {}): Promise<string[]> {
96
+ const { BUNDLE_ID, canBuildGui, ensureGuiApp, quitCmd } = await gui()
92
97
  if (canBuildGui()) {
93
98
  try {
94
- const { app, rebuilt } = ensureGuiApp({ root: o.root ?? guiRoot(process.cwd()),version: o.version ?? '0' })
99
+ const { app, rebuilt } = await ensureGuiApp({ root: o.root ?? guiRoot(process.cwd()),version: o.version ?? '0' })
95
100
  // a running copy is the old build: it quits, and the new one opens the window
96
101
  if (rebuilt) Bun.spawnSync(quitCmd(BUNDLE_ID), { stdout: 'ignore', stderr: 'ignore' })
97
102
  const cmd = ['open', '-a', app, url]
@@ -108,11 +113,12 @@ export function openWindow(url: string, o: { root?: string | null; version?: str
108
113
  * `.shipkit/wrapper/`, then opened with no url, so a running copy comes to
109
114
  * the front instead of stacking windows. Where no app can be built, a
110
115
  * Chrome app window on its url */
111
- export function openProcessApp(d: WrapperOf, o: { version: string; warn?: (l: string) => void }): string[] {
116
+ export async function openProcessApp(d: WrapperOf, o: { version: string; warn?: (l: string) => void }): Promise<string[]> {
112
117
  if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
118
+ const { canBuildGui, ensureWrapperApp, quitCmd } = await gui()
113
119
  if (canBuildGui()) {
114
120
  try {
115
- const { app, rebuilt, bundleId } = ensureWrapperApp(d, { version: o.version })
121
+ const { app, rebuilt, bundleId } = await ensureWrapperApp(d, { version: o.version })
116
122
  if (rebuilt) Bun.spawnSync(quitCmd(bundleId), { stdout: 'ignore', stderr: 'ignore' })
117
123
  const cmd = ['open', '-a', app]
118
124
  Bun.spawnSync(cmd, { stdout: 'ignore', stderr: 'ignore' })
@@ -0,0 +1,124 @@
1
+ // the insights page's boxes, computed from every package.json a scope of repos
2
+ // tracks. Pure: no disk, so the page, `sk insights` and the tests share it.
3
+ // Every box has one shape (a row per key, the repos using it, one value per
4
+ // use), so a new box is one function in BOXES and the page draws it unchanged.
5
+
6
+ /** one package.json, read: its repo, its dir inside the repo ('' at the root) */
7
+ export type Manifest = {
8
+ repo: string
9
+ where: string
10
+ json: { dependencies?: Record<string, string>; devDependencies?: Record<string, string>; peerDependencies?: Record<string, string>; scripts?: Record<string, string> }
11
+ }
12
+
13
+ /** one package.json using the row's key, and what it says for it */
14
+ export type InsightUse = { repo: string; where: string; value: string; dev?: boolean }
15
+
16
+ export type InsightRow = {
17
+ key: string
18
+ /** distinct repos using it: the sort */
19
+ repos: number
20
+ /** its distinct values, most used first (the version ranges of a package) */
21
+ values: string[]
22
+ uses: InsightUse[]
23
+ }
24
+
25
+ export type InsightBox = {
26
+ id: string
27
+ title: string
28
+ tags: string[]
29
+ /** one line on what it lists and how it sorts */
30
+ hint: string
31
+ /** what a row's key is, for the filter's placeholder */
32
+ noun: string
33
+ /** the filter reads the values too (a script's command), not only the key */
34
+ searchValues?: boolean
35
+ rows: InsightRow[]
36
+ }
37
+
38
+ export const SCOPES = ['house', 'dev'] as const
39
+ export type Scope = (typeof SCOPES)[number]
40
+
41
+ export type InsightsAnswer = {
42
+ now: number
43
+ scope: Scope
44
+ /** repos read, and package.json files found in them */
45
+ repos: number
46
+ files: number
47
+ boxes: InsightBox[]
48
+ /** a manifest that would not parse, as `repo/where: why` */
49
+ errors: string[]
50
+ ms: number
51
+ }
52
+
53
+ const byCount = <T>(xs: T[]): T[] => {
54
+ const n = new Map<T, number>()
55
+ for (const x of xs) n.set(x, (n.get(x) ?? 0) + 1)
56
+ return [...n.keys()].sort((a, b) => n.get(b)! - n.get(a)!)
57
+ }
58
+
59
+ /** one row per key, the uses gathered, sorted by distinct repos then key */
60
+ function rowsOf(uses: [string, InsightUse][]): InsightRow[] {
61
+ const by = new Map<string, InsightUse[]>()
62
+ for (const [k, u] of uses) {
63
+ if (!by.has(k)) by.set(k, [])
64
+ by.get(k)!.push(u)
65
+ }
66
+ return [...by]
67
+ .map(([key, us]) => ({ key, repos: new Set(us.map((u) => u.repo)).size, values: byCount(us.map((u) => u.value)), uses: us.sort((a, b) => a.repo.localeCompare(b.repo) || a.where.localeCompare(b.where)) }))
68
+ .sort((a, b) => b.repos - a.repos || a.key.localeCompare(b.key))
69
+ }
70
+
71
+ /** a range that names no published version: a workspace or local link */
72
+ export const localRange = (v: string): boolean => /^(workspace:|file:|link:|portal:)/.test(v)
73
+
74
+ function depUses(ms: Manifest[]): [string, InsightUse][] {
75
+ return ms.flatMap((m) => [
76
+ ...Object.entries(m.json.dependencies ?? {}).map(([k, v]): [string, InsightUse] => [k, { repo: m.repo, where: m.where, value: v }]),
77
+ ...Object.entries(m.json.devDependencies ?? {}).map(([k, v]): [string, InsightUse] => [k, { repo: m.repo, where: m.where, value: v, dev: true }]),
78
+ ])
79
+ }
80
+
81
+ export const BOXES: ((ms: Manifest[]) => InsightBox)[] = [
82
+ (ms) => ({
83
+ id: 'deps',
84
+ title: 'dependency usage',
85
+ tags: ['deps'],
86
+ hint: 'every package in a dependencies or devDependencies, by how many repos use it',
87
+ noun: 'package',
88
+ rows: rowsOf(depUses(ms)),
89
+ }),
90
+ (ms) => ({
91
+ id: 'drift',
92
+ title: 'version drift',
93
+ tags: ['deps', 'health'],
94
+ hint: 'packages asked at more than one version range across repos, by how many ranges',
95
+ noun: 'package',
96
+ rows: rowsOf(depUses(ms).filter(([, u]) => !localRange(u.value)))
97
+ .filter((r) => r.values.length > 1)
98
+ .sort((a, b) => b.values.length - a.values.length || b.repos - a.repos || a.key.localeCompare(b.key)),
99
+ }),
100
+ (ms) => ({
101
+ id: 'scripts',
102
+ title: 'script names',
103
+ tags: ['scripts'],
104
+ hint: 'every package.json script name, by how many repos declare it, each with its command',
105
+ noun: 'script',
106
+ searchValues: true,
107
+ rows: rowsOf(ms.flatMap((m) => Object.entries(m.json.scripts ?? {}).map(([k, v]): [string, InsightUse] => [k, { repo: m.repo, where: m.where, value: v }]))),
108
+ }),
109
+ ]
110
+
111
+ /** a row matches a query on its key, or on a value when its box searches them */
112
+ export const rowMatches = (b: InsightBox, r: InsightRow, q: string): boolean => {
113
+ const s = q.trim().toLowerCase()
114
+ return s === '' || r.key.toLowerCase().includes(s) || (b.searchValues === true && r.values.some((v) => v.toLowerCase().includes(s)))
115
+ }
116
+
117
+ /** every box over these manifests */
118
+ export const boxesOf = (ms: Manifest[]): InsightBox[] => BOXES.map((b) => b(ms))
119
+
120
+ /** every tag the boxes carry, in first seen order */
121
+ export const tagsOf = (boxes: InsightBox[]): string[] => [...new Set(boxes.flatMap((b) => b.tags))]
122
+
123
+ /** a use's place, as the page and the cli print it */
124
+ export const placeWords = (u: InsightUse): string => (u.where === '' ? u.repo : `${u.repo}/${u.where}`)