aidevops 3.21.12 → 3.22.1

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 (34) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/aidevops.sh +3 -2
  4. package/bun.lock +573 -0
  5. package/package.json +12 -1
  6. package/packages/gui-api/package.json +9 -0
  7. package/packages/gui-api/src/app.ts +49 -0
  8. package/packages/gui-api/src/file-adapter.ts +207 -0
  9. package/packages/gui-api/src/server.ts +12 -0
  10. package/packages/gui-api/src/status-adapter-utils.ts +108 -0
  11. package/packages/gui-api/src/status-adapter.ts +484 -0
  12. package/packages/gui-api/src/status-local-repos.ts +239 -0
  13. package/packages/gui-desktop/package.json +6 -0
  14. package/packages/gui-desktop/scripts/install-macos-app.sh +1039 -0
  15. package/packages/gui-shared/package.json +9 -0
  16. package/packages/gui-shared/src/contracts.ts +341 -0
  17. package/packages/gui-shared/src/fixtures.ts +250 -0
  18. package/packages/gui-shared/src/index.ts +2 -0
  19. package/packages/gui-web/index.html +24 -0
  20. package/packages/gui-web/package.json +25 -0
  21. package/packages/gui-web/src/App.tsx +269 -0
  22. package/packages/gui-web/src/AppNavigation.tsx +399 -0
  23. package/packages/gui-web/src/AppWorkspace.tsx +114 -0
  24. package/packages/gui-web/src/FileExplorerSurface.tsx +153 -0
  25. package/packages/gui-web/src/InventorySurfaces.tsx +117 -0
  26. package/packages/gui-web/src/PathActions.tsx +17 -0
  27. package/packages/gui-web/src/StatusSurfaces.tsx +396 -0
  28. package/packages/gui-web/src/app-model.ts +546 -0
  29. package/packages/gui-web/src/dashboard.ts +79 -0
  30. package/packages/gui-web/src/main.tsx +24 -0
  31. package/packages/gui-web/src/status-client.ts +93 -0
  32. package/packages/gui-web/src/styles.css +1540 -0
  33. package/packages/gui-web/vite.config.ts +14 -0
  34. package/setup.sh +78 -3
