aidevops 3.29.9 → 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.9-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.9
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.9
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.9",
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, useRef, 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
 
@@ -14,13 +18,16 @@ let draftRowCounter = 0;
14
18
  export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement {
15
19
  const [appCollection, setAppCollection] = useState<AppCollectionId>("aidevops");
16
20
  const [managedCategory, setManagedCategory] = useState<ManagedCategoryId>("core");
17
- const [recommendedOs, setRecommendedOs] = useState<RecommendedOsId>("all");
21
+ const [recommendedPlatform, setRecommendedPlatform] = useState<RecommendedPlatformFilterId>("all");
18
22
  const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
19
23
  const [expandedAppId, setExpandedAppId] = useState<string | null>(() => sortedManagedApps(status.managed_apps).find((app) => managedCategoryForApp(app) === "core")?.id ?? null);
20
24
  const [jobs, setJobs] = useState<Record<string, GuiAppActionJobSummary>>({});
25
+ const [policyJobs, setPolicyJobs] = useState<Record<string, GuiAppActionJobSummary[]>>({});
26
+ const [policyToggles, setPolicyToggles] = useState<ManagedPolicyToggleState>(() => readManagedPolicyToggles());
21
27
  const selectedJob = selectedJobId === null ? null : jobs[selectedJobId] ?? null;
22
- const visibleManagedApps = sortedManagedApps(status.managed_apps).filter((app) => managedCategoryForApp(app) === managedCategory);
23
- const visibleRecommendedApps = recommendedApps.filter((app) => recommendedOs === "all" || (app.os as readonly RecommendedOsId[]).includes(recommendedOs));
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));
24
31
 
25
32
  useEffect(() => {
26
33
  setExpandedAppId(visibleManagedApps[0]?.id ?? null);
@@ -55,22 +62,36 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
55
62
  expanded={expandedAppId === app.id}
56
63
  job={jobForApp(app.id, jobs, selectedJob)}
57
64
  key={app.id}
65
+ policyJobs={policyJobs[app.id] ?? []}
58
66
  onJob={(job) => {
59
67
  setJobs((current) => ({ ...current, [job.id]: job }));
60
68
  setSelectedJobId(job.id);
61
69
  setExpandedAppId(app.id);
62
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
+ }}
63
82
  onToggle={() => setExpandedAppId((current) => current === app.id ? null : app.id)}
64
83
  />
65
84
  ))}
66
85
  </div>
67
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>
68
- </> : <RecommendedAppsSurface apps={visibleRecommendedApps} os={recommendedOs} setOs={setRecommendedOs} />}
87
+ </> : <RecommendedAppsSurface apps={visibleRecommendedApps} platform={recommendedPlatform} setPlatform={setRecommendedPlatform} />}
69
88
  </section>
70
89
  );
71
90
  }
72
91
 
73
- 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
+
74
95
  return (
75
96
  <article className={expanded ? "managed-app-card expanded" : "managed-app-card"}>
76
97
  <button aria-expanded={expanded} className="managed-app-summary" data-tooltip={`${expanded ? "Collapse" : "Expand"} ${app.name} controls`} onClick={onToggle} type="button">
@@ -94,16 +115,18 @@ function ManagedAppPanel({ app, expanded, job, onJob, onToggle }: { app: GuiMana
94
115
  <div className="managed-app-actions">
95
116
  {app.actions.map((action) => <AppActionButton action={action.id} app={app} disabled={!action.enabled} key={action.id} onJob={onJob} commandPreview={action.command_preview} />)}
96
117
  </div>
97
- </div>
118
+ </div>
98
119
  <div className="managed-app-details">
99
- <ToggleSwitch checked={app.aidevops_install} label="setup installs" />
100
- <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)} />
101
122
  <AppMeta label="Installed" value={app.installed_version} />
102
123
  <AppMeta label="Latest" value={app.latest_version} />
103
124
  <AppMeta label={text.path} value={app.install_path_ref} />
104
125
  {job ? <AppLogLink job={job} /> : null}
105
126
  </div>
106
- {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}
107
130
  </div> : null}
108
131
  </article>
109
132
  );
@@ -118,13 +141,11 @@ function OriginLink({ href, label }: { href: string; label: string }): ReactElem
118
141
  return <span className="origin-missing">{label}: source pending</span>;
