aidevops 3.29.8 → 3.29.9

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.9-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.9
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.9
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.9",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,5 +1,5 @@
1
1
  import type { GuiAppActionId, GuiAppActionJobSummary, GuiManagedAppSummary, GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
2
- import { type Dispatch, type ReactElement, type SetStateAction, useEffect, useState } from "react";
2
+ import { type Dispatch, type ReactElement, type SetStateAction, useEffect, useRef, useState } from "react";
3
3
  import { FiChevronDown, FiDownload, FiExternalLink, FiRefreshCw, FiRepeat, FiTrash2 } from "react-icons/fi";
4
4
  import type { InventoryColumn } from "./app-model";
5
5
  import { installationRows, text } from "./app-model";
@@ -12,17 +12,19 @@ interface DraftInventoryRow {
12
12
  let draftRowCounter = 0;
13
13
 
14
14
  export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement {
15
+ const [appCollection, setAppCollection] = useState<AppCollectionId>("aidevops");
16
+ const [managedCategory, setManagedCategory] = useState<ManagedCategoryId>("core");
17
+ const [recommendedOs, setRecommendedOs] = useState<RecommendedOsId>("all");
15
18
  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] : []));
19
+ const [expandedAppId, setExpandedAppId] = useState<string | null>(() => sortedManagedApps(status.managed_apps).find((app) => managedCategoryForApp(app) === "core")?.id ?? null);
17
20
  const [jobs, setJobs] = useState<Record<string, GuiAppActionJobSummary>>({});
18
21
  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));
19
24
 
20
25
  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]);
26
+ setExpandedAppId(visibleManagedApps[0]?.id ?? null);
27
+ }, [managedCategory, status.managed_apps]);
26
28
 
27
29
  useEffect(() => {
28
30
  if (selectedJob === null || selectedJob.status !== "running") {
@@ -37,29 +39,33 @@ export function AppsSurface({ status }: { status: GuiStatusData }): ReactElement
37
39
  }, [selectedJob]);
38
40
 
39
41
  return (
40
- <section className="panel" aria-label={text.apps}>
42
+ <section className="apps-surface" aria-label={text.apps}>
41
43
  <div className="section-heading">
42
44
  <p className="eyebrow">{text.infrastructure}</p>
43
45
  <h2>{text.apps}</h2>
44
46
  <p>{text.appsIntro}</p>
45
47
  </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>
48
+ <TabNav label="Apps collections" tabs={appCollectionTabs} value={appCollection} onChange={(value) => setAppCollection(value)} />
49
+ {appCollection === "aidevops" ? <>
50
+ <TabNav label="aidevops app groups" tabs={managedCategoryTabs} value={managedCategory} onChange={(value) => setManagedCategory(value)} />
51
+ <div className="managed-app-list">
52
+ {visibleManagedApps.map((app) => (
53
+ <ManagedAppPanel
54
+ app={app}
55
+ expanded={expandedAppId === app.id}
56
+ job={jobForApp(app.id, jobs, selectedJob)}
57
+ key={app.id}
58
+ onJob={(job) => {
59
+ setJobs((current) => ({ ...current, [job.id]: job }));
60
+ setSelectedJobId(job.id);
61
+ setExpandedAppId(app.id);
62
+ }}
63
+ onToggle={() => setExpandedAppId((current) => current === app.id ? null : app.id)}
64
+ />
65
+ ))}
66
+ </div>
67
+ <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} />}
63
69
  </section>
64
70
  );
65
71
  }
@@ -74,7 +80,6 @@ function ManagedAppPanel({ app, expanded, job, onJob, onToggle }: { app: GuiMana
74
80
  <span>{app.description}</span>
75
81
  </span>
76
82
  <span className="managed-app-summary-meta">
77
- <SummaryChip label="Status" value={app.status} />
78
83
  <SummaryChip label="Installed" value={app.installed_version} />
79
84
  <SummaryChip label="Latest" value={app.latest_version} />
80
85
  <FiChevronDown aria-hidden="true" className="managed-app-chevron" />
@@ -96,7 +101,7 @@ function ManagedAppPanel({ app, expanded, job, onJob, onToggle }: { app: GuiMana
96
101
  <AppMeta label="Installed" value={app.installed_version} />
97
102
  <AppMeta label="Latest" value={app.latest_version} />
98
103
  <AppMeta label={text.path} value={app.install_path_ref} />
99
- <AppMeta label="Status" value={app.status} />
104
+ {job ? <AppLogLink job={job} /> : null}
100
105
  </div>
101
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>}
102
107
  </div> : null}
@@ -113,41 +118,73 @@ function OriginLink({ href, label }: { href: string; label: string }): ReactElem
113
118
  return <span className="origin-missing">{label}: source pending</span>;
114
119
  }
115
120
 
116
- return <a data-tooltip={`Open ${label.toLowerCase()} destination`} href={href} rel="noreferrer" target="_blank">{label} <FiExternalLink aria-hidden="true" /></a>;
121
+ return <a data-tooltip={href} href={href} rel="noreferrer" target="_blank">{label} <FiExternalLink aria-hidden="true" /></a>;
117
122
  }
