aidevops 3.29.19 → 3.29.21

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/README.md CHANGED
@@ -58,7 +58,7 @@ The result: an AI operations platform that manages projects across every busines
58
58
  [![Copyright](https://img.shields.io/badge/Copyright-Marcus%20Quinn%202025--2026-blue.svg)](https://github.com/marcusquinn)
59
59
 
60
60
  <!-- Release & Version Info -->
61
- [![Version](https://img.shields.io/badge/Version-3.29.19-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
61
+ [![Version](https://img.shields.io/badge/Version-3.29.21-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
62
62
  [![npm version](https://img.shields.io/npm/v/aidevops)](https://www.npmjs.com/package/aidevops)
63
63
  [![Homebrew](https://img.shields.io/badge/homebrew-marcusquinn%2Ftap-orange)](https://github.com/marcusquinn/homebrew-tap)
64
64
  [![GitHub repository](https://img.shields.io/badge/github-repository-181717.svg?logo=github)](https://github.com/marcusquinn/aidevops)
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.29.19
1
+ 3.29.21
package/aidevops.sh CHANGED
@@ -5,7 +5,7 @@
5
5
  # AI DevOps Framework CLI
6
6
  # Usage: aidevops <command> [options]
7
7
  #
8
- # Version: 3.29.19
8
+ # Version: 3.29.21
9
9
 
10
10
  set -euo pipefail
11
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aidevops",
3
- "version": "3.29.19",
3
+ "version": "3.29.21",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
5
  export function readJsonObject(pathName: string): { health: "present" | "missing" | "invalid"; value: Record<string, unknown> } {
@@ -112,12 +112,34 @@ export function firstExistingPathRef(pathRefs: string[]): string | null {
112
112
  return pathRefs.find((pathRef) => existsSync(expandHome(pathRef))) ?? null;
113
113
  }
114
114
 
115
+ export function firstExecutablePathRef(pathRefs: string[]): string | null {
116
+ return pathRefs.find((pathRef) => isExecutable(expandHome(pathRef))) ?? null;
117
+ }
118
+
119
+ export function isExecutable(pathName: string): boolean {
120
+ try {
121
+ if (!statSync(pathName).isFile()) {
122
+ return false;
123
+ }
124
+ accessSync(pathName, constants.X_OK);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
115
131
  export function resolveBinary(binary: string): string | null {
132
+ const candidatePath = binaryPathCandidates(binary).find((pathName) => isExecutable(pathName));
133
+ if (candidatePath !== undefined) {
134
+ return candidatePath;
135
+ }
136
+
116
137
  try {
117
138
  const output = execFileSync("which", [binary], {
139
+ env: { ...process.env, PATH: expandedPath() },
118
140
  encoding: "utf8",
119
141
  stdio: ["ignore", "pipe", "ignore"],
120
- timeout: 200,
142
+ timeout: 350,
121
143
  }).trim();
122
144
  return output.split("\n").find((line) => line.length > 0) ?? null;
123
145
  } catch {
@@ -125,6 +147,27 @@ export function resolveBinary(binary: string): string | null {
125
147
  }
126
148
  }
127
149
 
150
+ function binaryPathCandidates(binary: string): string[] {
151
+ return expandedPath().split(":").filter(Boolean).map((dir) => join(dir, binary));
152
+ }
153
+
154
+ function expandedPath(): string {
155
+ const home = process.env.HOME ?? "";
156
+ const extraPaths = [
157
+ "/opt/homebrew/bin",
158
+ "/usr/local/bin",
159
+ "/usr/bin",
160
+ "/bin",
161
+ "/usr/sbin",
162
+ "/sbin",
163
+ join(home, ".bun/bin"),
164
+ join(home, ".local/bin"),
165
+ join(home, ".cursor/bin"),
166
+ join(home, ".qlty/bin"),
167
+ ];
168
+ return [...new Set([...(process.env.PATH ?? "").split(":"), ...extraPaths].filter(Boolean))].join(":");
169
+ }
170
+
128
171
  export function readBinaryVersion(binaryPath: string, args: string[]): string {
129
172
  if (args.length === 0) {
130
173
  return "unknown";
@@ -132,9 +175,10 @@ export function readBinaryVersion(binaryPath: string, args: string[]): string {
132
175
 
133
176
  try {
134
177
  const output = execFileSync(binaryPath, args, {
178
+ env: { ...process.env, PATH: expandedPath() },
135
179
  encoding: "utf8",
136
- stdio: ["ignore", "pipe", "ignore"],
137
- timeout: 150,
180
+ stdio: ["ignore", "pipe", "pipe"],
181
+ timeout: 650,
138
182
  }).trim();
139
183
  return output.split("\n").find((line) => line.length > 0) ?? "unknown";
140
184
  } catch {
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import type { GuiAppActionId, GuiManagedAppSummary } from "../../gui-shared/src";
3
- import { collapseHome, expandHome, firstExistingPathRef, readBinaryVersion, resolveBinary } from "./status-adapter-utils";
3
+ import { collapseHome, expandHome, firstExecutablePathRef, firstExistingPathRef, readBinaryVersion, resolveBinary } from "./status-adapter-utils";
4
4
 
5
5
  type ManagedAppActionCommands = Partial<Record<GuiAppActionId, string>>;
6
6
 
@@ -180,7 +180,7 @@ function setupScopeActions(scope: string): ManagedAppActionCommands {
180
180
  }
181
181
 
182
182
  function managedAppSummary(definition: ManagedAppDefinition, latestVersion: string, installedVersion: string): GuiManagedAppSummary {
183
- const binaryPath = definition.binary === null ? null : resolveBinary(definition.binary);
183
+ const binaryPath = definition.binary === null ? null : executableInstallPath(definition.install_path_refs) ?? resolveBinary(definition.binary);
184
184
  const installPathRef = firstExistingPathRef(definition.install_path_refs) ?? definition.install_path_refs[0] ?? "not found";
185
185
  const installedVersionText = readManagedAppVersion(definition, binaryPath, installedVersion);
186
186
  const latestVersionText = definition.latest_version_source === "aidevops"
@@ -231,7 +231,13 @@ function readManagedAppVersion(definition: ManagedAppDefinition, binaryPath: str
231
231
  return readAppBundleVersion(existingPathRef) ?? "installed";
232
232
  }
233
233
 
234
- return readBinaryVersion(binaryPath, definition.version_args);
234
+ const versionText = readBinaryVersion(binaryPath, definition.version_args);
235
+ return versionText === "unknown" ? "installed" : versionText;
236
+ }
237
+
238
+ function executableInstallPath(pathRefs: string[]): string | null {
239
+ const executablePathRef = firstExecutablePathRef(pathRefs);
240
+ return executablePathRef === null ? null : expandHome(executablePathRef);
235
241
  }
236
242
 
237
243
  function readAppBundleVersion(pathRef: string): string | null {
@@ -239,5 +245,11 @@ function readAppBundleVersion(pathRef: string): string | null {
239
245
  return null;
240
246
  }
241
247
 
242
- return readBinaryVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", `${expandHome(pathRef)}/Contents/Info.plist`]);
248
+ const version = readBinaryVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", `${expandHome(pathRef)}/Contents/Info.plist`]);
249
+ if (version !== "unknown") {
250
+ return version;
251
+ }
252
+
253
+ const bundleVersion = readBinaryVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleVersion", `${expandHome(pathRef)}/Contents/Info.plist`]);
254
+ return bundleVersion === "unknown" ? null : bundleVersion;
243
255
  }
@@ -338,7 +338,7 @@ final class DraggableTitlebarView: NSView {
338
338
  }
339
339
  }
340
340
 
341
- final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScriptMessageHandler, NSMenuItemValidation, NSWindowDelegate {
341
+ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, NSMenuItemValidation, NSWindowDelegate {
342
342
  private var window: NSWindow!
343
343
  private var aboutWindow: NSWindow?
344
344
  private var titlebarToggleButton: NSButton?
@@ -416,6 +416,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
416
416
  decisionHandler(.allow)
417
417
  }
418
418
 
419
+ func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
420
+ guard let url = navigationAction.request.url else {
421
+ return nil
422
+ }
423
+
424
+ if url.scheme == "http" || url.scheme == "https" {
425
+ NSWorkspace.shared.open(url)
426
+ }
427
+
428
+ return nil
429
+ }
430
+
419
431
  private func isLocalAppURL(_ url: URL) -> Bool {
420
432
  let host = url.host ?? ""
421
433
  return (host == "127.0.0.1" || host == "localhost") && url.port == Int(webPort)
@@ -677,11 +689,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
677
689
  private func configureWindow() {
678
690
  let configuration = WKWebViewConfiguration()
679
691
  configuration.defaultWebpagePreferences.allowsContentJavaScript = true
692
+ configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
680
693
  let userContentController = WKUserContentController()
681
694
  userContentController.add(self, name: "accentHue")
682
695
  configuration.userContentController = userContentController
683
696
  webView = WKWebView(frame: .zero, configuration: configuration)
684
697
  webView.navigationDelegate = self
698
+ webView.uiDelegate = self
685
699
  webView.translatesAutoresizingMaskIntoConstraints = false
686
700
 
687
701
  let contentContainer = NSView()
@@ -1,5 +1,5 @@
1
1
  import type { GuiAppActionId, GuiAppActionJobSummary, GuiManagedAppSummary, GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
2
- import { type Dispatch, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, type SetStateAction, useEffect, useRef, useState } from "react";
2
+ import { type CSSProperties, type Dispatch, type FocusEvent as ReactFocusEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode, type SetStateAction, useEffect, useRef, useState } from "react";
3
3
  import type { IconType } from "react-icons";
4
4
  import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
5
5
  import { FiChevronDown, FiCode, FiDownload, FiExternalLink, FiGlobe, FiMonitor, FiRefreshCw, FiRepeat, FiTerminal, FiTrash2, FiX } from "react-icons/fi";
@@ -24,18 +24,20 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
24
24
  const [expandedAppId, setExpandedAppId] = useState<string | null>(() => sortedManagedApps(status.managed_apps).find((app) => managedCategoryForApp(app) === "core")?.id ?? null);
25
25
  const [jobs, setJobs] = useState<Record<string, GuiAppActionJobSummary>>({});
26
26
  const [dismissedJobIds, setDismissedJobIds] = useState<Set<string>>(() => new Set());
27
+ const [tooltip, setTooltip] = useState<AppTooltipState | null>(null);
27
28
  const [policyJobs, setPolicyJobs] = useState<Record<string, GuiAppActionJobSummary[]>>({});
28
29
  const [policyToggles, setPolicyToggles] = useState<ManagedPolicyToggleState>(() => readManagedPolicyToggles());
29
30
  const selectedJob = selectedJobId === null ? null : jobs[selectedJobId] ?? null;
30
31
  const managedApps = sortedManagedApps(status.managed_apps).map((app) => applyManagedPolicyToggles(app, policyToggles[app.id]));
31
32
  const visibleManagedApps = managedApps.filter((app) => managedCategoryForApp(app) === managedCategory);
33
+ const firstVisibleManagedAppId = visibleManagedApps[0]?.id ?? null;
32
34
  const visibleRecommendedApps = recommendedApps.filter((app) => recommendedAppMatchesFilters(app, recommendedPlatform, recommendedOs));
33
35
  const selectRecommendedPlatform = (value: RecommendedPlatformFilterId) => setRecommendedPlatform((current) => nextRecommendedFilterValue(current, value, "all"));
34
36
  const selectRecommendedOs = (value: RecommendedOsId) => setRecommendedOs((current) => nextRecommendedFilterValue(current, value, "all"));
35
37
 
36
38
  useEffect(() => {
37
- setExpandedAppId(visibleManagedApps[0]?.id ?? null);
38
- }, [managedCategory, status.managed_apps]);
39
+ setExpandedAppId(firstVisibleManagedAppId);
40
+ }, [firstVisibleManagedAppId]);
39
41
 
40
42
  const runningJobIdsKey = Object.values(jobs).filter((job) => job.status === "running").map((job) => job.id).sort().join("|");
41
43
 
@@ -57,7 +59,7 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
57
59
  }, [runningJobIdsKey]);
58
60
 
59
61
  return (
60
- <section className="apps-surface" aria-label={text.apps}>
62
+ <section className="apps-surface" aria-label={text.apps} onBlur={() => setTooltip(null)} onFocus={showFocusedAppTooltip(setTooltip)} onPointerLeave={() => setTooltip(null)} onPointerMove={showPointedAppTooltip(setTooltip)}>
61
63
  <div className="section-heading">
62
64
  <p className="eyebrow">{text.infrastructure}</p>
63
65
  <h2>{text.apps}</h2>
@@ -103,6 +105,7 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
103
105
  </div>
104
106
  <p className="empty-state compact-notice">Install/update actions run as allowlisted background jobs and stream inside each app panel. xterm.js with a node-pty bridge is the right next step for full TUI apps such as OpenCode; this view starts with non-interactive command logs.</p>
105
107
  </> : <RecommendedAppsSurface apps={visibleRecommendedApps} os={recommendedOs} platform={recommendedPlatform} setOs={selectRecommendedOs} setPlatform={selectRecommendedPlatform} />}
108
+ {tooltip === null ? null : <AppTooltip tooltip={tooltip} />}
106
109
  </section>
107
110
  );
108
111
  }
@@ -151,7 +154,8 @@ function ManagedAppPanel({ app, dismissedJobIds, expanded, job, onDismissJob, on
151
154
  }
152
155
 
153
156
  function SummaryChip({ label, value }: { label: string; value: string }): ReactElement {
154
- return <span className="managed-summary-chip"><small>{label}</small><span>{value}</span></span>;
157
+ const tooltip = `${label}: ${value}`;
158
+ return <span className="managed-summary-chip" data-tooltip={tooltip} title={tooltip}><small>{label}</small><span>{value}</span></span>;
155
159
  }
156
160
 
157
161
  function OriginLink({ href, label }: { href: string; label: string }): ReactElement {
@@ -159,7 +163,7 @@ function OriginLink({ href, label }: { href: string; label: string }): ReactElem
159
163
  return <span className="origin-missing">{label}: source pending</span>;
160
164
  }
161
165
 
162
- return <a href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank" title={href}>{label} <FiExternalLink aria-hidden="true" /></a>;
166
+ return <a data-tooltip={href} href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank" title={href}>{label} <FiExternalLink aria-hidden="true" /></a>;
163
167
  }
164
168
 
165
169
  function ToggleSwitch({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }): ReactElement {
@@ -218,7 +222,7 @@ function AppActionTerminal({ job, onDismiss }: { job: GuiAppActionJobSummary; on
218
222
  if (outputRef.current !== null) {
219
223
  outputRef.current.scrollTop = outputRef.current.scrollHeight;
220
224
  }
221
- }, [job.output]);
225
+ }, [job.output.length]);
222
226
 
223
227
  return (
224
228
  <section className="app-terminal" id={`app-job-${job.id}`} aria-label="App action terminal output">
@@ -594,17 +598,70 @@ function writeManagedPolicyToggles(policy: ManagedPolicyToggleState): void {
594
598
  window.localStorage.setItem("aidevops-gui-managed-app-policy", JSON.stringify(policy));
595
599
  }
596
600
 
601
+ interface AppTooltipState {
602
+ text: string;
603
+ x: number;
604
+ y: number;
605
+ }
606
+
607
+ function showPointedAppTooltip(setTooltip: Dispatch<SetStateAction<AppTooltipState | null>>) {
608
+ return (event: ReactPointerEvent<HTMLElement>) => {
609
+ const target = tooltipTarget(event.target);
610
+ setTooltip(target === null ? null : tooltipForElement(target));
611
+ };
612
+ }
613
+
614
+ function showFocusedAppTooltip(setTooltip: Dispatch<SetStateAction<AppTooltipState | null>>) {
615
+ return (event: ReactFocusEvent<HTMLElement>) => {
616
+ const target = tooltipTarget(event.target);
617
+ if (target !== null) {
618
+ setTooltip(tooltipForElement(target));
619
+ }
620
+ };
621
+ }
622
+
623
+ function tooltipTarget(target: EventTarget): HTMLElement | null {
624
+ return target instanceof HTMLElement ? target.closest<HTMLElement>("[data-tooltip]") : null;
625
+ }
626
+
627
+ function tooltipForElement(element: HTMLElement): AppTooltipState | null {
628
+ const textValue = element.dataset.tooltip;
629
+ if (textValue === undefined || textValue.length === 0) {
630
+ return null;
631
+ }
632
+
633
+ const rect = element.getBoundingClientRect();
634
+ return { text: textValue, x: rect.left + (rect.width / 2), y: rect.top };
635
+ }
636
+
637
+ function AppTooltip({ tooltip }: { tooltip: AppTooltipState }): ReactElement {
638
+ const style = {
639
+ "--tooltip-x": `${tooltip.x}px`,
640
+ "--tooltip-y": `${tooltip.y}px`,
641
+ } as CSSProperties;
642
+
643
+ return <div className="app-global-tooltip" role="tooltip" style={style}>{tooltip.text}</div>;
644
+ }
645
+
597
646
  function openExternalLink(event: ReactMouseEvent<HTMLAnchorElement>, href: string): void {
598
647
  event.stopPropagation();
599
648
  if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
600
649
  return;
601
650
  }
602
651
 
652
+ event.preventDefault();
653
+ if (document.documentElement.dataset.desktopShell === "macos") {
654
+ window.location.assign(href);
655
+ return;
656
+ }
657
+
603
658
  const opened = window.open(href, "_blank", "noopener,noreferrer");
604
659
  if (opened !== null) {
605
- event.preventDefault();
606
660
  opened.opener = null;
661
+ return;
607
662
  }
663
+
664
+ window.location.assign(href);
608
665
  }
609
666
 
610
667
  export function InstallationSurface(): ReactElement {
@@ -2558,6 +2558,32 @@ button.managed-toggle:focus-visible {
2558
2558
  transform: translate(-50%, 0);
2559
2559
  }
2560
2560
 
2561
+ .apps-surface [data-tooltip]::after {
2562
+ content: none;
2563
+ }
2564
+
2565
+ .app-global-tooltip {
2566
+ background: color-mix(in srgb, var(--surface-elevated) 94%, black);
2567
+ border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--border));
2568
+ border-radius: 10px;
2569
+ box-shadow: 0 18px 46px rgb(0 0 0 / 38%);
2570
+ color: var(--text-primary);
2571
+ font-size: 0.72rem;
2572
+ font-weight: 700;
2573
+ left: clamp(12px, var(--tooltip-x), calc(100vw - 12px));
2574
+ line-height: 1.35;
2575
+ max-width: min(440px, 72vw);
2576
+ overflow-wrap: anywhere;
2577
+ padding: 8px 10px;
2578
+ pointer-events: none;
2579
+ position: fixed;
2580
+ text-align: left;
2581
+ top: var(--tooltip-y);
2582
+ transform: translate(-50%, calc(-100% - 10px));
2583
+ width: max-content;
2584
+ z-index: 10000;
2585
+ }
2586
+
2561
2587
  .data-row,
2562
2588
  .editable-row {
2563
2589
  display: grid;
package/setup.sh CHANGED
@@ -12,7 +12,7 @@ shopt -s inherit_errexit 2>/dev/null || true
12
12
  # AI Assistant Server Access Framework Setup Script
13
13
  # Helps developers set up the framework for their infrastructure
14
14
  #
15
- # Version: 3.29.19
15
+ # Version: 3.29.21
16
16
  #
17
17
  # Quick Install:
18
18
  # npm install -g aidevops && aidevops update (recommended)