aidevops 3.29.8 → 3.29.11

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.8-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
61
+ [![Version](https://img.shields.io/badge/Version-3.29.11-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.8
1
+ 3.29.11
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.8
8
+ # Version: 3.29.11
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.8",
3
+ "version": "3.29.11",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -10,6 +10,7 @@ const appActionCommands: Record<string, Partial<Record<GuiAppActionId, string[]>
10
10
  "gui-desktop": { install: ["aidevops", "setup", "--scope", "gui-desktop"], update: ["aidevops", "setup", "--scope", "gui-desktop"], reinstall: ["aidevops", "setup", "--scope", "gui-desktop"] },
11
11
  hooks: { install: ["aidevops", "setup", "--scope", "hooks"], update: ["aidevops", "setup", "--scope", "hooks"], reinstall: ["aidevops", "setup", "--scope", "hooks"] },
12
12
  opencode: { install: ["aidevops", "setup", "--scope", "opencode"], update: ["aidevops", "setup", "--scope", "opencode"], reinstall: ["aidevops", "setup", "--scope", "opencode"] },
13
+ "opencode-cli": { install: ["aidevops", "setup", "--scope", "opencode"], update: ["aidevops", "setup", "--scope", "opencode"], reinstall: ["aidevops", "setup", "--scope", "opencode"] },
13
14
  pulse: { install: ["aidevops", "setup", "--scope", "pulse"], update: ["aidevops", "setup", "--scope", "pulse"], reinstall: ["aidevops", "setup", "--scope", "pulse"] },
14
15
  tabby: { install: ["aidevops", "setup", "--scope", "tabby"], update: ["aidevops", "setup", "--scope", "tabby"], reinstall: ["aidevops", "setup", "--scope", "tabby"] },
15
16
  bun: { install: ["./setup.sh", "--non-interactive"], update: ["aidevops", "update-tools", "--update"] },
@@ -83,12 +83,12 @@ const MANAGED_APP_DEFINITIONS: ManagedAppDefinition[] = [
83
83
  },
84
84
  {
85
85
  id: "opencode",
86
- name: "OpenCode",
87
- description: "AI terminal runtime configured by aidevops for local sessions.",
88
- category: "ai runtime",
89
- binary: "opencode",
90
- version_args: ["--version"],
91
- install_path_refs: ["~/Applications/OpenCode AIDevOps.app", "/Applications/OpenCode.app", "~/.config/opencode/opencode.json"],
86
+ name: "OpenCode Beta",
87
+ description: "Native OpenCode beta desktop app configured by aidevops.",
88
+ category: "desktop",
89
+ binary: null,
90
+ version_args: [],
91
+ install_path_refs: ["~/Applications/OpenCode AIDevOps.app", "~/Applications/OpenCode.app", "/Applications/OpenCode.app", "~/.config/opencode/opencode.json"],
92
92
  origin_website_url: "https://opencode.ai",
93
93
  origin_repo_url: "",
94
94
  aidevops_install: true,
@@ -96,6 +96,7 @@ const MANAGED_APP_DEFINITIONS: ManagedAppDefinition[] = [
96
96
  latest_version_source: "binary",
97
97
  action_commands: setupScopeActions("opencode"),
98
98
  },
99
+ binaryApp({ id: "opencode-cli", name: "OpenCode CLI", description: "Command-line runtime installed with OpenCode and configured by aidevops.", category: "cli", binary: "opencode", version_args: ["--version"], install_path_refs: ["/opt/homebrew/bin/opencode", "/usr/local/bin/opencode", "~/.bun/bin/opencode", "~/.config/opencode/opencode.json"], origin_website_url: "https://opencode.ai", origin_repo_url: "", aidevops_install: true, aidevops_update: true }),
99
100
  {
100
101
  id: "hooks",
101
102
  name: "Safety hooks",
@@ -139,7 +140,7 @@ const MANAGED_APP_DEFINITIONS: ManagedAppDefinition[] = [
139
140
  binaryApp({ id: "homebrew", name: "Homebrew", description: "macOS package manager used by setup/update when available.", category: "package manager", binary: "brew", version_args: ["--version"], install_path_refs: ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"], origin_website_url: "https://brew.sh", origin_repo_url: "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" }),
140
141
  binaryApp({ id: "qlty", name: "Qlty CLI", description: "Optional code quality aggregator installed by setup when selected.", category: "quality", binary: "qlty", version_args: ["--version"], install_path_refs: ["/opt/homebrew/bin/qlty", "/usr/local/bin/qlty", "~/.qlty/bin/qlty"], origin_website_url: "https://qlty.sh", origin_repo_url: "" }),
141
142
  binaryApp({ id: "rtk", name: "rtk", description: "Token-optimized GitHub/API helper used by interactive discovery.", category: "developer tooling", binary: "rtk", version_args: ["--version"], install_path_refs: ["~/.local/bin/rtk", "/opt/homebrew/bin/rtk", "/usr/local/bin/rtk"], origin_website_url: "https://github.com/rtk-ai/rtk#installation", origin_repo_url: "https://github.com/rtk-ai/rtk" }),
142
- binaryApp({ id: "cursor", name: "Cursor CLI", description: "Cursor command-line integration and config targets.", category: "ai runtime", binary: "cursor", version_args: ["--version"], install_path_refs: ["~/.cursor/bin/cursor", "/Applications/Cursor.app"], origin_website_url: "https://cursor.com/install", origin_repo_url: "" }),
143
+ binaryApp({ id: "cursor", name: "Cursor CLI", description: "Cursor command-line integration and config targets.", category: "cli", binary: "cursor", version_args: ["--version"], install_path_refs: ["~/.cursor/bin/cursor", "/Applications/Cursor.app"], origin_website_url: "https://cursor.com/install", origin_repo_url: "" }),
143
144
  binaryApp({ id: "zed", name: "Zed", description: "Optional editor installed by setup when selected.", category: "editor", binary: "zed", version_args: ["--version"], install_path_refs: ["/Applications/Zed.app", "/opt/homebrew/bin/zed", "/usr/local/bin/zed"], origin_website_url: "https://zed.dev/download", origin_repo_url: "", aidevops_install: false, aidevops_update: false }),
144
145
  binaryApp({ id: "orbstack", name: "OrbStack", description: "Optional local container/VM runtime for macOS development.", category: "runtime", binary: "orb", version_args: ["version"], install_path_refs: ["/Applications/OrbStack.app", "/opt/homebrew/bin/orb"], origin_website_url: "https://orbstack.dev/", origin_repo_url: "", aidevops_install: false, aidevops_update: false }),
145
146
  binaryApp({ id: "ollama", name: "Ollama", description: "Optional local model runtime for local AI workflows.", category: "ai runtime", binary: "ollama", version_args: ["--version"], install_path_refs: ["/Applications/Ollama.app", "/opt/homebrew/bin/ollama", "/usr/local/bin/ollama"], origin_website_url: "https://ollama.com", origin_repo_url: "" }),
@@ -221,8 +222,22 @@ function readManagedAppVersion(definition: ManagedAppDefinition, binaryPath: str
221
222
  }
222
223
 
223
224
  if (binaryPath === null) {
224
- return "not installed";
225
+ const existingPathRef = firstExistingPathRef(definition.install_path_refs);
226
+
227
+ if (existingPathRef === null) {
228
+ return "not installed";
229
+ }
230
+
231
+ return readAppBundleVersion(existingPathRef) ?? "installed";
225
232
  }
226
233
 
227
234
  return readBinaryVersion(binaryPath, definition.version_args);
228
235
  }
236
+
237
+ function readAppBundleVersion(pathRef: string): string | null {
238
+ if (!pathRef.endsWith(".app")) {
239
+ return null;
240
+ }
241
+
242
+ return readBinaryVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", `${expandHome(pathRef)}/Contents/Info.plist`]);
243
+ }
@@ -1,6 +1,10 @@
1
1
  import type { GuiAppActionId, GuiAppActionJobSummary, GuiManagedAppSummary, GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
2
- import { type Dispatch, type ReactElement, type SetStateAction, useEffect, useState } from "react";
3
- import { FiChevronDown, FiDownload, FiExternalLink, FiRefreshCw, FiRepeat, FiTrash2 } from "react-icons/fi";
2
+ import { type Dispatch, type MouseEvent as ReactMouseEvent, type ReactElement, type SetStateAction, useEffect, useRef, useState } from "react";
3
+ import type { IconType } from "react-icons";
4
+ import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
5
+ import { FiChevronDown, FiCode, FiDownload, FiExternalLink, FiGlobe, FiMonitor, FiRefreshCw, FiRepeat, FiTerminal, FiTrash2 } from "react-icons/fi";
6
+ import { IoLogoAndroid } from "react-icons/io";
7
+ import { SiIos } from "react-icons/si";
4
8
  import type { InventoryColumn } from "./app-model";
5
9
  import { installationRows, text } from "./app-model";
6
10
 
@@ -12,17 +16,22 @@ interface DraftInventoryRow {
12
16
  let draftRowCounter = 0;
13
17
 
14
18
  export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement {
19
+ const [appCollection, setAppCollection] = useState<AppCollectionId>("aidevops");
20
+ const [managedCategory, setManagedCategory] = useState<ManagedCategoryId>("core");
21
+ const [recommendedPlatform, setRecommendedPlatform] = useState<RecommendedPlatformFilterId>("all");
15
22
  const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
16
- const [expandedAppIds, setExpandedAppIds] = useState<Set<string>>(() => new Set(status.managed_apps.length > 0 ? [status.managed_apps[0].id] : []));
23
+ const [expandedAppId, setExpandedAppId] = useState<string | null>(() => sortedManagedApps(status.managed_apps).find((app) => managedCategoryForApp(app) === "core")?.id ?? null);
17
24
  const [jobs, setJobs] = useState<Record<string, GuiAppActionJobSummary>>({});
25
+ const [policyJobs, setPolicyJobs] = useState<Record<string, GuiAppActionJobSummary[]>>({});
26
+ const [policyToggles, setPolicyToggles] = useState<ManagedPolicyToggleState>(() => readManagedPolicyToggles());
18
27
  const selectedJob = selectedJobId === null ? null : jobs[selectedJobId] ?? null;
28
+ const managedApps = sortedManagedApps(status.managed_apps).map((app) => applyManagedPolicyToggles(app, policyToggles[app.id]));
29
+ const visibleManagedApps = managedApps.filter((app) => managedCategoryForApp(app) === managedCategory);
30
+ const visibleRecommendedApps = recommendedApps.filter((app) => recommendedPlatform === "all" || app.platforms.includes(recommendedPlatform));
19
31
 
20
32
  useEffect(() => {
21
- if (status.managed_apps.length === 0) {
22
- return;
23
- }
24
- setExpandedAppIds((current) => current.size === 0 ? new Set([status.managed_apps[0].id]) : current);
25
- }, [status.managed_apps]);
33
+ setExpandedAppId(visibleManagedApps[0]?.id ?? null);
34
+ }, [managedCategory, status.managed_apps]);
26
35
 
27
36
  useEffect(() => {
28
37
  if (selectedJob === null || selectedJob.status !== "running") {
@@ -37,34 +46,52 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
37
46
  }, [selectedJob]);
38
47
 
39
48
  return (
40
- <section className="panel" aria-label={text.apps}>
49
+ <section className="apps-surface" aria-label={text.apps}>
41
50
  <div className="section-heading">
42
51
  <p className="eyebrow">{text.infrastructure}</p>
43
52
  <h2>{text.apps}</h2>
44
53
  <p>{text.appsIntro}</p>
45
54
  </div>
46
- <div className="managed-app-list">
47
- {status.managed_apps.map((app) => (
48
- <ManagedAppPanel
49
- app={app}
50
- expanded={expandedAppIds.has(app.id)}
51
- job={jobForApp(app.id, jobs, selectedJob)}
52
- key={app.id}
53
- onJob={(job) => {
54
- setJobs((current) => ({ ...current, [job.id]: job }));
55
- setSelectedJobId(job.id);
56
- setExpandedAppIds((current) => toggledAppIds(current, app.id, true));
57
- }}
58
- onToggle={() => setExpandedAppIds((current) => toggledAppIds(current, app.id))}
59
- />
60
- ))}
61
- </div>
62
- <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>
55
+ <TabNav label="Apps collections" tabs={appCollectionTabs} value={appCollection} onChange={(value) => setAppCollection(value)} />
56
+ {appCollection === "aidevops" ? <>
57
+ <TabNav label="aidevops app groups" tabs={managedCategoryTabs} value={managedCategory} onChange={(value) => setManagedCategory(value)} />
58
+ <div className="managed-app-list">
59
+ {visibleManagedApps.map((app) => (
60
+ <ManagedAppPanel
61
+ app={app}
62
+ expanded={expandedAppId === app.id}
63
+ job={jobForApp(app.id, jobs, selectedJob)}
64
+ key={app.id}
65
+ policyJobs={policyJobs[app.id] ?? []}
66
+ onJob={(job) => {
67
+ setJobs((current) => ({ ...current, [job.id]: job }));
68
+ setSelectedJobId(job.id);
69
+ setExpandedAppId(app.id);
70
+ }}
71
+ onPolicyToggle={(policy, value) => {
72
+ setPolicyToggles((current) => {
73
+ const next = { ...current, [app.id]: { ...current[app.id], [policy]: value } };
74
+ writeManagedPolicyToggles(next);
75
+ return next;
76
+ });
77
+ const policyJob = createPolicyJob(app.id, policy, value);
78
+ setPolicyJobs((current) => ({ ...current, [app.id]: [...(current[app.id] ?? []), policyJob] }));
79
+ setSelectedJobId(policyJob.id);
80
+ setExpandedAppId(app.id);
81
+ }}
82
+ onToggle={() => setExpandedAppId((current) => current === app.id ? null : app.id)}
83
+ />
84
+ ))}
85
+ </div>
86
+ <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>
87
+ </> : <RecommendedAppsSurface apps={visibleRecommendedApps} platform={recommendedPlatform} setPlatform={setRecommendedPlatform} />}
63
88
  </section>
64
89
  );
65
90
  }
66
91
 
67
- function ManagedAppPanel({ app, expanded, job, onJob, onToggle }: { app: GuiManagedAppSummary; expanded: boolean; job: GuiAppActionJobSummary | null; onJob: (job: GuiAppActionJobSummary) => void; onToggle: () => void }): ReactElement {
92
+ function ManagedAppPanel({ app, expanded, job, onJob, onPolicyToggle, onToggle, policyJobs }: { app: GuiManagedAppSummary; expanded: boolean; job: GuiAppActionJobSummary | null; onJob: (job: GuiAppActionJobSummary) => void; onPolicyToggle: (policy: ManagedPolicyId, value: boolean) => void; onToggle: () => void; policyJobs: GuiAppActionJobSummary[] }): ReactElement {
93
+ const lockedPolicy = isEssentialManagedApp(app);
94
+
68
95
  return (
69
96
  <article className={expanded ? "managed-app-card expanded" : "managed-app-card"}>
70
97
  <button aria-expanded={expanded} className="managed-app-summary" data-tooltip={`${expanded ? "Collapse" : "Expand"} ${app.name} controls`} onClick={onToggle} type="button">
@@ -74,7 +101,6 @@ function ManagedAppPanel({ app, expanded, job, onJob, onToggle }: { app: GuiMana
74
101
  <span>{app.description}</span>
75
102
  </span>
76
103
  <span className="managed-app-summary-meta">
77
- <SummaryChip label="Status" value={app.status} />
78
104
  <SummaryChip label="Installed" value={app.installed_version} />
79
105
  <SummaryChip label="Latest" value={app.latest_version} />
80
106
  <FiChevronDown aria-hidden="true" className="managed-app-chevron" />
@@ -89,16 +115,18 @@ function ManagedAppPanel({ app, expanded, job, onJob, onToggle }: { app: GuiMana
89
115
  <div className="managed-app-actions">
90
116
  {app.actions.map((action) => <AppActionButton action={action.id} app={app} disabled={!action.enabled} key={action.id} onJob={onJob} commandPreview={action.command_preview} />)}
91
117
  </div>
92
- </div>
118
+ </div>
93
119
  <div className="managed-app-details">
94
- <ToggleSwitch checked={app.aidevops_install} label="setup installs" />
95
- <ToggleSwitch checked={app.aidevops_update} label="update maintains" />
120
+ <ToggleSwitch checked={app.aidevops_install} disabled={lockedPolicy} label="setup installs" onChange={(value) => onPolicyToggle("setup", value)} />
121
+ <ToggleSwitch checked={app.aidevops_update} disabled={lockedPolicy} label="update maintains" onChange={(value) => onPolicyToggle("update", value)} />
96
122
  <AppMeta label="Installed" value={app.installed_version} />
97
123
  <AppMeta label="Latest" value={app.latest_version} />
98
124
  <AppMeta label={text.path} value={app.install_path_ref} />
99
- <AppMeta label="Status" value={app.status} />
125
+ {job ? <AppLogLink job={job} /> : null}
100
126
  </div>
101
- {job ? <AppActionTerminal job={job} /> : <p className="empty-state compact-notice">No recent command output for this app. Run an action to open this app's terminal log.</p>}
127
+ {job ? <AppActionTerminal job={job} /> : null}
128
+ {policyJobs.map((policyJob) => <AppActionTerminal job={policyJob} key={policyJob.id} />)}
129
+ {job === null && policyJobs.length === 0 ? <p className="empty-state compact-notice">No recent command output for this app. Run an action or change a policy toggle to open this app's terminal log.</p> : null}
102
130
  </div> : null}
103
131
  </article>
104
132
  );
@@ -113,41 +141,71 @@ function OriginLink({ href, label }: { href: string; label: string }): ReactElem
113
141
  return <span className="origin-missing">{label}: source pending</span>;
114
142
  }
115
143
 
116
- return <a data-tooltip={`Open ${label.toLowerCase()} destination`} href={href} rel="noreferrer" target="_blank">{label} <FiExternalLink aria-hidden="true" /></a>;
144
+ return <a href={href} onClick={(event) => openExternalLink(event, href)} rel="noreferrer" target="_blank" title={href}>{label} <FiExternalLink aria-hidden="true" /></a>;
117
145
  }
118
146
 
119
- function ToggleSwitch({ checked, label }: { checked: boolean; label: string }): ReactElement {
120
- return <span className="managed-toggle"><span aria-hidden="true" className={checked ? "switch-track checked" : "switch-track"}><span /></span>{label}</span>;
147
+ function ToggleSwitch({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }): ReactElement {
148
+ return <button aria-pressed={checked} className={checked ? "managed-toggle checked" : "managed-toggle"} data-tooltip={disabled ? "Essential aidevops component; policy is locked on" : undefined} disabled={disabled} onClick={() => onChange(!checked)} title={disabled ? "Essential aidevops component; policy is locked on" : undefined} type="button"><span aria-hidden="true" className={checked ? "switch-track checked" : "switch-track"}><span /></span>{label}</button>;
121
149
  }
122
150
 
123
151
  function AppMeta({ label, value }: { label: string; value: string }): ReactElement {
124
152
  return <span className="app-meta"><small>{label}</small><strong>{value}</strong></span>;
125
153
  }
126
154
 
155
+ function AppLogLink({ job }: { job: GuiAppActionJobSummary }): ReactElement {
156
+ return <a className="app-meta app-log-link" href={`#app-job-${job.id}`}><small>Logs</small><strong>{job.action} · {job.status}</strong></a>;
157
+ }
158
+
127
159
  function AppActionButton({ action, app, commandPreview, disabled, onJob }: { action: GuiAppActionId; app: GuiManagedAppSummary; commandPreview: string; disabled: boolean; onJob: (job: GuiAppActionJobSummary) => void }): ReactElement {
128
160
  const icon = action === "install" ? <FiDownload /> : action === "update" ? <FiRefreshCw /> : action === "reinstall" ? <FiRepeat /> : <FiTrash2 />;
161
+ const [confirmOpen, setConfirmOpen] = useState(false);
129
162
 
130
163
  async function runAction(): Promise<void> {
131
164
  if (disabled) {
132
165
  return;
133
166
  }
134
- if ((action === "remove" || action === "reinstall") && !window.confirm(`${action} ${app.name}?`)) {
135
- return;
136
- }
137
167
 
138
168
  const response = await fetch(`/api/apps/${encodeURIComponent(app.id)}/actions/${action}`, { method: "POST" });
139
169
  const envelope = await response.json() as GuiResponseEnvelope<GuiAppActionJobSummary>;
140
170
  onJob(envelope.data);
141
171
  }
142
172
 
143
- return <button aria-label={`${action} ${app.name}`} className={action === "remove" ? "app-action-button remove" : "app-action-button"} data-tooltip={commandPreview} disabled={disabled} onClick={() => void runAction()} type="button">{icon}<span>{action}</span></button>;
173
+ return <>
174
+ <button aria-label={`${action} ${app.name}`} className={action === "remove" ? "app-action-button remove" : "app-action-button"} data-tooltip={commandPreview} disabled={disabled} onClick={() => setConfirmOpen(true)} type="button">{icon}<span>{action}</span></button>
175
+ {confirmOpen ? <ConfirmActionModal action={action} app={app} commandPreview={commandPreview} close={() => setConfirmOpen(false)} confirm={() => { setConfirmOpen(false); void runAction(); }} /> : null}
176
+ </>;
177
+ }
178
+
179
+ function ConfirmActionModal({ action, app, close, commandPreview, confirm }: { action: GuiAppActionId; app: GuiManagedAppSummary; close: () => void; commandPreview: string; confirm: () => void }): ReactElement {
180
+ return (
181
+ <div className="modal-backdrop" role="presentation">
182
+ <section aria-modal="true" className="confirm-modal" role="dialog" aria-labelledby={`confirm-${app.id}-${action}`}>
183
+ <p className="eyebrow">Confirm action</p>
184
+ <h3 id={`confirm-${app.id}-${action}`}>{action} {app.name}?</h3>
185
+ <p>This will start the allowlisted background command for this app.</p>
186
+ <code>{commandPreview}</code>
187
+ <div className="confirm-modal-actions">
188
+ <button className="secondary-action" onClick={close} type="button">Cancel</button>
189
+ <button className={action === "remove" ? "app-action-button remove" : "app-action-button"} onClick={confirm} type="button">Confirm</button>
190
+ </div>
191
+ </section>
192
+ </div>
193
+ );
144
194
  }
145
195
 
146
196
  function AppActionTerminal({ job }: { job: GuiAppActionJobSummary }): ReactElement {
197
+ const outputRef = useRef<HTMLPreElement | null>(null);
198
+
199
+ useEffect(() => {
200
+ if (outputRef.current !== null) {
201
+ outputRef.current.scrollTop = outputRef.current.scrollHeight;
202
+ }
203
+ }, [job.output]);
204
+
147
205
  return (
148
- <section className="app-terminal" aria-label="App action terminal output">
206
+ <section className="app-terminal" id={`app-job-${job.id}`} aria-label="App action terminal output">
149
207
  <header><strong>{job.app_id} {job.action}</strong><span>{job.status}{job.exit_code === null ? "" : ` (${job.exit_code})`}</span></header>
150
- <pre>{job.output.join("\n")}</pre>
208
+ <pre ref={outputRef}>{job.output.join("\n")}</pre>
151
209
  </section>
152
210
  );
153
211
  }
@@ -169,15 +227,277 @@ function jobForApp(appId: string, jobs: Record<string, GuiAppActionJobSummary>,
169
227
  return Object.values(jobs).reverse().find((job) => job.app_id === appId) ?? null;
170
228
  }
171
229
 
172
- function toggledAppIds(current: Set<string>, appId: string, forceOpen = false): Set<string> {
173
- const next = new Set(current);
174
- if (forceOpen || !next.has(appId)) {
175
- next.add(appId);
176
- } else {
177
- next.delete(appId);
230
+ function sortedManagedApps(apps: GuiManagedAppSummary[]): GuiManagedAppSummary[] {
231
+ return [...apps].sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
232
+ }
233
+
234
+ function managedCategoryForApp(app: GuiManagedAppSummary): ManagedCategoryId {
235
+ if (["core", "safety", "automation"].includes(app.category)) {
236
+ return "core";
237
+ }
238
+ if (app.category.includes("cli")) {
239
+ return "cli";
240
+ }
241
+ if (app.category.includes("ai")) {
242
+ return "ai";
243
+ }
244
+ if (["desktop", "editor"].includes(app.category)) {
245
+ return "desktop";
246
+ }
247
+ if (["terminal"].includes(app.category) || app.name.toLowerCase().includes("terminal")) {
248
+ return "terminal";
249
+ }
250
+
251
+ return "cli";
252
+ }
253
+
254
+ function isEssentialManagedApp(app: GuiManagedAppSummary): boolean {
255
+ return ["core", "safety", "automation"].includes(app.category);
256
+ }
257
+
258
+ type AppCollectionId = "aidevops" | "recommended";
259
+ type ManagedCategoryId = "core" | "ai" | "cli" | "desktop" | "terminal";
260
+ type RecommendedOsId = "all" | "macos" | "linux" | "windows" | "ios" | "android";
261
+ type RecommendedPlatformId = "webapp" | "saas" | "cli" | "api";
262
+ type RecommendedPlatformFilterId = "all" | RecommendedPlatformId;
263
+ type ManagedPolicyId = "setup" | "update";
264
+ type ManagedPolicyToggleState = Record<string, Partial<Record<ManagedPolicyId, boolean>>>;
265
+
266
+ interface TabOption<T extends string> {
267
+ id: T;
268
+ label: string;
269
+ }
270
+
271
+ interface RecommendedApp {
272
+ name: string;
273
+ description: string;
274
+ websiteUrl: string;
275
+ alternativeToUrl?: string;
276
+ repoUrl?: string;
277
+ iosUrl?: string;
278
+ androidUrl?: string;
279
+ fdroidUrl?: string;
280
+ obtainiumUrl?: string;
281
+ os: RecommendedOsId[];
282
+ platforms: RecommendedPlatformId[];
283
+ }
284
+
285
+ const appCollectionTabs: TabOption<AppCollectionId>[] = [
286
+ { id: "aidevops", label: "aidevops" },
287
+ { id: "recommended", label: "Recommended" },
288
+ ];
289
+
290
+ const managedCategoryTabs: TabOption<ManagedCategoryId>[] = [
291
+ { id: "core", label: "Core" },
292
+ { id: "ai", label: "AI" },
293
+ { id: "cli", label: "CLI" },
294
+ { id: "desktop", label: "Desktop" },
295
+ { id: "terminal", label: "Terminal" },
296
+ ];
297
+
298
+ const recommendedPlatformTabs: TabOption<RecommendedPlatformFilterId>[] = [
299
+ { id: "all", label: "All" },
300
+ { id: "webapp", label: "WebApp" },
301
+ { id: "saas", label: "SaaS" },
302
+ { id: "cli", label: "CLI" },
303
+ { id: "api", label: "API" },
304
+ ];
305
+
306
+ const recommendedApps = ([
307
+ { name: "Affinity Studio", description: "Creative design suite.", websiteUrl: "https://www.affinity.studio/", alternativeToUrl: "https://alternativeto.net/software/affinity-1/", os: ["macos", "windows", "ios"], platforms: [] },
308
+ { name: "Bitwarden", description: "Open source password manager.", websiteUrl: "https://bitwarden.com", alternativeToUrl: "https://alternativeto.net/software/bitwarden--free-password-manager/", repoUrl: "https://github.com/bitwarden", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "cli", "api"] },
309
+ { name: "Brave Browser", description: "Privacy-focused web browser.", websiteUrl: "https://brave.com/", alternativeToUrl: "https://alternativeto.net/software/brave/", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
310
+ { name: "Cloudron", description: "Self-hosted app platform.", websiteUrl: "https://www.cloudron.io/", alternativeToUrl: "https://alternativeto.net/software/cloudron/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
311
+ { name: "Collabora Online", description: "Online office collaboration.", websiteUrl: "https://www.collaboraonline.com/collabora-online/", alternativeToUrl: "https://alternativeto.net/software/collabora-online/", os: ["linux"], platforms: ["webapp", "api"] },
312
+ { name: "Cometly", description: "Marketing attribution analytics.", websiteUrl: "https://www.cometly.com/", os: [], platforms: ["saas", "api"] },
313
+ { name: "DaVinci Resolve", description: "Professional video editing, color, VFX, and audio post-production.", websiteUrl: "https://www.blackmagicdesign.com/products/davinciresolve/", alternativeToUrl: "https://alternativeto.net/software/davinci-resolve/", os: ["macos", "linux", "windows"], platforms: [] },
314
+ { name: "DocuSeal", description: "Open source document signing.", websiteUrl: "https://www.docuseal.com/", alternativeToUrl: "https://alternativeto.net/software/docuseal/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
315
+ { name: "Element", description: "Matrix messaging client.", websiteUrl: "https://element.io/", alternativeToUrl: "https://alternativeto.net/software/element-app/", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp"] },
316
+ { name: "Enpass", description: "Offline-first password manager.", websiteUrl: "https://www.enpass.io/", alternativeToUrl: "https://alternativeto.net/software/enpass/", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
317
+ { name: "EspoCRM", description: "Open source CRM.", websiteUrl: "https://www.espocrm.com/", alternativeToUrl: "https://alternativeto.net/software/espocrm/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
318
+ { name: "Fathom Analytics", description: "Privacy-first analytics.", websiteUrl: "https://usefathom.com/", alternativeToUrl: "https://alternativeto.net/software/fathom-analytics/", os: [], platforms: ["saas", "api"] },
319
+ { name: "FontBase", description: "Font management and reference tool.", websiteUrl: "https://fontba.se/", alternativeToUrl: "https://alternativeto.net/software/fontbase/", os: ["macos"], platforms: [] },
320
+ { name: "Forgejo", description: "Self-hosted Git forge.", websiteUrl: "https://forgejo.org/", alternativeToUrl: "https://alternativeto.net/software/forgejo/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
321
+ { name: "Ghost", description: "Publishing platform.", websiteUrl: "https://ghost.org/", alternativeToUrl: "https://alternativeto.net/software/ghost/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
322
+ { name: "Gitea", description: "Self-hosted Git service.", websiteUrl: "https://about.gitea.com/", alternativeToUrl: "https://alternativeto.net/software/gitea/", os: ["linux", "windows"], platforms: ["webapp", "cli", "api"] },
323
+ { name: "GitLab", description: "DevSecOps and Git hosting platform.", websiteUrl: "https://gitlab.com/", alternativeToUrl: "https://alternativeto.net/software/gitlab/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
324
+ { name: "LibreOffice", description: "Open source office suite.", websiteUrl: "https://www.libreoffice.org/", alternativeToUrl: "https://alternativeto.net/software/libreoffice/", os: ["macos", "linux", "windows"], platforms: [] },
325
+ { name: "LocalWP", description: "Local WordPress development.", websiteUrl: "https://localwp.com/", alternativeToUrl: "https://alternativeto.net/software/local-by-flywheel/", os: ["macos", "linux", "windows"], platforms: [] },
326
+ { name: "Matomo", description: "Open analytics platform.", websiteUrl: "https://matomo.org/", alternativeToUrl: "https://alternativeto.net/software/piwik/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
327
+ { name: "Nextcloud", description: "Open source content collaboration platform.", websiteUrl: "https://nextcloud.com/", alternativeToUrl: "https://alternativeto.net/software/nextcloud/", repoUrl: "https://github.com/nextcloud", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "api"] },
328
+ { name: "Nextcloud Talk", description: "Open source video calls, chat, and collaboration for Nextcloud.", websiteUrl: "https://nextcloud.com/talk/", repoUrl: "https://github.com/nextcloud/spreed", iosUrl: "https://apps.apple.com/us/app/nextcloud-talk/id1296825574", androidUrl: "https://play.google.com/store/apps/details?id=com.nextcloud.talk2&hl=en", os: ["ios", "android"], platforms: ["webapp"] },
329
+ { name: "OBS Studio", description: "Open source video recording and live streaming.", websiteUrl: "https://obsproject.com/", alternativeToUrl: "https://alternativeto.net/software/open-broadcaster-software/", repoUrl: "https://github.com/obsproject/obs-studio", os: ["macos", "linux", "windows"], platforms: [] },
330
+ { name: "ONLYOFFICE", description: "Office and document collaboration.", websiteUrl: "https://www.onlyoffice.com/", alternativeToUrl: "https://alternativeto.net/software/onlyoffice/", repoUrl: "https://github.com/ONLYOFFICE/", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "saas", "api"] },
331
+ { name: "OpenScreen", description: "Open screen-sharing project.", websiteUrl: "https://github.com/getopenscreen/openscreen/releases", alternativeToUrl: "https://alternativeto.net/software/openscreen/", repoUrl: "https://github.com/getopenscreen/openscreen", os: ["macos", "linux", "windows"], platforms: [] },
332
+ { name: "Osaurus", description: "AI app workspace.", websiteUrl: "https://osaurus.ai/", alternativeToUrl: "https://alternativeto.net/software/osaurus/", os: ["macos"], platforms: [] },
333
+ { name: "Parallels Desktop", description: "Run Windows, Linux, and virtual machines on macOS.", websiteUrl: "https://www.parallels.com/products/desktop/", alternativeToUrl: "https://alternativeto.net/software/parallels-desktop/about/", os: ["macos"], platforms: [] },
334
+ { name: "PDF Studio", description: "PDF editor.", websiteUrl: "https://www.qoppa.com/pdfstudio/", alternativeToUrl: "https://alternativeto.net/software/qoppa-pdf-studio/", os: ["macos", "linux", "windows"], platforms: [] },
335
+ { name: "Pixelmator Pro", description: "Professional image editor for macOS.", websiteUrl: "https://www.apple.com/pixelmator-pro/", os: ["macos"], platforms: [] },
336
+ { name: "PostHog", description: "Product analytics platform.", websiteUrl: "https://posthog.com/", alternativeToUrl: "https://alternativeto.net/software/posthog/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
337
+ { name: "Postiz", description: "Social media scheduling.", websiteUrl: "https://postiz.com/", alternativeToUrl: "https://alternativeto.net/software/postiz/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
338
+ { name: "PrivateBin", description: "Zero-knowledge pastebin.", websiteUrl: "https://privatebin.info/", alternativeToUrl: "https://alternativeto.net/software/privatebin/", repoUrl: "https://github.com/PrivateBin/PrivateBin", os: ["linux"], platforms: ["webapp", "api"] },
339
+ { name: "Proxmox", description: "Virtualization platform.", websiteUrl: "https://www.proxmox.com/", alternativeToUrl: "https://alternativeto.net/software/proxmox-virtual-environment/", os: ["linux"], platforms: ["webapp", "cli", "api"] },
340
+ { name: "QuickFile", description: "Accounting platform.", websiteUrl: "https://www.quickfile.co.uk/", os: [], platforms: ["saas", "api"] },
341
+ { name: "Reframed", description: "Creative framing utility.", websiteUrl: "https://www.reframed.dev/", alternativeToUrl: "https://alternativeto.net/software/reframed/", os: ["macos"], platforms: [] },
342
+ { name: "Rybbit", description: "Web analytics.", websiteUrl: "https://rybbit.com/", alternativeToUrl: "https://alternativeto.net/software/rybbit/", os: ["linux"], platforms: ["webapp", "saas", "api"] },
343
+ { name: "SchildiChat", description: "Matrix messaging client.", websiteUrl: "https://schildi.chat/", alternativeToUrl: "https://alternativeto.net/software/schildichat/", os: ["macos", "linux", "windows", "android"], platforms: ["webapp"] },
344
+ { name: "ScreenFlow", description: "Screen recording and editing.", websiteUrl: "https://www.telestream.net/screenflow/", alternativeToUrl: "https://alternativeto.net/software/screenflow/", os: ["macos", "ios"], platforms: [] },
345
+ { name: "SEO Utils", description: "Desktop SEO tools.", websiteUrl: "https://seoutils.app/", os: ["macos", "linux", "windows"], platforms: [] },
346
+ { name: "Shottr", description: "macOS screenshot utility.", websiteUrl: "https://shottr.cc/", alternativeToUrl: "https://alternativeto.net/software/shottr/", os: ["macos"], platforms: [] },
347
+ { name: "Signal", description: "Private messenger.", websiteUrl: "https://signal.org/", alternativeToUrl: "https://alternativeto.net/software/signal-private-messenger/", os: ["macos", "linux", "windows", "ios", "android"], platforms: [] },
348
+ { name: "SimpleX Chat", description: "Private messenger with no user IDs.", websiteUrl: "https://simplex.chat/", alternativeToUrl: "https://alternativeto.net/software/simplex-chat/about/", repoUrl: "https://github.com/simplex-chat", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["cli"] },
349
+ { name: "Telegram", description: "Cloud-based mobile and desktop messaging app.", websiteUrl: "https://telegram.org/", iosUrl: "https://telegram.org/dl/ios", androidUrl: "https://telegram.org/android", os: ["macos", "linux", "windows", "ios", "android"], platforms: ["webapp", "api"] },
350
+ { name: "Thunderbird", description: "Email and calendar app.", websiteUrl: "https://www.thunderbird.net/", alternativeToUrl: "https://alternativeto.net/software/mozilla-thunderbird/about/", os: ["macos", "linux", "windows"], platforms: [] },
351
+ { name: "Ubicloud", description: "Open cloud platform.", websiteUrl: "https://www.ubicloud.com/", alternativeToUrl: "https://alternativeto.net/software/ubicloud/about/", repoUrl: "https://github.com/ubicloud/ubicloud", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
352
+ { name: "Vaultwarden", description: "Alternative Bitwarden server implementation.", websiteUrl: "https://github.com/dani-garcia/vaultwarden", alternativeToUrl: "https://alternativeto.net/software/vaultwarden/", repoUrl: "https://github.com/dani-garcia/vaultwarden", os: ["linux"], platforms: ["webapp", "api"] },
353
+ { name: "VideoProc", description: "Video processing toolkit.", websiteUrl: "https://www.videoproc.com/", alternativeToUrl: "https://alternativeto.net/software/videoproc/about/", os: ["macos", "windows"], platforms: [] },
354
+ { name: "VirtualBox", description: "Virtualization app.", websiteUrl: "https://www.virtualbox.org/", alternativeToUrl: "https://alternativeto.net/software/virtualbox/about/", os: ["macos", "linux", "windows"], platforms: ["cli"] },
355
+ { name: "WordPress", description: "Open source publishing platform.", websiteUrl: "https://wordpress.org/", alternativeToUrl: "https://alternativeto.net/software/wordpress/about/", os: ["linux"], platforms: ["webapp", "saas", "cli", "api"] },
356
+ ] satisfies RecommendedApp[]).sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
357
+
358
+ function TabNav<T extends string>({ label, onChange, tabs, value }: { label: string; onChange: (value: T) => void; tabs: TabOption<T>[]; value: T }): ReactElement {
359
+ return <div aria-label={label} className="pill-tabs app-subnav" role="tablist">{tabs.map((tab) => <button aria-selected={value === tab.id} className={value === tab.id ? "active" : ""} key={tab.id} onClick={() => onChange(tab.id)} role="tab" type="button">{tab.label}</button>)}</div>;
360
+ }
361
+
362
+ function RecommendedAppsSurface({ apps, platform, setPlatform }: { apps: RecommendedApp[]; platform: RecommendedPlatformFilterId; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
363
+ return <>
364
+ <TabNav label="Recommended app platform filters" tabs={recommendedPlatformTabs} value={platform} onChange={setPlatform} />
365
+ <div className="recommended-app-grid">
366
+ {apps.map((app) => <RecommendedAppCard app={app} key={app.name} setPlatform={setPlatform} />)}
367
+ </div>
368
+ </>;
369
+ }
370
+
371
+ function RecommendedAppCard({ app, setPlatform }: { app: RecommendedApp; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
372
+ return <article className="recommended-app-card">
373
+ <div>
374
+ <strong>{app.name}</strong>
375
+ <p>{app.description}</p>
376
+ </div>
377
+ <div className="app-icon-groups">
378
+ <PlatformIconList platforms={app.platforms} setPlatform={setPlatform} />
379
+ <OsIconList os={app.os} />
380
+ </div>
381
+ <div className="managed-app-links plain-links">
382
+ <OriginLink href={app.websiteUrl} label={text.website} />
383
+ {app.alternativeToUrl ? <OriginLink href={app.alternativeToUrl} label="AlternativeTo" /> : null}
384
+ {app.repoUrl ? <OriginLink href={app.repoUrl} label="Repo" /> : null}
385
+ {app.iosUrl ? <OriginLink href={app.iosUrl} label="iOS" /> : null}
386
+ {app.androidUrl ? <OriginLink href={app.androidUrl} label="Android" /> : null}
387
+ {app.fdroidUrl ? <OriginLink href={app.fdroidUrl} label="F-Droid" /> : null}
388
+ {app.obtainiumUrl ? <OriginLink href={app.obtainiumUrl} label="Obtainium" /> : null}
389
+ </div>
390
+ </article>;
391
+ }
392
+
393
+ function PlatformIconList({ platforms, setPlatform }: { platforms: RecommendedPlatformId[]; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement | null {
394
+ if (platforms.length === 0) {
395
+ return null;
396
+ }
397
+
398
+ return <span className="os-icon-list platform-icon-list">{platforms.map((platform) => <PlatformIcon id={platform} key={platform} setPlatform={setPlatform} />)}</span>;
399
+ }
400
+
401
+ function PlatformIcon({ id, setPlatform }: { id: RecommendedPlatformId; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
402
+ const iconMap: Record<RecommendedPlatformId, { Icon: IconType; label: string }> = {
403
+ webapp: { Icon: FiMonitor, label: "WebApp" },
404
+ saas: { Icon: FiGlobe, label: "SaaS" },
405
+ cli: { Icon: FiTerminal, label: "CLI" },
406
+ api: { Icon: FiCode, label: "API" },
407
+ };
408
+ const { Icon, label } = iconMap[id];
409
+
410
+ return <button aria-label={`Filter by ${label}`} data-tooltip={`Filter by ${label}`} onClick={() => setPlatform(id)} title={`Filter by ${label}`} type="button"><Icon aria-hidden="true" focusable="false" /></button>;
411
+ }
412
+
413
+ function OsIconList({ os }: { os: RecommendedOsId[] }): ReactElement | null {
414
+ if (os.length === 0) {
415
+ return null;
416
+ }
417
+
418
+ return <span className="os-icon-list">{os.map((item) => <OsIcon id={item} key={item} />)}</span>;
419
+ }
420
+
421
+ function OsIcon({ id }: { id: RecommendedOsId }): ReactElement {
422
+ const iconMap: Record<RecommendedOsId, { Icon: IconType; label: string }> = {
423
+ all: { Icon: FiGlobe, label: "Web app" },
424
+ macos: { Icon: FaApple, label: "macOS" },
425
+ linux: { Icon: FaLinux, label: "Linux" },
426
+ windows: { Icon: FaWindows, label: "Windows" },
427
+ ios: { Icon: SiIos, label: "iOS" },
428
+ android: { Icon: IoLogoAndroid, label: "Android" },
429
+ };
430
+ const { Icon, label } = iconMap[id];
431
+
432
+ return <span aria-label={label} data-tooltip={label} role="img" title={label}><Icon aria-hidden="true" focusable="false" /></span>;
433
+ }
434
+
435
+ function applyManagedPolicyToggles(app: GuiManagedAppSummary, policy: Partial<Record<ManagedPolicyId, boolean>> | undefined): GuiManagedAppSummary {
436
+ if (policy === undefined) {
437
+ return app;
178
438
  }
179
439
 
180
- return next;
440
+ return {
441
+ ...app,
442
+ aidevops_install: policy.setup ?? app.aidevops_install,
443
+ aidevops_update: policy.update ?? app.aidevops_update,
444
+ };
445
+ }
446
+
447
+ function createPolicyJob(appId: string, policy: ManagedPolicyId, value: boolean): GuiAppActionJobSummary {
448
+ const mode = value ? "on" : "off";
449
+ const command = `aidevops app policy set ${appId} --${policy === "setup" ? "setup-installs" : "update-maintains"} ${mode}`;
450
+ const now = new Date().toISOString();
451
+
452
+ return {
453
+ id: `policy-${appId}-${policy}-${Date.now()}`,
454
+ app_id: appId,
455
+ action: "update",
456
+ status: "completed",
457
+ command_preview: command,
458
+ started_at: now,
459
+ finished_at: now,
460
+ exit_code: 0,
461
+ output: [
462
+ `$ ${command}`,
463
+ `Saved local aidevops.app policy override for ${appId}: ${policy}=${mode}.`,
464
+ "Persistence: browser localStorage key aidevops-gui-managed-app-policy.",
465
+ ],
466
+ };
467
+ }
468
+
469
+ function readManagedPolicyToggles(): ManagedPolicyToggleState {
470
+ if (typeof window === "undefined") {
471
+ return {};
472
+ }
473
+
474
+ try {
475
+ const raw = window.localStorage.getItem("aidevops-gui-managed-app-policy");
476
+ return raw === null ? {} : JSON.parse(raw) as ManagedPolicyToggleState;
477
+ } catch {
478
+ return {};
479
+ }
480
+ }
481
+
482
+ function writeManagedPolicyToggles(policy: ManagedPolicyToggleState): void {
483
+ if (typeof window === "undefined") {
484
+ return;
485
+ }
486
+
487
+ window.localStorage.setItem("aidevops-gui-managed-app-policy", JSON.stringify(policy));
488
+ }
489
+
490
+ function openExternalLink(event: ReactMouseEvent<HTMLAnchorElement>, href: string): void {
491
+ event.stopPropagation();
492
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
493
+ return;
494
+ }
495
+
496
+ const opened = window.open(href, "_blank", "noopener,noreferrer");
497
+ if (opened !== null) {
498
+ event.preventDefault();
499
+ opened.opener = null;
500
+ }
181
501
  }
182
502
 
183
503
  export function InstallationSurface(): ReactElement {
@@ -335,6 +335,19 @@ function plannedCommsGroup(label: string, items: SurfaceNavItem[]): SurfaceNavGr
335
335
  }
336
336
 
337
337
  export const navGroups: SurfaceNavGroup[] = [
338
+ {
339
+ label: text.infrastructure,
340
+ mode: "devops",
341
+ items: [
342
+ { id: "devices", label: text.devices, description: "Installed aidevops machines", icon: "device", badge: text.planned },
343
+ { id: "apps", label: text.apps, description: "Installed tools and apps", icon: "package" },
344
+ { id: "vpnsProxies", label: text.vpnsProxies, description: "VPN and proxy inventory", icon: "shield", badge: text.planned },
345
+ { id: "installation", label: text.installation, description: "Optional setup toggles", icon: "download" },
346
+ { id: "registrars", label: text.registrars, description: "Domain registrars", icon: "chain" },
347
+ { id: "hosts", label: text.hosts, description: "Hosting providers", icon: "server" },
348
+ { id: "servers", label: text.servers, description: "Servers and orchestrators", icon: "hardDrive" },
349
+ ],
350
+ },
338
351
  {
339
352
  label: text.development,
340
353
  mode: "devops",
@@ -360,19 +373,6 @@ export const navGroups: SurfaceNavGroup[] = [
360
373
  { id: "routines", label: text.routines, description: "Scheduled workflows", icon: "clock", badge: text.planned },
361
374
  ],
362
375
  },
363
- {
364
- label: text.infrastructure,
365
- mode: "devops",
366
- items: [
367
- { id: "devices", label: text.devices, description: "Installed aidevops machines", icon: "device", badge: text.planned },
368
- { id: "vpnsProxies", label: text.vpnsProxies, description: "VPN and proxy inventory", icon: "shield", badge: text.planned },
369
- { id: "apps", label: text.apps, description: "Installed tools and apps", icon: "package" },
370
- { id: "installation", label: text.installation, description: "Optional setup toggles", icon: "download" },
371
- { id: "registrars", label: text.registrars, description: "Domain registrars", icon: "chain" },
372
- { id: "hosts", label: text.hosts, description: "Hosting providers", icon: "server" },
373
- { id: "servers", label: text.servers, description: "Servers and orchestrators", icon: "hardDrive" },
374
- ],
375
- },
376
376
  {
377
377
  label: text.identities,
378
378
  mode: "comms",
@@ -1,6 +1,7 @@
1
1
  :root {
2
2
  --accent-hue: 123;
3
3
  --accent: hsl(var(--accent-hue) 50% 36%);
4
+ --accent-aaa-bg: hsl(var(--accent-hue) 58% 24%);
4
5
  --accent-strong: hsl(var(--accent-hue) 56% 28%);
5
6
  --bg-primary: #ffffff;
6
7
  --bg-secondary: #f8f8f8;
@@ -33,6 +34,7 @@
33
34
 
34
35
  :root[data-theme="dark"] {
35
36
  --accent: hsl(var(--accent-hue) 74% 66%);
37
+ --accent-aaa-bg: hsl(var(--accent-hue) 64% 24%);
36
38
  --accent-strong: hsl(var(--accent-hue) 75% 72%);
37
39
  --bg-primary: #0a0a0a;
38
40
  --bg-secondary: #111111;
@@ -921,6 +923,15 @@ code {
921
923
  min-width: 0;
922
924
  }
923
925
 
926
+ .header-actions > .header-icon-button,
927
+ .header-actions > .header-action-menu {
928
+ align-items: center;
929
+ display: inline-flex;
930
+ flex: 0 0 38px;
931
+ justify-content: center;
932
+ width: 38px;
933
+ }
934
+
924
935
  .workspace-search {
925
936
  background: var(--surface-muted);
926
937
  border: 1px solid var(--border);
@@ -1964,6 +1975,16 @@ code {
1964
1975
  margin-bottom: 12px;
1965
1976
  }
1966
1977
 
1978
+ .apps-surface {
1979
+ display: grid;
1980
+ align-content: start;
1981
+ gap: 10px;
1982
+ }
1983
+
1984
+ .apps-surface .section-heading {
1985
+ margin-bottom: 8px;
1986
+ }
1987
+
1967
1988
  .data-table,
1968
1989
  .editable-table,
1969
1990
  .installation-list,
@@ -1985,13 +2006,21 @@ code {
1985
2006
  border: 1px solid var(--border);
1986
2007
  border-radius: 18px;
1987
2008
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 7%);
1988
- overflow: visible;
2009
+ overflow: hidden;
2010
+ transition: background 140ms ease, border-color 140ms ease;
1989
2011
  }
1990
2012
 
1991
2013
  .managed-app-card.expanded {
1992
2014
  border-color: color-mix(in srgb, var(--accent) 32%, var(--border));
1993
2015
  }
1994
2016
 
2017
+ .managed-app-card:hover,
2018
+ .managed-app-card:focus-within {
2019
+ background:
2020
+ linear-gradient(135deg, color-mix(in srgb, var(--accent) 11%, transparent), transparent 40%),
2021
+ var(--surface-muted);
2022
+ }
2023
+
1995
2024
  .managed-app-summary {
1996
2025
  align-items: center;
1997
2026
  background: transparent;
@@ -2001,6 +2030,7 @@ code {
2001
2030
  gap: 16px;
2002
2031
  grid-template-columns: minmax(0, 1fr) auto;
2003
2032
  padding: 16px;
2033
+ position: relative;
2004
2034
  text-align: left;
2005
2035
  width: 100%;
2006
2036
  }
@@ -2039,13 +2069,16 @@ code {
2039
2069
  }
2040
2070
 
2041
2071
  .managed-summary-chip {
2072
+ align-items: center;
2042
2073
  background: color-mix(in srgb, var(--surface-elevated) 92%, transparent);
2043
2074
  border: 1px solid var(--border);
2044
2075
  border-radius: 999px;
2045
2076
  display: inline-grid;
2046
2077
  gap: 1px;
2078
+ justify-items: center;
2047
2079
  min-width: 118px;
2048
2080
  padding: 7px 11px;
2081
+ text-align: center;
2049
2082
  }
2050
2083
 
2051
2084
  .managed-summary-chip small {
@@ -2081,6 +2114,7 @@ code {
2081
2114
  display: grid;
2082
2115
  gap: 14px;
2083
2116
  padding: 0 16px 16px;
2117
+ position: relative;
2084
2118
  }
2085
2119
 
2086
2120
  .managed-app-toolbar {
@@ -2106,25 +2140,22 @@ code {
2106
2140
  .managed-app-links a,
2107
2141
  .origin-missing {
2108
2142
  align-items: center;
2109
- border: 1px solid var(--border);
2110
- border-radius: 999px;
2111
2143
  color: var(--text-secondary);
2112
2144
  display: inline-flex;
2113
- font-size: 0.78rem;
2145
+ font-size: 0.95em;
2114
2146
  font-weight: 800;
2115
2147
  gap: 4px;
2116
2148
  max-width: 100%;
2117
- padding: 6px 9px;
2149
+ padding: 4px 0;
2118
2150
  position: relative;
2119
2151
  text-decoration: none;
2120
2152
  }
2121
2153
 
2122
2154
  .managed-app-links a:hover,
2123
2155
  .managed-app-links a:focus-visible {
2124
- background: color-mix(in srgb, var(--accent) 12%, transparent);
2125
- border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
2126
2156
  color: var(--accent);
2127
2157
  outline: none;
2158
+ text-decoration: underline;
2128
2159
  }
2129
2160
 
2130
2161
  .origin-missing {
@@ -2157,9 +2188,25 @@ code {
2157
2188
  text-transform: uppercase;
2158
2189
  }
2159
2190
 
2191
+ button.managed-toggle {
2192
+ cursor: pointer;
2193
+ text-align: left;
2194
+ }
2195
+
2196
+ button.managed-toggle:disabled {
2197
+ cursor: not-allowed;
2198
+ opacity: 0.82;
2199
+ }
2200
+
2201
+ button.managed-toggle:hover,
2202
+ button.managed-toggle:focus-visible {
2203
+ border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
2204
+ outline: none;
2205
+ }
2206
+
2160
2207
  .switch-track {
2161
- background: var(--surface-muted);
2162
- border: 1px solid var(--border);
2208
+ background: color-mix(in srgb, #ef4444 18%, var(--surface-muted));
2209
+ border: 1px solid color-mix(in srgb, #ef4444 44%, var(--border));
2163
2210
  border-radius: 999px;
2164
2211
  display: inline-flex;
2165
2212
  padding: 2px;
@@ -2167,7 +2214,7 @@ code {
2167
2214
  }
2168
2215
 
2169
2216
  .switch-track span {
2170
- background: var(--text-muted);
2217
+ background: #ef4444;
2171
2218
  border-radius: 50%;
2172
2219
  height: 14px;
2173
2220
  transform: translateX(0);
@@ -2176,10 +2223,15 @@ code {
2176
2223
  }
2177
2224
 
2178
2225
  .switch-track.checked span {
2179
- background: var(--accent);
2226
+ background: #22c55e;
2180
2227
  transform: translateX(14px);
2181
2228
  }
2182
2229
 
2230
+ .switch-track.checked {
2231
+ background: color-mix(in srgb, #22c55e 18%, var(--surface-muted));
2232
+ border-color: color-mix(in srgb, #22c55e 48%, var(--border));
2233
+ }
2234
+
2183
2235
  .app-meta small {
2184
2236
  color: var(--text-muted);
2185
2237
  font-size: 0.72rem;
@@ -2195,6 +2247,17 @@ code {
2195
2247
  word-break: break-word;
2196
2248
  }
2197
2249
 
2250
+ .app-log-link {
2251
+ color: var(--text-primary);
2252
+ text-decoration: none;
2253
+ }
2254
+
2255
+ .app-log-link:hover,
2256
+ .app-log-link:focus-visible {
2257
+ border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
2258
+ outline: none;
2259
+ }
2260
+
2198
2261
  .app-action-button {
2199
2262
  align-items: center;
2200
2263
  background: color-mix(in srgb, var(--accent) 16%, var(--surface-elevated));
@@ -2278,6 +2341,45 @@ code {
2278
2341
  word-break: break-word;
2279
2342
  }
2280
2343
 
2344
+ .modal-backdrop {
2345
+ align-items: center;
2346
+ background: rgb(0 0 0 / 62%);
2347
+ display: flex;
2348
+ inset: 0;
2349
+ justify-content: center;
2350
+ padding: 18px;
2351
+ position: fixed;
2352
+ z-index: 100;
2353
+ }
2354
+
2355
+ .confirm-modal {
2356
+ background: var(--surface-elevated);
2357
+ border: 1px solid var(--border-hover);
2358
+ border-radius: 18px;
2359
+ box-shadow: 0 24px 90px rgb(0 0 0 / 48%);
2360
+ display: grid;
2361
+ gap: 12px;
2362
+ max-width: 520px;
2363
+ padding: 22px;
2364
+ width: min(100%, 520px);
2365
+ }
2366
+
2367
+ .confirm-modal h3,
2368
+ .confirm-modal p {
2369
+ margin: 0;
2370
+ }
2371
+
2372
+ .confirm-modal code {
2373
+ overflow-wrap: anywhere;
2374
+ }
2375
+
2376
+ .confirm-modal-actions {
2377
+ display: flex;
2378
+ flex-wrap: wrap;
2379
+ gap: 10px;
2380
+ justify-content: flex-end;
2381
+ }
2382
+
2281
2383
  [data-tooltip] {
2282
2384
  position: relative;
2283
2385
  }
@@ -2292,7 +2394,7 @@ code {
2292
2394
  content: attr(data-tooltip);
2293
2395
  font-size: 0.72rem;
2294
2396
  font-weight: 700;
2295
- left: 50%;
2397
+ left: clamp(10px, 50%, calc(100vw - 330px));
2296
2398
  line-height: 1.35;
2297
2399
  max-width: min(320px, 72vw);
2298
2400
  opacity: 0;
@@ -2303,7 +2405,7 @@ code {
2303
2405
  transform: translate(-50%, 4px);
2304
2406
  transition: opacity 140ms ease, transform 140ms ease;
2305
2407
  width: max-content;
2306
- z-index: 40;
2408
+ z-index: 400;
2307
2409
  }
2308
2410
 
2309
2411
  [data-tooltip]:hover::after,
@@ -2382,10 +2484,82 @@ code {
2382
2484
  background: var(--surface-muted);
2383
2485
  border: 1px solid var(--border);
2384
2486
  border-radius: 999px;
2385
- display: flex;
2487
+ align-items: center;
2488
+ display: inline-flex;
2386
2489
  gap: 4px;
2387
2490
  padding: 5px;
2388
- width: min(100%, 520px);
2491
+ width: max-content;
2492
+ max-width: 100%;
2493
+ }
2494
+
2495
+ .app-subnav {
2496
+ justify-self: start;
2497
+ }
2498
+
2499
+ .recommended-app-grid {
2500
+ display: grid;
2501
+ gap: 12px;
2502
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
2503
+ }
2504
+
2505
+ .recommended-app-card {
2506
+ background:
2507
+ linear-gradient(135deg, color-mix(in srgb, var(--accent) 6%, transparent), transparent 40%),
2508
+ var(--surface-muted);
2509
+ border: 1px solid var(--border);
2510
+ border-radius: 18px;
2511
+ display: grid;
2512
+ gap: 12px;
2513
+ padding: 16px;
2514
+ }
2515
+
2516
+ .recommended-app-card strong {
2517
+ font-size: 1.05rem;
2518
+ }
2519
+
2520
+ .recommended-app-card p {
2521
+ color: var(--text-secondary);
2522
+ margin: 4px 0 0;
2523
+ }
2524
+
2525
+ .os-icon-list {
2526
+ display: flex;
2527
+ flex-wrap: wrap;
2528
+ gap: 6px;
2529
+ }
2530
+
2531
+ .app-icon-groups {
2532
+ display: flex;
2533
+ flex-wrap: wrap;
2534
+ gap: 10px;
2535
+ }
2536
+
2537
+ .os-icon-list span,
2538
+ .os-icon-list button {
2539
+ align-items: center;
2540
+ background: transparent;
2541
+ border: 0;
2542
+ border-radius: 0;
2543
+ color: var(--text-secondary);
2544
+ display: inline-flex;
2545
+ font-size: 1.12rem;
2546
+ font-weight: 900;
2547
+ justify-content: center;
2548
+ min-height: 24px;
2549
+ min-width: 24px;
2550
+ padding: 0;
2551
+ }
2552
+
2553
+ .platform-icon-list button {
2554
+ cursor: pointer;
2555
+ }
2556
+
2557
+ .os-icon-list span:hover,
2558
+ .os-icon-list span:focus-visible,
2559
+ .os-icon-list button:hover,
2560
+ .os-icon-list button:focus-visible {
2561
+ color: var(--accent);
2562
+ outline: none;
2389
2563
  }
2390
2564
 
2391
2565
  .pill-tabs button {
@@ -2393,20 +2567,30 @@ code {
2393
2567
  border: 0;
2394
2568
  border-radius: 999px;
2395
2569
  color: var(--text-secondary);
2396
- flex: 1 1 0;
2570
+ flex: 0 0 auto;
2397
2571
  font-weight: 800;
2398
2572
  min-height: 38px;
2399
2573
  padding: 9px 14px;
2400
2574
  }
2401
2575
 
2402
- .pill-tabs button.active,
2576
+ .pill-tabs button.active {
2577
+ background: var(--accent-aaa-bg);
2578
+ color: #ffffff;
2579
+ }
2580
+
2403
2581
  .pill-tabs button:hover,
2404
2582
  .pill-tabs button:focus-visible {
2405
- background: var(--surface-elevated);
2406
- color: var(--text-primary);
2583
+ background: color-mix(in srgb, var(--accent) 16%, var(--surface-elevated));
2584
+ color: var(--accent);
2407
2585
  outline: none;
2408
2586
  }
2409
2587
 
2588
+ .pill-tabs button.active:hover,
2589
+ .pill-tabs button.active:focus-visible {
2590
+ background: var(--accent-aaa-bg);
2591
+ color: #ffffff;
2592
+ }
2593
+
2410
2594
  .count-pill,
2411
2595
  .repo-summary-strip span {
2412
2596
  background: color-mix(in srgb, var(--accent) 14%, transparent);
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.8
15
+ # Version: 3.29.11
16
16
  #
17
17
  # Quick Install:
18
18
  # npm install -g aidevops && aidevops update (recommended)