118
123
 
119
124
  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>;
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>;
121
128
  }
122
129
 
123
130
  function AppMeta({ label, value }: { label: string; value: string }): ReactElement {
124
131
  return <span className="app-meta"><small>{label}</small><strong>{value}</strong></span>;
125
132
  }
126
133
 
134
+ function AppLogLink({ job }: { job: GuiAppActionJobSummary }): ReactElement {
135
+ return <a className="app-meta app-log-link" href={`#app-job-${job.id}`}><small>Logs</small><strong>{job.action} · {job.status}</strong></a>;
136
+ }
137
+
127
138
  function AppActionButton({ action, app, commandPreview, disabled, onJob }: { action: GuiAppActionId; app: GuiManagedAppSummary; commandPreview: string; disabled: boolean; onJob: (job: GuiAppActionJobSummary) => void }): ReactElement {
128
139
  const icon = action === "install" ? <FiDownload /> : action === "update" ? <FiRefreshCw /> : action === "reinstall" ? <FiRepeat /> : <FiTrash2 />;
140
+ const [confirmOpen, setConfirmOpen] = useState(false);
129
141
 
130
142
  async function runAction(): Promise<void> {
131
143
  if (disabled) {
132
144
  return;
133
145
  }
134
- if ((action === "remove" || action === "reinstall") && !window.confirm(`${action} ${app.name}?`)) {
135
- return;
136
- }
137
146
 
138
147
  const response = await fetch(`/api/apps/${encodeURIComponent(app.id)}/actions/${action}`, { method: "POST" });
139
148
  const envelope = await response.json() as GuiResponseEnvelope<GuiAppActionJobSummary>;
140
149
  onJob(envelope.data);
141
150
  }
142
151
 
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>;
152
+ return <>
153
+ <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>
154
+ {confirmOpen ? <ConfirmActionModal action={action} app={app} commandPreview={commandPreview} close={() => setConfirmOpen(false)} confirm={() => { setConfirmOpen(false); void runAction(); }} /> : null}
155
+ </>;
156
+ }
157
+
158
+ function ConfirmActionModal({ action, app, close, commandPreview, confirm }: { action: GuiAppActionId; app: GuiManagedAppSummary; close: () => void; commandPreview: string; confirm: () => void }): ReactElement {
159
+ return (
160
+ <div className="modal-backdrop" role="presentation">
161
+ <section aria-modal="true" className="confirm-modal" role="dialog" aria-labelledby={`confirm-${app.id}-${action}`}>
162
+ <p className="eyebrow">Confirm action</p>
163
+ <h3 id={`confirm-${app.id}-${action}`}>{action} {app.name}?</h3>
164
+ <p>This will start the allowlisted background command for this app.</p>
165
+ <code>{commandPreview}</code>
166
+ <div className="confirm-modal-actions">
167
+ <button className="secondary-action" onClick={close} type="button">Cancel</button>
168
+ <button className={action === "remove" ? "app-action-button remove" : "app-action-button"} onClick={confirm} type="button">Confirm</button>
169
+ </div>
170
+ </section>
171
+ </div>
172
+ );
144
173
  }
145
174
 