@@ -0,0 +1,1039 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-License-Identifier: MIT
3
+ # SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
4
+
5
+ set -euo pipefail
6
+
7
+ APP_NAME="aidevops.app"
8
+ DEFAULT_APP_DIR="/Applications"
9
+
10
+ usage() {
11
+ printf 'Usage: %s [--check] [--app-dir DIR]\n' "$0"
12
+ return 0
13
+ }
14
+
15
+ repo_root() {
16
+ git rev-parse --show-toplevel
17
+ return 0
18
+ }
19
+
20
+ validate_environment() {
21
+ local root="$1"
22
+
23
+ if [[ "$(uname -s)" != "Darwin" ]]; then
24
+ printf 'gui desktop install currently supports macOS only\n' >&2
25
+ return 1
26
+ fi
27
+ if [[ ! -f "${root}/package.json" ]]; then
28
+ printf 'repo root missing package.json: %s\n' "$root" >&2
29
+ return 1
30
+ fi
31
+ if [[ ! -f "${root}/VERSION" ]]; then
32
+ printf 'repo root missing VERSION: %s\n' "$root" >&2
33
+ return 1
34
+ fi
35
+ if ! command -v bun >/dev/null 2>&1; then
36
+ printf 'bun is required to launch the current GUI scaffold\n' >&2
37
+ return 1
38
+ fi
39
+ if ! command -v sips >/dev/null 2>&1 || ! command -v iconutil >/dev/null 2>&1; then
40
+ printf 'macOS sips and iconutil are required to build the app icon\n' >&2
41
+ return 1
42
+ fi
43
+ if ! command -v swiftc >/dev/null 2>&1; then
44
+ printf 'macOS swiftc is required to build the native WebKit app wrapper\n' >&2
45
+ return 1
46
+ fi
47
+
48
+ return 0
49
+ }
50
+
51
+ gui_dependencies_present() {
52
+ local root="$1"
53
+
54
+ [[ -f "${root}/node_modules/vite/bin/vite.js" && -d "${root}/node_modules/react" && -d "${root}/node_modules/react-icons" ]]
55
+ return $?
56
+ }
57
+
58
+ ensure_gui_dependencies() {
59
+ local root="$1"
60
+
61
+ if gui_dependencies_present "$root"; then
62
+ return 0
63
+ fi
64
+
65
+ if [[ ! -f "${root}/bun.lock" ]]; then
66
+ printf 'GUI dependencies are missing and bun.lock was not found in %s\n' "$root" >&2
67
+ return 1
68
+ fi
69
+
70
+ printf 'Installing GUI dependencies with bun install --frozen-lockfile...\n'
71
+ if ! (cd "$root" && bun install --frozen-lockfile); then
72
+ printf 'GUI dependency installation failed in %s\n' "$root" >&2
73
+ return 1
74
+ fi
75
+
76
+ if ! gui_dependencies_present "$root"; then
77
+ printf 'GUI dependencies are still missing after bun install in %s\n' "$root" >&2
78
+ return 1
79
+ fi
80
+
81
+ return 0
82
+ }
83
+
84
+ app_version() {
85
+ local root="$1"
86
+
87
+ tr -d '\n' < "${root}/VERSION"
88
+ return 0
89
+ }
90
+
91
+ write_icon_assets() {
92
+ local resources_dir="$1"
93
+ local svg_path="${resources_dir}/aidevops.svg"
94
+ local png_path="${resources_dir}/aidevops-source.png"
95
+ local iconset_dir="${resources_dir}/aidevops.iconset"
96
+
97
+ cat > "$svg_path" <<'SVG'
98
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
99
+ <rect width="512" height="512" rx="64" fill="#0a0a0a"/>
100
+ <g transform="translate(36 36) scale(0.86)">
101
+ <path fill="#B2E969" d="M73.4 150.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L210.7 288 73.4 150.6zM240 400h192c17.7 0 32 14.3 32 32s-14.3 32-32 32H240c-17.7 0-32-14.3-32-32s14.3-32 32-32z"/>
102
+ </g>
103
+ </svg>
104
+ SVG
105
+
106
+ rm -rf "$iconset_dir"
107
+ mkdir -p "$iconset_dir"
108
+ sips -s format png "$svg_path" --out "$png_path" >/dev/null
109
+ sips -z 16 16 "$png_path" --out "${iconset_dir}/icon_16x16.png" >/dev/null
110
+ sips -z 32 32 "$png_path" --out "${iconset_dir}/icon_16x16@2x.png" >/dev/null
111
+ sips -z 32 32 "$png_path" --out "${iconset_dir}/icon_32x32.png" >/dev/null
112
+ sips -z 64 64 "$png_path" --out "${iconset_dir}/icon_32x32@2x.png" >/dev/null
113
+ sips -z 128 128 "$png_path" --out "${iconset_dir}/icon_128x128.png" >/dev/null
114
+ sips -z 256 256 "$png_path" --out "${iconset_dir}/icon_128x128@2x.png" >/dev/null
115
+ sips -z 256 256 "$png_path" --out "${iconset_dir}/icon_256x256.png" >/dev/null
116
+ sips -z 512 512 "$png_path" --out "${iconset_dir}/icon_256x256@2x.png" >/dev/null
117
+ sips -z 512 512 "$png_path" --out "${iconset_dir}/icon_512x512.png" >/dev/null
118
+ sips -z 1024 1024 "$png_path" --out "${iconset_dir}/icon_512x512@2x.png" >/dev/null
119
+ iconutil -c icns "$iconset_dir" -o "${resources_dir}/aidevops.icns"
120
+ rm -rf "$iconset_dir"
121
+ rm -f "$png_path"
122
+ return 0
123
+ }
124
+
125
+ write_service_helper() {
126
+ local root="$1"
127
+ local resources_dir="$2"
128
+ local helper_path="${resources_dir}/aidevops-gui-services.sh"
129
+
130
+ cat > "$helper_path" <<LAUNCHER
131
+ #!/usr/bin/env bash
132
+ set -euo pipefail
133
+
134
+ REPO_ROOT="${root}"
135
+ API_PORT="\${AIDEVOPS_GUI_API_PORT:-8787}"
136
+ WEB_PORT="\${AIDEVOPS_GUI_WEB_PORT:-5173}"
137
+ LOG_DIR="\${HOME}/Library/Logs/aidevops-gui"
138
+ LAUNCHER_LOG="\${LOG_DIR}/launcher.log"
139
+ BUN_BIN=""
140
+ MODE="\${1:-start}"
141
+ PATH="\${HOME}/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:\${PATH:-}"
142
+ export PATH
143
+ mkdir -p "\${LOG_DIR}"
144
+
145
+ cd "\${REPO_ROOT}"
146
+
147
+ notify() {
148
+ local message="\$1"
149
+ osascript -e "display notification \"\${message}\" with title \"aidevops\"" >/dev/null || true
150
+ return 0
151
+ }
152
+
153
+ url_ready() {
154
+ local url="\$1"
155
+ curl --silent --fail --max-time 2 "\${url}" >/dev/null 2>&1
156
+ return \$?
157
+ }
158
+
159
+ wait_for_url() {
160
+ local url="\$1"
161
+ local attempts="\$2"
162
+ local delay="\$3"
163
+ local attempt=1
164
+
165
+ while [[ "\${attempt}" -le "\${attempts}" ]]; do
166
+ if url_ready "\${url}"; then
167
+ return 0
168
+ fi
169
+ sleep "\${delay}"
170
+ attempt=\$((attempt + 1))
171
+ done
172
+
173
+ return 1
174
+ }
175
+
176
+ require_bun() {
177
+ local found=""
178
+
179
+ found="\$(command -v bun || true)"
180
+ if [[ -z "\${found}" ]]; then
181
+ printf 'bun is required to launch aidevops GUI; PATH=%s\n' "\${PATH}" >"\${LAUNCHER_LOG}"
182
+ notify "aidevops GUI cannot find bun. Install bun or add it to ~/.bun/bin."
183
+ return 1
184
+ fi
185
+ BUN_BIN="\${found}"
186
+ return 0
187
+ }
188
+
189
+ port_pids() {
190
+ local port="\$1"
191
+
192
+ lsof -t -nP -iTCP:"\${port}" -sTCP:LISTEN 2>/dev/null || true
193
+ return 0
194
+ }
195
+
196
+ pid_cwd() {
197
+ local pid="\$1"
198
+ local line=""
199
+
200
+ while IFS= read -r line; do
201
+ case "\${line}" in
202
+ n*)
203
+ printf '%s\n' "\${line#n}"
204
+ return 0
205
+ ;;
206
+ esac
207
+ done < <(lsof -a -p "\${pid}" -d cwd -Fn 2>/dev/null)
208
+
209
+ return 1
210
+ }
211
+
212
+ is_aidevops_checkout() {
213
+ local candidate="\$1"
214
+
215
+ [[ -f "\${candidate}/aidevops.sh" && -f "\${candidate}/packages/gui-web/vite.config.ts" ]]
216
+ return \$?
217
+ }
218
+
219
+ replace_stale_gui_on_port() {
220
+ local port="\$1"
221
+ local health_url="\$2"
222
+ local pid=""
223
+ local cwd=""
224
+
225
+ if ! url_ready "\${health_url}"; then
226
+ return 0
227
+ fi
228
+
229
+ while IFS= read -r pid; do
230
+ if [[ -z "\${pid}" ]]; then
231
+ continue
232
+ fi
233
+ cwd="\$(pid_cwd "\${pid}" || true)"
234
+ if [[ "\${cwd}" == "\${REPO_ROOT}" ]]; then
235
+ return 0
236
+ fi
237
+ done < <(port_pids "\${port}")
238
+
239
+ while IFS= read -r pid; do
240
+ if [[ -z "\${pid}" ]]; then
241
+ continue
242
+ fi
243
+ cwd="\$(pid_cwd "\${pid}" || true)"
244
+ if [[ -n "\${cwd}" ]] && is_aidevops_checkout "\${cwd}"; then
245
+ kill "\${pid}" >/dev/null 2>&1 || true
246
+ fi
247
+ done < <(port_pids "\${port}")
248
+
249
+ return 0
250
+ }
251
+
252
+ kill_gui_on_port_for_repo() {
253
+ local port="\$1"
254
+ local pid=""
255
+ local cwd=""
256
+
257
+ while IFS= read -r pid; do
258
+ if [[ -z "\${pid}" ]]; then
259
+ continue
260
+ fi
261
+ cwd="\$(pid_cwd "\${pid}" || true)"
262
+ if [[ "\${cwd}" == "\${REPO_ROOT}" ]]; then
263
+ kill "\${pid}" >/dev/null 2>&1 || true
264
+ fi
265
+ done < <(port_pids "\${port}")
266
+
267
+ return 0
268
+ }
269
+
270
+ stop_gui_services() {
271
+ kill_gui_on_port_for_repo "\${API_PORT}"
272
+ kill_gui_on_port_for_repo "\${WEB_PORT}"
273
+ return 0
274
+ }
275
+
276
+ api_contract_current() {
277
+ local payload=""
278
+
279
+ payload="\$(curl --silent --fail --max-time 2 "http://127.0.0.1:\${API_PORT}/api/status" 2>/dev/null || true)"
280
+ if [[ "\${payload}" == *'"local_repos"'* && "\${payload}" == *'"oauth_pool"'* ]]; then
281
+ return 0
282
+ fi
283
+ return 1
284
+ }
285
+
286
+ if [[ "\${MODE}" == "stop" ]]; then
287
+ stop_gui_services
288
+ exit 0
289
+ fi
290
+
291
+ if [[ -f "\${HOME}/.aidevops/agents/VERSION" && -f "\${REPO_ROOT}/VERSION" ]]; then
292
+ INSTALLED_VERSION="\$(tr -d '\n' < "\${HOME}/.aidevops/agents/VERSION")"
293
+ RUNNING_VERSION="\$(tr -d '\n' < "\${REPO_ROOT}/VERSION")"
294
+ if [[ "\${INSTALLED_VERSION}" != "\${RUNNING_VERSION}" ]]; then
295
+ notify "Restart aidevops GUI after update: installed \${INSTALLED_VERSION}, app \${RUNNING_VERSION}."
296
+ fi
297
+ fi
298
+
299
+ replace_stale_gui_on_port "\${API_PORT}" "http://127.0.0.1:\${API_PORT}/api/status"
300
+ replace_stale_gui_on_port "\${WEB_PORT}" "http://127.0.0.1:\${WEB_PORT}/"
301
+ if url_ready "http://127.0.0.1:\${API_PORT}/api/status" && ! api_contract_current; then
302
+ kill_gui_on_port_for_repo "\${API_PORT}"
303
+ kill_gui_on_port_for_repo "\${WEB_PORT}"
304
+ fi
305
+ sleep 1
306
+
307
+ if ! url_ready "http://127.0.0.1:\${API_PORT}/api/status" || ! url_ready "http://127.0.0.1:\${WEB_PORT}/"; then
308
+ require_bun
309
+ fi
310
+
311
+ if ! url_ready "http://127.0.0.1:\${API_PORT}/api/status"; then
312
+ nohup env AIDEVOPS_GUI_API_PORT="\${API_PORT}" "\${BUN_BIN}" run packages/gui-api/src/server.ts >"\${LOG_DIR}/api.log" 2>&1 &
313
+ fi
314
+ if ! url_ready "http://127.0.0.1:\${WEB_PORT}/"; then
315
+ nohup "\${BUN_BIN}" ./node_modules/vite/bin/vite.js --config packages/gui-web/vite.config.ts --host 127.0.0.1 --port "\${WEB_PORT}" >"\${LOG_DIR}/web.log" 2>&1 &
316
+ fi
317
+
318
+ if ! wait_for_url "http://127.0.0.1:\${API_PORT}/api/status" 20 1; then
319
+ printf 'aidevops GUI API did not become ready. Check %s/api.log.\n' "\${LOG_DIR}" >"\${LAUNCHER_LOG}"
320
+ notify "aidevops GUI API did not become ready. Check \${LOG_DIR}/api.log."
321
+ exit 1
322
+ fi
323
+ if ! wait_for_url "http://127.0.0.1:\${WEB_PORT}/" 30 1; then
324
+ printf 'aidevops GUI web did not become ready. Check %s/web.log.\n' "\${LOG_DIR}" >"\${LAUNCHER_LOG}"
325
+ notify "aidevops GUI web did not become ready. Check \${LOG_DIR}/web.log."
326
+ exit 1
327
+ fi
328
+
329
+ exit 0
330
+ LAUNCHER
331
+ chmod 755 "$helper_path"
332
+ return 0
333
+ }
334
+
335
+ write_webview_source() {
336
+ local resources_dir="$1"
337
+ local swift_source="${resources_dir}/aidevops-gui.swift"
338
+
339
+ cat > "$swift_source" <<'SWIFT'
340
+ import AppKit
341
+ import WebKit
342
+
343
+ final class DraggableTitlebarView: NSView {
344
+ override var mouseDownCanMoveWindow: Bool {
345
+ return true
346
+ }
347
+
348
+ override func mouseDown(with event: NSEvent) {
349
+ window?.performDrag(with: event)
350
+ }
351
+ }
352
+
353
+ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScriptMessageHandler, NSMenuItemValidation, NSWindowDelegate {
354
+ private var window: NSWindow!
355
+ private var aboutWindow: NSWindow?
356
+ private var titlebarToggleButton: NSButton?
357
+ private var webView: WKWebView!
358
+ private let apiPort = ProcessInfo.processInfo.environment["AIDEVOPS_GUI_API_PORT"] ?? "8787"
359
+ private let webPort = ProcessInfo.processInfo.environment["AIDEVOPS_GUI_WEB_PORT"] ?? "5173"
360
+ private let defaultAccentHue: CGFloat = 123
361
+ private let mainWindowFrameAutosaveName = "aidevops-main-window"
362
+ private let titlebarHeight: CGFloat = 24
363
+ private let serviceQueue = DispatchQueue(label: "sh.aidevops.gui.services")
364
+ private var servicesStopped = false
365
+
366
+ func applicationDidFinishLaunching(_ notification: Notification) {
367
+ configureMenu()
368
+ configureWindow()
369
+ loadStatusHTML(title: "Starting aidevops", detail: "Preparing the local read-only GUI services…")
370
+ startServicesAndLoadApp()
371
+ }
372
+
373
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
374
+ return false
375
+ }
376
+
377
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
378
+ saveMainWindowFrame()
379
+ stopServices()
380
+ return .terminateNow
381
+ }
382
+
383
+ func applicationWillTerminate(_ notification: Notification) {
384
+ saveMainWindowFrame()
385
+ stopServices()
386
+ }
387
+
388
+ func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
389
+ if !flag {
390
+ openDashboard(nil)
391
+ }
392
+ return true
393
+ }
394
+
395
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
396
+ guard message.name == "accentHue" else {
397
+ return
398
+ }
399
+
400
+ if let number = message.body as? NSNumber {
401
+ updateTitlebarAccent(hue: CGFloat(number.doubleValue))
402
+ return
403
+ }
404
+
405
+ if let value = message.body as? String, let hue = Double(value) {
406
+ updateTitlebarAccent(hue: CGFloat(hue))
407
+ }
408
+ }
409
+
410
+ private func configureMenu() {
411
+ let appName = "aidevops"
412
+ let mainMenu = NSMenu(title: "Main Menu")
413
+
414
+ let appMenuItem = NSMenuItem()
415
+ let appMenu = NSMenu(title: appName)
416
+ appMenu.addItem(menuItem("About \(appName)", action: #selector(showAbout(_:)), target: self))
417
+ appMenu.addItem(.separator())
418
+
419
+ let servicesItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "")
420
+ let servicesMenu = NSMenu(title: "Services")
421
+ servicesItem.submenu = servicesMenu
422
+ appMenu.addItem(servicesItem)
423
+ NSApp.servicesMenu = servicesMenu
424
+
425
+ appMenu.addItem(.separator())
426
+ appMenu.addItem(menuItem("Hide \(appName)", action: #selector(NSApplication.hide(_:)), key: "h", target: NSApp))
427
+ appMenu.addItem(menuItem("Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), key: "h", modifiers: [.command, .option], target: NSApp))
428
+ appMenu.addItem(menuItem("Show All", action: #selector(NSApplication.unhideAllApplications(_:)), target: NSApp))
429
+ appMenu.addItem(.separator())
430
+ appMenu.addItem(menuItem("Quit \(appName)", action: #selector(quitApplication(_:)), key: "q", target: self))
431
+ appMenuItem.submenu = appMenu
432
+ mainMenu.addItem(appMenuItem)
433
+
434
+ let fileMenuItem = NSMenuItem()
435
+ let fileMenu = NSMenu(title: "File")
436
+ fileMenu.addItem(menuItem("Open Dashboard", action: #selector(openDashboard(_:)), key: "n", target: self))
437
+ fileMenu.addItem(.separator())
438
+ fileMenu.addItem(menuItem("Close Window", action: #selector(NSWindow.performClose(_:)), key: "w"))
439
+ fileMenuItem.submenu = fileMenu
440
+ mainMenu.addItem(fileMenuItem)
441
+
442
+ let editMenuItem = NSMenuItem()
443
+ let editMenu = NSMenu(title: "Edit")
444
+ editMenu.addItem(menuItem("Undo", action: Selector(("undo:")), key: "z"))
445
+ editMenu.addItem(menuItem("Redo", action: Selector(("redo:")), key: "Z", modifiers: [.command, .shift]))
446
+ editMenu.addItem(.separator())
447
+ editMenu.addItem(menuItem("Cut", action: #selector(NSText.cut(_:)), key: "x"))
448
+ editMenu.addItem(menuItem("Copy", action: #selector(NSText.copy(_:)), key: "c"))
449
+ editMenu.addItem(menuItem("Paste", action: #selector(NSText.paste(_:)), key: "v"))
450
+ editMenu.addItem(menuItem("Paste and Match Style", action: #selector(NSTextView.pasteAsPlainText(_:)), key: "v", modifiers: [.command, .option, .shift]))
451
+ editMenu.addItem(menuItem("Delete", action: #selector(NSText.delete(_:))))
452
+ editMenu.addItem(.separator())
453
+ editMenu.addItem(menuItem("Select All", action: #selector(NSText.selectAll(_:)), key: "a"))
454
+ editMenuItem.submenu = editMenu
455
+ mainMenu.addItem(editMenuItem)
456
+
457
+ let viewMenuItem = NSMenuItem()
458
+ let viewMenu = NSMenu(title: "View")
459
+ viewMenu.addItem(menuItem("Reload Page", action: #selector(reloadPage(_:)), key: "r", target: self))
460
+ viewMenu.addItem(menuItem("Stop Loading", action: #selector(stopLoading(_:)), key: ".", target: self))
461
+ viewMenu.addItem(.separator())
462
+ viewMenu.addItem(menuItem("Back", action: #selector(goBack(_:)), key: "[", target: self))
463
+ viewMenu.addItem(menuItem("Forward", action: #selector(goForward(_:)), key: "]", target: self))
464
+ viewMenu.addItem(.separator())
465
+ viewMenu.addItem(menuItem("Actual Size", action: #selector(resetZoom(_:)), key: "0", target: self))
466
+ viewMenu.addItem(menuItem("Zoom In", action: #selector(zoomIn(_:)), key: "+", target: self))
467
+ viewMenu.addItem(menuItem("Zoom Out", action: #selector(zoomOut(_:)), key: "-", target: self))
468
+ viewMenu.addItem(.separator())
469
+ viewMenu.addItem(menuItem("Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), key: "f", modifiers: [.command, .control]))
470
+ viewMenuItem.submenu = viewMenu
471
+ mainMenu.addItem(viewMenuItem)
472
+
473
+ let windowMenuItem = NSMenuItem()
474
+ let windowMenu = NSMenu(title: "Window")
475
+ windowMenu.addItem(menuItem("Minimize", action: #selector(NSWindow.miniaturize(_:)), key: "m"))
476
+ windowMenu.addItem(menuItem("Zoom", action: #selector(NSWindow.performZoom(_:))))
477
+ windowMenu.addItem(menuItem("Hide Window", action: #selector(hideWindow(_:)), key: "h", modifiers: [.command, .shift], target: self))
478
+ windowMenu.addItem(.separator())
479
+ windowMenu.addItem(menuItem("Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), target: NSApp))
480
+ windowMenuItem.submenu = windowMenu
481
+ mainMenu.addItem(windowMenuItem)
482
+ NSApp.windowsMenu = windowMenu
483
+
484
+ let helpMenuItem = NSMenuItem()
485
+ let helpMenu = NSMenu(title: "Help")
486
+ helpMenu.addItem(menuItem("aidevops Help", action: #selector(showHelp(_:)), key: "?", modifiers: [.command, .shift], target: self))
487
+ helpMenuItem.submenu = helpMenu
488
+ mainMenu.addItem(helpMenuItem)
489
+ NSApp.helpMenu = helpMenu
490
+
491
+ NSApp.mainMenu = mainMenu
492
+ }
493
+
494
+ private func menuItem(_ title: String, action: Selector?, key: String = "", modifiers: NSEvent.ModifierFlags = .command, target: AnyObject? = nil) -> NSMenuItem {
495
+ let item = NSMenuItem(title: title, action: action, keyEquivalent: key)
496
+ item.keyEquivalentModifierMask = key.isEmpty ? [] : modifiers
497
+ item.target = target
498
+ return item
499
+ }
500
+
501
+ func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
502
+ switch menuItem.action {
503
+ case #selector(goBack(_:)):
504
+ return webView?.canGoBack ?? false
505
+ case #selector(goForward(_:)):
506
+ return webView?.canGoForward ?? false
507
+ case #selector(reloadPage(_:)), #selector(stopLoading(_:)), #selector(resetZoom(_:)), #selector(zoomIn(_:)), #selector(zoomOut(_:)):
508
+ return webView != nil
509
+ default:
510
+ return true
511
+ }
512
+ }
513
+
514
+ @objc private func showAbout(_ sender: Any?) {
515
+ if let existingWindow = aboutWindow {
516
+ existingWindow.makeKeyAndOrderFront(nil)
517
+ NSApp.activate(ignoringOtherApps: true)
518
+ return
519
+ }
520
+
521
+ let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
522
+ let about = NSWindow(
523
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 430),
524
+ styleMask: [.titled, .closable],
525
+ backing: .buffered,
526
+ defer: false
527
+ )
528
+ about.title = "About aidevops"
529
+ about.isReleasedWhenClosed = false
530
+
531
+ let content = NSView()
532
+ let stack = NSStackView()
533
+ stack.orientation = .vertical
534
+ stack.alignment = .leading
535
+ stack.spacing = 12
536
+ stack.edgeInsets = NSEdgeInsets(top: 28, left: 28, bottom: 28, right: 28)
537
+ stack.translatesAutoresizingMaskIntoConstraints = false
538
+
539
+ let title = aboutLabel("aidevops", font: .systemFont(ofSize: 28, weight: .bold), color: .labelColor)
540
+ let versionLabel = aboutLabel("Version \(version)", font: .systemFont(ofSize: 13, weight: .semibold), color: .secondaryLabelColor)
541
+ let detail = aboutLabel("Native macOS WebKit wrapper for the local read-only aidevops GUI. It provides AI-assisted development workflows, code quality, and deployment automation without exposing secrets or shell write routes.", font: .systemFont(ofSize: 13), color: .labelColor)
542
+ let copyright = aboutLabel("Copyright © 2025-2026 Marcus Quinn. Licensed under the MIT License.", font: .systemFont(ofSize: 12), color: .secondaryLabelColor)
543
+
544
+ let buttonRow = NSStackView()
545
+ buttonRow.orientation = .horizontal
546
+ buttonRow.spacing = 10
547
+ buttonRow.alignment = .centerY
548
+ buttonRow.addArrangedSubview(aboutButton("Website", action: #selector(openWebsite(_:))))
549
+ buttonRow.addArrangedSubview(aboutButton("GitHub Repository", action: #selector(openGitHub(_:))))
550
+
551
+ stack.addArrangedSubview(title)
552
+ stack.addArrangedSubview(versionLabel)
553
+ stack.addArrangedSubview(detail)
554
+ stack.addArrangedSubview(buttonRow)
555
+ stack.addArrangedSubview(copyright)
556
+ content.addSubview(stack)
557
+ NSLayoutConstraint.activate([
558
+ stack.leadingAnchor.constraint(equalTo: content.leadingAnchor),
559
+ stack.trailingAnchor.constraint(equalTo: content.trailingAnchor),
560
+ stack.topAnchor.constraint(equalTo: content.topAnchor),
561
+ stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor)
562
+ ])
563
+
564
+ about.contentView = content
565
+ about.center()
566
+ aboutWindow = about
567
+ about.makeKeyAndOrderFront(nil)
568
+ NSApp.activate(ignoringOtherApps: true)
569
+ }
570
+
571
+ private func aboutLabel(_ value: String, font: NSFont, color: NSColor) -> NSTextField {
572
+ let field = NSTextField(labelWithString: value)
573
+ field.font = font
574
+ field.textColor = color
575
+ field.lineBreakMode = .byWordWrapping
576
+ field.maximumNumberOfLines = 0
577
+ field.translatesAutoresizingMaskIntoConstraints = false
578
+ field.widthAnchor.constraint(lessThanOrEqualToConstant: 462).isActive = true
579
+ return field
580
+ }
581
+
582
+ private func aboutButton(_ title: String, action: Selector) -> NSButton {
583
+ let button = NSButton(title: title, target: self, action: action)
584
+ button.bezelStyle = .rounded
585
+ return button
586
+ }
587
+
588
+ @objc private func openWebsite(_ sender: Any?) {
589
+ openExternalURL("https://aidevops.sh")
590
+ }
591
+
592
+ @objc private func openGitHub(_ sender: Any?) {
593
+ openExternalURL("https://github.com/marcusquinn/aidevops")
594
+ }
595
+
596
+ private func openExternalURL(_ value: String) {
597
+ guard let url = URL(string: value) else {
598
+ return
599
+ }
600
+ NSWorkspace.shared.open(url)
601
+ }
602
+
603
+ @objc private func openDashboard(_ sender: Any?) {
604
+ window.makeKeyAndOrderFront(nil)
605
+ NSApp.activate(ignoringOtherApps: true)
606
+ loadAppURL()
607
+ }
608
+
609
+ @objc private func hideWindow(_ sender: Any?) {
610
+ saveMainWindowFrame()
611
+ window.orderOut(nil)
612
+ }
613
+
614
+ @objc private func quitApplication(_ sender: Any?) {
615
+ saveMainWindowFrame()
616
+ NSApp.terminate(nil)
617
+ }
618
+
619
+ func windowWillClose(_ notification: Notification) {
620
+ saveMainWindowFrame()
621
+ }
622
+
623
+ @objc private func reloadPage(_ sender: Any?) {
624
+ webView.reload()
625
+ }
626
+
627
+ @objc private func stopLoading(_ sender: Any?) {
628
+ webView.stopLoading()
629
+ }
630
+
631
+ @objc private func goBack(_ sender: Any?) {
632
+ if webView.canGoBack {
633
+ webView.goBack()
634
+ }
635
+ }
636
+
637
+ @objc private func goForward(_ sender: Any?) {
638
+ if webView.canGoForward {
639
+ webView.goForward()
640
+ }
641
+ }
642
+
643
+ @objc private func resetZoom(_ sender: Any?) {
644
+ webView.pageZoom = 1.0
645
+ }
646
+
647
+ @objc private func zoomIn(_ sender: Any?) {
648
+ webView.pageZoom = min(webView.pageZoom + 0.1, 3.0)
649
+ }
650
+
651
+ @objc private func zoomOut(_ sender: Any?) {
652
+ webView.pageZoom = max(webView.pageZoom - 0.1, 0.5)
653
+ }
654
+
655
+ @objc private func showHelp(_ sender: Any?) {
656
+ window.makeKeyAndOrderFront(nil)
657
+ NSApp.activate(ignoringOtherApps: true)
658
+ loadStatusHTML(title: "aidevops Help", detail: "Use the app menus for standard editing, view controls, reload/navigation, window management, and help. The desktop wrapper runs the local read-only aidevops GUI in WebKit.")
659
+ }
660
+
661
+ private func configureWindow() {
662
+ let configuration = WKWebViewConfiguration()
663
+ configuration.defaultWebpagePreferences.allowsContentJavaScript = true
664
+ let userContentController = WKUserContentController()
665
+ userContentController.add(self, name: "accentHue")
666
+ configuration.userContentController = userContentController
667
+ webView = WKWebView(frame: .zero, configuration: configuration)
668
+ webView.navigationDelegate = self
669
+ webView.translatesAutoresizingMaskIntoConstraints = false
670
+
671
+ let contentContainer = NSView()
672
+ contentContainer.translatesAutoresizingMaskIntoConstraints = false
673
+ let titlebarOverlay = makeTitlebarOverlay()
674
+ contentContainer.addSubview(webView)
675
+ contentContainer.addSubview(titlebarOverlay)
676
+ NSLayoutConstraint.activate([
677
+ webView.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor),
678
+ webView.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor),
679
+ webView.topAnchor.constraint(equalTo: contentContainer.topAnchor),
680
+ webView.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor),
681
+ titlebarOverlay.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor),
682
+ titlebarOverlay.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor),
683
+ titlebarOverlay.topAnchor.constraint(equalTo: contentContainer.topAnchor),
684
+ titlebarOverlay.heightAnchor.constraint(equalToConstant: titlebarHeight)
685
+ ])
686
+
687
+ window = NSWindow(
688
+ contentRect: NSRect(x: 0, y: 0, width: 1280, height: 860),
689
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
690
+ backing: .buffered,
691
+ defer: false
692
+ )
693
+ window.center()
694
+ window.title = "aidevops"
695
+ window.titleVisibility = .hidden
696
+ window.titlebarAppearsTransparent = true
697
+ window.styleMask.insert(.fullSizeContentView)
698
+ window.isMovableByWindowBackground = true
699
+ window.isReleasedWhenClosed = false
700
+ window.delegate = self
701
+ window.setFrameAutosaveName(mainWindowFrameAutosaveName)
702
+ window.contentView = contentContainer
703
+ if !window.setFrameUsingName(mainWindowFrameAutosaveName) {
704
+ window.center()
705
+ }
706
+ window.makeKeyAndOrderFront(nil)
707
+ NSApp.activate(ignoringOtherApps: true)
708
+ }
709
+
710
+ private func makeTitlebarOverlay() -> NSView {
711
+ let overlay = DraggableTitlebarView()
712
+ overlay.translatesAutoresizingMaskIntoConstraints = false
713
+ let defaultButtonAccent = accentColor(hueDegrees: defaultAccentHue, alpha: 0.9)
714
+
715
+ let toggleButton = NSButton(frame: .zero)
716
+ toggleButton.action = #selector(toggleMachineRail(_:))
717
+ toggleButton.bezelStyle = .regularSquare
718
+ toggleButton.contentTintColor = defaultButtonAccent
719
+ toggleButton.image = sidebarToggleImage()
720
+ toggleButton.imagePosition = .imageOnly
721
+ toggleButton.isBordered = false
722
+ toggleButton.setButtonType(.momentaryChange)
723
+ toggleButton.target = self
724
+ toggleButton.toolTip = "Hide or show the machine sidebar"
725
+ toggleButton.translatesAutoresizingMaskIntoConstraints = false
726
+
727
+ overlay.addSubview(toggleButton)
728
+ titlebarToggleButton = toggleButton
729
+ NSLayoutConstraint.activate([
730
+ toggleButton.leadingAnchor.constraint(equalTo: overlay.leadingAnchor, constant: 92),
731
+ toggleButton.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: 3.5),
732
+ toggleButton.heightAnchor.constraint(equalToConstant: 22),
733
+ toggleButton.widthAnchor.constraint(equalToConstant: 22)
734
+ ])
735
+
736
+ return overlay
737
+ }
738
+
739
+ private func updateTitlebarAccent(hue: CGFloat) {
740
+ let normalizedHue = min(359, max(0, hue))
741
+ titlebarToggleButton?.contentTintColor = accentColor(hueDegrees: normalizedHue, alpha: 0.9)
742
+ }
743
+
744
+ private func accentColor(hueDegrees: CGFloat, alpha: CGFloat) -> NSColor {
745
+ let hue = hueDegrees.truncatingRemainder(dividingBy: 360) / 360
746
+ let saturation: CGFloat = 0.74
747
+ let lightness: CGFloat = 0.66
748
+ let chroma = (1 - abs((2 * lightness) - 1)) * saturation
749
+ let huePrime = hue * 6
750
+ let x = chroma * (1 - abs(huePrime.truncatingRemainder(dividingBy: 2) - 1))
751
+ let match = lightness - (chroma / 2)
752
+ let red: CGFloat
753
+ let green: CGFloat
754
+ let blue: CGFloat
755
+
756
+ if huePrime < 1 {
757
+ red = chroma
758
+ green = x
759
+ blue = 0
760
+ } else if huePrime < 2 {
761
+ red = x
762
+ green = chroma
763
+ blue = 0
764
+ } else if huePrime < 3 {
765
+ red = 0
766
+ green = chroma
767
+ blue = x
768
+ } else if huePrime < 4 {
769
+ red = 0
770
+ green = x
771
+ blue = chroma
772
+ } else if huePrime < 5 {
773
+ red = x
774
+ green = 0
775
+ blue = chroma
776
+ } else {
777
+ red = chroma
778
+ green = 0
779
+ blue = x
780
+ }
781
+
782
+ return NSColor(srgbRed: red + match, green: green + match, blue: blue + match, alpha: alpha)
783
+ }
784
+
785
+ private func sidebarToggleImage() -> NSImage {
786
+ let image = NSImage(size: NSSize(width: 20, height: 20), flipped: false) { _ in
787
+ let outer = NSBezierPath(roundedRect: NSRect(x: 3, y: 3, width: 14, height: 14), xRadius: 3, yRadius: 3)
788
+ outer.lineWidth = 2.2
789
+ NSColor.labelColor.setStroke()
790
+ outer.stroke()
791
+
792
+ let divider = NSBezierPath()
793
+ divider.lineWidth = 2
794
+ divider.move(to: NSPoint(x: 8, y: 4))
795
+ divider.line(to: NSPoint(x: 8, y: 16))
796
+ divider.stroke()
797
+
798
+ let notchTop = NSBezierPath()
799
+ notchTop.lineWidth = 1.7
800
+ notchTop.move(to: NSPoint(x: 4.8, y: 12.4))
801
+ notchTop.line(to: NSPoint(x: 6.1, y: 12.4))
802
+ notchTop.stroke()
803
+
804
+ let notchBottom = NSBezierPath()
805
+ notchBottom.lineWidth = 1.7
806
+ notchBottom.move(to: NSPoint(x: 4.8, y: 7.6))
807
+ notchBottom.line(to: NSPoint(x: 6.1, y: 7.6))
808
+ notchBottom.stroke()
809
+
810
+ return true
811
+ }
812
+ image.isTemplate = true
813
+ return image
814
+ }
815
+
816
+ @objc private func toggleMachineRail(_ sender: Any?) {
817
+ webView.evaluateJavaScript("window.dispatchEvent(new CustomEvent('aidevops:toggle-machine-rail')); true;", completionHandler: nil)
818
+ }
819
+
820
+ private func saveMainWindowFrame() {
821
+ guard window != nil else {
822
+ return
823
+ }
824
+ window.saveFrame(usingName: mainWindowFrameAutosaveName)
825
+ }
826
+
827
+ private func startServicesAndLoadApp() {
828
+ serviceQueue.async {
829
+ if self.servicesStopped {
830
+ return
831
+ }
832
+ let result = self.runServiceHelper()
833
+ if self.servicesStopped {
834
+ return
835
+ }
836
+ DispatchQueue.main.async {
837
+ if result.ok {
838
+ self.loadAppURL()
839
+ } else {
840
+ self.loadStatusHTML(title: "aidevops could not start", detail: result.message)
841
+ }
842
+ }
843
+ }
844
+ }
845
+
846
+ private func runServiceHelper() -> (ok: Bool, message: String) {
847
+ return runServiceHelper(arguments: [])
848
+ }
849
+
850
+ private func stopServices() {
851
+ serviceQueue.sync {
852
+ if servicesStopped {
853
+ return
854
+ }
855
+ servicesStopped = true
856
+ _ = runServiceHelper(arguments: ["stop"])
857
+ }
858
+ }
859
+
860
+ private func runServiceHelper(arguments: [String]) -> (ok: Bool, message: String) {
861
+ guard let helperPath = Bundle.main.path(forResource: "aidevops-gui-services", ofType: "sh") else {
862
+ return (false, "The bundled service helper is missing from aidevops.app.")
863
+ }
864
+
865
+ let process = Process()
866
+ let pipe = Pipe()
867
+ process.executableURL = URL(fileURLWithPath: "/bin/bash")
868
+ process.arguments = [helperPath] + arguments
869
+ var environment = ProcessInfo.processInfo.environment
870
+ environment["AIDEVOPS_GUI_WRAPPER"] = "webkit"
871
+ process.environment = environment
872
+ process.standardOutput = pipe
873
+ process.standardError = pipe
874
+
875
+ do {
876
+ try process.run()
877
+ process.waitUntilExit()
878
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
879
+ let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
880
+ if process.terminationStatus == 0 {
881
+ return (true, output)
882
+ }
883
+ if output.isEmpty {
884
+ return (false, "The local GUI services exited with status \(process.terminationStatus). Check ~/Library/Logs/aidevops-gui/.")
885
+ }
886
+ return (false, output)
887
+ } catch {
888
+ return (false, "Could not launch the local GUI service helper: \(error.localizedDescription)")
889
+ }
890
+ }
891
+
892
+ private func loadAppURL() {
893
+ guard let url = URL(string: "http://127.0.0.1:\(webPort)/?desktop=macos") else {
894
+ loadStatusHTML(title: "Invalid aidevops URL", detail: "The configured web port is not valid: \(webPort)")
895
+ return
896
+ }
897
+ webView.load(URLRequest(url: url))
898
+ }
899
+
900
+ private func loadStatusHTML(title: String, detail: String) {
901
+ let escapedTitle = escapeHTML(title)
902
+ let escapedDetail = escapeHTML(detail).replacingOccurrences(of: "\n", with: "<br>")
903
+ let html = """
904
+ <!doctype html>
905
+ <html lang="en">
906
+ <head>
907
+ <meta charset="utf-8">
908
+ <meta name="viewport" content="width=device-width, initial-scale=1">
909
+ <style>
910
+ body { align-items: center; background: #0a0a0a; color: #f6f7f2; display: flex; font: 15px -apple-system, BlinkMacSystemFont, sans-serif; height: 100vh; justify-content: center; margin: 0; }
911
+ main { background: #11130f; border: 1px solid #2c3324; border-radius: 18px; max-width: 560px; padding: 28px; }
912
+ h1 { font-size: 22px; margin: 0 0 10px; }
913
+ p { color: #b9c3b0; line-height: 1.5; margin: 0; }
914
+ code { color: #b2e969; }
915
+ </style>
916
+ </head>
917
+ <body><main><h1>\(escapedTitle)</h1><p>\(escapedDetail)</p></main></body>
918
+ </html>
919
+ """
920
+ webView.loadHTMLString(html, baseURL: nil)
921
+ }
922
+
923
+ private func escapeHTML(_ value: String) -> String {
924
+ return value
925
+ .replacingOccurrences(of: "&", with: "&amp;")
926
+ .replacingOccurrences(of: "<", with: "&lt;")
927
+ .replacingOccurrences(of: ">", with: "&gt;")
928
+ .replacingOccurrences(of: "\"", with: "&quot;")
929
+ .replacingOccurrences(of: "'", with: "&#39;")
930
+ }
931
+ }
932
+
933
+ let app = NSApplication.shared
934
+ let delegate = AppDelegate()
935
+ app.delegate = delegate
936
+ app.setActivationPolicy(.regular)
937
+ app.run()
938
+ SWIFT
939
+ return 0
940
+ }
941
+
942
+ compile_webview_app() {
943
+ local resources_dir="$1"
944
+ local macos_dir="$2"
945
+ local swift_source="${resources_dir}/aidevops-gui.swift"
946
+
947
+ env MACOSX_DEPLOYMENT_TARGET=13.0 swiftc -framework AppKit -framework WebKit "$swift_source" -o "${macos_dir}/aidevops-gui"
948
+ chmod 755 "${macos_dir}/aidevops-gui"
949
+ return 0
950
+ }
951
+
952
+ write_app_bundle() {
953
+ local root="$1"
954
+ local app_dir="$2"
955
+ local app_path="${app_dir}/${APP_NAME}"
956
+ local contents_dir="${app_path}/Contents"
957
+ local macos_dir="${contents_dir}/MacOS"
958
+ local resources_dir="${contents_dir}/Resources"
959
+ local version=""
960
+
961
+ version="$(app_version "$root")"
962
+
963
+ mkdir -p "$macos_dir" "$resources_dir"
964
+ cat > "${contents_dir}/Info.plist" <<PLIST
965
+ <?xml version="1.0" encoding="UTF-8"?>
966
+ <plist version="1.0">
967
+ <dict>
968
+ <key>CFBundleName</key><string>aidevops</string>
969
+ <key>CFBundleDisplayName</key><string>aidevops</string>
970
+ <key>CFBundleIdentifier</key><string>sh.aidevops.gui</string>
971
+ <key>CFBundleVersion</key><string>${version}</string>
972
+ <key>CFBundleShortVersionString</key><string>${version}</string>
973
+ <key>CFBundleExecutable</key><string>aidevops-gui</string>
974
+ <key>CFBundleIconFile</key><string>aidevops.icns</string>
975
+ <key>LSMinimumSystemVersion</key><string>13.0</string>
976
+ <key>NSAppTransportSecurity</key>
977
+ <dict>
978
+ <key>NSAllowsLocalNetworking</key><true/>
979
+ </dict>
980
+ <key>NSHighResolutionCapable</key><true/>
981
+ </dict>
982
+ </plist>
983
+ PLIST
984
+ write_icon_assets "$resources_dir"
985
+ write_service_helper "$root" "$resources_dir"
986
+ write_webview_source "$resources_dir"
987
+ compile_webview_app "$resources_dir" "$macos_dir"
988
+ printf 'Installed %s\n' "$app_path"
989
+ return 0
990
+ }
991
+
992
+ main() {
993
+ local mode="install"
994
+ local app_dir="$DEFAULT_APP_DIR"
995
+ local root=""
996
+
997
+ while [[ $# -gt 0 ]]; do
998
+ local arg="$1"
999
+ case "$arg" in
1000
+ --check)
1001
+ mode="check"
1002
+ shift
1003
+ ;;
1004
+ --app-dir)
1005
+ if [[ $# -lt 2 ]]; then
1006
+ usage >&2
1007
+ return 1
1008
+ fi
1009
+ local next_arg="$2"
1010
+ app_dir="$next_arg"
1011
+ shift 2
1012
+ ;;
1013
+ --help|-h)
1014
+ usage
1015
+ return 0
1016
+ ;;
1017
+ *)
1018
+ usage >&2
1019
+ return 1
1020
+ ;;
1021
+ esac
1022
+ done
1023
+
1024
+ root="$(repo_root)"
1025
+ validate_environment "$root"
1026
+ if [[ "$mode" == "check" ]]; then
1027
+ if ! gui_dependencies_present "$root"; then
1028
+ printf 'GUI dependencies are missing; run bun install --frozen-lockfile in %s\n' "$root" >&2
1029
+ return 1
1030
+ fi
1031
+ printf 'macOS app bundle check passed for %s\n' "$root"
1032
+ return 0
1033
+ fi
1034
+ ensure_gui_dependencies "$root"
1035
+ write_app_bundle "$root" "$app_dir"
1036
+ return 0
1037
+ }
1038
+
1039
+ main "$@"