rvlib-shipkit 1.1.0 → 1.2.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 +67 -0
- package/package.json +3 -1
- package/src/check.ts +24 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
- package/src/checks/SK023-stores-under-cap.check.ts +49 -0
- package/src/checks/SK024-identity-colors.check.ts +49 -0
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +18 -5
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli.ts +22 -2
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/attach.ts +14 -9
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +40 -18
- package/src/core/discover-checks.ts +36 -18
- package/src/core/identity-color.ts +203 -0
- package/src/core/identity.ts +91 -0
- package/src/core/job-follow.ts +102 -0
- package/src/core/job-run.ts +27 -0
- package/src/core/jobs.ts +87 -27
- package/src/core/legacy-shapes.ts +1 -14
- package/src/core/machine-db.ts +83 -0
- package/src/core/one-lib.ts +85 -0
- package/src/core/port-holders.ts +43 -0
- package/src/core/retention.ts +213 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +3 -0
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +86 -0
- package/src/home/gui-app/ShipkitGui.swift +159 -0
- package/src/home/gui-app.ts +99 -0
- package/src/home/handoff.ts +51 -0
- package/src/home/home-launchd.ts +104 -0
- package/src/home/home-paths.ts +88 -0
- package/src/home/home-routes.ts +208 -0
- package/src/home/home-start.ts +105 -0
- package/src/home/home-state.ts +43 -0
- package/src/home/home-words.ts +12 -0
- package/src/home/hubs.ts +286 -0
- package/src/home/lib-watch.ts +139 -0
- package/src/home/machine-stores.ts +115 -0
- package/src/home/open-app.ts +16 -0
- package/src/home/pm-config.ts +37 -0
- package/src/home/pm.ts +141 -0
- package/src/home/resources.ts +338 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +149 -0
- package/src/home/run-unit-action.ts +25 -0
- package/src/home/run-web.ts +37 -0
- package/src/home/serve-daemon.ts +31 -0
- package/src/home/serve-web.ts +172 -0
- package/src/home/supervise.ts +346 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widgets.ts +102 -0
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +5 -0
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/run-log.ts +58 -23
- package/src/ledger/store.ts +228 -103
- package/src/ledger/world-cache.ts +15 -13
- package/src/plan/cadence.ts +237 -0
- package/src/plan/planner.ts +303 -0
- package/src/plan/repo-source.ts +100 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/repo-config.ts +51 -2
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +1 -1
- package/src/serve/hub-app/checks-svc.ts +12 -8
- package/src/serve/hub-app/col-card.tsx +4 -4
- package/src/serve/hub-app/cols-svc.ts +92 -13
- package/src/serve/hub-app/comfy-svc.ts +12 -6
- package/src/serve/hub-app/comfy-view.tsx +3 -3
- package/src/serve/hub-app/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +1 -1
- package/src/serve/hub-app/header-bar.tsx +44 -12
- package/src/serve/hub-app/health-svc.ts +3 -3
- package/src/serve/hub-app/help.tsx +10 -7
- package/src/serve/hub-app/home-st.ts +241 -0
- package/src/serve/hub-app/home-strip.css +70 -0
- package/src/serve/hub-app/home-strip.tsx +245 -0
- package/src/serve/hub-app/home.html +17 -0
- package/src/serve/hub-app/hub-app.tsx +201 -93
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +43 -23
- package/src/serve/hub-app/icons.tsx +14 -0
- package/src/serve/hub-app/index.html +3 -1
- package/src/serve/hub-app/keys.ts +25 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +12 -9
- package/src/serve/hub-app/machine-st.ts +270 -0
- package/src/serve/hub-app/media-svc.ts +63 -44
- package/src/serve/hub-app/media-view.tsx +20 -20
- package/src/serve/hub-app/next-run.ts +24 -30
- package/src/serve/hub-app/palette-st.ts +88 -0
- package/src/serve/hub-app/planned-st.ts +171 -0
- package/src/serve/hub-app/planned-view.tsx +109 -0
- package/src/serve/hub-app/planned.css +39 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +97 -0
- package/src/serve/hub-app/project-list.tsx +167 -0
- package/src/serve/hub-app/projects-svc.ts +139 -17
- package/src/serve/hub-app/prose-hits-st.ts +3 -3
- package/src/serve/hub-app/prose-hits-view.tsx +6 -6
- package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/screen-cache.ts +74 -0
- package/src/serve/hub-app/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +10 -10
- package/src/serve/hub-app/timeline-chart.tsx +24 -12
- package/src/serve/hub-app/timeline-svc.ts +5 -4
- package/src/serve/hub-app/timeline-view.tsx +13 -13
- package/src/serve/hub-app/tree-row.tsx +2 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-app/wikis-view.tsx +10 -6
- package/src/serve/hub-runtime.ts +542 -0
- package/src/serve/project-gutter-route.ts +21 -0
- package/src/serve/project-gutter.ts +3 -2
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +142 -44
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +114 -0
- package/src/shipkit.ts +2 -1
- package/src/suite/kept-timings.ts +1 -1
- package/src/wiki/graph-positions.ts +1 -1
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// the shipkit window on macOS: its own app, so the dock and cmd-tab show the
|
|
2
|
+
// house icon, never a second browser. One WKWebView per window on the home's
|
|
3
|
+
// page; every `open -a <app> <url>` opens one more window, cmd-n too. A link
|
|
4
|
+
// to another site opens in the default browser. Built once by `shipkit gui`
|
|
5
|
+
// (home/gui-app.ts) with swiftc, no Xcode project.
|
|
6
|
+
import AppKit
|
|
7
|
+
import WebKit
|
|
8
|
+
|
|
9
|
+
let homeFallback = "http://127.0.0.1:4799/"
|
|
10
|
+
|
|
11
|
+
final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
|
|
12
|
+
var windows: [NSWindow] = []
|
|
13
|
+
var opened = false
|
|
14
|
+
|
|
15
|
+
func applicationDidFinishLaunching(_ note: Notification) {
|
|
16
|
+
buildMenu()
|
|
17
|
+
// an open event may deliver the url right after launch: wait for it
|
|
18
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
19
|
+
if !self.opened {
|
|
20
|
+
let arg = CommandLine.arguments.dropFirst().first { $0.hasPrefix("http") }
|
|
21
|
+
self.open(URL(string: arg ?? homeFallback)!)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func application(_ app: NSApplication, open urls: [URL]) {
|
|
27
|
+
for u in urls { open(u) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func applicationShouldTerminateAfterLastWindowClosed(_ app: NSApplication) -> Bool { true }
|
|
31
|
+
|
|
32
|
+
func applicationShouldHandleReopen(_ app: NSApplication, hasVisibleWindows: Bool) -> Bool {
|
|
33
|
+
if !hasVisibleWindows { open(URL(string: homeFallback)!) }
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@objc func newWindow(_ sender: Any?) {
|
|
38
|
+
let current = (NSApp.keyWindow?.contentView as? WKWebView)?.url
|
|
39
|
+
open(current ?? URL(string: homeFallback)!)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@objc func reload(_ sender: Any?) {
|
|
43
|
+
(NSApp.keyWindow?.contentView as? WKWebView)?.reload()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// the zoom is one value for every window, kept across restarts
|
|
47
|
+
var zoom: CGFloat {
|
|
48
|
+
get { let z = UserDefaults.standard.double(forKey: "pageZoom"); return z > 0 ? CGFloat(z) : 1 }
|
|
49
|
+
set {
|
|
50
|
+
let z = min(3, max(0.3, newValue))
|
|
51
|
+
UserDefaults.standard.set(Double(z), forKey: "pageZoom")
|
|
52
|
+
for w in windows { (w.contentView as? WKWebView)?.pageZoom = z }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@objc func zoomIn(_ sender: Any?) { zoom = zoom * 1.1 }
|
|
57
|
+
@objc func zoomOut(_ sender: Any?) { zoom = zoom / 1.1 }
|
|
58
|
+
@objc func zoomReset(_ sender: Any?) { zoom = 1 }
|
|
59
|
+
|
|
60
|
+
func open(_ url: URL) {
|
|
61
|
+
opened = true
|
|
62
|
+
let config = WKWebViewConfiguration()
|
|
63
|
+
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
|
64
|
+
let web = WKWebView(frame: .zero, configuration: config)
|
|
65
|
+
web.navigationDelegate = self
|
|
66
|
+
web.uiDelegate = self
|
|
67
|
+
web.allowsBackForwardNavigationGestures = true
|
|
68
|
+
web.pageZoom = zoom
|
|
69
|
+
let frame = NSRect(x: 0, y: 0, width: 1400, height: 900)
|
|
70
|
+
let win = NSWindow(contentRect: frame, styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
|
|
71
|
+
win.contentView = web
|
|
72
|
+
win.title = "shipkit"
|
|
73
|
+
win.delegate = self
|
|
74
|
+
win.isReleasedWhenClosed = false
|
|
75
|
+
win.setFrameAutosaveName("shipkit-window")
|
|
76
|
+
if let last = windows.last { win.setFrameTopLeftPoint(NSPoint(x: last.frame.minX + 28, y: last.frame.maxY - 28)) } else { win.center() }
|
|
77
|
+
windows.append(win)
|
|
78
|
+
web.load(URLRequest(url: url))
|
|
79
|
+
win.makeKeyAndOrderFront(nil)
|
|
80
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func windowWillClose(_ note: Notification) {
|
|
84
|
+
windows.removeAll { $0 === note.object as? NSWindow }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// the page title names the window, so cmd-tab and the window menu tell repos apart
|
|
88
|
+
func webView(_ web: WKWebView, didFinish nav: WKNavigation!) {
|
|
89
|
+
web.window?.title = (web.title?.isEmpty == false) ? web.title! : "shipkit"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// same host: stay in the window; anything else: the default browser
|
|
93
|
+
func webView(_ web: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
94
|
+
guard let url = action.request.url, action.navigationType == .linkActivated else { return decisionHandler(.allow) }
|
|
95
|
+
let local = url.host == "127.0.0.1" || url.host == "localhost"
|
|
96
|
+
if local { return decisionHandler(.allow) }
|
|
97
|
+
NSWorkspace.shared.open(url)
|
|
98
|
+
decisionHandler(.cancel)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// window.open and target=_blank: one more shipkit window for a local page
|
|
102
|
+
func webView(_ web: WKWebView, createWebViewWith config: WKWebViewConfiguration, for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
|
103
|
+
if let url = action.request.url {
|
|
104
|
+
if url.host == "127.0.0.1" || url.host == "localhost" { open(url) } else { NSWorkspace.shared.open(url) }
|
|
105
|
+
}
|
|
106
|
+
return nil
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func buildMenu() {
|
|
110
|
+
let main = NSMenu()
|
|
111
|
+
let appItem = NSMenuItem()
|
|
112
|
+
main.addItem(appItem)
|
|
113
|
+
let appMenu = NSMenu()
|
|
114
|
+
appMenu.addItem(withTitle: "Hide shipkit", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
|
|
115
|
+
appMenu.addItem(withTitle: "Quit shipkit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
|
116
|
+
appItem.submenu = appMenu
|
|
117
|
+
let fileItem = NSMenuItem()
|
|
118
|
+
main.addItem(fileItem)
|
|
119
|
+
let file = NSMenu(title: "File")
|
|
120
|
+
file.addItem(withTitle: "New Window", action: #selector(newWindow(_:)), keyEquivalent: "n")
|
|
121
|
+
file.addItem(withTitle: "Reload", action: #selector(reload(_:)), keyEquivalent: "r")
|
|
122
|
+
file.addItem(withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
|
|
123
|
+
fileItem.submenu = file
|
|
124
|
+
let editItem = NSMenuItem()
|
|
125
|
+
main.addItem(editItem)
|
|
126
|
+
let edit = NSMenu(title: "Edit")
|
|
127
|
+
edit.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
|
|
128
|
+
edit.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
|
|
129
|
+
edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
|
|
130
|
+
edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
|
131
|
+
edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
|
|
132
|
+
edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
|
133
|
+
editItem.submenu = edit
|
|
134
|
+
let viewItem = NSMenuItem()
|
|
135
|
+
main.addItem(viewItem)
|
|
136
|
+
let view = NSMenu(title: "View")
|
|
137
|
+
view.addItem(withTitle: "Actual Size", action: #selector(zoomReset(_:)), keyEquivalent: "0")
|
|
138
|
+
view.addItem(withTitle: "Zoom In", action: #selector(zoomIn(_:)), keyEquivalent: "+")
|
|
139
|
+
// cmd-= zooms in too: the + sits behind shift on most layouts
|
|
140
|
+
let alsoIn = view.addItem(withTitle: "Zoom In", action: #selector(zoomIn(_:)), keyEquivalent: "=")
|
|
141
|
+
alsoIn.isAlternate = true
|
|
142
|
+
alsoIn.keyEquivalentModifierMask = [.command]
|
|
143
|
+
view.addItem(withTitle: "Zoom Out", action: #selector(zoomOut(_:)), keyEquivalent: "-")
|
|
144
|
+
viewItem.submenu = view
|
|
145
|
+
let winItem = NSMenuItem()
|
|
146
|
+
main.addItem(winItem)
|
|
147
|
+
let win = NSMenu(title: "Window")
|
|
148
|
+
win.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
|
|
149
|
+
winItem.submenu = win
|
|
150
|
+
NSApp.windowsMenu = win
|
|
151
|
+
NSApp.mainMenu = main
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let app = NSApplication.shared
|
|
156
|
+
let delegate = Delegate()
|
|
157
|
+
app.delegate = delegate
|
|
158
|
+
app.setActivationPolicy(.regular)
|
|
159
|
+
app.run()
|
|
@@ -0,0 +1,99 @@
|
|
|
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.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
7
|
+
import { homedir } from 'os'
|
|
8
|
+
import { dirname, join } from 'path'
|
|
9
|
+
import { fileURLToPath } from 'url'
|
|
10
|
+
|
|
11
|
+
const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.url))
|
|
12
|
+
|
|
13
|
+
export const BUNDLE_ID = 'dev.shipkit.gui'
|
|
14
|
+
export const guiAppPath = (): string => process.env.SHIPKIT_GUI_APP ?? join(homedir(), 'Applications', 'Shipkit.app')
|
|
15
|
+
|
|
16
|
+
/** the icon the app wears: the house repo's own mark, largest first. Only a
|
|
17
|
+
* png can become an icns */
|
|
18
|
+
export const APP_ICON_FILES = ['.shipkit/identity/icon.png', 'public/logo.png', 'public/favicon.png']
|
|
19
|
+
|
|
20
|
+
export function appIconOf(root: string): string | null {
|
|
21
|
+
for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function infoPlist(o: { version: string }): string {
|
|
26
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
|
+
<plist version="1.0">
|
|
29
|
+
<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>
|
|
33
|
+
\t<key>CFBundleExecutable</key><string>shipkit-gui</string>
|
|
34
|
+
\t<key>CFBundleIconFile</key><string>AppIcon</string>
|
|
35
|
+
\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>
|
|
39
|
+
\t<key>NSHighResolutionCapable</key><true/>
|
|
40
|
+
\t<key>NSAppTransportSecurity</key><dict><key>NSAllowsLocalNetworking</key><true/></dict>
|
|
41
|
+
</dict>
|
|
42
|
+
</plist>
|
|
43
|
+
`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Run = (cmd: string[]) => { code: number; err: string }
|
|
47
|
+
const runSync: Run = (cmd) => {
|
|
48
|
+
const p = Bun.spawnSync(cmd, { stdout: 'ignore', stderr: 'pipe' })
|
|
49
|
+
return { code: p.exitCode ?? 1, err: p.stderr.toString().trim() }
|
|
50
|
+
}
|
|
51
|
+
|
|
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)
|
|
55
|
+
if (icon != null) h.update(readFileSync(icon))
|
|
56
|
+
return h.digest('hex').slice(0, 16)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** the app, built or rebuilt when stale. Throws with the step that failed.
|
|
60
|
+
* `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()
|
|
63
|
+
const run = o.run ?? runSync
|
|
64
|
+
const icon = appIconOf(o.root)
|
|
65
|
+
const stamp = stampOf(readFileSync(SOURCE, 'utf8'), icon)
|
|
66
|
+
const stampFile = join(app, 'Contents', 'Resources', 'stamp')
|
|
67
|
+
if (existsSync(stampFile) && readFileSync(stampFile, 'utf8') === stamp) return { app, rebuilt: false }
|
|
68
|
+
const tmp = `${app}.building-${process.pid}`
|
|
69
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
70
|
+
const res = join(tmp, 'Contents', 'Resources')
|
|
71
|
+
mkdirSync(join(tmp, 'Contents', 'MacOS'), { recursive: true })
|
|
72
|
+
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)}`) }
|
|
76
|
+
if (icon != null) {
|
|
77
|
+
const set = join(res, 'AppIcon.iconset')
|
|
78
|
+
mkdirSync(set, { recursive: true })
|
|
79
|
+
for (const n of [16, 32, 128, 256, 512]) {
|
|
80
|
+
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)}`) }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const r = run(['iconutil', '-c', 'icns', set, '-o', join(res, 'AppIcon.icns')])
|
|
86
|
+
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)}`) }
|
|
88
|
+
}
|
|
89
|
+
writeFileSync(join(res, 'stamp'), stamp)
|
|
90
|
+
mkdirSync(dirname(app), { recursive: true })
|
|
91
|
+
rmSync(app, { recursive: true, force: true })
|
|
92
|
+
run(['mv', tmp, app])
|
|
93
|
+
// launch services caches icons by bundle: tell it this one changed
|
|
94
|
+
run(['/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister', '-f', app])
|
|
95
|
+
return { app, rebuilt: true }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** true when this machine can build the app */
|
|
99
|
+
export const canBuildGui = (platform = process.platform): boolean => platform === 'darwin' && Bun.which('swiftc') != null && Bun.which('iconutil') != null
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// a repo's own install of this lib (node_modules/.bin/shipkit, the .shipkit
|
|
2
|
+
// hooks) runs its cli.ts directly, past the machine launcher. On a machine
|
|
3
|
+
// with a house that copy hands the command to the house source, the one
|
|
4
|
+
// `~/.local/bin/shipkit` runs, so every repo runs one version.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: only an INSTALLED copy hands off.
|
|
7
|
+
// A house checkout (a worktree on a branch) runs its own source, since an
|
|
8
|
+
// agent there verifies that branch, never the main checkout.
|
|
9
|
+
import { existsSync, realpathSync } from 'fs'
|
|
10
|
+
import { fileURLToPath } from 'url'
|
|
11
|
+
import { houseCli, houseRootOf } from './home-start.ts'
|
|
12
|
+
|
|
13
|
+
type Env = Record<string, string | undefined>
|
|
14
|
+
const set = (v: string | undefined): boolean => v != null && v !== ''
|
|
15
|
+
|
|
16
|
+
/** set on the process a handoff starts, so it never hands off again */
|
|
17
|
+
export const HANDED_OFF_ENV = 'SHIPKIT_HANDED_OFF'
|
|
18
|
+
|
|
19
|
+
const real = (p: string): string => {
|
|
20
|
+
try {
|
|
21
|
+
return realpathSync(p)
|
|
22
|
+
} catch {
|
|
23
|
+
return p
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** the house cli this command should run instead, or null to run here */
|
|
28
|
+
export function handoffTarget(o: { own: string; env: Env; house: () => string | null; cliOf: (root: string) => string; exists?: (p: string) => boolean }): string | null {
|
|
29
|
+
if (!o.own.split(/[\\/]/).includes('node_modules')) return null
|
|
30
|
+
if (set(o.env.CI) || set(o.env.SHIPKIT_NO_HOME) || set(o.env[HANDED_OFF_ENV])) return null
|
|
31
|
+
const root = o.house()
|
|
32
|
+
if (root == null) return null
|
|
33
|
+
const target = o.cliOf(root)
|
|
34
|
+
if (!(o.exists ?? existsSync)(target)) return null
|
|
35
|
+
return real(target) === real(o.own) ? null : target
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const OWN_CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
|
|
39
|
+
|
|
40
|
+
/** the cli's very first step. Returns when this copy runs the command; else
|
|
41
|
+
* the process becomes the house cli (same pid, same argv) and never returns */
|
|
42
|
+
export function handoff(argv: string[], env: Env = process.env, cwd = process.cwd()): void {
|
|
43
|
+
const target = handoffTarget({ own: OWN_CLI, env, house: () => houseRootOf(cwd), cliOf: (root) => houseCli(root, '') })
|
|
44
|
+
if (target == null) return
|
|
45
|
+
const clean = Object.fromEntries(Object.entries({ ...env, [HANDED_OFF_ENV]: '1' }).filter((e): e is [string, string] => e[1] != null))
|
|
46
|
+
const args = [process.execPath, target, ...argv]
|
|
47
|
+
const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
|
|
48
|
+
if (typeof execve === 'function') execve.call(process, process.execPath, args, clean)
|
|
49
|
+
const child = Bun.spawnSync(args, { cwd, stdout: 'inherit', stderr: 'inherit', stdin: 'inherit', env: clean })
|
|
50
|
+
process.exit(child.exitCode ?? 1)
|
|
51
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// the LaunchAgent that keeps pm alive: started at login, restarted when it
|
|
2
|
+
// dies. macOS only; launchd is the one supervisor of the one process that must
|
|
3
|
+
// always run, and pm supervises everything else (the daemon, the web, the
|
|
4
|
+
// machine resources).
|
|
5
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
|
6
|
+
import { homedir } from 'os'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { homeDir, pmLog } from './home-paths.ts'
|
|
9
|
+
|
|
10
|
+
export const LABEL = 'com.shipkit.pm'
|
|
11
|
+
/** the agent that ran `shipkit home` before pm: an install replaces it */
|
|
12
|
+
export const LEGACY_LABEL = 'com.shipkit.home'
|
|
13
|
+
/** where launchd reads user agents. SHIPKIT_LAUNCH_AGENTS_DIR moves it, so a
|
|
14
|
+
* live smoke installs into a scratch dir and never the real one */
|
|
15
|
+
export const agentsDir = (home = homedir()): string => process.env.SHIPKIT_LAUNCH_AGENTS_DIR ?? join(home, 'Library', 'LaunchAgents')
|
|
16
|
+
export const plistPath = (home = homedir(), label = LABEL): string => join(agentsDir(home), `${label}.plist`)
|
|
17
|
+
|
|
18
|
+
const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
19
|
+
|
|
20
|
+
/** the agent: `bun <cli> pm` from the house repo, which replaces its image
|
|
21
|
+
* with run-pm.ts (execPm). No bun watch: through cli.ts it would follow
|
|
22
|
+
* every command, so any edit would restart pm. pm,
|
|
23
|
+
* the daemon and the web each watch their own code and restart in place on
|
|
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 */
|
|
26
|
+
export function renderPlist(o: { bun: string; cli: string; root: string; path: string; log: string }): string {
|
|
27
|
+
const args = [o.bun, o.cli, 'pm'].map((a) => `\t\t<string>${esc(a)}</string>`).join('\n')
|
|
28
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
29
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
30
|
+
<plist version="1.0">
|
|
31
|
+
<dict>
|
|
32
|
+
\t<key>Label</key>
|
|
33
|
+
\t<string>${LABEL}</string>
|
|
34
|
+
\t<key>ProgramArguments</key>
|
|
35
|
+
\t<array>
|
|
36
|
+
${args}
|
|
37
|
+
\t</array>
|
|
38
|
+
\t<key>WorkingDirectory</key>
|
|
39
|
+
\t<string>${esc(o.root)}</string>
|
|
40
|
+
\t<key>EnvironmentVariables</key>
|
|
41
|
+
\t<dict>
|
|
42
|
+
\t\t<key>PATH</key>
|
|
43
|
+
\t\t<string>${esc(o.path)}</string>
|
|
44
|
+
\t</dict>
|
|
45
|
+
\t<key>RunAtLoad</key>
|
|
46
|
+
\t<true/>
|
|
47
|
+
\t<key>KeepAlive</key>
|
|
48
|
+
\t<true/>
|
|
49
|
+
\t<key>StandardOutPath</key>
|
|
50
|
+
\t<string>${esc(o.log)}</string>
|
|
51
|
+
\t<key>StandardErrorPath</key>
|
|
52
|
+
\t<string>${esc(o.log)}</string>
|
|
53
|
+
</dict>
|
|
54
|
+
</plist>
|
|
55
|
+
`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Launchctl = (args: string[]) => Promise<{ code: number; out: string }>
|
|
59
|
+
|
|
60
|
+
const launchctl: Launchctl = async (args) => {
|
|
61
|
+
const p = Bun.spawn(['launchctl', ...args], { stdout: 'pipe', stderr: 'pipe' })
|
|
62
|
+
const out = `${await new Response(p.stdout).text()}${await new Response(p.stderr).text()}`.trim()
|
|
63
|
+
return { code: await p.exited, out }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const domain = (): string => `gui/${process.getuid?.() ?? 501}`
|
|
67
|
+
|
|
68
|
+
export async function installHome(o: { root: string; cli: string; print: (l: string) => void; run?: Launchctl }): Promise<number> {
|
|
69
|
+
if (process.platform !== 'darwin') {
|
|
70
|
+
o.print(`FAIL: the pm LaunchAgent is macOS only. Run \`shipkit pm\` under your own supervisor on ${process.platform}`)
|
|
71
|
+
return 1
|
|
72
|
+
}
|
|
73
|
+
const run = o.run ?? launchctl
|
|
74
|
+
const bun = Bun.which('bun') ?? process.execPath
|
|
75
|
+
const file = plistPath()
|
|
76
|
+
mkdirSync(agentsDir(), { recursive: true })
|
|
77
|
+
mkdirSync(homeDir(), { recursive: true })
|
|
78
|
+
const log = pmLog()
|
|
79
|
+
writeFileSync(file, renderPlist({ bun, cli: o.cli, root: o.root, path: process.env.PATH ?? '/usr/bin:/bin', log }))
|
|
80
|
+
// a loaded copy holds the old arguments: out first, then in
|
|
81
|
+
await run(['bootout', `${domain()}/${LABEL}`])
|
|
82
|
+
await run(['bootout', `${domain()}/${LEGACY_LABEL}`])
|
|
83
|
+
rmSync(plistPath(homedir(), LEGACY_LABEL), { force: true })
|
|
84
|
+
const r = await run(['bootstrap', domain(), file])
|
|
85
|
+
if (r.code !== 0) {
|
|
86
|
+
o.print(`FAIL: launchctl bootstrap refused ${file} (exit ${r.code}): ${r.out}`)
|
|
87
|
+
return 1
|
|
88
|
+
}
|
|
89
|
+
o.print(`ok: pm runs now and at every login, from ${o.root}, and runs the daemon and the web`)
|
|
90
|
+
o.print(` agent: ${file}`)
|
|
91
|
+
o.print(` log: ${log}`)
|
|
92
|
+
return 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function uninstallHome(o: { print: (l: string) => void; run?: Launchctl }): Promise<number> {
|
|
96
|
+
const run = o.run ?? launchctl
|
|
97
|
+
const file = plistPath()
|
|
98
|
+
const r = await run(['bootout', `${domain()}/${LABEL}`])
|
|
99
|
+
await run(['bootout', `${domain()}/${LEGACY_LABEL}`])
|
|
100
|
+
rmSync(plistPath(homedir(), LEGACY_LABEL), { force: true })
|
|
101
|
+
if (existsSync(file)) rmSync(file)
|
|
102
|
+
o.print(r.code === 0 ? `ok: pm, the daemon and the web are stopped and will not start at login. The resources and repo processes keep running` : `ok: no pm agent was loaded; ${file} is gone`)
|
|
103
|
+
return 0
|
|
104
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// where the home keeps its machine-local files, and the one port it serves on.
|
|
2
|
+
// Nothing here lives in a repo: the home is per machine, and a repo that is
|
|
3
|
+
// cloned elsewhere carries none of it.
|
|
4
|
+
import { homedir } from 'os'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
|
|
7
|
+
/** just below the hashed hub range (4800-4979), so no repo hub can hash onto it */
|
|
8
|
+
export const DEFAULT_HOME_PORT = 4799
|
|
9
|
+
|
|
10
|
+
export const homePort = (): number => {
|
|
11
|
+
const env = Number(process.env.SHIPKIT_HOME_PORT)
|
|
12
|
+
return Number.isInteger(env) && env > 0 ? env : DEFAULT_HOME_PORT
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** the daemon's internal port: every repo runtime and every api answer
|
|
16
|
+
* here, and the web on the home port is the one door a browser uses */
|
|
17
|
+
export const DEFAULT_DAEMON_PORT = 4798
|
|
18
|
+
|
|
19
|
+
export const daemonPort = (): number => {
|
|
20
|
+
const env = Number(process.env.SHIPKIT_DAEMON_PORT)
|
|
21
|
+
return Number.isInteger(env) && env > 0 ? env : DEFAULT_DAEMON_PORT
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** pm's port: its api (children, resources), which the web proxies at /api/pm/* */
|
|
25
|
+
export const DEFAULT_PM_PORT = 4797
|
|
26
|
+
|
|
27
|
+
export const pmPort = (): number => {
|
|
28
|
+
const env = Number(process.env.SHIPKIT_PM_PORT)
|
|
29
|
+
return Number.isInteger(env) && env > 0 ? env : DEFAULT_PM_PORT
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const pmUrl = (port = pmPort()): string => `http://127.0.0.1:${port}`
|
|
33
|
+
|
|
34
|
+
/** true when pm answers on this port */
|
|
35
|
+
export async function pmUp(port = pmPort(), timeoutMs = 400): Promise<boolean> {
|
|
36
|
+
try {
|
|
37
|
+
const r = await fetch(`${pmUrl(port)}/api/pm/status.json`, { signal: AbortSignal.timeout(timeoutMs) })
|
|
38
|
+
return ((await r.json()) as { pm?: string }).pm === 'shipkit-pm'
|
|
39
|
+
} catch {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** beside the rail order file (project-gutter-route.ts ORDER_FILE) */
|
|
45
|
+
export const homeDir = (): string => process.env.SHIPKIT_HOME_DIR ?? join(homedir(), '.config', 'shipkit', 'home')
|
|
46
|
+
|
|
47
|
+
/** set in the home process, so every process it starts (a check's child, a
|
|
48
|
+
* repo's declared process) runs its gate and its hub in process, never back
|
|
49
|
+
* through the home (run-serve.ts serveMode, via-hub.ts runsHere) */
|
|
50
|
+
export const WORKER_ENV = 'SHIPKIT_HOME_WORKER'
|
|
51
|
+
|
|
52
|
+
/** one repo's hub lines as the home prints them: its banner and its problem
|
|
53
|
+
* frames, what `shipkit serve` streams to VS Code (attach.ts) */
|
|
54
|
+
export const hubLog = (dir: string, repo: string): string => join(dir, 'hubs', `${repo}.log`)
|
|
55
|
+
/** pm's own output, where launchd writes it */
|
|
56
|
+
export const pmLog = (dir = homeDir()): string => join(dir, 'pm.log')
|
|
57
|
+
/** pm's children: the daemon and the web, each with its log and the key of
|
|
58
|
+
* the pid record the next pm adopts (supervise.ts pidKey) */
|
|
59
|
+
export const childLog = (dir: string, role: string): string => join(dir, `${role}.log`)
|
|
60
|
+
export const childPid = (dir: string, role: string): string => join(dir, `${role}.pid`)
|
|
61
|
+
export const resourceLog = (dir: string, id: string): string => join(dir, 'resources', `${id}.log`)
|
|
62
|
+
export const resourcePid = (dir: string, id: string): string => join(dir, 'resources', `${id}.pid`)
|
|
63
|
+
export const stateFile = (dir: string): string => join(dir, 'state.json')
|
|
64
|
+
|
|
65
|
+
/** the url prefix of a repo's hub on the home */
|
|
66
|
+
export const hubBase = (name: string): string => `/r/${encodeURIComponent(name)}`
|
|
67
|
+
|
|
68
|
+
/** the home page's url as a local client reaches it (the web) */
|
|
69
|
+
export const homeUrl = (port = homePort()): string => `http://127.0.0.1:${port}`
|
|
70
|
+
|
|
71
|
+
/** the daemon's url: a terminal client talks to it directly, so a gate
|
|
72
|
+
* never depends on the web being up */
|
|
73
|
+
export const daemonUrl = (port = daemonPort()): string => `http://127.0.0.1:${port}`
|
|
74
|
+
|
|
75
|
+
/** true when the daemon answers on this machine */
|
|
76
|
+
export const daemonUp = (timeoutMs = 400): Promise<boolean> => homeUp(daemonPort(), timeoutMs)
|
|
77
|
+
|
|
78
|
+
/** true when a shipkit home answers on this port: the daemon itself, or the
|
|
79
|
+
* web, which asks the daemon */
|
|
80
|
+
export async function homeUp(port = homePort(), timeoutMs = 400): Promise<boolean> {
|
|
81
|
+
try {
|
|
82
|
+
const r = await fetch(`${homeUrl(port)}/hub.json`, { signal: AbortSignal.timeout(timeoutMs) })
|
|
83
|
+
const j = (await r.json()) as { hub?: string }
|
|
84
|
+
return j.hub === 'shipkit-home'
|
|
85
|
+
} catch {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
}
|