119
142
  }
120
143
 
121
- return <a data-tooltip={href} 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>;
122
145
  }
123
146
 
124
- function ToggleSwitch({ checked, label }: { checked: boolean; label: string }): ReactElement {
125
- const [isChecked, setIsChecked] = useState(checked);
126
-
127
- return <button aria-pressed={isChecked} className={isChecked ? "managed-toggle checked" : "managed-toggle"} onClick={() => setIsChecked((current) => !current)} type="button"><span aria-hidden="true" className={isChecked ? "switch-track checked" : "switch-track"}><span /></span>{label}</button>;
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>;
128
149
  }
129
150
 
130
151
  function AppMeta({ label, value }: { label: string; value: string }): ReactElement {
@@ -214,6 +235,9 @@ function managedCategoryForApp(app: GuiManagedAppSummary): ManagedCategoryId {
214
235
  if (["core", "safety", "automation"].includes(app.category)) {
215
236
  return "core";
216
237
  }
238
+ if (app.category.includes("cli")) {
239
+ return "cli";
240
+ }
217
241
  if (app.category.includes("ai")) {
218
242
  return "ai";
219
243
  }
@@ -227,9 +251,17 @@ function managedCategoryForApp(app: GuiManagedAppSummary): ManagedCategoryId {
227
251
  return "cli";
228
252
  }
229
253
 
254
+ function isEssentialManagedApp(app: GuiManagedAppSummary): boolean {
255
+ return ["core", "safety", "automation"].includes(app.category);
256
+ }
257
+
230
258
  type AppCollectionId = "aidevops" | "recommended";
231
259
  type ManagedCategoryId = "core" | "ai" | "cli" | "desktop" | "terminal";
232
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>>>;
233
265
 
234
266
  interface TabOption<T extends string> {
235
267
  id: T;
@@ -242,7 +274,12 @@ interface RecommendedApp {
242
274
  websiteUrl: string;
243
275
  alternativeToUrl?: string;
244
276
  repoUrl?: string;
277
+ iosUrl?: string;
278
+ androidUrl?: string;
279
+ fdroidUrl?: string;
280
+ obtainiumUrl?: string;
245
281
  os: RecommendedOsId[];
282
+ platforms: RecommendedPlatformId[];
246
283
  }
247
284
 
248
285
  const appCollectionTabs: TabOption<AppCollectionId>[] = [
@@ -258,99 +295,209 @@ const managedCategoryTabs: TabOption<ManagedCategoryId>[] = [
258
295
  { id: "terminal", label: "Terminal" },
259
296
  ];
260
297
 
261
- const recommendedOsTabs: TabOption<RecommendedOsId>[] = [
298
+ const recommendedPlatformTabs: TabOption<RecommendedPlatformFilterId>[] = [
262
299
  { id: "all", label: "All" },
263
- { id: "macos", label: "macOS" },
264
- { id: "linux", label: "Linux" },
265
- { id: "windows", label: "Windows" },
266
- { id: "ios", label: "iOS" },
267
- { id: "android", label: "Android" },
300
+ { id: "webapp", label: "WebApp" },
301
+ { id: "saas", label: "SaaS" },
302
+ { id: "cli", label: "CLI" },
303
+ { id: "api", label: "API" },
268
304
  ];
269
305
 
270
306
  const recommendedApps = ([
271
- { name: "Affinity Studio", description: "Creative design suite.", websiteUrl: "https://www.affinity.studio/", alternativeToUrl: "https://alternativeto.net/software/affinity-1/", os: ["macos", "windows", "ios"] },
272
- { 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"] },
273
- { name: "Brave Browser", description: "Privacy-focused web browser.", websiteUrl: "https://brave.com/", alternativeToUrl: "https://alternativeto.net/software/brave/", os: ["macos", "linux", "windows", "ios", "android"] },
274
- { name: "Cloudron", description: "Self-hosted app platform.", websiteUrl: "https://www.cloudron.io/", alternativeToUrl: "https://alternativeto.net/software/cloudron/", os: ["linux"] },
275
- { name: "Collabora Online", description: "Online office collaboration.", websiteUrl: "https://www.collaboraonline.com/collabora-online/", alternativeToUrl: "https://alternativeto.net/software/collabora-online/", os: ["linux"] },
276
- { name: "Cometly", description: "Marketing attribution analytics.", websiteUrl: "https://www.cometly.com/", os: [] },
277
- { 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"] },
278
- { name: "DocuSeal", description: "Open source document signing.", websiteUrl: "https://www.docuseal.com/", alternativeToUrl: "https://alternativeto.net/software/docuseal/", os: ["linux"] },
279
- { name: "Element", description: "Matrix messaging client.", websiteUrl: "https://element.io/", alternativeToUrl: "https://alternativeto.net/software/element-app/", os: ["macos", "linux", "windows", "ios", "android"] },
280
- { name: "Enpass", description: "Offline-first password manager.", websiteUrl: "https://www.enpass.io/", alternativeToUrl: "https://alternativeto.net/software/enpass/", os: ["macos", "linux", "windows", "ios", "android"] },
281
- { name: "EspoCRM", description: "Open source CRM.", websiteUrl: "https://www.espocrm.com/", alternativeToUrl: "https://alternativeto.net/software/espocrm/", os: ["linux"] },
282
- { name: "Fathom Analytics", description: "Privacy-first analytics.", websiteUrl: "https://usefathom.com/", alternativeToUrl: "https://alternativeto.net/software/fathom-analytics/", os: [] },
283
- { name: "FonrBase", description: "Font management and reference tool.", websiteUrl: "https://fontba.se/", alternativeToUrl: "https://alternativeto.net/software/fontbase/", os: ["macos"] },
284
- { name: "Forgejo", description: "Self-hosted Git forge.", websiteUrl: "https://forgejo.org/", alternativeToUrl: "https://alternativeto.net/software/forgejo/", os: ["linux"] },
285
- { name: "Ghost", description: "Publishing platform.", websiteUrl: "https://ghost.org/", alternativeToUrl: "https://alternativeto.net/software/ghost/", os: ["linux"] },
286
- { name: "Gitea", description: "Self-hosted Git service.", websiteUrl: "https://about.gitea.com/", alternativeToUrl: "https://alternativeto.net/software/gitea/", os: ["linux", "windows"] },
287
- { name: "GitLab", description: "DevSecOps and Git hosting platform.", websiteUrl: "https://gitlab.com/", alternativeToUrl: "https://alternativeto.net/software/gitlab/", os: ["linux"] },
288
- { name: "LibreOffice", description: "Open source office suite.", websiteUrl: "https://www.libreoffice.org/", alternativeToUrl: "https://alternativeto.net/software/libreoffice/", os: ["macos", "linux", "windows"] },
289
- { name: "LocalWP", description: "Local WordPress development.", websiteUrl: "https://localwp.com/", alternativeToUrl: "https://alternativeto.net/software/local-by-flywheel/", os: ["macos", "linux", "windows"] },
290
- { name: "Matomo", description: "Open analytics platform.", websiteUrl: "https://matomo.org/", alternativeToUrl: "https://alternativeto.net/software/piwik/", os: ["linux"] },
291
- { 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"] },
292
- { 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"] },
293
- { name: "ONLYOFFICE", description: "Office and document collaboration.", websiteUrl: "https://www.onlyoffice.com/", alternativeToUrl: "https://alternativeto.net/software/onlyoffice/", os: ["macos", "linux", "windows", "ios", "android"] },
294
- { name: "OpenScreen", description: "Open screen-sharing project.", websiteUrl: "https://github.com/getopenscreen/openscreen", alternativeToUrl: "https://alternativeto.net/software/openscreen/", repoUrl: "https://github.com/getopenscreen/openscreen", os: ["linux"] },
295
- { name: "Osaurus", description: "AI app workspace.", websiteUrl: "https://osaurus.ai/", alternativeToUrl: "https://alternativeto.net/software/osaurus/", os: ["macos"] },
296
- { 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"] },
297
- { name: "PDF Studio", description: "PDF editor.", websiteUrl: "https://www.qoppa.com/pdfstudio/", alternativeToUrl: "https://alternativeto.net/software/qoppa-pdf-studio/", os: ["macos", "linux", "windows"] },
298
- { name: "PostHog", description: "Product analytics platform.", websiteUrl: "https://posthog.com/", alternativeToUrl: "https://alternativeto.net/software/posthog/", os: ["linux"] },
299
- { name: "Postiz", description: "Social media scheduling.", websiteUrl: "https://postiz.com/", alternativeToUrl: "https://alternativeto.net/software/postiz/", os: ["linux"] },
300
- { name: "PrivateBin", description: "Zero-knowledge pastebin.", websiteUrl: "https://privatebin.info/", alternativeToUrl: "https://alternativeto.net/software/privatebin/", repoUrl: "https://github.com/PrivateBin/PrivateBin", os: ["linux"] },
301
- { name: "Proxmox", description: "Virtualization platform.", websiteUrl: "https://www.proxmox.com/", alternativeToUrl: "https://alternativeto.net/software/proxmox-virtual-environment/", os: ["linux"] },
302
- { name: "QuickFile", description: "Accounting platform.", websiteUrl: "https://www.quickfile.co.uk/", os: [] },
303
- { name: "Reframed", description: "Creative framing utility.", websiteUrl: "https://www.reframed.dev/", alternativeToUrl: "https://alternativeto.net/software/reframed/", os: ["macos"] },
304
- { name: "Rybbit", description: "Web analytics.", websiteUrl: "https://rybbit.com/", alternativeToUrl: "https://alternativeto.net/software/rybbit/", os: ["linux"] },
305
- { name: "SchildiChat", description: "Matrix messaging client.", websiteUrl: "https://schildi.chat/", alternativeToUrl: "https://alternativeto.net/software/schildichat/", os: ["macos", "linux", "windows", "android"] },
306
- { name: "ScreenFlow", description: "Screen recording and editing.", websiteUrl: "https://www.telestream.net/screenflow/", alternativeToUrl: "https://alternativeto.net/software/screenflow/", os: ["macos", "ios"] },
307
- { name: "SEO Utils", description: "Desktop SEO tools.", websiteUrl: "https://seoutils.app/", os: ["macos", "linux", "windows"] },
308
- { name: "Shottr", description: "macOS screenshot utility.", websiteUrl: "https://shottr.cc/", alternativeToUrl: "https://alternativeto.net/software/shottr/", os: ["macos"] },
309
- { name: "Signal", description: "Private messenger.", websiteUrl: "https://signal.org/", alternativeToUrl: "https://alternativeto.net/software/signal-private-messenger/", os: ["macos", "linux", "windows", "ios", "android"] },
310
- { 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"] },
311
- { name: "Thunderbird", description: "Email and calendar app.", websiteUrl: "https://www.thunderbird.net/", alternativeToUrl: "https://alternativeto.net/software/mozilla-thunderbird/about/", os: ["macos", "linux", "windows"] },
312
- { name: "Ubicloud", description: "Open cloud platform.", websiteUrl: "https://www.ubicloud.com/", alternativeToUrl: "https://alternativeto.net/software/ubicloud/about/", os: ["linux"] },
313
- { 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"] },
314
- { name: "VideoProc", description: "Video processing toolkit.", websiteUrl: "https://www.videoproc.com/", alternativeToUrl: "https://alternativeto.net/software/videoproc/about/", os: ["macos", "windows"] },
315
- { name: "VirtualBox", description: "Virtualization app.", websiteUrl: "https://www.virtualbox.org/", alternativeToUrl: "https://alternativeto.net/software/virtualbox/about/", os: ["macos", "linux", "windows"] },
316
- { name: "WordPress", description: "Open source publishing platform.", websiteUrl: "https://wordpress.org/", alternativeToUrl: "https://alternativeto.net/software/wordpress/about/", os: ["linux"] },
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"] },
317
356
  ] satisfies RecommendedApp[]).sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
318
357
 
319
358
  function TabNav<T extends string>({ label, onChange, tabs, value }: { label: string; onChange: (value: T) => void; tabs: TabOption<T>[]; value: T }): ReactElement {
320
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>;
321
360
  }
322
361
 
323
- function RecommendedAppsSurface({ apps, os, setOs }: { apps: RecommendedApp[]; os: RecommendedOsId; setOs: (os: RecommendedOsId) => void }): ReactElement {
362
+ function RecommendedAppsSurface({ apps, platform, setPlatform }: { apps: RecommendedApp[]; platform: RecommendedPlatformFilterId; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
324
363
  return <>
325
- <TabNav label="Recommended app OS filters" tabs={recommendedOsTabs} value={os} onChange={setOs} />
364
+ <TabNav label="Recommended app platform filters" tabs={recommendedPlatformTabs} value={platform} onChange={setPlatform} />
326
365
  <div className="recommended-app-grid">
327
- {apps.map((app) => <RecommendedAppCard app={app} key={app.name} />)}
366
+ {apps.map((app) => <RecommendedAppCard app={app} key={app.name} setPlatform={setPlatform} />)}
328
367
  </div>
329
368
  </>;
330
369
  }
331
370
 
332
- function RecommendedAppCard({ app }: { app: RecommendedApp }): ReactElement {
371
+ function RecommendedAppCard({ app, setPlatform }: { app: RecommendedApp; setPlatform: (platform: RecommendedPlatformFilterId) => void }): ReactElement {
333
372
  return <article className="recommended-app-card">
334
373
  <div>
335
374
  <strong>{app.name}</strong>
336
375
  <p>{app.description}</p>
337
376
  </div>
338
- <OsIconList os={app.os} />
377
+ <div className="app-icon-groups">
378
+ <PlatformIconList platforms={app.platforms} setPlatform={setPlatform} />
379
+ <OsIconList os={app.os} />
380
+ </div>
339
381
  <div className="managed-app-links plain-links">
340
382
  <OriginLink href={app.websiteUrl} label={text.website} />
341
383
  {app.alternativeToUrl ? <OriginLink href={app.alternativeToUrl} label="AlternativeTo" /> : null}
342
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}
343
389
  </div>
344
390
  </article>;
345
391
  }
346
392
 
347
- function OsIconList({ os }: { os: RecommendedOsId[] }): ReactElement {
348
- const icons: Record<RecommendedOsId, string> = { all: "*", macos: "⌘", linux: "🐧", windows: "⊞", ios: "iOS", android: "🤖" };
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 {
349
414
  if (os.length === 0) {
350
- return <span className="os-icon-list"><span title="Web app">Web</span></span>;
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;
351
438
  }
352
439
 
353
- return <span className="os-icon-list">{os.map((item) => <span key={item} title={recommendedOsTabs.find((tab) => tab.id === item)?.label}>{icons[item]}</span>)}</span>;
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
+ }
354
501
  }
355
502
 
356
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);
@@ -1966,7 +1977,12 @@ code {
1966
1977
 
1967
1978
  .apps-surface {
1968
1979
  display: grid;
1969
- gap: 14px;
1980
+ align-content: start;
1981
+ gap: 10px;
1982
+ }
1983
+
1984
+ .apps-surface .section-heading {
1985
+ margin-bottom: 8px;
1970
1986
  }
1971
1987
 
1972
1988
  .data-table,
@@ -1990,13 +2006,21 @@ code {
1990
2006
  border: 1px solid var(--border);
1991
2007
  border-radius: 18px;
1992
2008
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 7%);
1993
- overflow: visible;
2009
+ overflow: hidden;
2010
+ transition: background 140ms ease, border-color 140ms ease;
1994
2011
  }
1995
2012
 
1996
2013
  .managed-app-card.expanded {
1997
2014
  border-color: color-mix(in srgb, var(--accent) 32%, var(--border));
1998
2015
  }
1999
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
+
2000
2024
  .managed-app-summary {
2001
2025
  align-items: center;
2002
2026
  background: transparent;
@@ -2006,6 +2030,7 @@ code {
2006
2030
  gap: 16px;
2007
2031
  grid-template-columns: minmax(0, 1fr) auto;
2008
2032
  padding: 16px;
2033
+ position: relative;
2009
2034
  text-align: left;
2010
2035
  width: 100%;
2011
2036
  }
@@ -2089,6 +2114,7 @@ code {
2089
2114
  display: grid;
2090
2115
  gap: 14px;
2091
2116
  padding: 0 16px 16px;
2117
+ position: relative;
2092
2118
  }
2093
2119
 
2094
2120
  .managed-app-toolbar {
@@ -2116,7 +2142,7 @@ code {
2116
2142
  align-items: center;
2117
2143
  color: var(--text-secondary);
2118
2144
  display: inline-flex;
2119
- font-size: 0.78rem;
2145
+ font-size: 0.95em;
2120
2146
  font-weight: 800;
2121
2147
  gap: 4px;
2122
2148
  max-width: 100%;
@@ -2167,6 +2193,11 @@ button.managed-toggle {
2167
2193
  text-align: left;
2168
2194
  }
2169
2195
 
2196
+ button.managed-toggle:disabled {
2197
+ cursor: not-allowed;
2198
+ opacity: 0.82;
2199
+ }
2200
+
2170
2201
  button.managed-toggle:hover,
2171
2202
  button.managed-toggle:focus-visible {
2172
2203
  border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
@@ -2363,7 +2394,7 @@ button.managed-toggle:focus-visible {
2363
2394
  content: attr(data-tooltip);
2364
2395
  font-size: 0.72rem;
2365
2396
  font-weight: 700;
2366
- left: 50%;
2397
+ left: clamp(10px, 50%, calc(100vw - 330px));
2367
2398
  line-height: 1.35;
2368
2399
  max-width: min(320px, 72vw);
2369
2400
  opacity: 0;
@@ -2374,7 +2405,7 @@ button.managed-toggle:focus-visible {
2374
2405
  transform: translate(-50%, 4px);
2375
2406
  transition: opacity 140ms ease, transform 140ms ease;
2376
2407
  width: max-content;
2377
- z-index: 40;
2408
+ z-index: 400;
2378
2409
  }
2379
2410
 
2380
2411
  [data-tooltip]:hover::after,
@@ -2453,14 +2484,16 @@ button.managed-toggle:focus-visible {
2453
2484
  background: var(--surface-muted);
2454
2485
  border: 1px solid var(--border);
2455
2486
  border-radius: 999px;
2456
- display: flex;
2487
+ align-items: center;
2488
+ display: inline-flex;
2457
2489
  gap: 4px;
2458
2490
  padding: 5px;
2459
- width: min(100%, 520px);
2491
+ width: max-content;
2492
+ max-width: 100%;
2460
2493
  }
2461
2494
 
2462
2495
  .app-subnav {
2463
- width: fit-content;
2496
+ justify-self: start;
2464
2497
  }
2465
2498
 
2466
2499
  .recommended-app-grid {
@@ -2495,19 +2528,38 @@ button.managed-toggle:focus-visible {
2495
2528
  gap: 6px;
2496
2529
  }
2497
2530
 
2498
- .os-icon-list span {
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 {
2499
2539
  align-items: center;
2500
- background: var(--surface-elevated);
2501
- border: 1px solid var(--border);
2502
- border-radius: 999px;
2540
+ background: transparent;
2541
+ border: 0;
2542
+ border-radius: 0;
2503
2543
  color: var(--text-secondary);
2504
2544
  display: inline-flex;
2505
- font-size: 0.72rem;
2545
+ font-size: 1.12rem;
2506
2546
  font-weight: 900;
2507
2547
  justify-content: center;
2508
- min-height: 28px;
2509
- min-width: 28px;
2510
- padding: 4px 7px;
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;
2511
2563
  }
2512
2564
 
2513
2565
  .pill-tabs button {
@@ -2515,20 +2567,30 @@ button.managed-toggle:focus-visible {
2515
2567
  border: 0;
2516
2568
  border-radius: 999px;
2517
2569
  color: var(--text-secondary);
2518
- flex: 1 1 0;
2570
+ flex: 0 0 auto;
2519
2571
  font-weight: 800;
2520
2572
  min-height: 38px;
2521
2573
  padding: 9px 14px;
2522
2574
  }
2523
2575
 
2524
- .pill-tabs button.active,
2576
+ .pill-tabs button.active {
2577
+ background: var(--accent-aaa-bg);
2578
+ color: #ffffff;
2579
+ }
2580
+
2525
2581
  .pill-tabs button:hover,
2526
2582
  .pill-tabs button:focus-visible {
2527
- background: var(--surface-elevated);
2528
- color: var(--text-primary);
2583
+ background: color-mix(in srgb, var(--accent) 16%, var(--surface-elevated));
2584
+ color: var(--accent);
2529
2585
  outline: none;
2530
2586
  }
2531
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
+
2532
2594
  .count-pill,
2533
2595
  .repo-summary-strip span {
2534
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.9
15
+ # Version: 3.29.11
16
16
  #
17
17
  # Quick Install:
18
18
  # npm install -g aidevops && aidevops update (recommended)