rvlib-shipkit 1.1.1 → 1.3.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 (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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
+ }