From cee6ec8c95fc47a067cb286c9627d9943c0433ac Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:24:43 +0000 Subject: [PATCH 1/2] fix(app): sync menu startup with running docker-git containers --- packages/app/src/docker-git/menu-actions.ts | 5 + .../app/src/docker-git/menu-input-handler.ts | 107 +++++++++++ packages/app/src/docker-git/menu-render.ts | 20 ++- packages/app/src/docker-git/menu-startup.ts | 83 +++++++++ packages/app/src/docker-git/menu.ts | 170 ++++++------------ .../tests/docker-git/fixtures/project-item.ts | 24 +++ .../docker-git/menu-select-connect.test.ts | 35 ++-- .../app/tests/docker-git/menu-startup.test.ts | 51 ++++++ 8 files changed, 348 insertions(+), 147 deletions(-) create mode 100644 packages/app/src/docker-git/menu-input-handler.ts create mode 100644 packages/app/src/docker-git/menu-startup.ts create mode 100644 packages/app/tests/docker-git/fixtures/project-item.ts create mode 100644 packages/app/tests/docker-git/menu-startup.test.ts diff --git a/packages/app/src/docker-git/menu-actions.ts b/packages/app/src/docker-git/menu-actions.ts index fd500df4..97558bf6 100644 --- a/packages/app/src/docker-git/menu-actions.ts +++ b/packages/app/src/docker-git/menu-actions.ts @@ -6,6 +6,7 @@ import { renderError } from "@effect-template/lib/usecases/errors" import { downAllDockerGitProjects, listProjectItems, + listProjectStatus, listRunningProjectItems } from "@effect-template/lib/usecases/projects" import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up" @@ -195,6 +196,10 @@ const runDeleteAction = (context: MenuContext) => { } const runComposeAction = (action: MenuAction, context: MenuContext) => { + if (action._tag === "Status" && context.state.activeDir === null) { + runWithSuspendedTui(listProjectStatus, context, "docker compose ps (all projects)") + return + } if (!requireActiveProject(context)) { return } diff --git a/packages/app/src/docker-git/menu-input-handler.ts b/packages/app/src/docker-git/menu-input-handler.ts new file mode 100644 index 00000000..df4bbdac --- /dev/null +++ b/packages/app/src/docker-git/menu-input-handler.ts @@ -0,0 +1,107 @@ +import { handleCreateInput } from "./menu-create.js" +import { handleMenuInput } from "./menu-menu.js" +import { handleSelectInput } from "./menu-select.js" +import type { MenuKeyInput, MenuRunner, MenuState, MenuViewContext, ViewState } from "./menu-types.js" + +export type InputStage = "cold" | "active" + +export type MenuInputContext = MenuViewContext & { + readonly busy: boolean + readonly view: ViewState + readonly inputStage: InputStage + readonly setInputStage: (stage: InputStage) => void + readonly selected: number + readonly setSelected: (update: (value: number) => number) => void + readonly setSkipInputs: (update: (value: number) => number) => void + readonly sshActive: boolean + readonly setSshActive: (active: boolean) => void + readonly state: MenuState + readonly runner: MenuRunner + readonly exit: () => void +} + +const activateInput = ( + input: string, + key: Pick, + context: Pick +): { readonly activated: boolean; readonly allowProcessing: boolean } => { + if (context.inputStage === "active") { + return { activated: false, allowProcessing: true } + } + + if (input.trim().length > 0) { + context.setInputStage("active") + return { activated: true, allowProcessing: true } + } + + if (key.upArrow || key.downArrow || key.return) { + context.setInputStage("active") + return { activated: true, allowProcessing: false } + } + + if (input.length > 0) { + context.setInputStage("active") + return { activated: true, allowProcessing: true } + } + + return { activated: false, allowProcessing: false } +} + +const shouldHandleMenuInput = ( + input: string, + key: Pick, + context: Pick +): boolean => { + const activation = activateInput(input, key, context) + if (activation.activated && !activation.allowProcessing) { + return false + } + return activation.allowProcessing +} + +export const handleUserInput = ( + input: string, + key: MenuKeyInput, + context: MenuInputContext +) => { + if (context.busy || context.sshActive) { + return + } + + if (context.view._tag === "Menu") { + if (!shouldHandleMenuInput(input, key, context)) { + return + } + handleMenuInput(input, key, { + selected: context.selected, + setSelected: context.setSelected, + state: context.state, + runner: context.runner, + exit: context.exit, + setView: context.setView, + setMessage: context.setMessage + }) + return + } + + if (context.view._tag === "Create") { + handleCreateInput(input, key, context.view, { + state: context.state, + setView: context.setView, + setMessage: context.setMessage, + runner: context.runner, + setActiveDir: context.setActiveDir + }) + return + } + + handleSelectInput(input, key, context.view, { + setView: context.setView, + setMessage: context.setMessage, + setActiveDir: context.setActiveDir, + activeDir: context.state.activeDir, + runner: context.runner, + setSshActive: context.setSshActive, + setSkipInputs: context.setSkipInputs + }) +} diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts index e82f900a..440029a1 100644 --- a/packages/app/src/docker-git/menu-render.ts +++ b/packages/app/src/docker-git/menu-render.ts @@ -103,15 +103,20 @@ const renderMenuMessage = ( ) } -export const renderMenu = ( - cwd: string, - activeDir: string | null, - selected: number, - busy: boolean, - message: string | null -): React.ReactElement => { +type MenuRenderInput = { + readonly cwd: string + readonly activeDir: string | null + readonly runningDockerGitContainers: number + readonly selected: number + readonly busy: boolean + readonly message: string | null +} + +export const renderMenu = (input: MenuRenderInput): React.ReactElement => { + const { activeDir, busy, cwd, message, runningDockerGitContainers, selected } = input const el = React.createElement const activeLabel = `Active: ${activeDir ?? "(none)"}` + const runningLabel = `Running docker-git containers: ${runningDockerGitContainers}` const cwdLabel = `CWD: ${cwd}` const items = menuItems.map((item, index) => { const indexLabel = `${index + 1})` @@ -134,6 +139,7 @@ export const renderMenu = ( "docker-git", compactElements([ el(Text, null, activeLabel), + el(Text, null, runningLabel), el(Text, null, cwdLabel), el(Box, { flexDirection: "column", marginTop: 1 }, ...items), hints, diff --git a/packages/app/src/docker-git/menu-startup.ts b/packages/app/src/docker-git/menu-startup.ts new file mode 100644 index 00000000..277624cd --- /dev/null +++ b/packages/app/src/docker-git/menu-startup.ts @@ -0,0 +1,83 @@ +import type { ProjectItem } from "@effect-template/lib/usecases/projects" + +export type MenuStartupSnapshot = { + readonly activeDir: string | null + readonly runningDockerGitContainers: number + readonly message: string | null +} + +const dockerGitContainerPrefix = "dg-" + +const emptySnapshot = (): MenuStartupSnapshot => ({ + activeDir: null, + runningDockerGitContainers: 0, + message: null +}) + +const uniqueDockerGitContainerNames = ( + runningContainerNames: ReadonlyArray +): ReadonlyArray => [ + ...new Set(runningContainerNames.filter((name) => name.startsWith(dockerGitContainerPrefix))) +] + +const detectKnownRunningProjects = ( + items: ReadonlyArray, + runningDockerGitNames: ReadonlyArray +): ReadonlyArray => { + const runningSet = new Set(runningDockerGitNames) + return items.filter((item) => runningSet.has(item.containerName)) +} + +const renderRunningHint = (runningCount: number): string => + runningCount === 1 + ? "Detected 1 running docker-git container." + : `Detected ${runningCount} running docker-git containers.` + +// CHANGE: infer initial menu state from currently running docker-git containers +// WHY: avoid "(none)" confusion when containers are already up outside this TUI session +// QUOTE(ISSUE): "У меня запущены контейнеры от docker-git но он говорит что они не запущены" +// REF: issue-13 +// SOURCE: n/a +// FORMAT THEOREM: forall startupState: snapshot(startupState) -> deterministic(menuState) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: activeDir is set only when exactly one known project is running +// COMPLEXITY: O(|containers| + |projects|) +export const resolveMenuStartupSnapshot = ( + items: ReadonlyArray, + runningContainerNames: ReadonlyArray +): MenuStartupSnapshot => { + const runningDockerGitNames = uniqueDockerGitContainerNames(runningContainerNames) + if (runningDockerGitNames.length === 0) { + return emptySnapshot() + } + + const knownRunningProjects = detectKnownRunningProjects(items, runningDockerGitNames) + if (knownRunningProjects.length === 1 && runningDockerGitNames.length === 1) { + const selected = knownRunningProjects[0] + if (!selected) { + return emptySnapshot() + } + return { + activeDir: selected.projectDir, + runningDockerGitContainers: 1, + message: `Auto-selected active project: ${selected.displayName}.` + } + } + + if (knownRunningProjects.length === 0) { + return { + activeDir: null, + runningDockerGitContainers: runningDockerGitNames.length, + message: `${renderRunningHint(runningDockerGitNames.length)} No matching project config found.` + } + } + + return { + activeDir: null, + runningDockerGitContainers: runningDockerGitNames.length, + message: `${renderRunningHint(runningDockerGitNames.length)} Use Select project to choose active.` + } +} + +export const defaultMenuStartupSnapshot = emptySnapshot diff --git a/packages/app/src/docker-git/menu.ts b/packages/app/src/docker-git/menu.ts index f15f8579..7cf1709e 100644 --- a/packages/app/src/docker-git/menu.ts +++ b/packages/app/src/docker-git/menu.ts @@ -1,24 +1,18 @@ +import { runDockerPsNames } from "@effect-template/lib/shell/docker" import { type InputCancelledError, InputReadError } from "@effect-template/lib/shell/errors" import { type AppError, renderError } from "@effect-template/lib/usecases/errors" +import { listProjectItems } from "@effect-template/lib/usecases/projects" import { NodeContext } from "@effect/platform-node" import { Effect, pipe } from "effect" import { render, useApp, useInput } from "ink" import React, { useEffect, useMemo, useState } from "react" -import { handleCreateInput, resolveCreateInputs } from "./menu-create.js" -import { handleMenuInput } from "./menu-menu.js" +import { resolveCreateInputs } from "./menu-create.js" +import { handleUserInput, type InputStage } from "./menu-input-handler.js" import { renderCreate, renderMenu, renderSelect, renderStepLabel } from "./menu-render.js" -import { handleSelectInput } from "./menu-select.js" import { leaveTui, resumeTui } from "./menu-shared.js" -import { - createSteps, - type MenuEnv, - type MenuKeyInput, - type MenuRunner, - type MenuState, - type MenuViewContext, - type ViewState -} from "./menu-types.js" +import { defaultMenuStartupSnapshot, resolveMenuStartupSnapshot } from "./menu-startup.js" +import { createSteps, type MenuEnv, type MenuState, type ViewState } from "./menu-types.js" // CHANGE: keep menu state in the TUI layer // WHY: provide a dynamic interface with live selection and inputs @@ -58,115 +52,11 @@ const useRunner = ( return { runEffect } } -type InputStage = "cold" | "active" - -type MenuInputContext = MenuViewContext & { - readonly busy: boolean - readonly view: ViewState - readonly inputStage: InputStage - readonly setInputStage: (stage: InputStage) => void - readonly selected: number - readonly setSelected: (update: (value: number) => number) => void - readonly setSkipInputs: (update: (value: number) => number) => void - readonly sshActive: boolean - readonly setSshActive: (active: boolean) => void - readonly state: MenuState - readonly runner: MenuRunner - readonly exit: () => void -} - -const activateInput = ( - input: string, - key: Pick, - context: Pick -): { readonly activated: boolean; readonly allowProcessing: boolean } => { - if (context.inputStage === "active") { - return { activated: false, allowProcessing: true } - } - - if (input.trim().length > 0) { - context.setInputStage("active") - return { activated: true, allowProcessing: true } - } - - if (key.upArrow || key.downArrow || key.return) { - context.setInputStage("active") - return { activated: true, allowProcessing: false } - } - - if (input.length > 0) { - context.setInputStage("active") - return { activated: true, allowProcessing: true } - } - - return { activated: false, allowProcessing: false } -} - -const shouldHandleMenuInput = ( - input: string, - key: Pick, - context: Pick -): boolean => { - const activation = activateInput(input, key, context) - if (activation.activated && !activation.allowProcessing) { - return false - } - return activation.allowProcessing -} - -const handleUserInput = ( - input: string, - key: MenuKeyInput, - context: MenuInputContext -) => { - if (context.busy) { - return - } - if (context.sshActive) { - return - } - if (context.view._tag === "Menu") { - if (!shouldHandleMenuInput(input, key, context)) { - return - } - handleMenuInput(input, key, { - selected: context.selected, - setSelected: context.setSelected, - state: context.state, - runner: context.runner, - exit: context.exit, - setView: context.setView, - setMessage: context.setMessage - }) - return - } - - if (context.view._tag === "Create") { - handleCreateInput(input, key, context.view, { - state: context.state, - setView: context.setView, - setMessage: context.setMessage, - runner: context.runner, - setActiveDir: context.setActiveDir - }) - return - } - - handleSelectInput(input, key, context.view, { - setView: context.setView, - setMessage: context.setMessage, - setActiveDir: context.setActiveDir, - activeDir: context.state.activeDir, - runner: context.runner, - setSshActive: context.setSshActive, - setSkipInputs: context.setSkipInputs - }) -} - type RenderContext = { readonly state: MenuState readonly view: ViewState readonly activeDir: string | null + readonly runningDockerGitContainers: number readonly selected: number readonly busy: boolean readonly message: string | null @@ -174,7 +64,14 @@ type RenderContext = { const renderView = (context: RenderContext) => { if (context.view._tag === "Menu") { - return renderMenu(context.state.cwd, context.activeDir, context.selected, context.busy, context.message) + return renderMenu({ + cwd: context.state.cwd, + activeDir: context.activeDir, + runningDockerGitContainers: context.runningDockerGitContainers, + selected: context.selected, + busy: context.busy, + message: context.message + }) } if (context.view._tag === "Create") { @@ -198,6 +95,7 @@ const renderView = (context: RenderContext) => { const useMenuState = () => { const [activeDir, setActiveDir] = useState(null) + const [runningDockerGitContainers, setRunningDockerGitContainers] = useState(0) const [selected, setSelected] = useState(0) const [busy, setBusy] = useState(false) const [message, setMessage] = useState(null) @@ -213,6 +111,8 @@ const useMenuState = () => { return { activeDir, setActiveDir, + runningDockerGitContainers, + setRunningDockerGitContainers, selected, setSelected, busy, @@ -245,6 +145,38 @@ const useReadyGate = (setReady: (ready: boolean) => void) => { }, [setReady]) } +const useStartupSnapshot = ( + setActiveDir: (value: string | null) => void, + setRunningDockerGitContainers: (value: number) => void, + setMessage: (message: string | null) => void +) => { + useEffect(() => { + let cancelled = false + + const startup = pipe( + Effect.all([listProjectItems, runDockerPsNames(process.cwd())]), + Effect.map(([items, runningNames]) => resolveMenuStartupSnapshot(items, runningNames)), + Effect.catchAll(() => Effect.succeed(defaultMenuStartupSnapshot())), + Effect.provide(NodeContext.layer) + ) + + void Effect.runPromise(startup).then((snapshot) => { + if (cancelled) { + return + } + setRunningDockerGitContainers(snapshot.runningDockerGitContainers) + setMessage(snapshot.message) + if (snapshot.activeDir !== null) { + setActiveDir(snapshot.activeDir) + } + }) + + return () => { + cancelled = true + } + }, [setActiveDir, setMessage, setRunningDockerGitContainers]) +} + const useSigintGuard = (exit: () => void, sshActive: boolean) => { useEffect(() => { const handleSigint = () => { @@ -265,6 +197,7 @@ const TuiApp = () => { const menu = useMenuState() useReadyGate(menu.setReady) + useStartupSnapshot(menu.setActiveDir, menu.setRunningDockerGitContainers, menu.setMessage) useSigintGuard(exit, menu.sshActive) useInput( @@ -304,6 +237,7 @@ const TuiApp = () => { state: menu.state, view: menu.view, activeDir: menu.activeDir, + runningDockerGitContainers: menu.runningDockerGitContainers, selected: menu.selected, busy: menu.busy, message: menu.message diff --git a/packages/app/tests/docker-git/fixtures/project-item.ts b/packages/app/tests/docker-git/fixtures/project-item.ts new file mode 100644 index 00000000..0b12c346 --- /dev/null +++ b/packages/app/tests/docker-git/fixtures/project-item.ts @@ -0,0 +1,24 @@ +import type { ProjectItem } from "@effect-template/lib/usecases/projects" + +export const makeProjectItem = ( + overrides: Partial = {} +): ProjectItem => ({ + projectDir: "/home/dev/.docker-git/org-repo", + displayName: "org/repo", + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + containerName: "dg-repo", + serviceName: "dg-repo", + sshUser: "dev", + sshPort: 2222, + targetDir: "/home/dev/org/repo", + sshCommand: "ssh -p 2222 dev@localhost", + sshKeyPath: null, + authorizedKeysPath: "/home/dev/.docker-git/org-repo/.docker-git/authorized_keys", + authorizedKeysExists: true, + envGlobalPath: "/home/dev/.orch/env/global.env", + envProjectPath: "/home/dev/.docker-git/org-repo/.orch/env/project.env", + codexAuthPath: "/home/dev/.orch/auth/codex", + codexHome: "/home/dev/.codex", + ...overrides +}) diff --git a/packages/app/tests/docker-git/menu-select-connect.test.ts b/packages/app/tests/docker-git/menu-select-connect.test.ts index 902a0942..84ad5a6d 100644 --- a/packages/app/tests/docker-git/menu-select-connect.test.ts +++ b/packages/app/tests/docker-git/menu-select-connect.test.ts @@ -2,28 +2,10 @@ import { Effect } from "effect" import { describe, expect, it } from "vitest" import type { ProjectItem } from "@effect-template/lib/usecases/projects" + import { selectHint } from "../../src/docker-git/menu-render-select.js" import { buildConnectEffect, isConnectMcpToggleInput } from "../../src/docker-git/menu-select-connect.js" - -const makeProjectItem = (): ProjectItem => ({ - projectDir: "/home/dev/provercoderai/docker-git/workspaces/org/repo", - displayName: "org/repo", - repoUrl: "https://github.com/org/repo.git", - repoRef: "main", - containerName: "dg-repo", - serviceName: "dg-repo", - sshUser: "dev", - sshPort: 2222, - targetDir: "/home/dev/org/repo", - sshCommand: "ssh -p 2222 dev@localhost", - sshKeyPath: null, - authorizedKeysPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.docker-git/authorized_keys", - authorizedKeysExists: true, - envGlobalPath: "/home/dev/provercoderai/docker-git/.orch/env/global.env", - envProjectPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/env/project.env", - codexAuthPath: "/home/dev/provercoderai/docker-git/.orch/auth/codex", - codexHome: "/home/dev/.codex" -}) +import { makeProjectItem } from "./fixtures/project-item.js" const record = (events: Array, entry: string): Effect.Effect => Effect.sync(() => { @@ -35,16 +17,25 @@ const makeConnectDeps = (events: Array) => ({ enableMcpPlaywright: (projectDir: string) => record(events, `enable:${projectDir}`) }) +const workspaceProject = () => + makeProjectItem({ + projectDir: "/home/dev/provercoderai/docker-git/workspaces/org/repo", + authorizedKeysPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.docker-git/authorized_keys", + envGlobalPath: "/home/dev/provercoderai/docker-git/.orch/env/global.env", + envProjectPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/env/project.env", + codexAuthPath: "/home/dev/provercoderai/docker-git/.orch/auth/codex" + }) + describe("menu-select-connect", () => { it("runs Playwright enable before SSH when toggle is ON", () => { - const item = makeProjectItem() + const item = workspaceProject() const events: Array = [] Effect.runSync(buildConnectEffect(item, true, makeConnectDeps(events))) expect(events).toEqual([`enable:${item.projectDir}`, `connect:${item.projectDir}`]) }) it("skips Playwright enable when toggle is OFF", () => { - const item = makeProjectItem() + const item = workspaceProject() const events: Array = [] Effect.runSync(buildConnectEffect(item, false, makeConnectDeps(events))) expect(events).toEqual([`connect:${item.projectDir}`]) diff --git a/packages/app/tests/docker-git/menu-startup.test.ts b/packages/app/tests/docker-git/menu-startup.test.ts new file mode 100644 index 00000000..64437cc6 --- /dev/null +++ b/packages/app/tests/docker-git/menu-startup.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest" + +import { resolveMenuStartupSnapshot } from "../../src/docker-git/menu-startup.js" +import { makeProjectItem } from "./fixtures/project-item.js" + +describe("menu-startup", () => { + it("returns empty snapshot when no docker-git containers are running", () => { + const snapshot = resolveMenuStartupSnapshot([makeProjectItem({})], ["postgres", "redis"]) + + expect(snapshot).toEqual({ + activeDir: null, + runningDockerGitContainers: 0, + message: null + }) + }) + + it("auto-selects active project when exactly one known docker-git container is running", () => { + const item = makeProjectItem({}) + const snapshot = resolveMenuStartupSnapshot([item], [item.containerName]) + + expect(snapshot.activeDir).toBe(item.projectDir) + expect(snapshot.runningDockerGitContainers).toBe(1) + expect(snapshot.message).toContain(item.displayName) + }) + + it("does not auto-select when multiple docker-git containers are running", () => { + const first = makeProjectItem({ + containerName: "dg-one", + displayName: "org/one", + projectDir: "/home/dev/.docker-git/org-one" + }) + const second = makeProjectItem({ + containerName: "dg-two", + displayName: "org/two", + projectDir: "/home/dev/.docker-git/org-two" + }) + const snapshot = resolveMenuStartupSnapshot([first, second], [first.containerName, second.containerName]) + + expect(snapshot.activeDir).toBeNull() + expect(snapshot.runningDockerGitContainers).toBe(2) + expect(snapshot.message).toContain("Use Select project") + }) + + it("shows warning when running docker-git containers have no matching configs", () => { + const snapshot = resolveMenuStartupSnapshot([], ["dg-unknown", "dg-another"]) + + expect(snapshot.activeDir).toBeNull() + expect(snapshot.runningDockerGitContainers).toBe(2) + expect(snapshot.message).toContain("No matching project config found") + }) +}) From b82e3fb68c535fcdd66ad6260bf47f96ed4f23e5 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:36:03 +0000 Subject: [PATCH 2/2] fix(ci): address effect lint and stabilize opencode e2e --- packages/app/src/docker-git/menu.ts | 5 ++++- scripts/e2e/opencode-autoconnect.sh | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/app/src/docker-git/menu.ts b/packages/app/src/docker-git/menu.ts index 7cf1709e..29a321a7 100644 --- a/packages/app/src/docker-git/menu.ts +++ b/packages/app/src/docker-git/menu.ts @@ -156,7 +156,10 @@ const useStartupSnapshot = ( const startup = pipe( Effect.all([listProjectItems, runDockerPsNames(process.cwd())]), Effect.map(([items, runningNames]) => resolveMenuStartupSnapshot(items, runningNames)), - Effect.catchAll(() => Effect.succeed(defaultMenuStartupSnapshot())), + Effect.match({ + onFailure: () => defaultMenuStartupSnapshot(), + onSuccess: (snapshot) => snapshot + }), Effect.provide(NodeContext.layer) ) diff --git a/scripts/e2e/opencode-autoconnect.sh b/scripts/e2e/opencode-autoconnect.sh index 13168084..a46f78db 100755 --- a/scripts/e2e/opencode-autoconnect.sh +++ b/scripts/e2e/opencode-autoconnect.sh @@ -176,4 +176,5 @@ process.exit(1) NODE' # Exercises Bun-based plugin install path (regression test for BUN_INSTALL env). -docker exec -u dev "$CONTAINER_NAME" opencode models openai | grep -m 1 -E '^openai/' >/dev/null +docker exec -u dev "$CONTAINER_NAME" bash -lc \ + 'output="$(opencode models openai)" && grep -m 1 -E "^openai/" <<< "$output" >/dev/null'