146
175
  function AppActionTerminal({ job }: { job: GuiAppActionJobSummary }): ReactElement {
176
+ const outputRef = useRef<HTMLPreElement | null>(null);
177
+
178
+ useEffect(() => {
179
+ if (outputRef.current !== null) {
180
+ outputRef.current.scrollTop = outputRef.current.scrollHeight;
181
+ }
182
+ }, [job.output]);
183
+
147
184
  return (
148
- <section className="app-terminal" aria-label="App action terminal output">
185
+ <section className="app-terminal" id={`app-job-${job.id}`} aria-label="App action terminal output">
149
186
  <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>
187
+ <pre ref={outputRef}>{job.output.join("\n")}</pre>
151
188
  </section>
152
189
  );
153
190
  }
@@ -169,15 +206,151 @@ function jobForApp(appId: string, jobs: Record<string, GuiAppActionJobSummary>,
169
206
  return Object.values(jobs).reverse().find((job) => job.app_id === appId) ?? null;
170
207
  }
171
208
 
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);
209
+ function sortedManagedApps(apps: GuiManagedAppSummary[]): GuiManagedAppSummary[] {
210
+ return [...apps].sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
211
+ }
212
+
213
+ function managedCategoryForApp(app: GuiManagedAppSummary): ManagedCategoryId {
214
+ if (["core", "safety", "automation"].includes(app.category)) {
215
+ return "core";
216
+ }
217
+ if (app.category.includes("ai")) {
218
+ return "ai";
219
+ }
220
+ if (["desktop", "editor"].includes(app.category)) {
221
+ return "desktop";
222
+ }
223
+ if (["terminal"].includes(app.category) || app.name.toLowerCase().includes("terminal")) {
224
+ return "terminal";
225
+ }
226
+
227
+ return "cli";
228
+ }
229
+
230
+ type AppCollectionId = "aidevops" | "recommended";
231
+ type ManagedCategoryId = "core" | "ai" | "cli" | "desktop" | "terminal";
232
+ type RecommendedOsId = "all" | "macos" | "linux" | "windows" | "ios" | "android";
233
+
234
+ interface TabOption<T extends string> {
235
+ id: T;
236
+ label: string;
237
+ }
238
+
239
+ interface RecommendedApp {
240
+ name: string;
241
+ description: string;
242
+ websiteUrl: string;
243
+ alternativeToUrl?: string;
244
+ repoUrl?: string;
245
+ os: RecommendedOsId[];
246
+ }
247
+
248
+ const appCollectionTabs: TabOption<AppCollectionId>[] = [
249
+ { id: "aidevops", label: "aidevops" },
250
+ { id: "recommended", label: "Recommended" },
251
+ ];
252
+
253
+ const managedCategoryTabs: TabOption<ManagedCategoryId>[] = [
254
+ { id: "core", label: "Core" },
255
+ { id: "ai", label: "AI" },
256
+ { id: "cli", label: "CLI" },
257
+ { id: "desktop", label: "Desktop" },
258
+ { id: "terminal", label: "Terminal" },
259
+ ];
260
+
261
+ const recommendedOsTabs: TabOption<RecommendedOsId>[] = [
262
+ { 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" },
268
+ ];
269
+
270
+ 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"] },
317
+ ] satisfies RecommendedApp[]).sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
318
+
319
+ function TabNav<T extends string>({ label, onChange, tabs, value }: { label: string; onChange: (value: T) => void; tabs: TabOption<T>[]; value: T }): ReactElement {
320
+ 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
+ }
322
+
323
+ function RecommendedAppsSurface({ apps, os, setOs }: { apps: RecommendedApp[]; os: RecommendedOsId; setOs: (os: RecommendedOsId) => void }): ReactElement {
324
+ return <>
325
+ <TabNav label="Recommended app OS filters" tabs={recommendedOsTabs} value={os} onChange={setOs} />
326
+ <div className="recommended-app-grid">
327
+ {apps.map((app) => <RecommendedAppCard app={app} key={app.name} />)}
328
+ </div>
329
+ </>;
330
+ }
331
+
332
+ function RecommendedAppCard({ app }: { app: RecommendedApp }): ReactElement {
333
+ return <article className="recommended-app-card">
334
+ <div>
335
+ <strong>{app.name}</strong>
336
+ <p>{app.description}</p>
337
+ </div>
338
+ <OsIconList os={app.os} />
339
+ <div className="managed-app-links plain-links">
340
+ <OriginLink href={app.websiteUrl} label={text.website} />
341
+ {app.alternativeToUrl ? <OriginLink href={app.alternativeToUrl} label="AlternativeTo" /> : null}
342
+ {app.repoUrl ? <OriginLink href={app.repoUrl} label="Repo" /> : null}
343
+ </div>
344
+ </article>;
345
+ }
346
+
347
+ function OsIconList({ os }: { os: RecommendedOsId[] }): ReactElement {
348
+ const icons: Record<RecommendedOsId, string> = { all: "*", macos: "⌘", linux: "🐧", windows: "⊞", ios: "iOS", android: "🤖" };
349
+ if (os.length === 0) {
350
+ return <span className="os-icon-list"><span title="Web app">Web</span></span>;
178
351
  }
179
352
 
180
- return next;
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>;
181
354
  }
