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:
+37
-5
@@ -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 ?? []
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user