Add add-on management: registry browser, install, remove (step 5)

Browse the ~270-entry DDEV add-on registry with search, install via
`ddev addon get`, remove via `ddev addon remove` — both routed through
the streaming commandRunner. `addon get`/`remove` support `--project
<name>` directly (unlike snapshot restore), so no cwd trick needed
here. Verified working with the project stopped, per the plan's
requirement.

Found and fixed a real bug via live UI testing: `ddev addon list
--installed --json-output` omits the `raw` key entirely when nothing
is installed (unlike `ddev list`/`ddev snapshot --list`, which include
`raw: null`). The shared runDdevRead helper treated that as an error,
so after removing the last add-on the query errored on refetch and
React Query kept showing the stale cached row instead of clearing it.
Split the helper into runDdevRead (strict, for describeProject where
missing data is a real error) and runDdevReadList (treats missing raw
as empty, for every list-style command) and fixed all four list call
sites to use it.

Verified end-to-end via CDP-driven clicks against the real scratch
project: search filtering, install (streamed output, confirmed via
`ddev addon list --installed` on the CLI), and remove — including
re-confirming after the fix that the installed-add-ons table actually
clears instead of showing stale data.
This commit is contained in:
R3ap3R
2026-08-03 00:02:51 -05:00
parent 9f67b13a47
commit ebfec5f787
9 changed files with 334 additions and 5 deletions
+37 -5
View File
@@ -1,6 +1,12 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import type { DdevProjectDetail, DdevProjectSummary, DdevSnapshot } from '../shared/types'
import type {
DdevAddonRegistryEntry,
DdevInstalledAddon,
DdevProjectDetail,
DdevProjectSummary,
DdevSnapshot
} from '../shared/types'
const execFileAsync = promisify(execFile)
@@ -57,7 +63,8 @@ async function execDdev<T>(args: string[], cwd?: string): Promise<DdevJsonEnvelo
return envelope
}
// Read commands (list/describe): the payload lives under `raw`.
// Read commands where `raw` is always expected on success (describe): throws
// if it's missing, since that indicates something actually went wrong.
async function runDdevRead<T>(args: string[], cwd?: string): Promise<T> {
const envelope = await execDdev<T>(args, cwd)
if (envelope.raw === undefined) {
@@ -68,6 +75,15 @@ async function runDdevRead<T>(args: string[], cwd?: string): Promise<T> {
return envelope.raw
}
// List-style read commands are inconsistent about `raw` when there's nothing
// to report: `ddev list`/`ddev snapshot --list` include `raw: null`, but
// `ddev addon list --installed` omits `raw` entirely for "no add-ons found".
// Treat a missing `raw` as "empty" here rather than an error.
async function runDdevReadList<T>(args: string[], cwd?: string): Promise<T | undefined> {
const envelope = await execDdev<T>(args, cwd)
return envelope.raw
}
// ddev sometimes prints non-JSON progress lines before the final JSON envelope
// (opt-in prompts, deprecation notices); the envelope is always the last line.
function tryParseLastJsonLine<T>(output: string): T | null {
@@ -85,7 +101,7 @@ function tryParseLastJsonLine<T>(output: string): T | null {
}
export async function listProjects(): Promise<DdevProjectSummary[]> {
const raw = await runDdevRead<DdevProjectSummary[] | null>(['list'])
const raw = await runDdevReadList<DdevProjectSummary[] | null>(['list'])
return raw ?? []
}
@@ -97,9 +113,25 @@ export async function describeProject(name: string): Promise<DdevProjectDetail>
// resolve the project, so every snapshot command runs with cwd = approot
// rather than passing the project name positionally like other commands.
export async function listSnapshots(name: string, approot: string): Promise<DdevSnapshot[]> {
const raw = await runDdevRead<Record<string, DdevSnapshot[] | null>>(
const raw = await runDdevReadList<Record<string, DdevSnapshot[] | null>>(
['snapshot', '--list'],
approot
)
return raw[name] ?? []
return raw?.[name] ?? []
}
export async function listAddonRegistry(): Promise<DdevAddonRegistryEntry[]> {
const raw = await runDdevReadList<DdevAddonRegistryEntry[] | null>(['addon', 'list'])
return raw ?? []
}
export async function listInstalledAddons(name: string): Promise<DdevInstalledAddon[]> {
const raw = await runDdevReadList<DdevInstalledAddon[] | null>([
'addon',
'list',
'--installed',
'--project',
name
])
return raw ?? []
}
+2
View File
@@ -5,6 +5,7 @@ import icon from '../../resources/icon.png?asset'
import { registerProjectsIpc } from './ipc/projects'
import { registerTerminalIpc } from './ipc/terminal'
import { registerDatabaseIpc } from './ipc/database'
import { registerAddonsIpc } from './ipc/addons'
function createWindow(): void {
// Create the browser window.
@@ -56,6 +57,7 @@ app.whenReady().then(() => {
registerProjectsIpc()
registerTerminalIpc()
registerDatabaseIpc()
registerAddonsIpc()
createWindow()
+16
View File
@@ -0,0 +1,16 @@
import { ipcMain } from 'electron'
import { listAddonRegistry, listInstalledAddons } from '../ddev'
import { runStreamed } from '../commandRunner'
export function registerAddonsIpc(): void {
ipcMain.handle('addons:listRegistry', () => listAddonRegistry())
ipcMain.handle('addons:listInstalled', (_event, name: string) => listInstalledAddons(name))
ipcMain.handle('addons:install', (event, operationId: string, name: string, addonRepo: string) =>
runStreamed(operationId, ['addon', 'get', addonRepo, '--project', name], event.sender)
)
ipcMain.handle('addons:remove', (event, operationId: string, name: string, addonName: string) =>
runStreamed(operationId, ['addon', 'remove', addonName, '--project', name], event.sender)
)
}