182
355
 
183
356
  export function InstallationSurface(): ReactElement {
@@ -1964,6 +1964,11 @@ code {
1964
1964
  margin-bottom: 12px;
1965
1965
  }
1966
1966
 
1967
+ .apps-surface {
1968
+ display: grid;
1969
+ gap: 14px;
1970
+ }
1971
+
1967
1972
  .data-table,
1968
1973
  .editable-table,
1969
1974
  .installation-list,
@@ -2039,13 +2044,16 @@ code {
2039
2044
  }
2040
2045
 
2041
2046
  .managed-summary-chip {
2047
+ align-items: center;
2042
2048
  background: color-mix(in srgb, var(--surface-elevated) 92%, transparent);
2043
2049
  border: 1px solid var(--border);
2044
2050
  border-radius: 999px;
2045
2051
  display: inline-grid;
2046
2052
  gap: 1px;
2053
+ justify-items: center;
2047
2054
  min-width: 118px;
2048
2055
  padding: 7px 11px;
2056
+ text-align: center;
2049
2057
  }
2050
2058
 
2051
2059
  .managed-summary-chip small {
@@ -2106,25 +2114,22 @@ code {
2106
2114
  .managed-app-links a,
2107
2115
  .origin-missing {
2108
2116
  align-items: center;
2109
- border: 1px solid var(--border);
2110
- border-radius: 999px;
2111
2117
  color: var(--text-secondary);
2112
2118
  display: inline-flex;
2113
2119
  font-size: 0.78rem;
2114
2120
  font-weight: 800;
2115
2121
  gap: 4px;
2116
2122
  max-width: 100%;
2117
- padding: 6px 9px;
2123
+ padding: 4px 0;
2118
2124
  position: relative;
2119
2125
  text-decoration: none;
2120
2126
  }
2121
2127
 
2122
2128
  .managed-app-links a:hover,
2123
2129
  .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
2130
  color: var(--accent);
2127
2131
  outline: none;
2132
+ text-decoration: underline;
2128
2133
  }
2129
2134
 
2130
2135
  .origin-missing {
@@ -2157,9 +2162,20 @@ code {
2157
2162
  text-transform: uppercase;
2158
2163
  }
2159
2164
 
2165
+ button.managed-toggle {
2166
+ cursor: pointer;
2167
+ text-align: left;
2168
+ }
2169
+
2170
+ button.managed-toggle:hover,
2171
+ button.managed-toggle:focus-visible {
2172
+ border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
2173
+ outline: none;
2174
+ }
2175
+
2160
2176
  .switch-track {
2161
- background: var(--surface-muted);
2162
- border: 1px solid var(--border);
2177
+ background: color-mix(in srgb, #ef4444 18%, var(--surface-muted));
2178
+ border: 1px solid color-mix(in srgb, #ef4444 44%, var(--border));
2163
2179
  border-radius: 999px;
2164
2180
  display: inline-flex;
2165
2181
  padding: 2px;
@@ -2167,7 +2183,7 @@ code {
2167
2183
  }
2168
2184
 
2169
2185
  .switch-track span {
2170
- background: var(--text-muted);
2186
+ background: #ef4444;
2171
2187
  border-radius: 50%;
2172
2188
  height: 14px;
2173
2189
  transform: translateX(0);
@@ -2176,10 +2192,15 @@ code {
2176
2192
  }
2177
2193
 
2178
2194
  .switch-track.checked span {
2179
- background: var(--accent);
2195
+ background: #22c55e;
2180
2196
  transform: translateX(14px);
2181
2197
  }
2182
2198
 
2199
+ .switch-track.checked {
2200
+ background: color-mix(in srgb, #22c55e 18%, var(--surface-muted));
2201
+ border-color: color-mix(in srgb, #22c55e 48%, var(--border));
2202
+ }
2203
+
2183
2204
  .app-meta small {
2184
2205
  color: var(--text-muted);
2185
2206
  font-size: 0.72rem;
@@ -2195,6 +2216,17 @@ code {
2195
2216
  word-break: break-word;
2196
2217
  }
2197
2218
 
2219
+ .app-log-link {
2220
+ color: var(--text-primary);
2221
+ text-decoration: none;
2222
+ }
2223
+
2224
+ .app-log-link:hover,
2225
+ .app-log-link:focus-visible {
2226
+ border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
2227
+ outline: none;
2228
+ }
2229
+
2198
2230
  .app-action-button {
2199
2231
  align-items: center;
2200
2232
  background: color-mix(in srgb, var(--accent) 16%, var(--surface-elevated));
@@ -2278,6 +2310,45 @@ code {
2278
2310
  word-break: break-word;
2279
2311
  }
2280
2312
 
2313
+ .modal-backdrop {
2314
+ align-items: center;
2315
+ background: rgb(0 0 0 / 62%);
2316
+ display: flex;
2317
+ inset: 0;
2318
+ justify-content: center;
2319
+ padding: 18px;
2320
+ position: fixed;
2321
+ z-index: 100;
2322
+ }
2323
+
2324
+ .confirm-modal {
2325
+ background: var(--surface-elevated);
2326
+ border: 1px solid var(--border-hover);
2327
+ border-radius: 18px;
2328
+ box-shadow: 0 24px 90px rgb(0 0 0 / 48%);
2329
+ display: grid;
2330
+ gap: 12px;
2331
+ max-width: 520px;
2332
+ padding: 22px;
2333
+ width: min(100%, 520px);
2334
+ }
2335
+
2336
+ .confirm-modal h3,
2337
+ .confirm-modal p {
2338
+ margin: 0;
2339
+ }
2340
+
2341
+ .confirm-modal code {
2342
+ overflow-wrap: anywhere;
2343
+ }
2344
+
2345
+ .confirm-modal-actions {
2346
+ display: flex;
2347
+ flex-wrap: wrap;
2348
+ gap: 10px;
2349
+ justify-content: flex-end;
2350
+ }
2351
+
2281
2352
  [data-tooltip] {
2282
2353
  position: relative;
2283
2354
  }
@@ -2388,6 +2459,57 @@ code {
2388
2459
  width: min(100%, 520px);
2389
2460
  }
2390
2461
 
2462
+ .app-subnav {
2463
+ width: fit-content;
2464
+ }
2465
+
2466
+ .recommended-app-grid {
2467
+ display: grid;
2468
+ gap: 12px;
2469
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
2470
+ }
2471
+
2472
+ .recommended-app-card {
2473
+ background:
2474
+ linear-gradient(135deg, color-mix(in srgb, var(--accent) 6%, transparent), transparent 40%),
2475
+ var(--surface-muted);
2476
+ border: 1px solid var(--border);
2477
+ border-radius: 18px;
2478
+ display: grid;
2479
+ gap: 12px;
2480
+ padding: 16px;
2481
+ }
2482
+
2483
+ .recommended-app-card strong {
2484
+ font-size: 1.05rem;
2485
+ }
2486
+
2487
+ .recommended-app-card p {
2488
+ color: var(--text-secondary);
2489
+ margin: 4px 0 0;
2490
+ }
2491
+
2492
+ .os-icon-list {
2493
+ display: flex;
2494
+ flex-wrap: wrap;
2495
+ gap: 6px;
2496
+ }
2497
+
2498
+ .os-icon-list span {
2499
+ align-items: center;
2500
+ background: var(--surface-elevated);
2501
+ border: 1px solid var(--border);
2502
+ border-radius: 999px;
2503
+ color: var(--text-secondary);
2504
+ display: inline-flex;
2505
+ font-size: 0.72rem;
2506
+ font-weight: 900;
2507
+ justify-content: center;
2508
+ min-height: 28px;
2509
+ min-width: 28px;
2510
+ padding: 4px 7px;
2511
+ }
2512
+
2391
2513
  .pill-tabs button {
2392
2514
  background: transparent;
2393
2515
  border: 0;
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.9
16
16
  #
17
17
  # Quick Install:
18
18
  # npm install -g aidevops && aidevops update (recommended)