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.
- package/CHANGELOG.md +54 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +12 -8
- package/src/assets/server/studio-server.ts +14 -2
- package/src/check.ts +5 -8
- package/src/checks/SK001-package-json-shape.check.ts +5 -4
- package/src/checks/SK003-claude-md-shape.check.ts +2 -2
- package/src/checks/SK030-cache-audit.check.ts +34 -0
- package/src/checks/SK031-trace-audit.check.ts +39 -0
- package/src/checks/SK032-ignored-inputs.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +10 -6
- package/src/cli/cmd_CacheAudit.ts +43 -0
- package/src/cli/cmd_CrossRepoActivity.ts +2 -1
- package/src/cli/cmd_GlobalProcesses.ts +4 -3
- package/src/cli/cmd_Home.ts +3 -2
- package/src/cli/cmd_Insights.ts +78 -0
- package/src/cli/cmd_Keys.ts +12 -1
- package/src/cli/cmd_Overview.ts +1 -1
- package/src/cli/cmd_Serve.ts +2 -1
- package/src/cli/cmd_TraceAudit.ts +62 -0
- package/src/cli.ts +6 -0
- package/src/collections/collection-types.ts +3 -0
- package/src/collections/providers.ts +5 -3
- package/src/core/app-icon.ts +154 -0
- package/src/core/audit-report.ts +21 -0
- package/src/core/cache-audit.ts +72 -0
- package/src/core/canonical-scripts.ts +1 -1
- package/src/core/check-ctx.ts +14 -5
- package/src/core/check-runner.ts +6 -3
- package/src/core/floor-resolve.ts +5 -7
- package/src/core/git-root.ts +13 -0
- package/src/core/ignored-block.ts +94 -0
- package/src/core/ignored-inputs.ts +47 -0
- package/src/core/jobs.ts +9 -2
- package/src/core/one-lib.ts +59 -4
- package/src/core/read-set.ts +97 -9
- package/src/core/run-ci.ts +6 -1
- package/src/core/run-fixers.ts +8 -2
- package/src/core/trace-audit.ts +129 -0
- package/src/home/gui-app/ShipkitGui.swift +125 -41
- package/src/home/gui-app.ts +24 -27
- package/src/home/home-routes.ts +15 -0
- package/src/home/home-start.ts +11 -5
- package/src/home/insights-shape.ts +124 -0
- package/src/home/insights.ts +90 -0
- package/src/home/open-app.ts +2 -2
- package/src/home/serve-web.ts +3 -3
- package/src/home/via-hub.ts +14 -0
- package/src/home/widgets.ts +12 -2
- package/src/ledger/db.ts +2 -13
- package/src/ledger/gate-checks.ts +21 -3
- package/src/ledger/git-key.ts +148 -30
- package/src/ledger/git-scan.ts +5 -2
- package/src/ledger/ledger.ts +131 -16
- package/src/ledger/store.ts +14 -0
- package/src/ledger/toolchain-identity.ts +210 -25
- package/src/problems/extract.ts +3 -0
- package/src/repo-config.ts +18 -1
- package/src/serve/hub-app/admin-svc.ts +36 -3
- package/src/serve/hub-app/admin-view.tsx +33 -1
- package/src/serve/hub-app/col-card.tsx +1 -44
- package/src/serve/hub-app/col-tables-view.tsx +71 -5
- package/src/serve/hub-app/col-tables.ts +27 -0
- package/src/serve/hub-app/home-st.ts +13 -6
- package/src/serve/hub-app/home-strip.css +7 -9
- package/src/serve/hub-app/home-strip.tsx +8 -17
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +20 -4
- package/src/serve/hub-app/hub.css +24 -20
- package/src/serve/hub-app/icons.tsx +1 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-st.ts +111 -0
- package/src/serve/hub-app/insights-view.tsx +112 -0
- package/src/serve/hub-app/insights.css +24 -0
- package/src/serve/hub-app/keys.ts +11 -3
- package/src/serve/hub-app/machine-st.ts +23 -0
- package/src/serve/hub-app/palette-st.ts +2 -1
- package/src/serve/hub-app/processes-view.tsx +16 -9
- package/src/serve/hub-app/project-list.css +4 -1
- package/src/serve/hub-app/project-list.tsx +9 -5
- package/src/serve/hub-app/projects-svc.ts +11 -1
- package/src/serve/hub-app/repo-sections.ts +31 -17
- package/src/serve/hub-app/repo-st.ts +25 -6
- package/src/serve/hub-app/sidebar.tsx +5 -5
- package/src/serve/hub-app/tree-build.ts +2 -0
- package/src/serve/hub-app/tree-row.tsx +18 -5
- package/src/serve/hub-app/tree-svc.ts +27 -7
- package/src/serve/hub-runtime.ts +4 -2
- package/src/serve/project-gutter-route.ts +22 -1
- package/src/shipkit-files.ts +3 -2
- package/src/suite/judge-suites.ts +6 -0
- package/src/trace/interpose.c +655 -0
- package/src/trace/read-trace.ts +485 -0
- 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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
final class Pane: NSView,
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
59
|
+
var isFindBarVisible = false {
|
|
42
60
|
didSet {
|
|
43
|
-
|
|
44
|
-
if let bar = findBarView { addSubview(bar) }
|
|
61
|
+
bar.isHidden = !isFindBarVisible
|
|
45
62
|
needsLayout = true
|
|
46
63
|
}
|
|
47
64
|
}
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
111
|
+
func controlTextDidChange(_ note: Notification) { fromTop() }
|
|
59
112
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
132
|
-
@objc func
|
|
133
|
-
|
|
134
|
-
|
|
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(
|
|
155
|
-
|
|
156
|
-
|
|
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(
|
|
246
|
-
item(find, "Find Next", #selector(
|
|
247
|
-
item(find, "Find Previous", #selector(
|
|
248
|
-
item(find, "Hide Find Bar", #selector(
|
|
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()
|
package/src/home/gui-app.ts
CHANGED
|
@@ -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.
|
|
8
|
-
//
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
139
|
-
const RASTER = /\.(png|jpe?g|gif|webp
|
|
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 */
|
package/src/home/home-routes.ts
CHANGED
|
@@ -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 })
|
package/src/home/home-start.ts
CHANGED
|
@@ -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 {
|
|
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}`)
|