Merge upstream/main into reproduce/issue-1720
Resolve conflicts after 914 upstream commits: - CHANGELOG.md: keep brew opencode fix entry in Unreleased - .gitignore: keep superpowers docs exclusion, take upstream additions Drop /usr/local/ TOOLCHAIN_SEGMENTS addition — /usr/local/bin is part of the default macOS PATH, so treating it as user-configured would skip the login-shell fallback that this fix relies on. Upstream tests (pass 1602) confirm minimal system PATH must not look user-configured.
This commit is contained in:
@@ -1,3 +1,225 @@
|
||||
## [Unreleased]
|
||||
|
||||
- If OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117).
|
||||
|
||||
## [1.19.0] - 2026-08-19
|
||||
|
||||
- **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders.
|
||||
- **Chat:** an open conversation no longer keeps re-coloring the same code blocks in the background, preventing high CPU use while the chat is idle (thanks to @makeittech).
|
||||
- Settings: OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech).
|
||||
- Usage: Command Code plan limits now appear in the Usage page and work status panel.
|
||||
- Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting.
|
||||
- Chat: typing with Chinese, Japanese, or Korean input methods no longer interrupts composition or jumps the cursor to the end of the composer (thanks to @makeittech).
|
||||
- The context usage readout no longer climbs over 100% after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds (thanks to @pocharlies).
|
||||
- Usage: the context usage readout in the chat header now also shows the session's cost in its tooltip (thanks to @YunFeng0817).
|
||||
- Attachments: extracted Office and OpenDocument content is now capped and presented more compactly, preventing large documents and their images from overwhelming the message context.
|
||||
- Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude`; names you renamed yourself are kept.
|
||||
- Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech).
|
||||
|
||||
## [1.18.4] - 2026-08-14
|
||||
|
||||
- **Chat:** new messages now remain at the end of the conversation instead of jumping before older messages after the message ID sequence rolls over; history loading, revert, and redo follow the same chronological order.
|
||||
|
||||
## [1.18.3] - 2026-08-14
|
||||
|
||||
- Chat images: completed assistant replies now collect Markdown images into a compact gallery with thumbnails and full-screen previews, including workspace-local images across multi-root workspaces (thanks to @ChangeHow).
|
||||
- Sessions: switching projects now selects a session owned by the new workspace, and a message already being prepared stays with the session where it was submitted instead of being rerouted by a later project switch (thanks to @makeittech).
|
||||
- Usage: quota limits enabled for display now refresh every three minutes, with a manual refresh action available at any time.
|
||||
- Usage: OpenCode Go quota tracking now uses the existing OpenCode API key instead of requiring separate browser cookies and a workspace ID.
|
||||
- Chat: the Focus Chat command and Add to Context action now place the cursor in the chat input so typing can continue immediately.
|
||||
- Chat: typing `!` to enter shell mode no longer inserts the trigger into the command or moves the caret to the wrong side of it (thanks to @RyderAsKing).
|
||||
- Chat: line numbers with three or more digits no longer wrap in code blocks (thanks to @ChangeHow).
|
||||
|
||||
## [1.18.2] - 2026-08-10
|
||||
|
||||
- **Settings:** OpenCode configuration changes now accumulate behind a single Apply & Restart action instead of restarting OpenCode after every edit; the confirmation warns when active chats will be stopped (thanks to @makeittech).
|
||||
- Chat: messages submitted before switching sessions stay with the session and workspace they were sent from, and are cancelled rather than crossing into a different runtime (thanks to @Wsyjq).
|
||||
- Chat: queued messages no longer send into a response that is still streaming, and tool cards left running by an interrupted response settle instead of remaining stuck (thanks to @makeittech).
|
||||
- Chat: shell command output is expanded by default, and adding a message to context returns focus to the composer (thanks to @pascalandr, @makeittech).
|
||||
- Chat: fresh messages no longer replay their entry animation after they have already been shown (thanks to @makeittech).
|
||||
- Chat: the composer caret is now easier to see.
|
||||
- MCP: authorization now handles browser callbacks more reliably, settings distinguish available and unavailable servers more clearly, and failed connections expose a retry action.
|
||||
- Usage: added xAI quota reporting (thanks to @iamhenry).
|
||||
- Attachments: removing an attached Office or OpenDocument file also removes the images extracted from that document (thanks to @chiamsun).
|
||||
- Notebooks: notebook links now open in the notebook editor when a compatible extension is installed (thanks to @TTTPOB).
|
||||
- Settings: rapid edits to notification templates no longer overwrite one another, and the collapsed-user-message preference now persists correctly (thanks to @AmanTahiliani, @pascalandr).
|
||||
- Security: updated archive extraction to address GHSA-xcpc-8h2w-3j85 (thanks to @mel0nyrame).
|
||||
- UI: dialogs, dropdowns, popovers, and tooltips now use consistent glass styling.
|
||||
|
||||
## [1.18.1] - 2026-08-04
|
||||
|
||||
- **Providers:** signing in to an OAuth-only provider (such as Cursor) now completes in the browser — the login is stored and the provider updates instead of remaining signed out. OAuth-only providers show a Connect flow instead of an API key form, and their models stay hidden until you are signed in.
|
||||
- **Sessions:** archived sessions can now be restored to the active list — from the sidebar context menu, the archived-sessions page, or the bulk-selection bar (thanks to @makeittech).
|
||||
- Providers: sign-ins that need extra details (such as GitHub Copilot Enterprise) now ask for them before opening the browser, and device codes come with a working copy button.
|
||||
- Chat: a manually chosen model now stays selected after a delegated subtask finishes, instead of reverting to the agent's default model.
|
||||
- Chat: Ctrl/Cmd+L now adds the selected text to the chat input, or focuses it when nothing is selected.
|
||||
|
||||
## [1.18.0] - 2026-08-04
|
||||
|
||||
- **Providers:** custom OpenAI-compatible providers can now be added and edited from Settings, including their endpoint, models, credentials, headers, and configuration scope (thanks to @makeittech).
|
||||
- UI/Localization: added German interface translations (thanks to @SGD-DEV).
|
||||
- Chat/Tools: Bash output now applies terminal control characters and strips ANSI formatting, preventing progress output and rewritten lines from appearing as raw escape sequences (thanks to @catan271).
|
||||
- Chat: queued messages now retry after a temporary send failure or an interrupted turn instead of remaining stuck until another session update.
|
||||
- Settings/Skills: repository-local `.agents/skills` now appear for the active workspace (thanks to @makeittech).
|
||||
- Settings/Skills: renaming a skill now preserves its instructions and supporting files; only skills in locations OpenChamber can safely rename show the action (thanks to @makeittech).
|
||||
- Usage: added DeepSeek quota tracking (thanks to @airtaxi).
|
||||
- Usage: Kimi for Coding now calculates usage correctly when the provider reports either used or remaining quota (thanks to @makeittech).
|
||||
- Chat: clicking an apply_patch tool result now opens each changed file at its correct path instead of always opening the first file (thanks to @nabsiddiqui).
|
||||
- Chat: assistant messages no longer render active HTML.
|
||||
- Sidebar: a worktree shared by more than one project no longer appears twice.
|
||||
- Sidebar: session titles no longer clip at the ends of their rows.
|
||||
- Sessions: archiving and unarchiving now stays scoped to the current workspace.
|
||||
|
||||
## [1.17.2] - 2026-08-01
|
||||
|
||||
- Per-session permission auto-accept now replies to live permission requests correctly when auto-accept is turned on.
|
||||
- Chat: messages arriving at the start of the SSE stream no longer drop silently after initial connection.
|
||||
- Sessions with active agents now show a live activity indicator even when the sidebar is collapsed (thanks to @pascalandr).
|
||||
- Usage: all Z.ai usage windows now appear in the usage view.
|
||||
- Chat: tool descriptions now show the glob pattern when a tool's input uses one.
|
||||
- Chat: clicking in the padding area of the composer now correctly places the cursor (thanks to @IbrahimKhan12).
|
||||
- Chat: the `/` command menu no longer lists a skill twice when a command shares its name (thanks to @IbrahimKhan12).
|
||||
|
||||
## [1.17.1] - 2026-07-29
|
||||
|
||||
- **Chat tools:** Bash tool cards now show output before a command finishes, keep it in a fixed-height pane, and follow new lines until you scroll away. Long-running commands no longer remain at a 300-second duration, and their timers continue until they finish.
|
||||
- **OpenCode updates:** the extension can now update a managed OpenCode installation and restart it automatically; externally managed servers are left unchanged (thanks to @yulia-ivashko).
|
||||
- Chat: a stalled response now reconnects instead of remaining stuck in the extension.
|
||||
- Chat: fully loaded histories no longer show "Load older" again after a refresh.
|
||||
- Chat: messages removed by reverting no longer reappear after you send another message.
|
||||
- Chat: slash-command starters now include text already entered in the draft as command arguments.
|
||||
- Settings: subpanels keep a visible vertical scrollbar and no longer show a horizontal scrollbar (thanks to @sergiofspedro).
|
||||
- Usage: OpenAI business-account Codex usage now shows the configured spend limit (thanks to @jrandiny).
|
||||
|
||||
## [1.17.0] - 2026-07-28
|
||||
|
||||
- **Context panel:** a new surface rail brings Changes, pull requests, files, terminal, notes, plans, previews, and side chats into one resizable panel. The pull-request surface now shows live checks and comments, and can attach failed checks or comments to a chat draft.
|
||||
- **Chat composer:** prompts now render Markdown emphasis, attention lines, file and agent mentions, slash commands, snippets, attachment citations, and `~path` references directly while you type. File mentions can be edited in place.
|
||||
- Sessions: the sidebar now uses clearer project zones and single-line session rows, with folders shown after ungrouped sessions and archived sessions kept in their existing workspace sections.
|
||||
- Sessions: fixed missing activity-based sorting; chats now move in the list when they start or finish instead of repeatedly reordering while a response streams.
|
||||
- Chat: selecting text from Markdown code blocks now preserves the code fences, language, and surrounding block structure when adding it to the composer or starting a new session (thanks to @ChangeHow).
|
||||
- Chat: code blocks no longer shift line layout or merge adjacent text while rendering, and copied code keeps its original text (thanks to @ChangeHow).
|
||||
- Chat/Permissions: sending a message while a permission prompt is open now denies pending requests in the session and its subagents, then queues the message for the next turn (thanks to @tomzx).
|
||||
- Chat/Subagents: subagent chats can be prompted when direct subagent prompting is enabled, even if the parent session has not loaded.
|
||||
- Chat: jumping to messages in long conversations now lands on the intended message when earlier rows have not been rendered yet.
|
||||
- Settings: added an option to hide starter suggestions on the new-session screen.
|
||||
- Shortcuts: fixed a regression where double-Escape could be primed when the current session was not active.
|
||||
- Usage: added Crof and NeuralWatt quota tracking, including the missing Crof switch arm that previously fell back to "Unsupported provider" (thanks to @kydorn).
|
||||
|
||||
## [1.16.3] - 2026-07-22
|
||||
|
||||
- **Chat attachments:** added Office and OpenDocument files (`.docx`, `.pptx`, `.xlsx`, `.odt`, `.odp`, and `.ods`), with readable text and supported embedded images extracted before sending. Attachments also support more source-code formats, notebooks, HAR files with credentials and cookies removed, SVG and Draw.io files, and HEIC/HEIF images; the composer warns when the selected model may ignore an attachment type.
|
||||
- **Performance:** opening and switching sessions now prioritizes the selected and visible chats in large workspaces. Failed refreshes keep the existing session list, parent sessions no longer disappear when their sub-sessions load first, and session data no longer crosses between workspaces or extension views.
|
||||
- Chat: assistant turns now show model, agent, thinking level, duration, and time together in the footer, and replies separated by hidden system or subagent prompts display as one continuous turn. The working indicator shows the model actually producing the active response, streaming at the bottom no longer jitters, and new user messages finish their entry animation instead of snapping into place.
|
||||
- Chat/Tools: attachments returned by plugin and custom tools remain visible after streaming and refreshes, with the same image previews and file chips as chat attachments (thanks to @FrostiDrinks).
|
||||
- Cursor: opening a chat no longer crashes when the editor webview does not expose its usual messaging APIs, and disposed editor tabs no longer receive late streaming messages (thanks to @makeittech).
|
||||
- Startup: the active workspace is now detected before startup state is restored, preventing projects outside the editor workspace from replacing it.
|
||||
- Agent Manager: worktree creation now recovers when an earlier Git operation left the repository locked, and removing a worktree while setup is still running no longer brings it back.
|
||||
- Sessions: workspace groups now default to manual ordering instead of recent-activity order; explicit sorting choices remain unchanged.
|
||||
|
||||
## [1.16.2] - 2026-07-18
|
||||
|
||||
- **Settings:** pages now use a consistent responsive layout, navigation is grouped by area, and save failures are shown in the page header. Agent tool permissions now distinguish inherited and explicit rules and show session-granted rules separately (thanks to @makeittech).
|
||||
- Permissions: per-session auto-accept works again, persists across extension restarts, and applies to subagent sessions while an OpenChamber view is open.
|
||||
- Sessions: new drafts and sessions now stay with the workspace selected in the sidebar, including multi-root and nested workspace setups (thanks to @bashrusakh).
|
||||
- Chat: if creating a session fails, the new-session draft stays open and restores the submitted prompt instead of discarding it.
|
||||
|
||||
## [1.16.1] - 2026-07-14
|
||||
|
||||
- **Performance:** large workspace session lists no longer regroup every session while chats stream. Opening a long chat after an empty or aborted agent turn also no longer repeatedly loads larger portions of its history.
|
||||
- Chat: shell-mode command cards now update their status and output while the command runs, with syntax highlighting for the command and output.
|
||||
- Chat: task (subagents) now track the correct subagent when several run at once, preventing one subagent's activity or "Open subtask" action from pointing to another session.
|
||||
|
||||
## [1.16.0] - 2026-07-13
|
||||
|
||||
- Chat: sessions with an active [goal](https://docs.openchamber.dev/session-goals/) (started from the web or desktop app) show the goal strip with its live status above the composer.
|
||||
- Chat: code blocks highlight correctly again — the webview's security policy was blocking the syntax highlighter (thanks to @bashrusakh).
|
||||
- Chat: queued messages now send when the session is already idle instead of waiting forever (thanks to @bashrusakh).
|
||||
- Chat: pending agent questions stay answerable after a restart, and session renames no longer flicker back to the old title (thanks to @bashrusakh).
|
||||
- Chat: tool output rendering no longer breaks on tools that return non-text results (thanks to @bashrusakh).
|
||||
- Settings: a new editor font size setting for the code editor (thanks to @bashrusakh).
|
||||
- Sessions: pinned sessions survive refreshes (thanks to @bashrusakh).
|
||||
- Agents: saving agent settings from the UI no longer drops custom YAML frontmatter fields (thanks to @bashrusakh).
|
||||
- Notifications: subagent completion notifications now follow the same settings as the main app.
|
||||
- Usage: OpenCode Go usage tracking was added.
|
||||
- Windows: paths no longer mismatch on drive letter casing, which could split one project into duplicates (thanks to @bashrusakh).
|
||||
|
||||
## [1.15.0] - 2026-07-10
|
||||
|
||||
- Chat/Tools: every tool call now expands to show its input, result, and errors, including MCP, plugin, and custom tools; Read and Skill stay compact links to their files. JSON results now offer navigable summary, tree, and raw views.
|
||||
- Chat/Tools: expanded file-edit and patch results include per-file buttons to open the diff or jump to the first changed line in the editor.
|
||||
- Chat/Thinking: reasoning parts stay separate and in chronological order instead of merging into one block, and collapsed previews no longer show empty trailing HTML comments.
|
||||
- Chat: Mermaid diagrams now have zoom controls (thanks to @c-w-xiaohei).
|
||||
- Chat: code blocks can show line numbers that stay aligned while streaming, and a new Wrap Code Block Lines setting controls long-line wrapping.
|
||||
- Chat: with Sticky User Header enabled, user messages no longer float over earlier messages in long conversations.
|
||||
- Chat: if sending a message times out or loses the connection after OpenCode accepted it, the extension now keeps the sent message instead of rolling it back as failed.
|
||||
- Editor Integration: the "Add to Context" command and active-editor pin-selection now use workspace-relative filenames so the model reads the correct file when names collide (thanks to @Catan).
|
||||
|
||||
## [1.14.1] - 2026-07-07
|
||||
|
||||
- Chat: the timeline dialog can now load older messages when the current session history has not all been fetched yet.
|
||||
- Chat: file references with line ranges like `src/file.ts:10-20` are now clickable in messages (thanks to @Catan).
|
||||
- Chat: favorite models now stay saved after restarting the extension (thanks to @Catan).
|
||||
- Settings: closing Settings returns to the previous extension view instead of always showing the sessions list (thanks to @Catan).
|
||||
|
||||
## [1.14.0] - 2026-07-05
|
||||
|
||||
- Chat: loading older messages keeps your scroll position steady.
|
||||
- Chat: the stop button now aborts sessions running in a different project or worktree than the currently open one — previously those aborts silently did nothing.
|
||||
- Startup: on Windows, OpenCode installed via npm now launches from paths with spaces (such as C:\Program Files\nodejs), a binary path pasted with surrounding quotes into the Opencode Binary setting works, and discovery also checks the system-wide npm prefix and Scoop's shims.
|
||||
|
||||
## [1.13.9] - 2026-07-02
|
||||
|
||||
- Agents: clearing optional agent fields now removes them from agent config instead of saving `null` values.
|
||||
- Startup: the extension no longer picks OpenCode desktop app installs when looking for the standalone OpenCode CLI.
|
||||
- Chat: fixed edge cases where late-loading tool content, subagent content, or streaming Thinking blocks could pull the conversation away from the latest message or fight manual scrolling.
|
||||
- Chat: embedded JSON examples in messages no longer render as generated-result cards.
|
||||
- Sync: chat state now recovers after idle reconnects instead of leaving sessions stuck in a stale busy state.
|
||||
|
||||
## [1.13.8] - 2026-06-29
|
||||
|
||||
- Chat: a new Follow-up behavior setting controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh).
|
||||
- Sync: a connected but quiet session (for example an agent running a long tool call) no longer triggers repeated background refreshes every ~15 seconds (thanks to @tomzx).
|
||||
|
||||
## [1.13.7] - 2026-06-28
|
||||
|
||||
- Chat: with tool calls (such as Bash and Edit) shown expanded by default, scrolling no longer twitches, and slow scrolling no longer jumps past several messages.
|
||||
- Providers: the Add provider form stays open while provider data refreshes or a model is picked in the background, instead of snapping back to an existing provider.
|
||||
|
||||
## [1.13.6] - 2026-06-28
|
||||
|
||||
- Chat: scrolling in conversations now stays steady while sending, queueing, streaming, switching sessions, and loading older messages.
|
||||
|
||||
## [1.13.5] - 2026-06-27
|
||||
|
||||
- No notable changes.
|
||||
|
||||
## [1.13.4] - 2026-06-27
|
||||
|
||||
- UI/Localization: added Japanese interface translations (thanks to @yuchi0531).
|
||||
- Chat: queued messages can now be reordered by dragging them in the queue (thanks to @makeittech).
|
||||
- Chat: sending a message now closes an open question prompt instead of leaving stale question UI in the composer (thanks to @tomzx).
|
||||
- Chat: conversations pinned to the bottom no longer jiggle or double-scroll after sending, and revisiting older sessions snaps to the latest message without a smooth-scroll delay.
|
||||
- Models: the model picker now remembers provider group expansion and custom ordering, and Shift+Delete removes a recent model from recents (thanks to @makeittech).
|
||||
- Agents: agent edits against an external OpenCode server no longer show a saved-state update when the save did not succeed (thanks to @makeittech).
|
||||
- Providers: the add-provider form no longer loses the selected provider during background provider refreshes (thanks to @IbrahimKhan12).
|
||||
|
||||
## [1.13.3] - 2026-06-24
|
||||
|
||||
- Chat: selecting a user-installed skill from the slash command menu now invokes the skill instead of inserting the skill name as plain text (thanks to @IbrahimKhan12).
|
||||
- Chat: pasted text containing `@` no longer opens file mention autocomplete unexpectedly (thanks to @charpeni).
|
||||
- Chat: code blocks in user messages now preserve characters like `<` and `->` instead of escaping them inside the code block (thanks to @bashrusakh).
|
||||
- Chat: switching sessions and loading older messages no longer causes the conversation to jump backward or oscillate around the current scroll position (thanks to @herjarsa).
|
||||
- Chat: Arrow Up opens prompt history again when the cursor is at the start of the composer.
|
||||
- Sessions: new sessions now stay attached to the selected workspace directory instead of sometimes appearing under a stale project (thanks to @bashrusakh).
|
||||
- Sessions: pinned sessions and folder rows no longer disappear from the sidebar after an empty session-list refresh (thanks to @bashrusakh).
|
||||
- Agents: agent settings now include thinking variant, temperature, and top-p controls, and clearing temperature or top-p now removes the override (thanks to @bashrusakh).
|
||||
- Settings/Models: per-model visibility and sibling model selections now stay saved after changes (thanks to @attilaszasz).
|
||||
- Settings: font size and padding preferences now apply inside the extension webview (thanks to @Sin991114).
|
||||
- Settings/Skills: the skills catalog refreshes after catalog settings change (thanks to @gokulkgm).
|
||||
- Usage: MiniMax M3 and Token Plan usage now handle the provider's latest API response format (thanks to @baruchvitorino).
|
||||
- Startup: managed OpenCode server processes left behind by a previous extension crash are cleaned up on the next start.
|
||||
|
||||
## [1.13.2] - 2026-06-18
|
||||
|
||||
- Chat/Performance: long conversations and large session lists now stay smooth and responsive while a response is streaming (thanks to @bashrusakh).
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# OpenChamber VS Code Extension
|
||||
|
||||
[](https://github.com/btriapitsyn/openchamber/stargazers)
|
||||
[](https://github.com/btriapitsyn/openchamber/releases/latest)
|
||||
[](https://github.com/openchamber/openchamber/stargazers)
|
||||
[](https://github.com/openchamber/openchamber/releases/latest)
|
||||
[](https://discord.gg/ZYRSdnwwKA)
|
||||
[](https://ko-fi.com/G2G41SAWNS)
|
||||
|
||||
[OpenCode](https://opencode.ai) AI coding agent, right inside your editor. No tab-switching, no context loss.
|
||||
|
||||

|
||||

|
||||
|
||||
**Like the extension? There's also a [desktop app and web version](https://github.com/btriapitsyn/openchamber) with even more features.**
|
||||
**Like the extension? There's also a [desktop app and web version](https://github.com/openchamber/openchamber) with even more features.**
|
||||
|
||||
## What you get
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
"name": "openchamber",
|
||||
"displayName": "OpenChamber",
|
||||
"description": "%extension.description%",
|
||||
"version": "1.13.2",
|
||||
"version": "1.19.0",
|
||||
"publisher": "fedaykindev",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/btriapitsyn/openchamber.git"
|
||||
"url": "https://github.com/openchamber/openchamber.git"
|
||||
},
|
||||
"qna": "https://discord.gg/ZYRSdnwwKA",
|
||||
"license": "MIT",
|
||||
@@ -230,6 +230,7 @@
|
||||
"watch:webview": "vite build --watch",
|
||||
"type-check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.webview.json",
|
||||
"lint": "bun x eslint --ext .ts,.tsx src webview",
|
||||
"test": "node ../../scripts/run-isolated-tests.mjs src webview",
|
||||
"package": "vsce package --no-dependencies"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -244,8 +245,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "^1.17.7",
|
||||
"adm-zip": "^0.5.16",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
"adm-zip": "^0.6.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
|
||||
@@ -140,6 +140,14 @@ export class AgentManagerPanelProvider {
|
||||
});
|
||||
}
|
||||
|
||||
public notifyPermissionAutoAcceptSynced(snapshot: unknown): void {
|
||||
this._panel?.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'permissionAutoAcceptSynced',
|
||||
payload: snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
public notifyWindowFocusChanged(focused: boolean): void {
|
||||
if (!this._panel) {
|
||||
return;
|
||||
@@ -177,7 +185,7 @@ export class AgentManagerPanelProvider {
|
||||
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
||||
const { path, headers, streamId: requestedStreamId } = (payload || {}) as { path?: string; headers?: Record<string, string>; streamId?: string };
|
||||
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
||||
|
||||
if (!this._openCodeManager) {
|
||||
@@ -189,8 +197,11 @@ export class AgentManagerPanelProvider {
|
||||
};
|
||||
}
|
||||
|
||||
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
|
||||
const streamId = typeof requestedStreamId === 'string' && /^sse_webview_\d+_\d+$/.test(requestedStreamId)
|
||||
? requestedStreamId
|
||||
: `sse_${++this._sseCounter}_${Date.now()}`;
|
||||
const controller = new AbortController();
|
||||
this._sseStreams.set(streamId, controller);
|
||||
|
||||
try {
|
||||
const start = await openSseProxy({
|
||||
@@ -203,8 +214,6 @@ export class AgentManagerPanelProvider {
|
||||
},
|
||||
});
|
||||
|
||||
this._sseStreams.set(streamId, controller);
|
||||
|
||||
start.run
|
||||
.then(() => {
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:end', streamId });
|
||||
@@ -230,6 +239,7 @@ export class AgentManagerPanelProvider {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
this._sseStreams.delete(streamId);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -200,6 +200,18 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public focusChatInput(): void {
|
||||
if (!this._view) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._view.show(true);
|
||||
void this._view.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'focusChatInput',
|
||||
});
|
||||
}
|
||||
|
||||
public addContextSelection(selection: { filePath: string; filename: string; text: string }) {
|
||||
if (!this._view) {
|
||||
return;
|
||||
@@ -318,6 +330,14 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
});
|
||||
}
|
||||
|
||||
public notifyPermissionAutoAcceptSynced(snapshot: unknown): void {
|
||||
this._view?.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'permissionAutoAcceptSynced',
|
||||
payload: snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the webview to run the full OpenCode reload flow (overlay + managed
|
||||
* restart via the bridge + config/data refresh) — the same flow used after an
|
||||
@@ -528,7 +548,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
||||
const { path, headers, streamId: requestedStreamId } = (payload || {}) as { path?: string; headers?: Record<string, string>; streamId?: string };
|
||||
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
||||
|
||||
if (!this._openCodeManager) {
|
||||
@@ -540,8 +560,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
};
|
||||
}
|
||||
|
||||
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
|
||||
const streamId = typeof requestedStreamId === 'string' && /^sse_webview_\d+_\d+$/.test(requestedStreamId)
|
||||
? requestedStreamId
|
||||
: `sse_${++this._sseCounter}_${Date.now()}`;
|
||||
const controller = new AbortController();
|
||||
this._sseStreams.set(streamId, { controller, view: this._view });
|
||||
|
||||
try {
|
||||
const start = await openSseProxy({
|
||||
@@ -554,8 +577,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
},
|
||||
});
|
||||
|
||||
this._sseStreams.set(streamId, { controller, view: this._view });
|
||||
|
||||
start.run
|
||||
.then(() => {
|
||||
this._view?.webview.postMessage({ type: 'api:sse:end', streamId });
|
||||
@@ -581,6 +602,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
this._sseStreams.delete(streamId);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -21,6 +21,12 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
|
||||
- `bridge-git-process-runtime.ts`
|
||||
- Git process execution and environment setup (`execGit`), including SSH agent socket resolution.
|
||||
|
||||
- `gitService.ts`
|
||||
- Owns VS Code Git and worktree operations.
|
||||
- Fast worktree creation reports bootstrap phases explicitly: `directory-created`, then `git-ready` after Git population/upstream work, and `setup-ready` after setup commands. Existing worktrees without tracked bootstrap state fall back to `ready`/`setup-ready`; shared webview consumers also accept legacy responses without `phase`.
|
||||
- Worktree removal waits for an active create/bootstrap task for the same directory so background Git and setup work cannot race deletion or restore stale bootstrap state.
|
||||
- Worktree population enables Git `core.longpaths` (local repo config plus `-c core.longpaths=true` on `git reset --hard`) so deeply nested checkouts under the managed data-dir worktree root do not fail on Windows MAX_PATH with "Filename too long".
|
||||
|
||||
- `bridge-fs-runtime.ts`
|
||||
- Bridge handlers for filesystem-related message routes.
|
||||
- Uses shared FS helpers via injected dependencies.
|
||||
@@ -31,18 +37,29 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
|
||||
- directory listing
|
||||
- file search
|
||||
- file read path safety checks
|
||||
- active-directory selection across multi-root workspaces
|
||||
- dropped-file parsing and attachment reading
|
||||
- models metadata fetch helper
|
||||
- Read paths are authorized in the requested workspace path space before symlink resolution, matching the web runtime; directly requested outside-workspace paths remain denied.
|
||||
|
||||
The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can run bounded local decompression off the main thread. Blob scripts remain disallowed by `script-src`.
|
||||
|
||||
- `bridge-localfs-proxy-runtime.ts`
|
||||
- Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers.
|
||||
- Workspace-contained Markdown gallery images use these local filesystem
|
||||
routes without calling the server grant route. Grant requests for OpenCode
|
||||
temporary-directory images return an explicit unsupported response instead
|
||||
of being forwarded to OpenCode.
|
||||
|
||||
- `bridge-proxy-runtime.ts`
|
||||
- Proxy route handlers (`api:proxy`, `api:session:message`) with injected helper dependencies.
|
||||
- SSE routes are intentionally excluded from the generic proxy and use `sseProxy.ts`, whose upstream-only stall watchdog closes a quiet OpenCode stream so the webview can reconnect instead of trusting an open but silent response.
|
||||
- The webview allocates each SSE stream ID and installs its listener before requesting the upstream stream, so immediate OpenCode replay events cannot race the bridge start response.
|
||||
|
||||
- `bridge-config-runtime.ts`
|
||||
- Config and skills message handlers (`api:config/*`).
|
||||
- Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`).
|
||||
- OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty, while other content that yields no JSON value (YAML, plain text) fails closed. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, including plugin list/read via `getPluginConfigSources`. Writes still refuse to overwrite the broken file.
|
||||
|
||||
- `bridge-settings-runtime.ts`
|
||||
- Settings read/write and OpenCode skills discovery via API for bridge consumers.
|
||||
@@ -51,6 +68,19 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
|
||||
- System/editor/provider/quota/notification/update-check message handlers.
|
||||
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
|
||||
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
|
||||
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
|
||||
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API).
|
||||
|
||||
- `opencode-upgrade-runtime.ts`
|
||||
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
|
||||
|
||||
- `bridge-permission-auto-accept-runtime.ts`
|
||||
- Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract.
|
||||
- Serializes reads and read-modify-write updates, persists a monotonic policy revision, and broadcasts the exact committed snapshot to every active OpenChamber webview. Permission replies remain foreground UI-owned because VS Code does not run the OpenChamber server runtime.
|
||||
|
||||
## Shared webview message ordering
|
||||
|
||||
Message and part ordering is owned by [`packages/ui/src/sync/DOCUMENTATION.md`](../../ui/src/sync/DOCUMENTATION.md#session-message-loading). The VS Code webview consumes that shared sync implementation; bridge and proxy runtimes pass OpenCode records through without adding runtime-specific ordering.
|
||||
|
||||
## Extension guideline
|
||||
|
||||
|
||||
@@ -199,6 +199,16 @@ export class SessionEditorPanelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public notifyPermissionAutoAcceptSynced(snapshot: unknown): void {
|
||||
for (const entry of this._panels.values()) {
|
||||
entry.panel.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'permissionAutoAcceptSynced',
|
||||
payload: snapshot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public notifyWindowFocusChanged(focused: boolean): void {
|
||||
for (const entry of this._panels.values()) {
|
||||
entry.panel.webview.postMessage({
|
||||
@@ -405,7 +415,7 @@ export class SessionEditorPanelProvider {
|
||||
private async _startSseProxy(message: BridgeRequest, entry: SessionPanelState): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
||||
const { path, headers, streamId: requestedStreamId } = (payload || {}) as { path?: string; headers?: Record<string, string>; streamId?: string };
|
||||
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
||||
|
||||
if (!this._openCodeManager) {
|
||||
@@ -417,8 +427,11 @@ export class SessionEditorPanelProvider {
|
||||
};
|
||||
}
|
||||
|
||||
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
|
||||
const streamId = typeof requestedStreamId === 'string' && /^sse_webview_\d+_\d+$/.test(requestedStreamId)
|
||||
? requestedStreamId
|
||||
: `sse_${++this._sseCounter}_${Date.now()}`;
|
||||
const controller = new AbortController();
|
||||
entry.sseStreams.set(streamId, controller);
|
||||
|
||||
try {
|
||||
const start = await openSseProxy({
|
||||
@@ -427,20 +440,19 @@ export class SessionEditorPanelProvider {
|
||||
headers: this._buildSseHeaders(headers),
|
||||
signal: controller.signal,
|
||||
onChunk: (chunk) => {
|
||||
entry.panel.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk });
|
||||
// Panel may be disposed before SSE callbacks fire.
|
||||
entry.panel?.webview?.postMessage({ type: 'api:sse:chunk', streamId, chunk });
|
||||
},
|
||||
});
|
||||
|
||||
entry.sseStreams.set(streamId, controller);
|
||||
|
||||
start.run
|
||||
.then(() => {
|
||||
entry.panel.webview.postMessage({ type: 'api:sse:end', streamId });
|
||||
entry.panel?.webview?.postMessage({ type: 'api:sse:end', streamId });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
const messageText = error instanceof Error ? error.message : String(error);
|
||||
entry.panel.webview.postMessage({ type: 'api:sse:end', streamId, error: messageText });
|
||||
entry.panel?.webview?.postMessage({ type: 'api:sse:end', streamId, error: messageText });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -458,6 +470,7 @@ export class SessionEditorPanelProvider {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
entry.sseStreams.delete(streamId);
|
||||
const messageText = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -52,6 +52,58 @@ afterEach(() => {
|
||||
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
|
||||
describe('VS Code config bridge plugin parity', () => {
|
||||
test('explicit config reload restarts OpenCode', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-reload-'));
|
||||
tempRoots.push(root);
|
||||
const ctx = createCtx(root);
|
||||
|
||||
const reloaded = await handleConfigBridgeMessage({
|
||||
id: 'reload',
|
||||
type: 'api:config/reload',
|
||||
}, ctx, deps);
|
||||
|
||||
expect(reloaded).toEqual({
|
||||
id: 'reload',
|
||||
type: 'api:config/reload',
|
||||
success: true,
|
||||
data: { restarted: true },
|
||||
});
|
||||
expect(ctx.restart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('removes agent fields when update payload sends null', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-agent-null-'));
|
||||
tempRoots.push(root);
|
||||
const ctx = createCtx(root);
|
||||
const configDir = path.join(root, '.opencode');
|
||||
const configPath = path.join(configDir, 'opencode.json');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
agent: {
|
||||
build: {
|
||||
variant: 'fast',
|
||||
temperature: 0.3,
|
||||
top_p: 0.8,
|
||||
mode: 'subagent',
|
||||
},
|
||||
},
|
||||
}, null, 2), 'utf8');
|
||||
|
||||
const updated = await handleConfigBridgeMessage({
|
||||
id: 'update-agent-null-fields',
|
||||
type: 'api:config/agents',
|
||||
payload: {
|
||||
method: 'PATCH',
|
||||
name: 'build',
|
||||
directory: root,
|
||||
body: { variant: null, temperature: null, top_p: null },
|
||||
},
|
||||
}, ctx, deps);
|
||||
|
||||
expect(updated?.success).toBe(true);
|
||||
expect(readJson(configPath).agent.build).toEqual({ mode: 'subagent' });
|
||||
});
|
||||
|
||||
test('creates, lists, updates, and deletes project plugin entries', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugins-'));
|
||||
tempRoots.push(root);
|
||||
@@ -69,7 +121,14 @@ describe('VS Code config bridge plugin parity', () => {
|
||||
}, ctx, deps);
|
||||
|
||||
expect(created?.success).toBe(true);
|
||||
expect(ctx.restart).toHaveBeenCalledTimes(1);
|
||||
expect(created?.data).toMatchObject({
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
requiresRestart: true,
|
||||
restartDeferred: true,
|
||||
message: 'Plugin entry changed. Restart OpenCode to apply.',
|
||||
});
|
||||
expect(ctx.restart).not.toHaveBeenCalled();
|
||||
|
||||
const listed = await handleConfigBridgeMessage({
|
||||
id: 'list',
|
||||
@@ -218,27 +277,36 @@ describe('VS Code config bridge plugin parity', () => {
|
||||
expect(fs.readFileSync(path.join(configDir, 'plugins', 'demo-plugin.ts'), 'utf8')).toBe('export default {}');
|
||||
});
|
||||
|
||||
test('reports plugin mutation success when restart fails after writing config', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugin-restart-'));
|
||||
test('creates MCP config with deferred restart when restart would fail', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-mcp-deferred-'));
|
||||
tempRoots.push(root);
|
||||
const ctx = createCtx(root, async () => {
|
||||
throw new Error('restart failed');
|
||||
});
|
||||
|
||||
const created = await handleConfigBridgeMessage({
|
||||
id: 'create-restart-failure',
|
||||
type: 'api:config/plugins',
|
||||
id: 'create-mcp-deferred',
|
||||
type: 'api:config/mcp',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
target: 'entry',
|
||||
name: 'mcp-deferred',
|
||||
directory: root,
|
||||
body: { scope: 'project', spec: 'plugin-restart' },
|
||||
body: { scope: 'project', type: 'local', command: ['node', 'server.js'] },
|
||||
},
|
||||
}, ctx, deps);
|
||||
|
||||
expect(created?.success).toBe(true);
|
||||
expect(created?.data).toMatchObject({ success: true, requiresReload: false, reloadFailed: true });
|
||||
expect(created?.data?.warning).toContain('restart failed');
|
||||
expect(readJson(path.join(root, '.opencode', 'opencode.json')).plugin).toEqual(['plugin-restart']);
|
||||
expect(created?.data).toMatchObject({
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
requiresRestart: true,
|
||||
restartDeferred: true,
|
||||
message: 'MCP server "mcp-deferred" created. Restart OpenCode to apply.',
|
||||
});
|
||||
expect(ctx.restart).not.toHaveBeenCalled();
|
||||
expect(readJson(path.join(root, '.opencode', 'opencode.json')).mcp['mcp-deferred']).toMatchObject({
|
||||
type: 'local',
|
||||
command: ['node', 'server.js'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
renameSkill,
|
||||
isManagedSkillPath,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
@@ -56,6 +58,7 @@ import {
|
||||
installSkillsFromRepository as installSkillsFromGit,
|
||||
type SkillsCatalogSourceConfig,
|
||||
} from './skillsCatalog';
|
||||
import { buildDeferredRestartResponse } from './config-mutation-response';
|
||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||
|
||||
type BridgeMessageInput = {
|
||||
@@ -84,31 +87,9 @@ const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: str
|
||||
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath)
|
||||
);
|
||||
|
||||
const pluginMutationPayload = async (
|
||||
ctx: BridgeContext | undefined,
|
||||
deps: ConfigRuntimeDeps,
|
||||
label: string,
|
||||
) => {
|
||||
try {
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `${label}. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
reloadFailed: false,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
message: `${label}, but OpenCode reload failed.`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
reloadFailed: true,
|
||||
warning: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
const pluginMutationPayload = (label: string) => buildDeferredRestartResponse(
|
||||
`${label}. Restart OpenCode to apply.`,
|
||||
);
|
||||
|
||||
const parseSkillsCatalogSources = (settings: Record<string, unknown>): SkillsCatalogSourceConfig[] => {
|
||||
const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs;
|
||||
@@ -222,8 +203,12 @@ export async function handleConfigBridgeMessage(
|
||||
}
|
||||
await fs.promises.mkdir(path.dirname(AGENTS_MD_PATH), { recursive: true });
|
||||
await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8');
|
||||
await ctx?.manager?.restart();
|
||||
return { id, type, success: true, data: { success: true } };
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: buildDeferredRestartResponse('AGENTS.md saved. Restart OpenCode to apply.'),
|
||||
};
|
||||
}
|
||||
|
||||
case 'api:magic-prompts:get': {
|
||||
@@ -295,33 +280,21 @@ export async function handleConfigBridgeMessage(
|
||||
const scopeValue = body?.scope as string | undefined;
|
||||
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
|
||||
createAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Agent ${agentName} created successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Agent ${agentName} updated successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -329,17 +302,11 @@ export async function handleConfigBridgeMessage(
|
||||
const scopeValue = body?.scope as string | undefined;
|
||||
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
|
||||
deleteAgent(agentName, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Agent ${agentName} deleted successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -378,49 +345,31 @@ export async function handleConfigBridgeMessage(
|
||||
const scopeValue = body?.scope as string | undefined;
|
||||
const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined;
|
||||
createCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Command ${commandName} created successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Command ${commandName} updated successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteCommand(commandName, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Command ${commandName} deleted successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -458,49 +407,31 @@ export async function handleConfigBridgeMessage(
|
||||
if (normalizedMethod === 'POST') {
|
||||
const scope = body?.scope as 'user' | 'project' | undefined;
|
||||
createMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${mcpName}" created. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`MCP server "${mcpName}" created. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${mcpName}" updated. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`MCP server "${mcpName}" updated. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteMcpConfig(mcpName, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${mcpName}" deleted. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`MCP server "${mcpName}" deleted. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -562,7 +493,7 @@ export async function handleConfigBridgeMessage(
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: await pluginMutationPayload(ctx, deps, 'Plugin entry changed'),
|
||||
data: pluginMutationPayload('Plugin entry changed'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -590,7 +521,7 @@ export async function handleConfigBridgeMessage(
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: await pluginMutationPayload(ctx, deps, 'Plugin file changed'),
|
||||
data: pluginMutationPayload('Plugin file changed'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -652,7 +583,21 @@ export async function handleConfigBridgeMessage(
|
||||
|
||||
if (!name && normalizedMethod === 'GET') {
|
||||
const skills = await resolveDiscoveredSkills(deps, ctx, workingDirectory);
|
||||
return { id, type, success: true, data: { skills } };
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
skills: skills.map((skill) => ({
|
||||
...skill,
|
||||
renamable: Boolean(
|
||||
skill.path
|
||||
&& skill.path !== '<built-in>'
|
||||
&& isManagedSkillPath(skill.path, workingDirectory)
|
||||
),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const skillName = typeof name === 'string' ? name.trim() : '';
|
||||
@@ -678,49 +623,49 @@ export async function handleConfigBridgeMessage(
|
||||
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
|
||||
const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode';
|
||||
createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Skill ${skillName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Skill ${skillName} created successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
if (typeof body?.renameTo === 'string') {
|
||||
const newName = body.renameTo.trim();
|
||||
renameSkill(skillName, newName, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
name: newName,
|
||||
requiresReload: true,
|
||||
message: `Skill renamed to ${newName} successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Skill ${skillName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Skill ${skillName} updated successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteSkill(skillName, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Skill ${skillName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Skill ${skillName} deleted successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -773,11 +718,7 @@ export async function handleConfigBridgeMessage(
|
||||
if (data.ok) {
|
||||
const installed = data.installed || [];
|
||||
const skipped = data.skipped || [];
|
||||
const requiresReload = installed.length > 0;
|
||||
|
||||
if (requiresReload) {
|
||||
await ctx?.manager?.restart();
|
||||
}
|
||||
const requiresRestart = installed.length > 0;
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -787,9 +728,12 @@ export async function handleConfigBridgeMessage(
|
||||
ok: true,
|
||||
installed,
|
||||
skipped,
|
||||
requiresReload,
|
||||
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
||||
reloadDelayMs: requiresReload ? deps.clientReloadDelayMs : undefined,
|
||||
...(requiresRestart
|
||||
? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.')
|
||||
: {
|
||||
requiresReload: false,
|
||||
message: 'No skills were installed',
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { execGit } from './bridge-git-process-runtime';
|
||||
|
||||
const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_FILE_ATTACH_SIZE_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const createGitCheckIgnoreTimeoutMs = () => {
|
||||
const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS);
|
||||
@@ -99,7 +99,7 @@ export const readUriAsAttachment = async (
|
||||
|
||||
const size = stat.size ?? 0;
|
||||
if (size > MAX_FILE_ATTACH_SIZE_BYTES) {
|
||||
return { skipped: { name, reason: 'File exceeds 10MB limit' } };
|
||||
return { skipped: { name, reason: 'File exceeds 20MB limit' } };
|
||||
}
|
||||
|
||||
const bytes = await vscode.workspace.fs.readFile(uri);
|
||||
@@ -538,7 +538,13 @@ export const fetchModelsMetadata = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getFsAccessRoot = (): string => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const getFsAccessRoot = (requestedRoot?: string): string => {
|
||||
const workspaceRoots = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [];
|
||||
const requested = requestedRoot ? path.resolve(requestedRoot) : '';
|
||||
return workspaceRoots.find((root) => path.resolve(root) === requested)
|
||||
|| workspaceRoots[0]
|
||||
|| os.homedir();
|
||||
};
|
||||
|
||||
export const getFsMimeType = (filePath: string): string => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
@@ -564,25 +570,21 @@ export type FsReadPathResolution =
|
||||
| { ok: true; resolvedPath: string }
|
||||
| { ok: false; status: number; error: string };
|
||||
|
||||
export const resolveFileReadPath = async (targetPath: string): Promise<FsReadPathResolution> => {
|
||||
export const resolveFileReadPath = async (targetPath: string, requestedRoot?: string): Promise<FsReadPathResolution> => {
|
||||
const trimmed = targetPath.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, status: 400, error: 'Path is required' };
|
||||
}
|
||||
|
||||
const baseRoot = getFsAccessRoot();
|
||||
const baseRoot = getFsAccessRoot(requestedRoot);
|
||||
const resolved = resolveUserPath(trimmed, baseRoot);
|
||||
if (!resolved) {
|
||||
return { ok: false, status: 400, error: 'Path is required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const [canonicalPath, canonicalBase] = await Promise.all([
|
||||
fs.promises.realpath(resolved),
|
||||
fs.promises.realpath(baseRoot).catch(() => path.resolve(baseRoot)),
|
||||
]);
|
||||
|
||||
if (!isPathInside(canonicalPath, canonicalBase)) {
|
||||
const canonicalPath = await fs.promises.realpath(resolved);
|
||||
if (!isPathInside(resolved, path.resolve(baseRoot))) {
|
||||
return { ok: false, status: 403, error: 'Access to file denied' };
|
||||
}
|
||||
|
||||
|
||||
@@ -481,7 +481,11 @@ export async function handleFsBridgeMessage(
|
||||
}
|
||||
|
||||
case 'api:files/pick': {
|
||||
const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false;
|
||||
const options = payload as { allowMany?: boolean; extensions?: unknown };
|
||||
const allowMany = options?.allowMany !== false;
|
||||
const extensions = Array.isArray(options?.extensions)
|
||||
? options.extensions.filter((extension): extension is string => typeof extension === 'string' && extension.length > 0)
|
||||
: [];
|
||||
const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri;
|
||||
|
||||
const picks = await vscode.window.showOpenDialog({
|
||||
@@ -490,6 +494,7 @@ export async function handleFsBridgeMessage(
|
||||
canSelectMany: allowMany,
|
||||
defaultUri,
|
||||
openLabel: 'Attach',
|
||||
filters: extensions.length > 0 ? { Files: extensions } : undefined,
|
||||
});
|
||||
|
||||
if (!picks || picks.length === 0) {
|
||||
|
||||
@@ -7,6 +7,8 @@ const gitService = {
|
||||
cherryPick: mock(),
|
||||
revertCommit: mock(),
|
||||
resetToCommit: mock(),
|
||||
createWorktree: mock(),
|
||||
getWorktreeBootstrapStatus: mock(),
|
||||
};
|
||||
|
||||
mock.module('./gitService', () => gitService);
|
||||
@@ -21,6 +23,8 @@ describe('bridge git runtime index mutations', () => {
|
||||
gitService.cherryPick.mockReset();
|
||||
gitService.revertCommit.mockReset();
|
||||
gitService.resetToCommit.mockReset();
|
||||
gitService.createWorktree.mockReset();
|
||||
gitService.getWorktreeBootstrapStatus.mockReset();
|
||||
});
|
||||
|
||||
it('accepts legacy stage path payloads', async () => {
|
||||
@@ -109,4 +113,66 @@ describe('bridge git runtime index mutations', () => {
|
||||
expect(gitService.revertCommit).not.toHaveBeenCalled();
|
||||
expect(gitService.resetToCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves bootstrap phases in status responses', async () => {
|
||||
const bootstrapStatus = {
|
||||
status: 'pending',
|
||||
phase: 'git-ready',
|
||||
error: null,
|
||||
updatedAt: 123,
|
||||
};
|
||||
gitService.getWorktreeBootstrapStatus.mockResolvedValue(bootstrapStatus);
|
||||
|
||||
const response = await handleStandardGitBridgeMessage({
|
||||
id: 'bootstrap-status',
|
||||
type: 'api:git/worktrees/bootstrap-status',
|
||||
payload: { directory: '/repo-worktree' },
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
id: 'bootstrap-status',
|
||||
type: 'api:git/worktrees/bootstrap-status',
|
||||
success: true,
|
||||
data: bootstrapStatus,
|
||||
});
|
||||
expect(gitService.getWorktreeBootstrapStatus).toHaveBeenCalledWith('/repo-worktree');
|
||||
});
|
||||
|
||||
it('preserves the directory-created phase in fast create responses', async () => {
|
||||
const created = {
|
||||
head: '',
|
||||
name: 'feature',
|
||||
branch: 'openchamber/feature',
|
||||
path: '/repo-worktree',
|
||||
directoryCreated: true,
|
||||
bootstrapStatus: {
|
||||
status: 'pending',
|
||||
phase: 'directory-created',
|
||||
error: null,
|
||||
updatedAt: 123,
|
||||
},
|
||||
};
|
||||
gitService.createWorktree.mockResolvedValue(created);
|
||||
|
||||
const response = await handleStandardGitBridgeMessage({
|
||||
id: 'create-worktree',
|
||||
type: 'api:git/worktrees',
|
||||
payload: {
|
||||
directory: '/repo',
|
||||
method: 'POST',
|
||||
worktreeName: 'feature',
|
||||
returnAfterDirectoryCreated: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
id: 'create-worktree',
|
||||
type: 'api:git/worktrees',
|
||||
success: true,
|
||||
data: created,
|
||||
});
|
||||
expect(gitService.createWorktree).toHaveBeenCalledWith('/repo', expect.objectContaining({
|
||||
returnAfterDirectoryCreated: true,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -531,12 +531,14 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput
|
||||
}
|
||||
|
||||
case 'api:git/identity': {
|
||||
const { directory, method, userName, userEmail, sshKey } = (payload || {}) as {
|
||||
const { directory, method, userName, userEmail, sshKey, signCommits, signingKey } = (payload || {}) as {
|
||||
directory?: string;
|
||||
method?: string;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
sshKey?: string | null;
|
||||
signCommits?: boolean;
|
||||
signingKey?: string | null;
|
||||
};
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
@@ -552,7 +554,14 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput
|
||||
if (!userName || !userEmail) {
|
||||
return { id, type, success: false, error: 'userName and userEmail are required' };
|
||||
}
|
||||
const result = await gitService.setGitIdentity(directory!, userName, userEmail, sshKey);
|
||||
const result = await gitService.setGitIdentity(
|
||||
directory!,
|
||||
userName,
|
||||
userEmail,
|
||||
sshKey,
|
||||
signCommits === true,
|
||||
signingKey ?? null
|
||||
);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
const existingFiles = new Set();
|
||||
const fsPromises = {
|
||||
realpath: mock(async (filePath) => {
|
||||
if (existingFiles.has(filePath)) return filePath;
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}),
|
||||
stat: mock(async (filePath) => {
|
||||
if (existingFiles.has(filePath)) return { isFile: () => true, size: 4, mtimeMs: 1 };
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}),
|
||||
readFile: mock(async () => Buffer.from('test')),
|
||||
};
|
||||
|
||||
mock.module('fs', () => ({
|
||||
promises: {
|
||||
realpath: mock(async () => {
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}),
|
||||
stat: mock(async () => {
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}),
|
||||
},
|
||||
default: {
|
||||
promises: {
|
||||
realpath: mock(async () => {
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}),
|
||||
stat: mock(async () => {
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}),
|
||||
},
|
||||
},
|
||||
promises: fsPromises,
|
||||
default: { promises: fsPromises },
|
||||
}));
|
||||
|
||||
mock.module('vscode', () => ({
|
||||
@@ -34,13 +27,24 @@ mock.module('vscode', () => ({
|
||||
file: (fsPath) => ({ fsPath }),
|
||||
},
|
||||
workspace: {
|
||||
workspaceFolders: [{ uri: { fsPath: '/workspace' } }],
|
||||
workspaceFolders: [
|
||||
{ uri: { fsPath: '/workspace' } },
|
||||
{ uri: { fsPath: '/workspace-two' } },
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
const { tryHandleLocalFsProxy } = await import('./bridge-localfs-proxy-runtime');
|
||||
|
||||
describe('bridge local fs proxy', () => {
|
||||
it('does not forward server-owned Markdown image grant routes to OpenCode', async () => {
|
||||
const response = await tryHandleLocalFsProxy('POST', '/api/openchamber/sessions/ses_1/markdown-image-grants');
|
||||
|
||||
expect(response?.status).toBe(501);
|
||||
expect(Buffer.from(response?.bodyBase64 ?? '', 'base64').toString('utf8'))
|
||||
.toContain('not supported in the VS Code runtime');
|
||||
});
|
||||
|
||||
it('returns a quiet optional stat miss for missing files', async () => {
|
||||
const response = await tryHandleLocalFsProxy('GET', '/api/fs/stat?path=%2Fmissing.ts&optional=true');
|
||||
|
||||
@@ -56,4 +60,15 @@ describe('bridge local fs proxy', () => {
|
||||
|
||||
expect(response?.status).toBe(404);
|
||||
});
|
||||
|
||||
it('reads from the active directory when it is the second workspace root', async () => {
|
||||
existingFiles.add('/workspace-two/image.png');
|
||||
const response = await tryHandleLocalFsProxy(
|
||||
'GET',
|
||||
'/api/fs/raw?path=%2Fworkspace-two%2Fimage.png&directory=%2Fworkspace-two',
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(Buffer.from(response?.bodyBase64 ?? '', 'base64').toString()).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,9 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
|
||||
}
|
||||
|
||||
const fsProxyPath = normalizeFsProxyPath(parsed.pathname);
|
||||
if (/^\/api\/openchamber\/sessions\/[^/]+\/markdown-image-grants$/.test(parsed.pathname)) {
|
||||
return buildProxyJsonError(501, 'Markdown image grants are not supported in the VS Code runtime');
|
||||
}
|
||||
if (!fsProxyPath) {
|
||||
return null;
|
||||
}
|
||||
@@ -66,7 +69,10 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
|
||||
|
||||
const targetPath = parsed.searchParams.get('path') || '';
|
||||
const optional = parsed.searchParams.get('optional') === 'true';
|
||||
const resolution: FsReadPathResolution = await resolveFileReadPath(targetPath);
|
||||
const resolution: FsReadPathResolution = await resolveFileReadPath(
|
||||
targetPath,
|
||||
parsed.searchParams.get('directory') || undefined,
|
||||
);
|
||||
if (!resolution.ok) {
|
||||
if (fsProxyPath === '/api/fs/stat' && optional && resolution.status === 404) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { handlePermissionAutoAcceptBridgeMessage } from './bridge-permission-auto-accept-runtime';
|
||||
|
||||
const createContext = () => {
|
||||
const values = new Map<string, unknown>();
|
||||
return {
|
||||
globalState: {
|
||||
get: (key: string) => values.get(key),
|
||||
update: async (key: string, value: unknown) => { values.set(key, value); },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('VS Code permission auto-accept policy bridge', () => {
|
||||
test('persists policy and broadcasts the authoritative snapshot', async () => {
|
||||
const context = createContext();
|
||||
const broadcasts: unknown[] = [];
|
||||
const dependencies = { broadcast: async (snapshot: unknown) => { broadcasts.push(snapshot); } };
|
||||
const response = await handlePermissionAutoAcceptBridgeMessage({
|
||||
id: '1',
|
||||
type: 'api:permission-auto-accept:set',
|
||||
payload: { sessionId: 'root', enabled: true },
|
||||
}, context, dependencies);
|
||||
|
||||
assert.equal(response?.success, true);
|
||||
assert.deepEqual(response?.data, { sessions: { root: true }, revision: 1 });
|
||||
assert.deepEqual(broadcasts, [{ sessions: { root: true }, revision: 1 }]);
|
||||
|
||||
const reloaded = await handlePermissionAutoAcceptBridgeMessage({
|
||||
id: '2',
|
||||
type: 'api:permission-auto-accept:get',
|
||||
}, context, dependencies);
|
||||
assert.deepEqual(reloaded?.data, { sessions: { root: true }, revision: 1 });
|
||||
});
|
||||
|
||||
test('serializes concurrent writes without losing policy entries', async () => {
|
||||
const context = createContext();
|
||||
const dependencies = { broadcast: async () => undefined };
|
||||
const first = handlePermissionAutoAcceptBridgeMessage({
|
||||
id: '1',
|
||||
type: 'api:permission-auto-accept:set',
|
||||
payload: { sessionId: 'root', enabled: true },
|
||||
}, context, dependencies);
|
||||
const second = handlePermissionAutoAcceptBridgeMessage({
|
||||
id: '2',
|
||||
type: 'api:permission-auto-accept:set',
|
||||
payload: { sessionId: 'child', enabled: false },
|
||||
}, context, dependencies);
|
||||
|
||||
await Promise.all([first, second]);
|
||||
const reloaded = await handlePermissionAutoAcceptBridgeMessage({
|
||||
id: '3',
|
||||
type: 'api:permission-auto-accept:get',
|
||||
}, context, dependencies);
|
||||
assert.deepEqual(reloaded?.data, { sessions: { root: true, child: false }, revision: 2 });
|
||||
});
|
||||
|
||||
test('rejects malformed policy writes', async () => {
|
||||
const broadcasts: unknown[] = [];
|
||||
const response = await handlePermissionAutoAcceptBridgeMessage({
|
||||
id: '1',
|
||||
type: 'api:permission-auto-accept:set',
|
||||
payload: { sessionId: 'root', enabled: 'yes' },
|
||||
}, createContext(), { broadcast: async (snapshot) => { broadcasts.push(snapshot); } });
|
||||
|
||||
assert.equal(response?.success, false);
|
||||
assert.deepEqual(broadcasts, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
const STORAGE_KEY = 'permissionAutoAccept';
|
||||
|
||||
type PolicyContext = {
|
||||
globalState: {
|
||||
get: (key: string) => unknown;
|
||||
update: (key: string, value: unknown) => PromiseLike<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export type PermissionAutoAcceptSnapshot = {
|
||||
sessions: Record<string, boolean>;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
const normalizeSnapshot = (value: unknown): PermissionAutoAcceptSnapshot => {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as { sessions?: unknown }
|
||||
: {};
|
||||
const entries = source.sessions && typeof source.sessions === 'object' && !Array.isArray(source.sessions)
|
||||
? Object.entries(source.sessions)
|
||||
: [];
|
||||
const sessions: Record<string, boolean> = {};
|
||||
for (const [sessionId, enabled] of entries) {
|
||||
if (sessionId && typeof enabled === 'boolean') sessions[sessionId] = enabled;
|
||||
}
|
||||
const revision = Number.isSafeInteger((source as { revision?: unknown }).revision)
|
||||
&& Number((source as { revision?: unknown }).revision) >= 0
|
||||
? Number((source as { revision?: unknown }).revision)
|
||||
: 0;
|
||||
return { sessions, revision };
|
||||
};
|
||||
|
||||
const readPermissionAutoAcceptPolicy = (context: PolicyContext) =>
|
||||
normalizeSnapshot(context.globalState.get(STORAGE_KEY));
|
||||
|
||||
const operationQueues = new WeakMap<object, Promise<void>>();
|
||||
|
||||
const serialize = async <T>(context: PolicyContext, operation: () => Promise<T>): Promise<T> => {
|
||||
const owner = context.globalState as object;
|
||||
const previous = operationQueues.get(owner) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((resolve) => { release = resolve; });
|
||||
const queued = previous.catch(() => undefined).then(() => current);
|
||||
operationQueues.set(owner, queued);
|
||||
await previous.catch(() => undefined);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
release();
|
||||
if (operationQueues.get(owner) === queued) operationQueues.delete(owner);
|
||||
}
|
||||
};
|
||||
|
||||
async function setPermissionAutoAcceptPolicy(
|
||||
context: PolicyContext,
|
||||
sessionId: string,
|
||||
enabled: boolean,
|
||||
broadcast: (snapshot: PermissionAutoAcceptSnapshot) => PromiseLike<unknown>,
|
||||
) {
|
||||
return serialize(context, async () => {
|
||||
const current = readPermissionAutoAcceptPolicy(context);
|
||||
const snapshot = {
|
||||
sessions: { ...current.sessions, [sessionId]: enabled },
|
||||
revision: current.revision + 1,
|
||||
};
|
||||
await context.globalState.update(STORAGE_KEY, snapshot);
|
||||
await broadcast(snapshot);
|
||||
return snapshot;
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePermissionAutoAcceptBridgeMessage(
|
||||
message: { id: string; type: string; payload?: unknown },
|
||||
context?: PolicyContext,
|
||||
dependencies?: { broadcast: (snapshot: PermissionAutoAcceptSnapshot) => PromiseLike<unknown> },
|
||||
) {
|
||||
if (message.type !== 'api:permission-auto-accept:get' && message.type !== 'api:permission-auto-accept:set') {
|
||||
return null;
|
||||
}
|
||||
if (!context) return { id: message.id, type: message.type, success: false, error: 'Extension context is unavailable' };
|
||||
|
||||
if (message.type === 'api:permission-auto-accept:get') {
|
||||
const snapshot = await serialize(context, async () => readPermissionAutoAcceptPolicy(context));
|
||||
return { id: message.id, type: message.type, success: true, data: snapshot };
|
||||
}
|
||||
|
||||
const payload = message.payload && typeof message.payload === 'object'
|
||||
? message.payload as { sessionId?: unknown; enabled?: unknown }
|
||||
: {};
|
||||
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : '';
|
||||
if (!sessionId) return { id: message.id, type: message.type, success: false, error: 'sessionId is required' };
|
||||
if (typeof payload.enabled !== 'boolean') {
|
||||
return { id: message.id, type: message.type, success: false, error: 'enabled must be a boolean' };
|
||||
}
|
||||
|
||||
const snapshot = await setPermissionAutoAcceptPolicy(
|
||||
context,
|
||||
sessionId,
|
||||
payload.enabled,
|
||||
dependencies?.broadcast ?? (() => Promise.resolve()),
|
||||
);
|
||||
return { id: message.id, type: message.type, success: true, data: snapshot };
|
||||
}
|
||||
@@ -291,7 +291,7 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
|
||||
|
||||
const keysToClear = new Set<string>();
|
||||
|
||||
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) {
|
||||
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary', 'smallModelOverride', 'walkthroughModelOverride']) {
|
||||
const value = restChanges[key];
|
||||
if (typeof value === 'string' && value.trim().length === 0) {
|
||||
keysToClear.add(key);
|
||||
@@ -299,18 +299,31 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof restChanges.usageAutoRefresh !== 'boolean') {
|
||||
delete restChanges.usageAutoRefresh;
|
||||
if ('smallModelUseDefault' in restChanges && typeof restChanges.smallModelUseDefault !== 'boolean') {
|
||||
delete restChanges.smallModelUseDefault;
|
||||
}
|
||||
|
||||
if (typeof restChanges.usageShowPredValues !== 'boolean') {
|
||||
delete restChanges.usageShowPredValues;
|
||||
if ('sessionRecapEnabled' in restChanges && typeof restChanges.sessionRecapEnabled !== 'boolean') {
|
||||
delete restChanges.sessionRecapEnabled;
|
||||
}
|
||||
|
||||
if (typeof restChanges.usageRefreshIntervalMs === 'number' && Number.isFinite(restChanges.usageRefreshIntervalMs)) {
|
||||
restChanges.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(restChanges.usageRefreshIntervalMs)));
|
||||
} else {
|
||||
delete restChanges.usageRefreshIntervalMs;
|
||||
if ('sessionSuggestionEnabled' in restChanges && typeof restChanges.sessionSuggestionEnabled !== 'boolean') {
|
||||
delete restChanges.sessionSuggestionEnabled;
|
||||
}
|
||||
|
||||
if ('sessionGoalEnabled' in restChanges && typeof restChanges.sessionGoalEnabled !== 'boolean') {
|
||||
delete restChanges.sessionGoalEnabled;
|
||||
}
|
||||
|
||||
if ('sessionGoalDefaultBudgetEnabled' in restChanges && typeof restChanges.sessionGoalDefaultBudgetEnabled !== 'boolean') {
|
||||
delete restChanges.sessionGoalDefaultBudgetEnabled;
|
||||
}
|
||||
|
||||
if ('sessionGoalDefaultBudget' in restChanges) {
|
||||
const budget = restChanges.sessionGoalDefaultBudget;
|
||||
if (typeof budget !== 'number' || !Number.isFinite(budget) || budget <= 0) {
|
||||
delete restChanges.sessionGoalDefaultBudget;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof restChanges.opencodeBinary === 'string') {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
const executeCommand = mock(async () => undefined);
|
||||
|
||||
class Position {
|
||||
constructor(line, character) {
|
||||
this.line = line;
|
||||
this.character = character;
|
||||
}
|
||||
}
|
||||
|
||||
class Range {
|
||||
constructor(start, end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
}
|
||||
|
||||
mock.module('vscode', () => ({
|
||||
commands: { executeCommand },
|
||||
workspace: {
|
||||
workspaceFolders: [],
|
||||
},
|
||||
Uri: {
|
||||
file: (fsPath) => ({ scheme: 'file', fsPath }),
|
||||
},
|
||||
Position,
|
||||
Range,
|
||||
}));
|
||||
|
||||
mock.module('./opencodeConfig', () => ({
|
||||
removeProviderConfig: mock(),
|
||||
getProviderSources: mock(),
|
||||
upsertProviderConfig: mock(),
|
||||
}));
|
||||
mock.module('./opencodeAuth', () => ({
|
||||
getProviderAuth: mock(),
|
||||
removeProviderAuth: mock(),
|
||||
}));
|
||||
mock.module('./quotaProviders', () => ({
|
||||
fetchQuotaForProvider: mock(),
|
||||
listConfiguredQuotaProviders: mock(),
|
||||
}));
|
||||
mock.module('./opencodeGoQuota', () => ({ fetchOpenCodeGoUsage: mock() }));
|
||||
mock.module('./quotaCredentials', () => ({
|
||||
credentialStatus: mock(),
|
||||
deleteCredential: mock(),
|
||||
importCursorCredential: mock(),
|
||||
normalizeCredential: mock(),
|
||||
readCredential: mock(),
|
||||
validateCredential: mock(),
|
||||
writeCredential: mock(),
|
||||
}));
|
||||
mock.module('./sessionActivityWatcher', () => ({ getSessionActivitySnapshot: mock() }));
|
||||
|
||||
const { handleSystemBridgeMessage } = await import('./bridge-system-runtime.ts');
|
||||
|
||||
const deps = {
|
||||
resolveUserPath: (value) => value,
|
||||
fetchModelsMetadata: async () => ({}),
|
||||
updateCheckUrl: 'https://example.com/update-check',
|
||||
clientReloadDelayMs: 800,
|
||||
};
|
||||
|
||||
describe('VS Code system bridge editor:openFile', () => {
|
||||
beforeEach(() => {
|
||||
executeCommand.mockClear();
|
||||
});
|
||||
|
||||
test('uses vscode.open so VS Code can select the notebook editor', async () => {
|
||||
const response = await handleSystemBridgeMessage({
|
||||
id: 'open-notebook',
|
||||
type: 'editor:openFile',
|
||||
payload: { path: '/workspace/notebook.ipynb' },
|
||||
}, undefined, deps);
|
||||
|
||||
expect(response).toEqual({ id: 'open-notebook', type: 'editor:openFile', success: true });
|
||||
expect(executeCommand).toHaveBeenCalledWith(
|
||||
'vscode.open',
|
||||
{ scheme: 'file', fsPath: '/workspace/notebook.ipynb' },
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves line and column selection for regular files', async () => {
|
||||
await handleSystemBridgeMessage({
|
||||
id: 'open-text',
|
||||
type: 'editor:openFile',
|
||||
payload: { path: '/workspace/source.ts', line: 4, column: 7 },
|
||||
}, undefined, deps);
|
||||
|
||||
const position = new Position(3, 7);
|
||||
expect(executeCommand).toHaveBeenCalledWith(
|
||||
'vscode.open',
|
||||
{ scheme: 'file', fsPath: '/workspace/source.ts' },
|
||||
{ selection: new Range(position, position) },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,13 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { removeProviderConfig, getProviderSources } from './opencodeConfig';
|
||||
import { removeProviderConfig, getProviderSources, upsertProviderConfig } from './opencodeConfig';
|
||||
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
|
||||
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders';
|
||||
import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials';
|
||||
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
|
||||
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime';
|
||||
import { buildDeferredRestartResponse } from './config-mutation-response';
|
||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||
|
||||
type BridgeMessageInput = {
|
||||
@@ -74,10 +77,13 @@ const getOrCreateInstallId = (scope: string): string => {
|
||||
return installId;
|
||||
};
|
||||
|
||||
const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'web' => {
|
||||
const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'android' | 'ios' | 'web' => {
|
||||
// The webview already sends API-shaped values; Node's os.platform() is the fallback source.
|
||||
if (value === 'macos' || value === 'windows' || value === 'linux' || value === 'android' || value === 'ios' || value === 'web') {
|
||||
return value;
|
||||
}
|
||||
if (value === 'darwin') return 'macos';
|
||||
if (value === 'win32') return 'windows';
|
||||
if (value === 'linux') return 'linux';
|
||||
return 'web';
|
||||
};
|
||||
|
||||
@@ -267,6 +273,15 @@ export async function handleSystemBridgeMessage(
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:opencode/upgrade-status': {
|
||||
return { id, type, success: true, data: await getOpenCodeUpgradeStatus(ctx?.manager) };
|
||||
}
|
||||
|
||||
case 'api:opencode/upgrade': {
|
||||
const target = (payload as { target?: unknown } | undefined)?.target;
|
||||
return { id, type, success: true, data: await upgradeManagedOpenCode(ctx?.manager, target) };
|
||||
}
|
||||
|
||||
case 'api:session-activity:get': {
|
||||
return { id, type, success: true, data: getSessionActivitySnapshot() };
|
||||
}
|
||||
@@ -303,7 +318,6 @@ export async function handleSystemBridgeMessage(
|
||||
: os.arch();
|
||||
const reportUsage = body.reportUsage !== false;
|
||||
|
||||
const installId = getOrCreateInstallId('vscode');
|
||||
const requestBody = {
|
||||
appType: 'vscode',
|
||||
deviceClass,
|
||||
@@ -311,7 +325,7 @@ export async function handleSystemBridgeMessage(
|
||||
arch: mapNodeArchToApiArch(archRaw),
|
||||
channel: 'stable',
|
||||
currentVersion,
|
||||
installId,
|
||||
...(reportUsage ? { installId: getOrCreateInstallId('vscode') } : {}),
|
||||
instanceMode,
|
||||
reportUsage,
|
||||
};
|
||||
@@ -342,13 +356,12 @@ export async function handleSystemBridgeMessage(
|
||||
case 'editor:openFile': {
|
||||
const { path: filePath, line, column } = payload as { path: string; line?: number; column?: number };
|
||||
try {
|
||||
const doc = await vscode.workspace.openTextDocument(filePath);
|
||||
const options: vscode.TextDocumentShowOptions = {};
|
||||
if (typeof line === 'number') {
|
||||
const pos = new vscode.Position(Math.max(0, line - 1), column || 0);
|
||||
options.selection = new vscode.Range(pos, pos);
|
||||
}
|
||||
await vscode.window.showTextDocument(doc, options);
|
||||
await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(filePath), options);
|
||||
return { id, type, success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -429,21 +442,19 @@ export async function handleSystemBridgeMessage(
|
||||
return { id, type, success: false, error: 'Invalid scope' };
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
await ctx?.manager?.restart();
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
removed,
|
||||
requiresReload: removed,
|
||||
message: removed
|
||||
? `Provider ${providerId} disconnected successfully. Reloading interface…`
|
||||
: `Provider ${providerId} was not configured.`,
|
||||
reloadDelayMs: removed ? deps.clientReloadDelayMs : undefined,
|
||||
...(removed
|
||||
? buildDeferredRestartResponse(`Provider ${providerId} disconnected successfully. Restart OpenCode to apply.`)
|
||||
: {
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
message: `Provider ${providerId} was not configured.`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -471,6 +482,64 @@ export async function handleSystemBridgeMessage(
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:provider:upsert': {
|
||||
const {
|
||||
providerID,
|
||||
providerId: providerIdAlias,
|
||||
config,
|
||||
scope,
|
||||
directory,
|
||||
} = (payload || {}) as {
|
||||
providerID?: string;
|
||||
providerId?: string;
|
||||
config?: unknown;
|
||||
scope?: string;
|
||||
directory?: string;
|
||||
};
|
||||
const providerId = (typeof providerID === 'string' && providerID.trim())
|
||||
|| (typeof providerIdAlias === 'string' && providerIdAlias.trim())
|
||||
|| '';
|
||||
if (!providerId) {
|
||||
return { id, type, success: false, error: 'Provider ID is required' };
|
||||
}
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
return { id, type, success: false, error: 'Provider config is required' };
|
||||
}
|
||||
const normalizedScope = typeof scope === 'string' ? scope : 'user';
|
||||
if (normalizedScope !== 'user' && normalizedScope !== 'project' && normalizedScope !== 'custom') {
|
||||
return { id, type, success: false, error: 'Invalid scope' };
|
||||
}
|
||||
try {
|
||||
const workingDirectory = typeof directory === 'string' && directory.trim().length > 0
|
||||
? directory.trim()
|
||||
: ctx?.manager?.getWorkingDirectory();
|
||||
const result = upsertProviderConfig(
|
||||
providerId,
|
||||
config,
|
||||
workingDirectory,
|
||||
normalizedScope,
|
||||
{ hasStoredAuth: Boolean(getProviderAuth(providerId)) },
|
||||
);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
providerId: result.providerId,
|
||||
path: result.path,
|
||||
config: result.config,
|
||||
requiresReload: true,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:quota:providers': {
|
||||
try {
|
||||
const providers = listConfiguredQuotaProviders();
|
||||
@@ -481,6 +550,36 @@ export async function handleSystemBridgeMessage(
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:quota:credentials': {
|
||||
const { providerId, method, credential: input } = (payload || {}) as { providerId?: ManagedProvider; method?: string; credential?: unknown };
|
||||
try {
|
||||
if (!providerId || !['ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' };
|
||||
if (method === 'GET') return { id, type, success: true, data: credentialStatus(providerId) };
|
||||
if (method === 'DELETE') { deleteCredential(providerId); return { id, type, success: true, data: { configured: false } }; }
|
||||
if (method === 'IMPORT') {
|
||||
if (providerId !== 'cursor') return { id, type, success: false, error: 'Import unavailable' };
|
||||
const credential = importCursorCredential();
|
||||
await validateCredential(providerId, credential);
|
||||
return { id, type, success: true, data: writeCredential(providerId, credential) };
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
const credential = normalizeCredential(providerId, input);
|
||||
if (!credential) return { id, type, success: false, error: 'Invalid credential' };
|
||||
await validateCredential(providerId, credential);
|
||||
return { id, type, success: true, data: writeCredential(providerId, credential) };
|
||||
}
|
||||
if (method === 'VALIDATE') {
|
||||
const credential = readCredential(providerId);
|
||||
if (!credential) return { id, type, success: false, error: 'Not configured' };
|
||||
await validateCredential(providerId, credential);
|
||||
return { id, type, success: true, data: { valid: true } };
|
||||
}
|
||||
return { id, type, success: false, error: 'Unsupported method' };
|
||||
} catch (error) {
|
||||
return { id, type, success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:quota:get': {
|
||||
const { providerId } = (payload || {}) as { providerId?: string };
|
||||
if (!providerId) {
|
||||
@@ -524,15 +623,6 @@ export async function handleSystemBridgeMessage(
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:notifications/auto-accept': {
|
||||
const request = (payload || {}) as { sessionId?: unknown; enabled?: unknown };
|
||||
const sessionId = typeof request.sessionId === 'string' ? request.sessionId.trim() : '';
|
||||
if (!sessionId) {
|
||||
return { id, type, success: false, error: 'sessionId is required' };
|
||||
}
|
||||
return { id, type, success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { handleFsBridgeMessage } from './bridge-fs-runtime';
|
||||
import { handleConfigBridgeMessage } from './bridge-config-runtime';
|
||||
import { handleSystemBridgeMessage } from './bridge-system-runtime';
|
||||
import { handleProxyBridgeMessage } from './bridge-proxy-runtime';
|
||||
import { handlePermissionAutoAcceptBridgeMessage } from './bridge-permission-auto-accept-runtime';
|
||||
import {
|
||||
fetchOpenCodeSkillsFromApi,
|
||||
persistSettings,
|
||||
@@ -63,6 +64,18 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
const { id, type, payload } = message;
|
||||
|
||||
try {
|
||||
const permissionAutoAcceptResponse = await handlePermissionAutoAcceptBridgeMessage(
|
||||
{ id, type, payload },
|
||||
ctx?.context,
|
||||
{
|
||||
broadcast: (snapshot) => vscode.commands.executeCommand(
|
||||
'openchamber.internal.permissionAutoAcceptSynced',
|
||||
snapshot,
|
||||
),
|
||||
},
|
||||
);
|
||||
if (permissionAutoAcceptResponse) return permissionAutoAcceptResponse;
|
||||
|
||||
const standardGitResponse = await handleStandardGitBridgeMessage({ id, type, payload });
|
||||
if (standardGitResponse) {
|
||||
return standardGitResponse;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
type CommandCodeCredits = {
|
||||
credits?: { monthlyCredits?: number; purchasedCredits?: number; freeCredits?: number };
|
||||
windowLimits?: {
|
||||
fiveHour?: { used?: number; cap?: number; resetAt?: number };
|
||||
weekly?: { used?: number; cap?: number; resetAt?: number };
|
||||
};
|
||||
};
|
||||
|
||||
type WindowData = { usedPercent: number | null; resetAt: number | null; windowSeconds: number | null; valueLabel: string };
|
||||
|
||||
const toWindow = (data: WindowData) => ({
|
||||
usedPercent: data.usedPercent,
|
||||
remainingPercent: data.usedPercent === null ? null : Math.max(0, 100 - data.usedPercent),
|
||||
windowSeconds: data.windowSeconds,
|
||||
resetAfterSeconds: data.resetAt === null ? null : Math.max(0, Math.floor((data.resetAt - Date.now()) / 1000)),
|
||||
resetAt: data.resetAt,
|
||||
resetAtFormatted: null,
|
||||
resetAfterFormatted: null,
|
||||
valueLabel: data.valueLabel,
|
||||
});
|
||||
|
||||
const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
||||
const formatCredits = (value: number): string => String(Math.round((value + Number.EPSILON) * 100) / 100);
|
||||
|
||||
const parseCredits = (value: unknown): CommandCodeCredits | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const payload = value as CommandCodeCredits;
|
||||
return payload;
|
||||
};
|
||||
|
||||
const parseOrgId = (value: unknown): string | null | undefined => {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const org = (value as { org?: { id?: unknown } }).org;
|
||||
return typeof org?.id === 'string' && org.id.trim() ? org.id.trim() : null;
|
||||
};
|
||||
|
||||
const parseCommandCodeCredits = (payload: CommandCodeCredits) => {
|
||||
const windows: Record<string, ReturnType<typeof toWindow>> = {};
|
||||
for (const [label, value] of [['monthly_credits', payload.credits?.monthlyCredits], ['purchased_credits', payload.credits?.purchasedCredits], ['free_credits', payload.credits?.freeCredits]] as const) {
|
||||
if (isFiniteNumber(value)) windows[label] = toWindow({ usedPercent: null, resetAt: null, windowSeconds: null, valueLabel: formatCredits(value) });
|
||||
}
|
||||
for (const [label, limit, seconds] of [['5h', payload.windowLimits?.fiveHour, 5 * 60 * 60], ['weekly', payload.windowLimits?.weekly, 7 * 24 * 60 * 60]] as const) {
|
||||
if (!isFiniteNumber(limit?.used) || !isFiniteNumber(limit.cap) || limit.cap <= 0) continue;
|
||||
const resetAt = isFiniteNumber(limit.resetAt) ? (limit.resetAt < 1_000_000_000_000 ? limit.resetAt * 1000 : limit.resetAt) : null;
|
||||
windows[label] = toWindow({ usedPercent: Math.min(100, Math.max(0, limit.used / limit.cap * 100)), resetAt, windowSeconds: seconds, valueLabel: `${formatCredits(limit.used)} / ${formatCredits(limit.cap)}` });
|
||||
}
|
||||
return windows;
|
||||
};
|
||||
|
||||
const requestJson = async (path: string, apiKey: string): Promise<unknown> => {
|
||||
const response = await fetch(`https://api.commandcode.ai${path}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(15_000) });
|
||||
if (response.status === 401 || response.status === 403) throw new Error('Command Code authentication failed');
|
||||
if (!response.ok) throw new Error(`Command Code usage API returned HTTP ${response.status}`);
|
||||
return response.json().catch(() => null);
|
||||
};
|
||||
|
||||
export const fetchCommandCodeUsage = async (apiKey: string) => {
|
||||
const orgId = parseOrgId(await requestJson('/alpha/whoami', apiKey));
|
||||
if (orgId === undefined) throw new Error('Command Code account could not be determined');
|
||||
const creditsPath = orgId ? `/alpha/billing/credits?orgId=${encodeURIComponent(orgId)}` : '/alpha/billing/credits';
|
||||
const payload = parseCredits(await requestJson(creditsPath, apiKey));
|
||||
if (!payload) throw new Error('Command Code usage data could not be parsed');
|
||||
const windows = parseCommandCodeCredits(payload);
|
||||
if (!Object.keys(windows).length) throw new Error('Command Code usage data could not be parsed');
|
||||
return windows;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Shared response shape for OpenCode config mutations.
|
||||
*
|
||||
* Settings writes persist to disk immediately but defer the OpenCode restart
|
||||
* so the UI can accumulate pending changes and apply them once via
|
||||
* api:config/reload ("Apply & Restart OpenCode").
|
||||
*/
|
||||
export function buildDeferredRestartResponse(message: string) {
|
||||
return {
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
requiresRestart: true,
|
||||
restartDeferred: true,
|
||||
message,
|
||||
};
|
||||
}
|
||||
@@ -182,7 +182,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.focusChat', async () => {
|
||||
await vscode.commands.executeCommand('openchamber.chatView.focus');
|
||||
if (!(await revealChatViewForPayload())) {
|
||||
return;
|
||||
}
|
||||
chatViewProvider?.focusChatInput();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -200,6 +203,14 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.internal.permissionAutoAcceptSynced', (snapshot: unknown) => {
|
||||
chatViewProvider?.notifyPermissionAutoAcceptSynced(snapshot);
|
||||
sessionEditorProvider?.notifyPermissionAutoAcceptSynced(snapshot);
|
||||
agentManagerProvider?.notifyPermissionAutoAcceptSynced(snapshot);
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeWindowState((state) => {
|
||||
chatViewProvider?.notifyWindowFocusChanged(state.focused);
|
||||
@@ -297,13 +308,14 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
}
|
||||
|
||||
// Get file info for context
|
||||
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
|
||||
// false matches the relativePath broadcast for the active editor, so this attachment dedupes against the pin-selection suggestion.
|
||||
const filePath = vscode.workspace.asRelativePath(editor.document.uri, false);
|
||||
// Get line numbers (1-based for display)
|
||||
const startLine = selection.start.line + 1;
|
||||
const endLine = selection.end.line + 1;
|
||||
const lineRange = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
|
||||
|
||||
const filename = `${editor.document.fileName.split(/[\\/]/).pop() || filePath}:${lineRange}`;
|
||||
const filename = `${filePath}:${lineRange}`;
|
||||
const contextSelection = {
|
||||
filePath: editor.document.uri.fsPath,
|
||||
filename,
|
||||
|
||||
+308
-254
@@ -14,11 +14,26 @@ import type { API as GitAPI, Repository, GitExtension, Status } from './git.d';
|
||||
|
||||
let gitApi: GitAPI | null = null;
|
||||
let gitExtensionEnabled = false;
|
||||
const worktreeBootstrapState = new Map<string, { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>();
|
||||
|
||||
type WorktreeBootstrapStatus = {
|
||||
status: 'pending' | 'ready' | 'failed';
|
||||
phase: 'directory-created' | 'git-ready' | 'setup-ready';
|
||||
error: string | null;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const worktreeBootstrapState = new Map<string, WorktreeBootstrapStatus>();
|
||||
const activeWorktreeBootstrapTasks = new Map<string, Promise<unknown>>();
|
||||
|
||||
const WORKTREE_BOOTSTRAP_PENDING = 'pending' as const;
|
||||
const WORKTREE_BOOTSTRAP_READY = 'ready' as const;
|
||||
const WORKTREE_BOOTSTRAP_FAILED = 'failed' as const;
|
||||
const WORKTREE_PHASE_DIRECTORY_CREATED = 'directory-created' as const;
|
||||
const WORKTREE_PHASE_GIT_READY = 'git-ready' as const;
|
||||
const WORKTREE_PHASE_SETUP_READY = 'setup-ready' as const;
|
||||
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
|
||||
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
|
||||
const GIT_NULL_REF = '0'.repeat(40);
|
||||
|
||||
const toBootstrapStateKey = (directory: string): string => {
|
||||
const normalized = normalizeDirectoryPath(directory);
|
||||
@@ -28,16 +43,35 @@ const toBootstrapStateKey = (directory: string): string => {
|
||||
return path.resolve(normalized);
|
||||
};
|
||||
|
||||
const setWorktreeBootstrapState = (directory: string, status: 'pending' | 'ready' | 'failed', error: string | null = null): void => {
|
||||
const setWorktreeBootstrapState = (
|
||||
directory: string,
|
||||
status: WorktreeBootstrapStatus['status'],
|
||||
phase: WorktreeBootstrapStatus['phase'],
|
||||
error: string | null = null,
|
||||
): WorktreeBootstrapStatus | null => {
|
||||
const key = toBootstrapStateKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
worktreeBootstrapState.set(key, {
|
||||
|
||||
const state: WorktreeBootstrapStatus = {
|
||||
status,
|
||||
phase,
|
||||
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
};
|
||||
worktreeBootstrapState.set(key, state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const setWorktreeBootstrapFailure = (directory: string, error: unknown): void => {
|
||||
const current = worktreeBootstrapState.get(toBootstrapStateKey(directory));
|
||||
setWorktreeBootstrapState(
|
||||
directory,
|
||||
WORKTREE_BOOTSTRAP_FAILED,
|
||||
current?.phase ?? WORKTREE_PHASE_DIRECTORY_CREATED,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
};
|
||||
|
||||
const clearWorktreeBootstrapState = (directory: string): void => {
|
||||
@@ -48,6 +82,36 @@ const clearWorktreeBootstrapState = (directory: string): void => {
|
||||
worktreeBootstrapState.delete(key);
|
||||
};
|
||||
|
||||
const trackWorktreeBootstrapTask = (directory: string, task: Promise<unknown>): void => {
|
||||
const key = toBootstrapStateKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeWorktreeBootstrapTasks.set(key, task);
|
||||
const clearTask = () => {
|
||||
if (activeWorktreeBootstrapTasks.get(key) === task) {
|
||||
activeWorktreeBootstrapTasks.delete(key);
|
||||
}
|
||||
};
|
||||
void task.then(clearTask, clearTask);
|
||||
};
|
||||
|
||||
const waitForActiveWorktreeBootstrap = async (directory: string): Promise<void> => {
|
||||
const key = toBootstrapStateKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const task = activeWorktreeBootstrapTasks.get(key);
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
await task.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
||||
|
||||
@@ -120,7 +184,7 @@ async function buildGitEnv(): Promise<NodeJS.ProcessEnv> {
|
||||
/**
|
||||
* Initialize the git extension API
|
||||
*/
|
||||
export async function initGitExtension(): Promise<GitAPI | null> {
|
||||
async function initGitExtension(): Promise<GitAPI | null> {
|
||||
if (gitApi && gitExtensionEnabled) {
|
||||
return gitApi;
|
||||
}
|
||||
@@ -163,7 +227,7 @@ export async function initGitExtension(): Promise<GitAPI | null> {
|
||||
/**
|
||||
* Get the git API, initializing if necessary
|
||||
*/
|
||||
export async function getGitApi(): Promise<GitAPI | null> {
|
||||
async function getGitApi(): Promise<GitAPI | null> {
|
||||
if (gitApi && gitExtensionEnabled) {
|
||||
return gitApi;
|
||||
}
|
||||
@@ -173,7 +237,7 @@ export async function getGitApi(): Promise<GitAPI | null> {
|
||||
/**
|
||||
* Get repository for a given directory
|
||||
*/
|
||||
export async function getRepository(directory: string): Promise<Repository | null> {
|
||||
async function getRepository(directory: string): Promise<Repository | null> {
|
||||
const api = await getGitApi();
|
||||
if (!api) return null;
|
||||
|
||||
@@ -333,20 +397,20 @@ export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
||||
|
||||
// ============== Status Operations ==============
|
||||
|
||||
export interface GitStatusFile {
|
||||
interface GitStatusFile {
|
||||
path: string;
|
||||
index: string;
|
||||
working_dir: string;
|
||||
}
|
||||
|
||||
export interface GitMergeInProgress {
|
||||
interface GitMergeInProgress {
|
||||
/** Short SHA of MERGE_HEAD */
|
||||
head: string;
|
||||
/** First line of MERGE_MSG */
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitRebaseInProgress {
|
||||
interface GitRebaseInProgress {
|
||||
/** Branch name being rebased */
|
||||
headName: string;
|
||||
/** Short SHA of the onto commit */
|
||||
@@ -591,7 +655,7 @@ async function getGitStatusRaw(directory: string): Promise<GitStatusResult> {
|
||||
|
||||
// ============== Branch Operations ==============
|
||||
|
||||
export interface GitBranchDetails {
|
||||
interface GitBranchDetails {
|
||||
current: boolean;
|
||||
name: string;
|
||||
commit: string;
|
||||
@@ -719,43 +783,6 @@ export async function checkoutBranch(directory: string, branch: string): Promise
|
||||
return { success: result.exitCode === 0, branch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach HEAD at current commit
|
||||
* This allows the current branch to be used in a worktree
|
||||
*/
|
||||
export async function detachHead(directory: string): Promise<{ success: boolean; commit: string }> {
|
||||
// Get current HEAD commit
|
||||
const headResult = await execGit(['rev-parse', 'HEAD'], directory);
|
||||
if (headResult.exitCode !== 0) {
|
||||
return { success: false, commit: '' };
|
||||
}
|
||||
|
||||
const commit = headResult.stdout.trim();
|
||||
|
||||
// Checkout the commit directly to detach HEAD
|
||||
const result = await execGit(['checkout', '--detach', 'HEAD'], directory);
|
||||
return { success: result.exitCode === 0, commit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current HEAD branch name (null if detached)
|
||||
*/
|
||||
export async function getCurrentBranch(directory: string): Promise<string | null> {
|
||||
const repo = await getRepository(directory);
|
||||
|
||||
if (repo) {
|
||||
const head = repo.state.HEAD;
|
||||
return head?.name || null;
|
||||
}
|
||||
|
||||
// Fallback to raw git
|
||||
const result = await execGit(['symbolic-ref', '--short', 'HEAD'], directory);
|
||||
if (result.exitCode === 0) {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
return null; // Detached HEAD
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new branch
|
||||
*/
|
||||
@@ -815,7 +842,7 @@ export interface GitWorktreeInfo {
|
||||
branch: string;
|
||||
path: string;
|
||||
directoryCreated?: true;
|
||||
bootstrapStatus?: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number };
|
||||
bootstrapStatus?: WorktreeBootstrapStatus;
|
||||
}
|
||||
|
||||
type WorktreeListEntry = {
|
||||
@@ -825,7 +852,7 @@ type WorktreeListEntry = {
|
||||
branch?: string;
|
||||
};
|
||||
|
||||
export interface GitWorktreeValidationError {
|
||||
interface GitWorktreeValidationError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -1062,6 +1089,168 @@ const runGitCommandOrThrow = async (cwd: string, args: string[], fallbackMessage
|
||||
return result;
|
||||
};
|
||||
|
||||
const wait = (milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
const isIndexLockError = (result: GitCommandResult): boolean => {
|
||||
const message = [result?.message, result?.stderr, result?.stdout].filter(Boolean).join('\n');
|
||||
return /index\.lock['"]?: File exists|another git process seems to be running/i.test(message);
|
||||
};
|
||||
|
||||
const getWorktreeIndexLockPath = async (directory: string): Promise<string | null> => {
|
||||
const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'index.lock']);
|
||||
if (!result.success) {
|
||||
return null;
|
||||
}
|
||||
const value = String(result.stdout || '').trim();
|
||||
return value ? (path.isAbsolute(value) ? value : path.resolve(directory, value)) : null;
|
||||
};
|
||||
|
||||
const getFileIdentity = async (filePath: string): Promise<string | null> => {
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// OpenChamber places managed worktrees under a deep data-dir path
|
||||
// (`<XDG_DATA_HOME>/opencode/worktree/<40-char project id>/<name>/`). On
|
||||
// Windows that prefix plus a deeply nested repo file routinely exceeds
|
||||
// MAX_PATH (260). Git can check those paths out when core.longpaths is
|
||||
// enabled; without it, `git reset --hard` during bootstrap fails with
|
||||
// "Filename too long" and leaves a half-populated worktree (issue #2746).
|
||||
const WORKTREE_POPULATE_RESET_ARGS = ['-c', 'core.longpaths=true', 'reset', '--hard'] as const;
|
||||
|
||||
const isFilenameTooLongError = (message: string | null | undefined): boolean =>
|
||||
/file ?name too long/i.test(String(message || ''));
|
||||
|
||||
const formatWorktreePopulateError = (message: string | null | undefined): string => {
|
||||
const text = String(message || '').trim() || 'Failed to populate worktree';
|
||||
if (!isFilenameTooLongError(text)) {
|
||||
return text;
|
||||
}
|
||||
return [
|
||||
text,
|
||||
'The worktree checkout path exceeds this system\'s path-length limit.',
|
||||
'OpenChamber enables Git `core.longpaths` for worktree population; if this still fails on Windows, enable OS long paths (LongPathsEnabled) or open the repository from a shorter absolute path.',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const ensureWorktreeLongpaths = async (directory: string): Promise<void> => {
|
||||
const current = await runGitCommand(directory, ['config', '--get', 'core.longpaths']);
|
||||
if (String(current.stdout || '').trim().toLowerCase() === 'true') {
|
||||
return;
|
||||
}
|
||||
// Local config is shared across linked worktrees via the common git dir, so
|
||||
// subsequent OpenChamber and CLI git operations in this repo also get long
|
||||
// path support. Failures here are non-fatal: populate still passes
|
||||
// `-c core.longpaths=true` on reset.
|
||||
await runGitCommand(directory, ['config', 'core.longpaths', 'true']);
|
||||
};
|
||||
|
||||
const populateWorktreeWithLockRecovery = async (directory: string): Promise<void> => {
|
||||
await ensureWorktreeLongpaths(directory);
|
||||
|
||||
let result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
if (!isIndexLockError(result)) {
|
||||
throw new Error(formatWorktreePopulateError(result.message));
|
||||
}
|
||||
|
||||
await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS);
|
||||
result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
if (!isIndexLockError(result)) {
|
||||
throw new Error(formatWorktreePopulateError(result.message));
|
||||
}
|
||||
|
||||
const lockPath = await getWorktreeIndexLockPath(directory);
|
||||
const identity = lockPath ? await getFileIdentity(lockPath) : null;
|
||||
await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS);
|
||||
|
||||
result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) {
|
||||
throw new Error(formatWorktreePopulateError(result.message));
|
||||
}
|
||||
|
||||
await fs.promises.unlink(lockPath).catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const finalResult = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]);
|
||||
if (!finalResult.success) {
|
||||
throw new Error(formatWorktreePopulateError(finalResult.message || 'Failed to populate worktree'));
|
||||
}
|
||||
};
|
||||
|
||||
// Worktrees are created with `git worktree add --no-checkout` and populated
|
||||
// with `git reset --hard`, neither of which runs git's post-checkout hook —
|
||||
// git only runs it for checkouts, clone, and worktree add *without*
|
||||
// --no-checkout. Invoke the hook explicitly after population to restore git's
|
||||
// checkout semantics: git passes the previous HEAD (null ref for a brand-new
|
||||
// worktree), the new HEAD, and flag 1 for a branch checkout, and runs the hook
|
||||
// from the worktree top-level.
|
||||
const runPostCheckoutHook = async (directory: string): Promise<void> => {
|
||||
let hookDirectory: string | null = null;
|
||||
try {
|
||||
const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'hooks']);
|
||||
if (!result.success) return;
|
||||
hookDirectory = normalizeDirectoryPath(String(result.stdout || '').trim());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!hookDirectory) return;
|
||||
|
||||
const hookPath = path.join(hookDirectory, 'post-checkout');
|
||||
try {
|
||||
const stat = await fs.promises.stat(hookPath);
|
||||
if (!stat.isFile()) return;
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.promises.access(hookPath, fs.constants.X_OK);
|
||||
}
|
||||
} catch {
|
||||
// Missing or non-executable hooks are skipped, matching git.
|
||||
return;
|
||||
}
|
||||
|
||||
const [headResult, gitDirResult] = await Promise.all([
|
||||
runGitCommand(directory, ['rev-parse', 'HEAD']),
|
||||
runGitCommand(directory, ['rev-parse', '--absolute-git-dir']),
|
||||
]);
|
||||
if (!headResult.success || !gitDirResult.success) return;
|
||||
const head = String(headResult.stdout || '').trim();
|
||||
const gitDir = String(gitDirResult.stdout || '').trim();
|
||||
if (!head || !gitDir) return;
|
||||
|
||||
try {
|
||||
await execFileAsync(hookPath, [GIT_NULL_REF, head, '1'], {
|
||||
cwd: directory,
|
||||
env: {
|
||||
...(await buildGitEnv()),
|
||||
GIT_DIR: gitDir,
|
||||
GIT_WORK_TREE: path.resolve(directory),
|
||||
},
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch (error) {
|
||||
// A failing hook must not fail worktree creation or session bootstrap:
|
||||
// warn and continue.
|
||||
console.warn('[GitService] post-checkout hook failed in worktree:', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const ensureOpenCodeProjectId = async (primaryWorktree: string): Promise<string> => {
|
||||
const gitDir = path.join(primaryWorktree, '.git');
|
||||
const idFile = path.join(gitDir, 'opencode');
|
||||
@@ -1293,74 +1482,11 @@ const loadProjectStartCommand = async (projectID: string): Promise<string> => {
|
||||
}
|
||||
};
|
||||
|
||||
const getProjectStoragePath = (projectID: string) => {
|
||||
return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
|
||||
};
|
||||
|
||||
const updateProjectSandboxes = async (
|
||||
projectID: string,
|
||||
primaryWorktree: string,
|
||||
updater: (project: {
|
||||
id: string;
|
||||
worktree: string;
|
||||
vcs: string;
|
||||
sandboxes: string[];
|
||||
time: { created: number; updated: number };
|
||||
}) => void
|
||||
) => {
|
||||
const storagePath = getProjectStoragePath(projectID);
|
||||
await fs.promises.mkdir(path.dirname(storagePath), { recursive: true });
|
||||
|
||||
const now = Date.now();
|
||||
const base = {
|
||||
id: projectID,
|
||||
worktree: primaryWorktree,
|
||||
vcs: 'git',
|
||||
sandboxes: [] as string[],
|
||||
time: { created: now, updated: now },
|
||||
};
|
||||
|
||||
const parsed = await fs.promises.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw) as typeof base).catch(() => null);
|
||||
const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base;
|
||||
current.id = String(current.id || projectID);
|
||||
current.worktree = String(current.worktree || primaryWorktree);
|
||||
current.vcs = current.vcs || 'git';
|
||||
current.sandboxes = Array.isArray(current.sandboxes)
|
||||
? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const createdAt = Number(current?.time?.created);
|
||||
current.time = {
|
||||
created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now,
|
||||
updated: now,
|
||||
};
|
||||
|
||||
updater(current);
|
||||
|
||||
current.sandboxes = [...new Set(current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean))];
|
||||
await fs.promises.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
const syncProjectSandboxAdd = async (projectID: string, primaryWorktree: string, sandboxPath: string) => {
|
||||
const sandbox = String(sandboxPath || '').trim();
|
||||
if (!sandbox) {
|
||||
return;
|
||||
}
|
||||
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
|
||||
if (!project.sandboxes.includes(sandbox)) {
|
||||
project.sandboxes.push(sandbox);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const syncProjectSandboxRemove = async (projectID: string, primaryWorktree: string, sandboxPath: string) => {
|
||||
const sandbox = String(sandboxPath || '').trim();
|
||||
if (!sandbox) {
|
||||
return;
|
||||
}
|
||||
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
|
||||
project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox);
|
||||
});
|
||||
};
|
||||
// OpenCode owns its own project/sandbox registry and records a worktree as a
|
||||
// sandbox itself when an instance boots for that directory. OpenChamber used to
|
||||
// write that state into OpenCode's storage JSON directly, behind the back of the
|
||||
// running process — and since OpenCode v2 reads sandboxes from its database, the
|
||||
// JSON write did not even reach it. Registration is not ours to perform.
|
||||
|
||||
const isInsideOrSameDirectory = (root: string, target: string): boolean => {
|
||||
const relative = path.relative(root, target);
|
||||
@@ -1385,14 +1511,6 @@ const cleanupFailedFastWorktreeCreate = async (
|
||||
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
|
||||
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
|
||||
|
||||
if (!isAttached) {
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, candidateDirectory);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to clean up OpenCode sandbox metadata after worktree failure:', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInsideWorktreeRoot || isAttached) {
|
||||
return;
|
||||
}
|
||||
@@ -1454,9 +1572,10 @@ const queueWorktreeBootstrap = (args: {
|
||||
ensureRemoteUrl,
|
||||
startCommand,
|
||||
} = args;
|
||||
setTimeout(() => {
|
||||
const run = async () => {
|
||||
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
|
||||
const task = new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
.then(async () => {
|
||||
await populateWorktreeWithLockRecovery(directory);
|
||||
await runPostCheckoutHook(directory);
|
||||
if (setUpstream) {
|
||||
await applyUpstreamConfiguration({
|
||||
primaryWorktree,
|
||||
@@ -1471,21 +1590,18 @@ const queueWorktreeBootstrap = (args: {
|
||||
console.warn('[GitService] Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_PENDING, WORKTREE_PHASE_GIT_READY);
|
||||
await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
|
||||
console.warn('[GitService] Worktree start script task failed:', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY);
|
||||
};
|
||||
|
||||
void run().catch((error) => {
|
||||
setWorktreeBootstrapState(
|
||||
directory,
|
||||
WORKTREE_BOOTSTRAP_FAILED,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY, WORKTREE_PHASE_SETUP_READY);
|
||||
})
|
||||
.catch((error) => {
|
||||
setWorktreeBootstrapFailure(directory, error);
|
||||
console.warn('[GitService] Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}, 0);
|
||||
|
||||
trackWorktreeBootstrapTask(directory, task);
|
||||
};
|
||||
|
||||
const ensureRemoteWithUrl = async (primaryWorktree: string, remoteName: string, remoteUrl: string) => {
|
||||
@@ -1754,6 +1870,19 @@ export async function validateWorktreeCreate(directory: string, input: CreateGit
|
||||
}
|
||||
}
|
||||
|
||||
const assertWorktreeCreatePreflight = async (directory: string, input: CreateGitWorktreePayload = {}): Promise<void> => {
|
||||
const validation = await validateWorktreeCreate(directory, input);
|
||||
if (validation?.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = validation?.errors
|
||||
?.map((error) => error?.message)
|
||||
.filter(Boolean)
|
||||
.join('\n') || 'Failed to validate worktree creation';
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
export async function previewWorktreeCreate(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> {
|
||||
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
||||
const context = await resolveWorktreeProjectContext(directory);
|
||||
@@ -1861,19 +1990,17 @@ async function attachGitWorktreeToCandidate(
|
||||
|
||||
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
|
||||
|
||||
try {
|
||||
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const shouldSetUpstream = Boolean(input?.setUpstream);
|
||||
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
|
||||
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
|
||||
|
||||
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
|
||||
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
|
||||
const bootstrapStatus = setWorktreeBootstrapState(
|
||||
candidate.directory,
|
||||
WORKTREE_BOOTSTRAP_PENDING,
|
||||
WORKTREE_PHASE_DIRECTORY_CREATED,
|
||||
) ?? {
|
||||
status: WORKTREE_BOOTSTRAP_PENDING,
|
||||
phase: WORKTREE_PHASE_DIRECTORY_CREATED,
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -1907,6 +2034,11 @@ async function attachGitWorktreeToCandidate(
|
||||
export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> {
|
||||
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
||||
const context = await resolveWorktreeProjectContext(directory);
|
||||
|
||||
if (input?.returnAfterDirectoryCreated === true) {
|
||||
await assertWorktreeCreatePreflight(directory, input);
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(context.worktreeRoot, { recursive: true });
|
||||
|
||||
const preferredName = String(input?.worktreeName || input?.name || '').trim();
|
||||
@@ -1922,15 +2054,13 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
|
||||
if (input?.returnAfterDirectoryCreated === true) {
|
||||
await fs.promises.mkdir(candidate.directory, { recursive: false });
|
||||
|
||||
try {
|
||||
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
|
||||
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
|
||||
const bootstrapStatus = setWorktreeBootstrapState(
|
||||
candidate.directory,
|
||||
WORKTREE_BOOTSTRAP_PENDING,
|
||||
WORKTREE_PHASE_DIRECTORY_CREATED,
|
||||
) ?? {
|
||||
status: WORKTREE_BOOTSTRAP_PENDING,
|
||||
phase: WORKTREE_PHASE_DIRECTORY_CREATED,
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -1938,15 +2068,12 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
|
||||
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
|
||||
: candidate.branch;
|
||||
|
||||
void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => {
|
||||
setWorktreeBootstrapState(
|
||||
candidate.directory,
|
||||
WORKTREE_BOOTSTRAP_FAILED,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
void cleanupFailedFastWorktreeCreate(context, candidate);
|
||||
const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => {
|
||||
setWorktreeBootstrapFailure(candidate.directory, error);
|
||||
await cleanupFailedFastWorktreeCreate(context, candidate);
|
||||
console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
trackWorktreeBootstrapTask(candidate.directory, task);
|
||||
|
||||
return {
|
||||
head: '',
|
||||
@@ -1961,7 +2088,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
|
||||
return attachGitWorktreeToCandidate(context, candidate, input);
|
||||
}
|
||||
|
||||
export async function getWorktreeBootstrapStatus(directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> {
|
||||
export async function getWorktreeBootstrapStatus(directory: string): Promise<WorktreeBootstrapStatus> {
|
||||
const key = toBootstrapStateKey(directory);
|
||||
if (!key) {
|
||||
throw new Error('Worktree directory is required');
|
||||
@@ -1974,6 +2101,7 @@ export async function getWorktreeBootstrapStatus(directory: string): Promise<{ s
|
||||
|
||||
return {
|
||||
status: WORKTREE_BOOTSTRAP_READY,
|
||||
phase: WORKTREE_PHASE_SETUP_READY,
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -1985,6 +2113,8 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
|
||||
throw new Error('Worktree directory is required');
|
||||
}
|
||||
|
||||
await waitForActiveWorktreeBootstrap(targetDirectory);
|
||||
|
||||
const context = await resolveWorktreeProjectContext(directory);
|
||||
const deleteLocalBranch = input?.deleteLocalBranch === true;
|
||||
|
||||
@@ -2014,12 +2144,6 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
|
||||
await fs.promises.rm(targetDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
clearWorktreeBootstrapState(targetDirectory);
|
||||
|
||||
return true;
|
||||
@@ -2042,54 +2166,11 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree);
|
||||
} catch (error) {
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
clearWorktreeBootstrapState(matchedEntry.worktree);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get branches that are available for worktree checkout
|
||||
* (branches not already checked out in any worktree)
|
||||
*/
|
||||
export async function getAvailableBranchesForWorktree(directory: string): Promise<GitBranchDetails[]> {
|
||||
const [branches, worktrees] = await Promise.all([
|
||||
getGitBranches(directory),
|
||||
listGitWorktrees(directory),
|
||||
]);
|
||||
|
||||
// Get set of branches already checked out in worktrees
|
||||
const checkedOutBranches = new Set<string>();
|
||||
for (const wt of worktrees) {
|
||||
if (wt.branch) {
|
||||
checkedOutBranches.add(wt.branch.replace(/^refs\/heads\//, ''));
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out branches that are already checked out
|
||||
const availableBranches: GitBranchDetails[] = [];
|
||||
for (const name of branches.all) {
|
||||
// Skip remote branches for worktree creation
|
||||
if (name.startsWith('remotes/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!checkedOutBranches.has(name)) {
|
||||
const details = branches.branches[name];
|
||||
if (details) {
|
||||
availableBranches.push(details);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return availableBranches;
|
||||
}
|
||||
|
||||
// ============== Diff Operations ==============
|
||||
|
||||
/**
|
||||
@@ -2262,10 +2343,6 @@ export async function revertGitFile(
|
||||
}
|
||||
}
|
||||
|
||||
export async function stageGitFile(directory: string, filePath: string): Promise<void> {
|
||||
await stageGitFiles(directory, [filePath]);
|
||||
}
|
||||
|
||||
export async function stageGitFiles(directory: string, filePaths: string[]): Promise<void> {
|
||||
const paths = filePaths.map((path) => path.trim()).filter(Boolean);
|
||||
|
||||
@@ -2301,10 +2378,6 @@ export async function stageGitFiles(directory: string, filePaths: string[]): Pro
|
||||
}
|
||||
}
|
||||
|
||||
export async function unstageGitFile(directory: string, filePath: string): Promise<void> {
|
||||
await unstageGitFiles(directory, [filePath]);
|
||||
}
|
||||
|
||||
export async function unstageGitFiles(directory: string, filePaths: string[]): Promise<void> {
|
||||
const paths = filePaths.map((path) => path.trim()).filter(Boolean);
|
||||
|
||||
@@ -3284,12 +3357,15 @@ export async function setGitIdentity(
|
||||
directory: string,
|
||||
userName: string,
|
||||
userEmail: string,
|
||||
sshKey?: string | null
|
||||
sshKey?: string | null,
|
||||
signCommits?: boolean | null,
|
||||
signingKey?: string | null
|
||||
): Promise<{ success: boolean }> {
|
||||
const repo = await getRepository(directory);
|
||||
|
||||
// Build SSH command once if needed
|
||||
const sshCommand = sshKey ? buildSshCommand(sshKey) : null;
|
||||
const shouldSignCommits = signCommits === true && typeof signingKey === 'string' && signingKey.trim().length > 0;
|
||||
|
||||
if (repo) {
|
||||
try {
|
||||
@@ -3298,6 +3374,11 @@ export async function setGitIdentity(
|
||||
if (sshCommand) {
|
||||
await repo.setConfig('core.sshCommand', sshCommand);
|
||||
}
|
||||
if (shouldSignCommits) {
|
||||
await repo.setConfig('gpg.format', 'ssh');
|
||||
await repo.setConfig('user.signingkey', signingKey.trim());
|
||||
await repo.setConfig('commit.gpgsign', 'true');
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[GitService] Failed to set identity:', error);
|
||||
@@ -3310,6 +3391,11 @@ export async function setGitIdentity(
|
||||
if (sshCommand) {
|
||||
await execGit(['config', 'core.sshCommand', sshCommand], directory);
|
||||
}
|
||||
if (shouldSignCommits) {
|
||||
await execGit(['config', 'gpg.format', 'ssh'], directory);
|
||||
await execGit(['config', 'user.signingkey', signingKey.trim()], directory);
|
||||
await execGit(['config', 'commit.gpgsign', 'true'], directory);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -3616,38 +3702,6 @@ export async function resetToCommit(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============== Stash Operations ==============
|
||||
|
||||
/**
|
||||
* Stash changes
|
||||
*/
|
||||
export async function stash(
|
||||
directory: string,
|
||||
options?: { message?: string; includeUntracked?: boolean }
|
||||
): Promise<{ success: boolean }> {
|
||||
const args = ['stash', 'push'];
|
||||
|
||||
// Include untracked files by default
|
||||
if (options?.includeUntracked !== false) {
|
||||
args.push('--include-untracked');
|
||||
}
|
||||
|
||||
if (options?.message) {
|
||||
args.push('-m', options.message);
|
||||
}
|
||||
|
||||
const result = await execGit(args, directory);
|
||||
return { success: result.exitCode === 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the most recent stash
|
||||
*/
|
||||
export async function stashPop(directory: string): Promise<{ success: boolean }> {
|
||||
const result = await execGit(['stash', 'pop'], directory);
|
||||
return { success: result.exitCode === 0 };
|
||||
}
|
||||
|
||||
// ============== Worktree Validation & Canonicalization ==============
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
mock.module('vscode', () => ({
|
||||
extensions: { getExtension: () => undefined },
|
||||
Uri: { file: (fsPath) => ({ fsPath }) },
|
||||
}));
|
||||
|
||||
const { getWorktreeBootstrapStatus } = await import('./gitService.ts?worktree-bootstrap-test');
|
||||
|
||||
describe('VS Code worktree bootstrap phases', () => {
|
||||
it('treats missing bootstrap state as fully ready', async () => {
|
||||
await expect(getWorktreeBootstrapStatus('/untracked-worktree')).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
phase: 'setup-ready',
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
||||
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
||||
const API_USER_URL = 'https://api.github.com/user';
|
||||
const API_EMAILS_URL = 'https://api.github.com/user/emails';
|
||||
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||
|
||||
export const DEFAULT_GITHUB_CLIENT_ID = 'Ov23lizomPOC3eFYo56r';
|
||||
export const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
|
||||
|
||||
type StoredAuth = {
|
||||
accessToken: string;
|
||||
scope?: string;
|
||||
tokenType?: string;
|
||||
createdAt?: number;
|
||||
user?: { login: string; id?: number; avatarUrl?: string };
|
||||
accountId?: string;
|
||||
current?: boolean;
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type DeviceCodeResponse = {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
verification_uri_complete?: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
};
|
||||
|
||||
type TokenResponse = {
|
||||
access_token?: string;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
};
|
||||
|
||||
const authFilePath = (context: vscode.ExtensionContext) =>
|
||||
path.join(context.globalStorageUri.fsPath, 'github-auth.json');
|
||||
|
||||
const resolveAccountId = (auth: StoredAuth): string => {
|
||||
if (typeof auth.accountId === 'string' && auth.accountId.trim()) {
|
||||
return auth.accountId.trim();
|
||||
}
|
||||
if (auth.user?.login) {
|
||||
return auth.user.login.trim();
|
||||
}
|
||||
if (typeof auth.user?.id === 'number') {
|
||||
return String(auth.user.id);
|
||||
}
|
||||
if (auth.accessToken) {
|
||||
return `token:${auth.accessToken.slice(0, 8)}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeAuthList = (list: StoredAuth[]): { list: StoredAuth[]; changed: boolean } => {
|
||||
let changed = false;
|
||||
let currentFound = false;
|
||||
const normalized = list
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
accountId: resolveAccountId(entry),
|
||||
current: Boolean(entry.current),
|
||||
}))
|
||||
.filter((entry) => Boolean(entry.accessToken));
|
||||
|
||||
normalized.forEach((entry) => {
|
||||
if (entry.current && !currentFound) {
|
||||
currentFound = true;
|
||||
} else if (entry.current && currentFound) {
|
||||
entry.current = false;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!currentFound && normalized.length > 0) {
|
||||
normalized[0].current = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return { list: normalized, changed };
|
||||
};
|
||||
|
||||
export const readGitHubAuthList = async (context: vscode.ExtensionContext): Promise<StoredAuth[]> => {
|
||||
try {
|
||||
const raw = await fs.readFile(authFilePath(context), 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed) return [];
|
||||
const list = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const { list: normalized, changed } = normalizeAuthList(list as StoredAuth[]);
|
||||
if (changed) {
|
||||
await fs.writeFile(authFilePath(context), JSON.stringify(normalized, null, 2), 'utf8');
|
||||
}
|
||||
return normalized;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const readGitHubAuth = async (context: vscode.ExtensionContext): Promise<StoredAuth | null> => {
|
||||
const list = await readGitHubAuthList(context);
|
||||
if (!list.length) return null;
|
||||
return list.find((entry) => entry.current) ?? list[0] ?? null;
|
||||
};
|
||||
|
||||
export const writeGitHubAuth = async (context: vscode.ExtensionContext, auth: StoredAuth): Promise<void> => {
|
||||
const list = await readGitHubAuthList(context);
|
||||
const next = {
|
||||
...auth,
|
||||
accountId: resolveAccountId(auth),
|
||||
current: true,
|
||||
};
|
||||
const index = list.findIndex((entry) => entry.accountId === next.accountId);
|
||||
if (index >= 0) {
|
||||
list[index] = next;
|
||||
} else {
|
||||
list.push(next);
|
||||
}
|
||||
list.forEach((entry) => {
|
||||
entry.current = entry.accountId === next.accountId;
|
||||
});
|
||||
|
||||
await fs.mkdir(context.globalStorageUri.fsPath, { recursive: true });
|
||||
await fs.writeFile(authFilePath(context), JSON.stringify(list, null, 2), 'utf8');
|
||||
try {
|
||||
// best-effort perms on unix
|
||||
await fs.chmod(authFilePath(context), 0o600);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
export const activateGitHubAuth = async (context: vscode.ExtensionContext, accountId: string): Promise<boolean> => {
|
||||
const list = await readGitHubAuthList(context);
|
||||
if (!list.length) return false;
|
||||
const id = accountId.trim();
|
||||
if (!id) return false;
|
||||
let found = false;
|
||||
list.forEach((entry) => {
|
||||
if (entry.accountId === id) {
|
||||
entry.current = true;
|
||||
found = true;
|
||||
} else {
|
||||
entry.current = false;
|
||||
}
|
||||
});
|
||||
if (!found) return false;
|
||||
await fs.writeFile(authFilePath(context), JSON.stringify(list, null, 2), 'utf8');
|
||||
return true;
|
||||
};
|
||||
|
||||
export const clearGitHubAuth = async (context: vscode.ExtensionContext): Promise<boolean> => {
|
||||
try {
|
||||
const list = await readGitHubAuthList(context);
|
||||
if (!list.length) return true;
|
||||
const remaining = list.filter((entry) => !entry.current);
|
||||
if (!remaining.length) {
|
||||
await fs.rm(authFilePath(context));
|
||||
return true;
|
||||
}
|
||||
remaining[0].current = true;
|
||||
await fs.writeFile(authFilePath(context), JSON.stringify(remaining, null, 2), 'utf8');
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'code' in err && (err as { code?: string }).code === 'ENOENT') return true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const postForm = async <T extends JsonRecord>(url: string, params: Record<string, string>): Promise<T> => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'OpenChamber',
|
||||
},
|
||||
body: new URLSearchParams(params).toString(),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as T | null;
|
||||
if (!response.ok) {
|
||||
const errorDescription = typeof payload?.error_description === 'string' ? payload.error_description : '';
|
||||
const error = typeof payload?.error === 'string' ? payload.error : '';
|
||||
throw new Error(errorDescription || error || response.statusText);
|
||||
}
|
||||
return payload as T;
|
||||
};
|
||||
|
||||
export const startDeviceFlow = async (clientId: string, scope: string) => {
|
||||
const payload = await postForm<DeviceCodeResponse>(DEVICE_CODE_URL, { client_id: clientId, scope });
|
||||
return {
|
||||
deviceCode: payload.device_code,
|
||||
userCode: payload.user_code,
|
||||
verificationUri: payload.verification_uri,
|
||||
verificationUriComplete: payload.verification_uri_complete,
|
||||
expiresIn: payload.expires_in,
|
||||
interval: payload.interval,
|
||||
scope,
|
||||
};
|
||||
};
|
||||
|
||||
export const exchangeDeviceCode = async (clientId: string, deviceCode: string) => {
|
||||
const payload = await postForm<TokenResponse>(ACCESS_TOKEN_URL, {
|
||||
client_id: clientId,
|
||||
device_code: deviceCode,
|
||||
grant_type: DEVICE_GRANT_TYPE,
|
||||
});
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const fetchMe = async (accessToken: string) => {
|
||||
const response = await fetch(API_USER_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': 'OpenChamber',
|
||||
},
|
||||
});
|
||||
if (response.status === 401) {
|
||||
const error = new Error('unauthorized');
|
||||
(error as unknown as { status?: number }).status = 401;
|
||||
throw error;
|
||||
}
|
||||
const payload = (await response.json().catch(() => null)) as JsonRecord | null;
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(`GitHub /user failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const name = typeof payload.name === 'string' ? payload.name : undefined;
|
||||
let email = typeof payload.email === 'string' ? payload.email : undefined;
|
||||
if (!email) {
|
||||
try {
|
||||
const emailsResponse = await fetch(API_EMAILS_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': 'OpenChamber',
|
||||
},
|
||||
});
|
||||
if (emailsResponse.status === 401) {
|
||||
const error = new Error('unauthorized');
|
||||
(error as unknown as { status?: number }).status = 401;
|
||||
throw error;
|
||||
}
|
||||
const list = (await emailsResponse.json().catch(() => null)) as Array<Record<string, unknown>> | null;
|
||||
if (emailsResponse.ok && Array.isArray(list)) {
|
||||
const primaryVerified = list.find((e) => Boolean(e?.primary) && Boolean(e?.verified) && typeof e?.email === 'string');
|
||||
const anyVerified = list.find((e) => Boolean(e?.verified) && typeof e?.email === 'string');
|
||||
email = (primaryVerified?.email as string | undefined) || (anyVerified?.email as string | undefined);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
login: String(payload.login || ''),
|
||||
id: typeof payload.id === 'number' ? payload.id : undefined,
|
||||
avatarUrl: typeof payload.avatar_url === 'string' ? payload.avatar_url : undefined,
|
||||
name,
|
||||
email,
|
||||
};
|
||||
};
|
||||
@@ -1,280 +0,0 @@
|
||||
import { resolveRepoFromDirectory } from './githubPr';
|
||||
|
||||
const API_BASE = 'https://api.github.com';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type GitHubRepoRef = { owner: string; repo: string; url: string };
|
||||
|
||||
type GitHubIssuesListResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
issues?: Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: 'open' | 'closed';
|
||||
author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null;
|
||||
labels?: Array<{ name: string; color?: string }>;
|
||||
}>;
|
||||
page?: number;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
type GitHubIssueGetResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
issue?: {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: 'open' | 'closed';
|
||||
author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null;
|
||||
labels?: Array<{ name: string; color?: string }>;
|
||||
body?: string;
|
||||
assignees?: Array<{ login: string; id?: number; avatarUrl?: string; name?: string; email?: string }>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type GitHubIssueCommentsResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
comments?: Array<{
|
||||
id: number;
|
||||
url: string;
|
||||
body: string;
|
||||
author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const githubFetch = async (
|
||||
url: string,
|
||||
accessToken: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': 'OpenChamber',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
|
||||
return (await response.json().catch(() => null)) as T | null;
|
||||
};
|
||||
|
||||
const readString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||
|
||||
const mapUser = (raw: unknown) => {
|
||||
const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null;
|
||||
const login = readString(rec?.login);
|
||||
if (!login) return null;
|
||||
return {
|
||||
login,
|
||||
id: typeof rec?.id === 'number' ? rec.id : undefined,
|
||||
avatarUrl: readString(rec?.avatar_url) || undefined,
|
||||
name: undefined,
|
||||
email: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const mapLabels = (raw: unknown): Array<{ name: string; color?: string }> => {
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
return list
|
||||
.map((item) => {
|
||||
const rec = item && typeof item === 'object' ? (item as JsonRecord) : null;
|
||||
const name = readString(rec?.name);
|
||||
if (!name) return null;
|
||||
return {
|
||||
name,
|
||||
color: readString(rec?.color) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{ name: string; color?: string }>;
|
||||
};
|
||||
|
||||
export const listIssues = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
page: number = 1,
|
||||
searchQuery?: string,
|
||||
): Promise<GitHubIssuesListResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, issues: [] };
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const q = `repo:${repo.owner}/${repo.repo} ${searchQuery} type:issue state:open`;
|
||||
const url = new URL(`${API_BASE}/search/issues`);
|
||||
url.searchParams.set('q', q);
|
||||
url.searchParams.set('per_page', '50');
|
||||
url.searchParams.set('page', String(page));
|
||||
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
|
||||
const json = await jsonOrNull<{ total_count?: number; items?: unknown[] }>(resp);
|
||||
if (!resp.ok || !json) {
|
||||
throw new Error('Failed to search issues');
|
||||
}
|
||||
|
||||
const totalCount = typeof json.total_count === 'number' ? json.total_count : 0;
|
||||
const items = Array.isArray(json.items) ? json.items : [];
|
||||
const issues = items
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
if (!rec || rec.pull_request) return null;
|
||||
const number = typeof rec.number === 'number' ? rec.number : 0;
|
||||
if (!number) return null;
|
||||
const state = readString(rec.state) === 'closed' ? 'closed' : 'open';
|
||||
return {
|
||||
number,
|
||||
title: readString(rec.title) || '',
|
||||
url: readString(rec.html_url) || '',
|
||||
state,
|
||||
author: mapUser(rec.user),
|
||||
labels: mapLabels(rec.labels),
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubIssuesListResult['issues'];
|
||||
|
||||
const fetchedCount = (page - 1) * 50 + items.length;
|
||||
const hasMore = fetchedCount < totalCount;
|
||||
return { connected: true, repo, issues: issues || [], page, hasMore };
|
||||
}
|
||||
|
||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues`);
|
||||
url.searchParams.set('state', 'open');
|
||||
url.searchParams.set('per_page', '50');
|
||||
url.searchParams.set('page', String(page));
|
||||
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
|
||||
const link = resp.headers.get('link') || '';
|
||||
const hasMore = /rel="next"/.test(link);
|
||||
|
||||
const json = await jsonOrNull<unknown[]>(resp);
|
||||
if (!resp.ok || !Array.isArray(json)) {
|
||||
throw new Error('Failed to load issues');
|
||||
}
|
||||
|
||||
const issues = json
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
if (!rec || rec.pull_request) return null;
|
||||
const number = typeof rec.number === 'number' ? rec.number : 0;
|
||||
if (!number) return null;
|
||||
const state = readString(rec.state) === 'closed' ? 'closed' : 'open';
|
||||
return {
|
||||
number,
|
||||
title: readString(rec.title) || '',
|
||||
url: readString(rec.html_url) || '',
|
||||
state,
|
||||
author: mapUser(rec.user),
|
||||
labels: mapLabels(rec.labels),
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubIssuesListResult['issues'];
|
||||
|
||||
return { connected: true, repo, issues: issues || [], page, hasMore };
|
||||
};
|
||||
|
||||
export const getIssue = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
number: number,
|
||||
): Promise<GitHubIssueGetResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, issue: null };
|
||||
}
|
||||
|
||||
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}`, accessToken);
|
||||
if (resp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
const json = await jsonOrNull<JsonRecord>(resp);
|
||||
if (!resp.ok || !json) {
|
||||
throw new Error('Failed to load issue');
|
||||
}
|
||||
if (json.pull_request) {
|
||||
throw new Error('Not a GitHub issue');
|
||||
}
|
||||
|
||||
const state = readString(json.state) === 'closed' ? 'closed' : 'open';
|
||||
const assigneesRaw = Array.isArray(json.assignees) ? json.assignees : [];
|
||||
const assignees = assigneesRaw.map(mapUser).filter(Boolean) as Array<NonNullable<ReturnType<typeof mapUser>>>;
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
repo,
|
||||
issue: {
|
||||
number: typeof json.number === 'number' ? json.number : number,
|
||||
title: readString(json.title) || '',
|
||||
url: readString(json.html_url) || '',
|
||||
state,
|
||||
author: mapUser(json.user),
|
||||
labels: mapLabels(json.labels),
|
||||
body: readString(json.body) || '',
|
||||
assignees,
|
||||
createdAt: readString(json.created_at) || undefined,
|
||||
updatedAt: readString(json.updated_at) || undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const listIssueComments = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
number: number,
|
||||
): Promise<GitHubIssueCommentsResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, comments: [] };
|
||||
}
|
||||
|
||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}/comments`);
|
||||
url.searchParams.set('per_page', '100');
|
||||
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
const json = await jsonOrNull<unknown[]>(resp);
|
||||
if (!resp.ok || !Array.isArray(json)) {
|
||||
throw new Error('Failed to load issue comments');
|
||||
}
|
||||
|
||||
const comments = json
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
if (!rec) return null;
|
||||
const id = typeof rec.id === 'number' ? rec.id : 0;
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
url: readString(rec.html_url) || '',
|
||||
body: readString(rec.body) || '',
|
||||
author: mapUser(rec.user),
|
||||
createdAt: readString(rec.created_at) || undefined,
|
||||
updatedAt: readString(rec.updated_at) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubIssueCommentsResult['comments'];
|
||||
|
||||
return { connected: true, repo, comments: comments || [] };
|
||||
};
|
||||
@@ -1,486 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const API_BASE = 'https://api.github.com';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type GitHubRepoRef = { owner: string; repo: string; url: string };
|
||||
|
||||
type GitHubChecksSummary = {
|
||||
state: 'success' | 'failure' | 'pending' | 'unknown';
|
||||
total: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
type GitHubPullRequest = {
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string;
|
||||
url: string;
|
||||
state: 'open' | 'closed' | 'merged';
|
||||
draft: boolean;
|
||||
base: string;
|
||||
head: string;
|
||||
headSha?: string;
|
||||
mergeable?: boolean | null;
|
||||
mergeableState?: string | null;
|
||||
};
|
||||
|
||||
type GitHubPullRequestStatus = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
branch?: string;
|
||||
pr?: GitHubPullRequest | null;
|
||||
checks?: GitHubChecksSummary | null;
|
||||
canMerge?: boolean;
|
||||
};
|
||||
|
||||
type GitHubPullRequestCreateInput = {
|
||||
directory: string;
|
||||
title: string;
|
||||
head: string;
|
||||
base: string;
|
||||
body?: string;
|
||||
draft?: boolean;
|
||||
};
|
||||
|
||||
type GitHubPullRequestUpdateInput = {
|
||||
directory: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
type GitHubPullRequestMergeInput = {
|
||||
directory: string;
|
||||
number: number;
|
||||
method: 'merge' | 'squash' | 'rebase';
|
||||
};
|
||||
|
||||
type GitHubPullRequestMergeResult = { merged: boolean; message?: string };
|
||||
|
||||
const parseGitHubRemoteUrl = (raw: string): GitHubRepoRef | null => {
|
||||
const value = raw.trim();
|
||||
if (!value) return null;
|
||||
|
||||
if (value.startsWith('git@github.com:')) {
|
||||
const rest = value.slice('git@github.com:'.length);
|
||||
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||
const [owner, repo] = cleaned.split('/');
|
||||
if (!owner || !repo) return null;
|
||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||
}
|
||||
|
||||
if (value.startsWith('ssh://git@github.com/')) {
|
||||
const rest = value.slice('ssh://git@github.com/'.length);
|
||||
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||
const [owner, repo] = cleaned.split('/');
|
||||
if (!owner || !repo) return null;
|
||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.hostname !== 'github.com') return null;
|
||||
const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
|
||||
const [owner, repo] = cleaned.split('/');
|
||||
if (!owner || !repo) return null;
|
||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getOriginRemoteUrl = async (directory: string): Promise<string | null> => {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['-C', directory, 'remote', 'get-url', 'origin']);
|
||||
const url = String(stdout || '').trim();
|
||||
return url || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveRepoFromDirectory = async (directory: string): Promise<GitHubRepoRef | null> => {
|
||||
const remote = await getOriginRemoteUrl(directory);
|
||||
if (!remote) return null;
|
||||
return parseGitHubRemoteUrl(remote);
|
||||
};
|
||||
|
||||
const githubFetch = async (
|
||||
url: string,
|
||||
accessToken: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': 'OpenChamber',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
|
||||
return (await response.json().catch(() => null)) as T | null;
|
||||
};
|
||||
|
||||
const readString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||
|
||||
export const getPullRequestStatus = async (
|
||||
accessToken: string,
|
||||
userLogin: string | null,
|
||||
directory: string,
|
||||
branch: string,
|
||||
): Promise<GitHubPullRequestStatus> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, branch, pr: null, checks: null, canMerge: false };
|
||||
}
|
||||
|
||||
const listNumberByHead = async (state: 'open' | 'closed'): Promise<number | null> => {
|
||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('head', `${repo.owner}:${branch}`);
|
||||
url.searchParams.set('per_page', '10');
|
||||
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) {
|
||||
return null;
|
||||
}
|
||||
const list = await jsonOrNull<Array<{ number: number }>>(resp);
|
||||
return (resp.ok && Array.isArray(list) && list.length > 0) ? list[0].number : null;
|
||||
};
|
||||
|
||||
const listNumberByHeadRef = async (state: 'open' | 'closed'): Promise<number | null> => {
|
||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('per_page', '100');
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) {
|
||||
return null;
|
||||
}
|
||||
const list = await jsonOrNull<Array<JsonRecord>>(resp);
|
||||
if (!resp.ok || !Array.isArray(list)) return null;
|
||||
|
||||
const match = list.find((prItem) => {
|
||||
const head = prItem?.head && typeof prItem.head === 'object' ? (prItem.head as JsonRecord) : null;
|
||||
return readString(head?.ref) === branch;
|
||||
});
|
||||
return match && typeof match.number === 'number' ? match.number : null;
|
||||
};
|
||||
|
||||
// PR status by branch:
|
||||
// - Prefer open PRs.
|
||||
// - If none, surface closed/merged PRs.
|
||||
// - Fork PR support: head owner differs -> head filter yields empty; fall back to matching head.ref.
|
||||
let number = await listNumberByHead('open');
|
||||
if (!number) number = await listNumberByHead('closed');
|
||||
if (!number) number = await listNumberByHeadRef('open');
|
||||
if (!number) number = await listNumberByHeadRef('closed');
|
||||
|
||||
// Detect auth revocation (best-effort)
|
||||
if (number === null) {
|
||||
const probeUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||
probeUrl.searchParams.set('state', 'open');
|
||||
probeUrl.searchParams.set('per_page', '1');
|
||||
const probeResp = await githubFetch(probeUrl.toString(), accessToken);
|
||||
if (probeResp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (!number) {
|
||||
return { connected: true, repo, branch, pr: null, checks: null, canMerge: false };
|
||||
}
|
||||
const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken);
|
||||
if (prResp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
const prJson = await jsonOrNull<JsonRecord>(prResp);
|
||||
if (!prResp.ok || !prJson) {
|
||||
throw new Error('Failed to load PR');
|
||||
}
|
||||
|
||||
const merged = Boolean(prJson.merged || prJson.merged_at);
|
||||
const prState = readString(prJson.state);
|
||||
const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open');
|
||||
const pr: GitHubPullRequest = {
|
||||
number: typeof prJson.number === 'number' ? prJson.number : 0,
|
||||
title: readString(prJson.title) || '',
|
||||
body: readString(prJson.body) || '',
|
||||
url: readString(prJson.html_url) || '',
|
||||
state,
|
||||
draft: Boolean(prJson.draft),
|
||||
base: readString((prJson.base as JsonRecord | undefined)?.ref) || '',
|
||||
head: readString((prJson.head as JsonRecord | undefined)?.ref) || '',
|
||||
headSha: readString((prJson.head as JsonRecord | undefined)?.sha) || undefined,
|
||||
mergeable: typeof prJson.mergeable === 'boolean' ? prJson.mergeable : null,
|
||||
mergeableState: readString(prJson.mergeable_state) || undefined,
|
||||
};
|
||||
|
||||
let checks: GitHubChecksSummary | null = null;
|
||||
if (pr.headSha) {
|
||||
// Prefer check-runs (Actions)
|
||||
const runsResp = await githubFetch(
|
||||
`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${pr.headSha}/check-runs`,
|
||||
accessToken,
|
||||
);
|
||||
const runsJson = await jsonOrNull<JsonRecord>(runsResp);
|
||||
const runs = Array.isArray((runsJson as JsonRecord | null)?.check_runs)
|
||||
? ((runsJson as JsonRecord).check_runs as unknown[])
|
||||
: [];
|
||||
|
||||
if (runsResp.ok && runs.length > 0) {
|
||||
const counts = { success: 0, failure: 0, pending: 0 };
|
||||
runs.forEach((r) => {
|
||||
const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null;
|
||||
const status = readString(rec?.status);
|
||||
const conclusion = readString(rec?.conclusion);
|
||||
if (status === 'queued' || status === 'in_progress') {
|
||||
counts.pending += 1;
|
||||
return;
|
||||
}
|
||||
if (!conclusion) {
|
||||
counts.pending += 1;
|
||||
return;
|
||||
}
|
||||
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
||||
counts.success += 1;
|
||||
} else {
|
||||
counts.failure += 1;
|
||||
}
|
||||
});
|
||||
const total = counts.success + counts.failure + counts.pending;
|
||||
const state2 = counts.failure > 0
|
||||
? 'failure'
|
||||
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
checks = { state: state2, total, ...counts };
|
||||
}
|
||||
|
||||
// Fallback: classic statuses
|
||||
if (!checks) {
|
||||
const statusResp = await githubFetch(
|
||||
`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${pr.headSha}/status`,
|
||||
accessToken,
|
||||
);
|
||||
const statusJson = await jsonOrNull<JsonRecord>(statusResp);
|
||||
if (statusResp.ok && statusJson) {
|
||||
const statuses = Array.isArray(statusJson.statuses) ? (statusJson.statuses as unknown[]) : [];
|
||||
const counts = { success: 0, failure: 0, pending: 0 };
|
||||
statuses.forEach((s) => {
|
||||
const st = readString((s as JsonRecord | null)?.state);
|
||||
if (st === 'success') counts.success += 1;
|
||||
else if (st === 'failure' || st === 'error') counts.failure += 1;
|
||||
else if (st === 'pending') counts.pending += 1;
|
||||
});
|
||||
const total = counts.success + counts.failure + counts.pending;
|
||||
const state2 = counts.failure > 0
|
||||
? 'failure'
|
||||
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
checks = { state: state2, total, ...counts };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let canMerge = false;
|
||||
if (userLogin) {
|
||||
const permResp = await githubFetch(
|
||||
`${API_BASE}/repos/${repo.owner}/${repo.repo}/collaborators/${encodeURIComponent(userLogin)}/permission`,
|
||||
accessToken,
|
||||
);
|
||||
const permJson = await jsonOrNull<{ permission?: string }>(permResp);
|
||||
const perm = typeof permJson?.permission === 'string' ? permJson.permission : '';
|
||||
canMerge = perm === 'admin' || perm === 'maintain' || perm === 'write';
|
||||
}
|
||||
|
||||
return { connected: true, repo, branch, pr, checks, canMerge };
|
||||
};
|
||||
|
||||
export const createPullRequest = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
payload: GitHubPullRequestCreateInput,
|
||||
): Promise<GitHubPullRequest> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
throw new Error('Unable to resolve GitHub repo from git remote');
|
||||
}
|
||||
|
||||
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`, accessToken, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
head: payload.head,
|
||||
base: payload.base,
|
||||
...(typeof payload.body === 'string' ? { body: payload.body } : {}),
|
||||
...(typeof payload.draft === 'boolean' ? { draft: payload.draft } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const json = await jsonOrNull<JsonRecord>(resp);
|
||||
if (!resp.ok || !json) {
|
||||
throw new Error('Failed to create PR');
|
||||
}
|
||||
|
||||
return {
|
||||
number: typeof json.number === 'number' ? json.number : 0,
|
||||
title: readString(json.title) || '',
|
||||
body: readString(json.body) || '',
|
||||
url: readString(json.html_url) || '',
|
||||
state: readString(json.state) === 'closed' ? 'closed' : 'open',
|
||||
draft: Boolean(json.draft),
|
||||
base: readString((json.base as JsonRecord | undefined)?.ref) || payload.base,
|
||||
head: readString((json.head as JsonRecord | undefined)?.ref) || payload.head,
|
||||
headSha: readString((json.head as JsonRecord | undefined)?.sha) || undefined,
|
||||
mergeable: typeof json.mergeable === 'boolean' ? json.mergeable : null,
|
||||
mergeableState: readString(json.mergeable_state) || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const updatePullRequest = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
payload: GitHubPullRequestUpdateInput,
|
||||
): Promise<GitHubPullRequest> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
throw new Error('Unable to resolve GitHub repo from git remote');
|
||||
}
|
||||
|
||||
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${payload.number}`, accessToken, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
...(typeof payload.body === 'string' ? { body: payload.body } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (resp.status === 403) {
|
||||
throw new Error('Not authorized to edit this PR');
|
||||
}
|
||||
if (resp.status === 401) {
|
||||
const error = new Error('unauthorized');
|
||||
(error as unknown as { status?: number }).status = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const json = await jsonOrNull<JsonRecord>(resp);
|
||||
if (!resp.ok || !json) {
|
||||
const message = readString(json?.message);
|
||||
const firstError = Array.isArray(json?.errors) && json.errors.length > 0
|
||||
? readString((json.errors[0] as JsonRecord)?.message || (json.errors[0] as JsonRecord)?.code)
|
||||
: '';
|
||||
const details = [message, firstError].filter(Boolean).join(' · ');
|
||||
throw new Error(details || 'Failed to update PR');
|
||||
}
|
||||
|
||||
const merged = Boolean(json.merged || json.merged_at);
|
||||
const state = merged ? 'merged' : (readString(json.state) === 'closed' ? 'closed' : 'open');
|
||||
|
||||
return {
|
||||
number: typeof json.number === 'number' ? json.number : payload.number,
|
||||
title: readString(json.title) || payload.title,
|
||||
body: readString(json.body) || '',
|
||||
url: readString(json.html_url) || '',
|
||||
state,
|
||||
draft: Boolean(json.draft),
|
||||
base: readString((json.base as JsonRecord | undefined)?.ref) || '',
|
||||
head: readString((json.head as JsonRecord | undefined)?.ref) || '',
|
||||
headSha: readString((json.head as JsonRecord | undefined)?.sha) || undefined,
|
||||
mergeable: typeof json.mergeable === 'boolean' ? json.mergeable : null,
|
||||
mergeableState: readString(json.mergeable_state) || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const mergePullRequest = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
payload: GitHubPullRequestMergeInput,
|
||||
): Promise<GitHubPullRequestMergeResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
throw new Error('Unable to resolve GitHub repo from git remote');
|
||||
}
|
||||
|
||||
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${payload.number}/merge`, accessToken, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ merge_method: payload.method }),
|
||||
});
|
||||
|
||||
if (resp.status === 403) {
|
||||
throw new Error('Not authorized to merge this PR');
|
||||
}
|
||||
if (resp.status === 405 || resp.status === 409) {
|
||||
return { merged: false, message: 'PR not mergeable' };
|
||||
}
|
||||
const json = await jsonOrNull<JsonRecord>(resp);
|
||||
if (!resp.ok || !json) {
|
||||
throw new Error('Failed to merge PR');
|
||||
}
|
||||
return { merged: Boolean(json.merged), message: readString(json.message) || undefined };
|
||||
};
|
||||
|
||||
export const markPullRequestReady = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
number: number,
|
||||
): Promise<{ ready: boolean }> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
throw new Error('Unable to resolve GitHub repo from git remote');
|
||||
}
|
||||
|
||||
const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken);
|
||||
if (prResp.status === 401) {
|
||||
return { ready: false };
|
||||
}
|
||||
const prJson = await jsonOrNull<JsonRecord>(prResp);
|
||||
const nodeId = typeof prJson?.node_id === 'string' ? prJson.node_id : '';
|
||||
const isDraft = Boolean((prJson as Record<string, unknown> | null)?.draft);
|
||||
if (!prResp.ok || !nodeId) {
|
||||
throw new Error('Failed to resolve PR node id');
|
||||
}
|
||||
|
||||
if (!isDraft) {
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
const resp = await githubFetch(`${API_BASE}/graphql`, accessToken, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query:
|
||||
'mutation($pullRequestId: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { pullRequest { id isDraft } } }',
|
||||
variables: { pullRequestId: nodeId },
|
||||
}),
|
||||
});
|
||||
|
||||
if (resp.status === 403) {
|
||||
throw new Error('Not authorized to mark PR ready');
|
||||
}
|
||||
if (resp.status === 401) {
|
||||
const error = new Error('unauthorized');
|
||||
(error as unknown as { status?: number }).status = 401;
|
||||
throw error;
|
||||
}
|
||||
const json = await jsonOrNull<JsonRecord>(resp);
|
||||
if (!resp.ok || !json) {
|
||||
throw new Error('Failed to mark PR ready');
|
||||
}
|
||||
if (json.errors) {
|
||||
throw new Error('GitHub GraphQL error');
|
||||
}
|
||||
return { ready: true };
|
||||
};
|
||||
@@ -1,672 +0,0 @@
|
||||
import { resolveRepoFromDirectory } from './githubPr';
|
||||
|
||||
const API_BASE = 'https://api.github.com';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type GitHubRepoRef = { owner: string; repo: string; url: string };
|
||||
|
||||
type GitHubUserSummary = { login: string; id?: number; avatarUrl?: string; name?: string; email?: string };
|
||||
|
||||
type GitHubChecksSummary = {
|
||||
state: 'success' | 'failure' | 'pending' | 'unknown';
|
||||
total: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
type GitHubCheckRun = {
|
||||
id?: number;
|
||||
name: string;
|
||||
app?: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
};
|
||||
status?: string;
|
||||
conclusion?: string | null;
|
||||
detailsUrl?: string;
|
||||
output?: {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
text?: string;
|
||||
};
|
||||
job?: {
|
||||
runId?: number;
|
||||
jobId?: number;
|
||||
url?: string;
|
||||
name?: string;
|
||||
conclusion?: string | null;
|
||||
steps?: Array<{
|
||||
name: string;
|
||||
status?: string;
|
||||
conclusion?: string | null;
|
||||
number?: number;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
}>;
|
||||
};
|
||||
annotations?: Array<{
|
||||
path?: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
level?: string;
|
||||
message: string;
|
||||
title?: string;
|
||||
rawDetails?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string; sshUrl?: string };
|
||||
|
||||
type GitHubPullRequestSummary = {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: 'open' | 'closed' | 'merged';
|
||||
draft: boolean;
|
||||
base: string;
|
||||
head: string;
|
||||
headSha?: string;
|
||||
mergeable?: boolean | null;
|
||||
mergeableState?: string | null;
|
||||
author?: GitHubUserSummary | null;
|
||||
body?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
headLabel?: string;
|
||||
headRepo?: GitHubPullRequestHeadRepo | null;
|
||||
};
|
||||
|
||||
type GitHubIssueComment = {
|
||||
id: number;
|
||||
url: string;
|
||||
body: string;
|
||||
author?: GitHubUserSummary | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type GitHubPullRequestReviewComment = {
|
||||
id: number;
|
||||
url: string;
|
||||
body: string;
|
||||
author?: GitHubUserSummary | null;
|
||||
path?: string;
|
||||
line?: number | null;
|
||||
position?: number | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type GitHubPullRequestFile = {
|
||||
filename: string;
|
||||
status?: string;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
changes?: number;
|
||||
patch?: string;
|
||||
};
|
||||
|
||||
export type GitHubPullRequestsListResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
prs?: GitHubPullRequestSummary[];
|
||||
page?: number;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
export type GitHubPullRequestContextResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
pr?: GitHubPullRequestSummary | null;
|
||||
issueComments?: GitHubIssueComment[];
|
||||
reviewComments?: GitHubPullRequestReviewComment[];
|
||||
files?: GitHubPullRequestFile[];
|
||||
diff?: string;
|
||||
checks?: GitHubChecksSummary | null;
|
||||
checkRuns?: GitHubCheckRun[];
|
||||
};
|
||||
|
||||
const githubFetch = async (
|
||||
url: string,
|
||||
accessToken: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': 'OpenChamber',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const githubFetchText = async (
|
||||
url: string,
|
||||
accessToken: string,
|
||||
accept: string,
|
||||
): Promise<Response> => {
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
Accept: accept,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': 'OpenChamber',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
|
||||
return (await response.json().catch(() => null)) as T | null;
|
||||
};
|
||||
|
||||
const readString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||
|
||||
const mapUser = (raw: unknown): GitHubUserSummary | null => {
|
||||
const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null;
|
||||
const login = readString(rec?.login);
|
||||
if (!login) return null;
|
||||
return {
|
||||
login,
|
||||
id: typeof rec?.id === 'number' ? rec.id : undefined,
|
||||
avatarUrl: readString(rec?.avatar_url) || undefined,
|
||||
name: undefined,
|
||||
email: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const mapHeadRepo = (raw: unknown): GitHubPullRequestHeadRepo | null => {
|
||||
const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null;
|
||||
const ownerLogin = readString((rec?.owner as JsonRecord | undefined)?.login);
|
||||
const repo = readString(rec?.name);
|
||||
const url = readString(rec?.html_url);
|
||||
if (!ownerLogin || !repo || !url) return null;
|
||||
return {
|
||||
owner: ownerLogin,
|
||||
repo,
|
||||
url,
|
||||
cloneUrl: readString(rec?.clone_url) || undefined,
|
||||
sshUrl: readString(rec?.ssh_url) || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const computeChecks = async (
|
||||
accessToken: string,
|
||||
repo: GitHubRepoRef,
|
||||
sha: string
|
||||
): Promise<{ summary: GitHubChecksSummary | null; runs: GitHubCheckRun[] }> => {
|
||||
const runsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${sha}/check-runs`, accessToken);
|
||||
if (runsResp.status === 401) {
|
||||
return { summary: null, runs: [] };
|
||||
}
|
||||
const runsJson = await jsonOrNull<JsonRecord>(runsResp);
|
||||
const runs = Array.isArray((runsJson as JsonRecord | null)?.check_runs)
|
||||
? ((runsJson as JsonRecord).check_runs as unknown[])
|
||||
: [];
|
||||
|
||||
const mappedRuns: GitHubCheckRun[] = runs
|
||||
.map((r) => {
|
||||
const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null;
|
||||
const name = readString(rec?.name);
|
||||
if (!name) return null;
|
||||
const output = rec?.output && typeof rec.output === 'object' ? (rec.output as JsonRecord) : null;
|
||||
const app = rec?.app && typeof rec.app === 'object' ? (rec.app as JsonRecord) : null;
|
||||
return {
|
||||
id: typeof rec?.id === 'number' ? rec.id : undefined,
|
||||
name,
|
||||
app: app
|
||||
? {
|
||||
name: readString(app.name) || undefined,
|
||||
slug: readString(app.slug) || undefined,
|
||||
}
|
||||
: undefined,
|
||||
status: readString(rec?.status) || undefined,
|
||||
conclusion: (rec?.conclusion === null || typeof rec?.conclusion === 'string') ? (rec?.conclusion as string | null) : undefined,
|
||||
detailsUrl: readString(rec?.details_url) || undefined,
|
||||
output: output
|
||||
? {
|
||||
title: readString(output.title) || undefined,
|
||||
summary: readString(output.summary) || undefined,
|
||||
text: readString(output.text) || undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubCheckRun[];
|
||||
|
||||
if (runsResp.ok && runs.length > 0) {
|
||||
const counts = { success: 0, failure: 0, pending: 0 };
|
||||
runs.forEach((r) => {
|
||||
const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null;
|
||||
const status = readString(rec?.status);
|
||||
const conclusion = readString(rec?.conclusion);
|
||||
if (status === 'queued' || status === 'in_progress') {
|
||||
counts.pending += 1;
|
||||
return;
|
||||
}
|
||||
if (!conclusion) {
|
||||
counts.pending += 1;
|
||||
return;
|
||||
}
|
||||
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
||||
counts.success += 1;
|
||||
} else {
|
||||
counts.failure += 1;
|
||||
}
|
||||
});
|
||||
const total = counts.success + counts.failure + counts.pending;
|
||||
const state = counts.failure > 0
|
||||
? 'failure'
|
||||
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
return { summary: { state, total, ...counts }, runs: mappedRuns };
|
||||
}
|
||||
|
||||
const statusResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${sha}/status`, accessToken);
|
||||
const statusJson = await jsonOrNull<JsonRecord>(statusResp);
|
||||
if (!statusResp.ok || !statusJson) return { summary: null, runs: mappedRuns };
|
||||
const statuses = Array.isArray(statusJson.statuses) ? (statusJson.statuses as unknown[]) : [];
|
||||
const counts = { success: 0, failure: 0, pending: 0 };
|
||||
statuses.forEach((s) => {
|
||||
const st = readString((s as JsonRecord | null)?.state);
|
||||
if (st === 'success') counts.success += 1;
|
||||
else if (st === 'failure' || st === 'error') counts.failure += 1;
|
||||
else if (st === 'pending') counts.pending += 1;
|
||||
});
|
||||
const total = counts.success + counts.failure + counts.pending;
|
||||
const state = counts.failure > 0
|
||||
? 'failure'
|
||||
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
||||
return { summary: { state, total, ...counts }, runs: mappedRuns };
|
||||
};
|
||||
|
||||
export const listPullRequests = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
page: number = 1,
|
||||
searchQuery?: string,
|
||||
): Promise<GitHubPullRequestsListResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, prs: [] };
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const q = `repo:${repo.owner}/${repo.repo} ${searchQuery} type:pr state:open`;
|
||||
const searchUrl = new URL(`${API_BASE}/search/issues`);
|
||||
searchUrl.searchParams.set('q', q);
|
||||
searchUrl.searchParams.set('per_page', '50');
|
||||
searchUrl.searchParams.set('page', String(page));
|
||||
|
||||
const searchResp = await githubFetch(searchUrl.toString(), accessToken);
|
||||
if (searchResp.status === 401) return { connected: false };
|
||||
|
||||
const searchJson = await jsonOrNull<{ total_count?: number; items?: unknown[] }>(searchResp);
|
||||
if (!searchResp.ok || !searchJson) {
|
||||
throw new Error('Failed to search PRs');
|
||||
}
|
||||
|
||||
const totalCount = typeof searchJson.total_count === 'number' ? searchJson.total_count : 0;
|
||||
const searchItems = Array.isArray(searchJson.items) ? searchJson.items : [];
|
||||
const prNumbers = searchItems.map((item) => {
|
||||
const rec = item && typeof item === 'object' ? (item as JsonRecord) : null;
|
||||
return typeof rec?.number === 'number' ? rec.number : 0;
|
||||
}).filter((n) => n > 0);
|
||||
|
||||
let prs: GitHubPullRequestSummary[] = [];
|
||||
if (prNumbers.length > 0) {
|
||||
const entries = await Promise.all(prNumbers.map(async (number) => {
|
||||
const prUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`);
|
||||
const prResp = await githubFetch(prUrl.toString(), accessToken);
|
||||
if (prResp.status === 401) return 'unauthorized' as const;
|
||||
if (!prResp.ok) return null;
|
||||
return jsonOrNull<JsonRecord>(prResp);
|
||||
}));
|
||||
if (entries.includes('unauthorized')) return { connected: false };
|
||||
prs = entries
|
||||
.filter((entry): entry is JsonRecord => Boolean(entry) && entry !== 'unauthorized')
|
||||
.map((rec) => {
|
||||
const number = typeof rec?.number === 'number' ? rec.number : 0;
|
||||
const mergedAt = readString(rec?.merged_at);
|
||||
const stateRaw = readString(rec?.state);
|
||||
const state = mergedAt ? 'merged' : (stateRaw === 'closed' ? 'closed' : 'open');
|
||||
const base = rec?.base && typeof rec.base === 'object' ? (rec.base as JsonRecord) : null;
|
||||
const head = rec?.head && typeof rec.head === 'object' ? (rec.head as JsonRecord) : null;
|
||||
return {
|
||||
number,
|
||||
title: readString(rec?.title) || '',
|
||||
url: readString(rec?.html_url) || '',
|
||||
state,
|
||||
draft: Boolean(rec?.draft),
|
||||
base: readString(base?.ref) || '',
|
||||
head: readString(head?.ref) || '',
|
||||
headSha: readString(head?.sha) || undefined,
|
||||
mergeable: typeof rec?.mergeable === 'boolean' ? rec.mergeable : null,
|
||||
mergeableState: readString(rec?.mergeable_state) || undefined,
|
||||
author: mapUser(rec?.user),
|
||||
headLabel: readString(head?.label) || undefined,
|
||||
headRepo: mapHeadRepo(head?.repo),
|
||||
} as GitHubPullRequestSummary;
|
||||
});
|
||||
}
|
||||
|
||||
const fetchedCount = (page - 1) * 50 + searchItems.length;
|
||||
const hasMore = fetchedCount < totalCount;
|
||||
return { connected: true, repo, prs, page, hasMore };
|
||||
}
|
||||
|
||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||
url.searchParams.set('state', 'open');
|
||||
url.searchParams.set('per_page', '50');
|
||||
url.searchParams.set('page', String(page));
|
||||
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) return { connected: false };
|
||||
|
||||
const link = resp.headers.get('link') || '';
|
||||
const hasMore = /rel="next"/.test(link);
|
||||
const json = await jsonOrNull<unknown[]>(resp);
|
||||
if (!resp.ok || !Array.isArray(json)) throw new Error('Failed to load PRs');
|
||||
|
||||
const prs = json.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
const number = typeof rec?.number === 'number' ? rec.number : 0;
|
||||
const mergedAt = readString(rec?.merged_at);
|
||||
const stateRaw = readString(rec?.state);
|
||||
const state = mergedAt ? 'merged' : (stateRaw === 'closed' ? 'closed' : 'open');
|
||||
|
||||
const base = rec?.base && typeof rec.base === 'object' ? (rec.base as JsonRecord) : null;
|
||||
const head = rec?.head && typeof rec.head === 'object' ? (rec.head as JsonRecord) : null;
|
||||
|
||||
return {
|
||||
number,
|
||||
title: readString(rec?.title) || '',
|
||||
url: readString(rec?.html_url) || '',
|
||||
state,
|
||||
draft: Boolean(rec?.draft),
|
||||
base: readString(base?.ref) || '',
|
||||
head: readString(head?.ref) || '',
|
||||
headSha: readString(head?.sha) || undefined,
|
||||
mergeable: typeof rec?.mergeable === 'boolean' ? rec.mergeable : null,
|
||||
mergeableState: readString(rec?.mergeable_state) || undefined,
|
||||
author: mapUser(rec?.user),
|
||||
headLabel: readString(head?.label) || undefined,
|
||||
headRepo: mapHeadRepo(head?.repo),
|
||||
} as GitHubPullRequestSummary;
|
||||
});
|
||||
|
||||
return { connected: true, repo, prs, page, hasMore };
|
||||
};
|
||||
|
||||
export const getPullRequestContext = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
number: number,
|
||||
includeDiff: boolean,
|
||||
includeCheckDetails: boolean,
|
||||
): Promise<GitHubPullRequestContextResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, pr: null };
|
||||
}
|
||||
|
||||
const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken);
|
||||
if (prResp.status === 401) return { connected: false };
|
||||
const prJson = await jsonOrNull<JsonRecord>(prResp);
|
||||
if (!prResp.ok || !prJson) throw new Error('Failed to load PR');
|
||||
|
||||
const merged = Boolean(prJson.merged_at) || Boolean(prJson.merged);
|
||||
const prState = readString(prJson.state);
|
||||
const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open');
|
||||
const base = prJson.base && typeof prJson.base === 'object' ? (prJson.base as JsonRecord) : null;
|
||||
const head = prJson.head && typeof prJson.head === 'object' ? (prJson.head as JsonRecord) : null;
|
||||
|
||||
const pr: GitHubPullRequestSummary = {
|
||||
number: typeof prJson.number === 'number' ? prJson.number : number,
|
||||
title: readString(prJson.title) || '',
|
||||
url: readString(prJson.html_url) || '',
|
||||
state,
|
||||
draft: Boolean(prJson.draft),
|
||||
base: readString(base?.ref) || '',
|
||||
head: readString(head?.ref) || '',
|
||||
headSha: readString(head?.sha) || undefined,
|
||||
mergeable: typeof prJson.mergeable === 'boolean' ? prJson.mergeable : null,
|
||||
mergeableState: readString(prJson.mergeable_state) || undefined,
|
||||
author: mapUser(prJson.user),
|
||||
headLabel: readString(head?.label) || undefined,
|
||||
headRepo: mapHeadRepo(head?.repo),
|
||||
body: readString(prJson.body) || '',
|
||||
createdAt: readString(prJson.created_at) || undefined,
|
||||
updatedAt: readString(prJson.updated_at) || undefined,
|
||||
};
|
||||
|
||||
const issueCommentsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}/comments?per_page=100`, accessToken);
|
||||
if (issueCommentsResp.status === 401) return { connected: false };
|
||||
const issueCommentsJson = await jsonOrNull<unknown[]>(issueCommentsResp);
|
||||
if (!issueCommentsResp.ok || !Array.isArray(issueCommentsJson)) throw new Error('Failed to load PR issue comments');
|
||||
const issueComments: GitHubIssueComment[] = issueCommentsJson
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
const id = typeof rec?.id === 'number' ? rec.id : 0;
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
url: readString(rec?.html_url) || '',
|
||||
body: readString(rec?.body) || '',
|
||||
author: mapUser(rec?.user),
|
||||
createdAt: readString(rec?.created_at) || undefined,
|
||||
updatedAt: readString(rec?.updated_at) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubIssueComment[];
|
||||
|
||||
const reviewCommentsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}/comments?per_page=100`, accessToken);
|
||||
if (reviewCommentsResp.status === 401) return { connected: false };
|
||||
const reviewCommentsJson = await jsonOrNull<unknown[]>(reviewCommentsResp);
|
||||
if (!reviewCommentsResp.ok || !Array.isArray(reviewCommentsJson)) throw new Error('Failed to load PR review comments');
|
||||
const reviewComments: GitHubPullRequestReviewComment[] = reviewCommentsJson
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
const id = typeof rec?.id === 'number' ? rec.id : 0;
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
url: readString(rec?.html_url) || '',
|
||||
body: readString(rec?.body) || '',
|
||||
author: mapUser(rec?.user),
|
||||
path: readString(rec?.path) || undefined,
|
||||
line: typeof rec?.line === 'number' ? rec.line : null,
|
||||
position: typeof rec?.position === 'number' ? rec.position : null,
|
||||
createdAt: readString(rec?.created_at) || undefined,
|
||||
updatedAt: readString(rec?.updated_at) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubPullRequestReviewComment[];
|
||||
|
||||
const filesResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}/files?per_page=100`, accessToken);
|
||||
if (filesResp.status === 401) return { connected: false };
|
||||
const filesJson = await jsonOrNull<unknown[]>(filesResp);
|
||||
if (!filesResp.ok || !Array.isArray(filesJson)) throw new Error('Failed to load PR files');
|
||||
const files: GitHubPullRequestFile[] = filesJson
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
const filename = readString(rec?.filename);
|
||||
if (!filename) return null;
|
||||
return {
|
||||
filename,
|
||||
status: readString(rec?.status) || undefined,
|
||||
additions: typeof rec?.additions === 'number' ? rec.additions : undefined,
|
||||
deletions: typeof rec?.deletions === 'number' ? rec.deletions : undefined,
|
||||
changes: typeof rec?.changes === 'number' ? rec.changes : undefined,
|
||||
patch: readString(rec?.patch) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubPullRequestFile[];
|
||||
|
||||
const checksResult = pr.headSha ? await computeChecks(accessToken, repo, pr.headSha) : { summary: null, runs: [] };
|
||||
const checks = checksResult.summary;
|
||||
const checkRuns = checksResult.runs;
|
||||
|
||||
if (includeCheckDetails && checkRuns.length > 0) {
|
||||
const parseIds = (url: string | undefined): { runId: number | null; jobId: number | null } => {
|
||||
if (!url) return { runId: null, jobId: null };
|
||||
const match = url.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/);
|
||||
if (!match) return { runId: null, jobId: null };
|
||||
const runId = Number(match[1]);
|
||||
const jobId = match[2] ? Number(match[2]) : null;
|
||||
return {
|
||||
runId: Number.isFinite(runId) && runId > 0 ? runId : null,
|
||||
jobId: jobId && Number.isFinite(jobId) && jobId > 0 ? jobId : null,
|
||||
};
|
||||
};
|
||||
|
||||
const jobsByRunId = new Map<number, JsonRecord[]>();
|
||||
const runIds = new Set<number>();
|
||||
checkRuns.forEach((r) => {
|
||||
const ids = parseIds(r.detailsUrl);
|
||||
if (ids.runId) runIds.add(ids.runId);
|
||||
});
|
||||
|
||||
for (const runId of runIds) {
|
||||
const jobsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/actions/runs/${runId}/jobs?per_page=100`, accessToken);
|
||||
if (jobsResp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
const jobsJson = await jsonOrNull<JsonRecord>(jobsResp);
|
||||
const jobs = Array.isArray(jobsJson?.jobs) ? (jobsJson?.jobs as unknown[]) : [];
|
||||
jobsByRunId.set(runId, jobs.filter((j) => j && typeof j === 'object') as JsonRecord[]);
|
||||
}
|
||||
|
||||
const annotationsByRunId = new Map<number, Array<{
|
||||
path?: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
level?: string;
|
||||
message: string;
|
||||
title?: string;
|
||||
rawDetails?: string;
|
||||
}>>();
|
||||
|
||||
for (const run of checkRuns) {
|
||||
const runId = typeof run.id === 'number' ? run.id : 0;
|
||||
const conclusion = (run.conclusion || '').toLowerCase();
|
||||
const shouldLoadAnnotations = Boolean(
|
||||
runId > 0
|
||||
&& conclusion
|
||||
&& !['success', 'neutral', 'skipped'].includes(conclusion),
|
||||
);
|
||||
if (!shouldLoadAnnotations) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const annotations: Array<{
|
||||
path?: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
level?: string;
|
||||
message: string;
|
||||
title?: string;
|
||||
rawDetails?: string;
|
||||
}> = [];
|
||||
|
||||
for (let page = 1; page <= 3; page += 1) {
|
||||
const annotationsResp = await githubFetch(
|
||||
`${API_BASE}/repos/${repo.owner}/${repo.repo}/check-runs/${runId}/annotations?per_page=50&page=${page}`,
|
||||
accessToken,
|
||||
);
|
||||
if (annotationsResp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
const annotationsJson = await jsonOrNull<unknown[]>(annotationsResp);
|
||||
const chunk = Array.isArray(annotationsJson) ? annotationsJson : [];
|
||||
chunk.forEach((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
const message = readString(rec?.message);
|
||||
if (!message) return;
|
||||
annotations.push({
|
||||
path: readString(rec?.path) || undefined,
|
||||
startLine: typeof rec?.start_line === 'number' ? rec.start_line : undefined,
|
||||
endLine: typeof rec?.end_line === 'number' ? rec.end_line : undefined,
|
||||
level: readString(rec?.annotation_level) || undefined,
|
||||
message,
|
||||
title: readString(rec?.title) || undefined,
|
||||
rawDetails: readString(rec?.raw_details) || undefined,
|
||||
});
|
||||
});
|
||||
if (chunk.length < 50) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (annotations.length > 0) {
|
||||
annotationsByRunId.set(runId, annotations);
|
||||
}
|
||||
}
|
||||
|
||||
for (const run of checkRuns) {
|
||||
if (run.id && annotationsByRunId.has(run.id)) {
|
||||
run.annotations = annotationsByRunId.get(run.id);
|
||||
}
|
||||
|
||||
const ids = parseIds(run.detailsUrl);
|
||||
if (!ids.runId) continue;
|
||||
const jobs = jobsByRunId.get(ids.runId) ?? [];
|
||||
const picked = ids.jobId
|
||||
? jobs.find((j) => typeof j.id === 'number' && j.id === ids.jobId)
|
||||
: jobs.find((j) => readString(j.name) === run.name);
|
||||
if (!picked) {
|
||||
run.job = { runId: ids.runId, ...(ids.jobId ? { jobId: ids.jobId } : {}), url: run.detailsUrl };
|
||||
continue;
|
||||
}
|
||||
const stepsRaw = Array.isArray(picked.steps) ? (picked.steps as unknown[]) : [];
|
||||
const steps = stepsRaw
|
||||
.map((s) => {
|
||||
const rec = s && typeof s === 'object' ? (s as JsonRecord) : null;
|
||||
const name = readString(rec?.name);
|
||||
if (!name) return null;
|
||||
return {
|
||||
name,
|
||||
status: readString(rec?.status) || undefined,
|
||||
conclusion: (rec?.conclusion === null || typeof rec?.conclusion === 'string')
|
||||
? (rec?.conclusion as string | null)
|
||||
: undefined,
|
||||
number: typeof rec?.number === 'number' ? rec.number : undefined,
|
||||
startedAt: readString(rec?.started_at) || undefined,
|
||||
completedAt: readString(rec?.completed_at) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
name: string;
|
||||
status?: string;
|
||||
conclusion?: string | null;
|
||||
number?: number;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
}>;
|
||||
|
||||
run.job = {
|
||||
runId: ids.runId,
|
||||
jobId: typeof picked.id === 'number' ? picked.id : undefined,
|
||||
url: readString(picked.html_url) || undefined,
|
||||
name: readString(picked.name) || undefined,
|
||||
conclusion: (picked.conclusion === null || typeof picked.conclusion === 'string')
|
||||
? (picked.conclusion as string | null)
|
||||
: undefined,
|
||||
steps: steps.length > 0 ? steps : undefined,
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
let diff: string | undefined;
|
||||
if (includeDiff) {
|
||||
const diffResp = await githubFetchText(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken, 'application/vnd.github.v3.diff');
|
||||
if (diffResp.status === 401) return { connected: false };
|
||||
if (diffResp.ok) {
|
||||
diff = await diffResp.text().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return { connected: true, repo, pr, issueComments, reviewComments, files, diff, checks, checkRuns };
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ConnectionStatus, OpenCodeManager } from './opencode';
|
||||
|
||||
export const API_URL_WAIT_TIMEOUT_MS = 30000;
|
||||
const API_URL_WAIT_TIMEOUT_MS = 30000;
|
||||
|
||||
export async function waitForApiUrl(
|
||||
manager: OpenCodeManager | undefined,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode, type OpenCodeUpgradeManager } from './opencode-upgrade-runtime';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const createManager = (mode: 'managed' | 'external' = 'managed') => {
|
||||
let restartCount = 0;
|
||||
const manager: OpenCodeUpgradeManager = {
|
||||
getApiUrl: () => 'http://127.0.0.1:4096',
|
||||
getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
|
||||
getDebugInfo: () => ({ mode }),
|
||||
restart: async () => { restartCount += 1; },
|
||||
};
|
||||
return { manager, getRestartCount: () => restartCount };
|
||||
};
|
||||
|
||||
describe('VS Code OpenCode upgrades', () => {
|
||||
test('reports an available update for a managed OpenCode process', async () => {
|
||||
const { manager } = createManager();
|
||||
globalThis.fetch = (async (input: Parameters<typeof fetch>[0]) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/global/health')) return new Response(JSON.stringify({ version: '1.18.8' }));
|
||||
if (url.includes('registry.npmjs.org')) return new Response(JSON.stringify({ version: '1.18.9' }));
|
||||
return new Response(JSON.stringify({ tag_name: 'v1.18.9' }));
|
||||
}) as typeof fetch;
|
||||
|
||||
assert.deepEqual(await getOpenCodeUpgradeStatus(manager), {
|
||||
available: true,
|
||||
currentVersion: '1.18.8',
|
||||
latestVersion: '1.18.9',
|
||||
upgrade: { supported: true, manager: 'opencode', reason: null },
|
||||
});
|
||||
});
|
||||
|
||||
test('fails closed for externally managed OpenCode without contacting the updater', async () => {
|
||||
const { manager } = createManager('external');
|
||||
let fetchCount = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCount += 1;
|
||||
return new Response('{}');
|
||||
}) as typeof fetch;
|
||||
|
||||
assert.deepEqual(await upgradeManagedOpenCode(manager), {
|
||||
status: 409,
|
||||
body: {
|
||||
success: false,
|
||||
code: 'OPENCODE_UPGRADE_UNSUPPORTED',
|
||||
error: 'This OpenCode runtime cannot be upgraded by OpenChamber.',
|
||||
},
|
||||
});
|
||||
assert.equal(fetchCount, 0);
|
||||
});
|
||||
|
||||
test('upgrades then restarts the extension-owned OpenCode process', async () => {
|
||||
const { manager, getRestartCount } = createManager();
|
||||
let request: RequestInit | undefined;
|
||||
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
assert.equal(String(input), 'http://127.0.0.1:4096/global/upgrade');
|
||||
request = init;
|
||||
return new Response(JSON.stringify({ success: true, version: '1.18.9' }));
|
||||
}) as typeof fetch;
|
||||
|
||||
assert.deepEqual(await upgradeManagedOpenCode(manager, '1.18.9'), {
|
||||
status: 200,
|
||||
body: { success: true, version: '1.18.9', restarted: true },
|
||||
});
|
||||
assert.equal(getRestartCount(), 1);
|
||||
assert.equal(request?.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(String(request?.body)), { target: '1.18.9' });
|
||||
assert.equal((request?.headers as Record<string, string>).Authorization, 'Basic test');
|
||||
});
|
||||
|
||||
test('serializes concurrent managed upgrades', async () => {
|
||||
const { manager } = createManager();
|
||||
let release: (response: Response) => void = () => {};
|
||||
globalThis.fetch = (() => new Promise<Response>((resolve) => { release = resolve; })) as typeof fetch;
|
||||
|
||||
const first = upgradeManagedOpenCode(manager);
|
||||
const second = await upgradeManagedOpenCode(manager);
|
||||
assert.equal(second.status, 409);
|
||||
assert.equal(second.body.code, 'OPENCODE_UPGRADE_IN_PROGRESS');
|
||||
|
||||
release(new Response(JSON.stringify({ success: true })));
|
||||
assert.equal((await first).status, 200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
type UpgradeCapability = {
|
||||
supported: boolean;
|
||||
manager: 'opencode' | 'external' | 'openchamber' | null;
|
||||
reason: 'external' | 'unavailable' | 'windows-arm64-workaround' | null;
|
||||
};
|
||||
|
||||
export type OpenCodeUpgradeManager = {
|
||||
getApiUrl(): string | null;
|
||||
getOpenCodeAuthHeaders(): Record<string, string>;
|
||||
getDebugInfo(): { mode: 'managed' | 'external' };
|
||||
restart(): Promise<void>;
|
||||
};
|
||||
|
||||
type UpgradeResult = { status: number; body: Record<string, unknown> };
|
||||
|
||||
let openCodeUpgradePromise: Promise<UpgradeResult> | null = null;
|
||||
|
||||
// TEMPORARY WORKAROUND — Windows ARM64: native opencode.exe fails with a Bun
|
||||
// FFI/TinyCC dlopen error (https://github.com/anomalyco/opencode/issues/19130).
|
||||
// Disable OpenCode self-upgrade on ARM64 so it can't overwrite the working x64
|
||||
// binary with the broken ARM64 build. Remove when the upstream issue is resolved.
|
||||
const isWindowsArm64 = (): boolean => process.platform === 'win32' && process.arch === 'arm64';
|
||||
|
||||
const parseVersion = (value: unknown): { parts: number[]; prerelease: boolean } => {
|
||||
const normalized = String(value || '').replace(/^v/, '').split('+')[0];
|
||||
const prereleaseIndex = normalized.indexOf('-');
|
||||
const core = prereleaseIndex >= 0 ? normalized.slice(0, prereleaseIndex) : normalized;
|
||||
return {
|
||||
parts: core.split('.').map((part) => {
|
||||
const parsed = Number.parseInt(part || '0', 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}),
|
||||
prerelease: prereleaseIndex >= 0,
|
||||
};
|
||||
};
|
||||
|
||||
const compareVersions = (left: unknown, right: unknown): number => {
|
||||
const a = parseVersion(left);
|
||||
const b = parseVersion(right);
|
||||
for (let index = 0; index < Math.max(a.parts.length, b.parts.length); index += 1) {
|
||||
const difference = (a.parts[index] || 0) - (b.parts[index] || 0);
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return a.prerelease === b.prerelease ? 0 : (a.prerelease ? -1 : 1);
|
||||
};
|
||||
|
||||
const getCapability = (manager?: OpenCodeUpgradeManager): UpgradeCapability => {
|
||||
if (isWindowsArm64()) return { supported: false, manager: 'openchamber', reason: 'windows-arm64-workaround' };
|
||||
if (!manager) return { supported: false, manager: null, reason: 'unavailable' };
|
||||
if (manager.getDebugInfo().mode !== 'managed') return { supported: false, manager: 'external', reason: 'external' };
|
||||
if (!manager.getApiUrl()) return { supported: false, manager: null, reason: 'unavailable' };
|
||||
return { supported: true, manager: 'opencode', reason: null };
|
||||
};
|
||||
|
||||
const getApiUrl = (manager?: OpenCodeUpgradeManager): string | null => {
|
||||
const apiUrl = manager?.getApiUrl();
|
||||
return apiUrl ? `${apiUrl.replace(/\/+$/, '')}/` : null;
|
||||
};
|
||||
|
||||
const fetchLatestVersion = async (): Promise<string> => {
|
||||
const results = await Promise.allSettled([
|
||||
fetch('https://registry.npmjs.org/opencode-ai/latest', { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`OpenCode npm registry responded with ${response.status}`);
|
||||
const payload = await response.json() as { version?: unknown };
|
||||
return typeof payload.version === 'string' ? payload.version.trim().replace(/^v/, '') : '';
|
||||
}),
|
||||
fetch('https://api.github.com/repos/anomalyco/opencode/releases/latest', { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`OpenCode releases responded with ${response.status}`);
|
||||
const payload = await response.json() as { tag_name?: unknown };
|
||||
return typeof payload.tag_name === 'string' ? payload.tag_name.trim().replace(/^v/, '') : '';
|
||||
}),
|
||||
]);
|
||||
const versions = results.flatMap((result) => result.status === 'fulfilled' && result.value ? [result.value] : []);
|
||||
if (versions.length === 0) throw new Error('Failed to resolve latest OpenCode version');
|
||||
return versions.sort((left, right) => compareVersions(right, left))[0];
|
||||
};
|
||||
|
||||
export const getOpenCodeUpgradeStatus = async (manager?: OpenCodeUpgradeManager): Promise<Record<string, unknown>> => {
|
||||
const upgrade = getCapability(manager);
|
||||
const apiUrl = getApiUrl(manager);
|
||||
if (!upgrade.supported || !apiUrl || !manager) return { available: false, currentVersion: null, latestVersion: null, upgrade };
|
||||
try {
|
||||
const [healthResponse, latestVersion] = await Promise.all([
|
||||
fetch(new URL('global/health', apiUrl).toString(), { method: 'GET', headers: { Accept: 'application/json', ...manager.getOpenCodeAuthHeaders() } }),
|
||||
fetchLatestVersion(),
|
||||
]);
|
||||
const health = await healthResponse.json().catch(() => null) as { version?: unknown; error?: unknown } | null;
|
||||
if (!healthResponse.ok) {
|
||||
const error = typeof health?.error === 'string' ? health.error : healthResponse.statusText || 'Failed to read OpenCode version';
|
||||
return { available: null, error, upgrade };
|
||||
}
|
||||
const currentVersion = typeof health?.version === 'string' && health.version.trim() ? health.version.trim().replace(/^v/, '') : null;
|
||||
return { available: currentVersion ? compareVersions(latestVersion, currentVersion) > 0 : null, currentVersion, latestVersion, upgrade };
|
||||
} catch (error) {
|
||||
return { available: null, error: error instanceof Error ? error.message : String(error), upgrade };
|
||||
}
|
||||
};
|
||||
|
||||
export const upgradeManagedOpenCode = async (manager: OpenCodeUpgradeManager | undefined, target?: unknown): Promise<UpgradeResult> => {
|
||||
const upgrade = getCapability(manager);
|
||||
const apiUrl = getApiUrl(manager);
|
||||
if (!upgrade.supported || !apiUrl || !manager) {
|
||||
return { status: 409, body: { success: false, code: 'OPENCODE_UPGRADE_UNSUPPORTED', error: 'This OpenCode runtime cannot be upgraded by OpenChamber.' } };
|
||||
}
|
||||
if (openCodeUpgradePromise) {
|
||||
return { status: 409, body: { success: false, code: 'OPENCODE_UPGRADE_IN_PROGRESS', error: 'An OpenCode upgrade is already in progress.' } };
|
||||
}
|
||||
const targetVersion = typeof target === 'string' ? target.trim() : '';
|
||||
const operation = (async (): Promise<UpgradeResult> => {
|
||||
try {
|
||||
const response = await fetch(new URL('global/upgrade', apiUrl).toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...manager.getOpenCodeAuthHeaders() },
|
||||
body: JSON.stringify(targetVersion ? { target: targetVersion } : {}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as { error?: unknown } | null;
|
||||
if (!response.ok) return { status: response.status, body: { success: false, error: typeof payload?.error === 'string' ? payload.error : response.statusText || 'Failed to upgrade OpenCode' } };
|
||||
try {
|
||||
await manager.restart();
|
||||
} catch (error) {
|
||||
return { status: 500, body: { success: false, upgraded: true, error: error instanceof Error ? `OpenCode upgraded, but restart failed: ${error.message}` : 'OpenCode upgraded, but restart failed' } };
|
||||
}
|
||||
return { status: 200, body: { ...(payload && typeof payload === 'object' ? payload : { success: true }), restarted: true } };
|
||||
} catch (error) {
|
||||
return { status: 500, body: { success: false, error: error instanceof Error ? error.message : 'Failed to upgrade OpenCode' } };
|
||||
}
|
||||
})();
|
||||
openCodeUpgradePromise = operation;
|
||||
try {
|
||||
return await operation;
|
||||
} finally {
|
||||
if (openCodeUpgradePromise === operation) openCodeUpgradePromise = null;
|
||||
}
|
||||
};
|
||||
@@ -9,6 +9,8 @@ import { spawn } from 'child_process';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { normalizeWindowsDriveLetter } from './pathUtils';
|
||||
import { resolveWorkingDirectoryChange } from './workingDirectoryChange';
|
||||
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './opencodeProcessRegistry';
|
||||
import { applyProviderEnvAliases } from './provider-env-aliases';
|
||||
|
||||
const t = vscode.l10n.t;
|
||||
|
||||
@@ -20,7 +22,7 @@ const WINDOWS_EXECUTABLE_EXTENSIONS = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.C
|
||||
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`));
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export type OpenCodeDebugInfo = {
|
||||
type OpenCodeDebugInfo = {
|
||||
mode: 'managed' | 'external';
|
||||
status: ConnectionStatus;
|
||||
lastError?: string;
|
||||
@@ -46,7 +48,7 @@ export type OpenCodeDebugInfo = {
|
||||
authSource: 'user-env' | 'generated' | 'rotated' | null;
|
||||
};
|
||||
|
||||
export type SetWorkingDirectoryResult =
|
||||
type SetWorkingDirectoryResult =
|
||||
| { success: true; path: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
@@ -73,7 +75,8 @@ function generateSecureOpenCodePassword(): string {
|
||||
}
|
||||
|
||||
function buildOpenCodeAuthHeader(password: string): string {
|
||||
return `Basic ${Buffer.from(`opencode:${password}`, 'utf8').toString('base64')}`;
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME?.trim() || 'opencode';
|
||||
return `Basic ${Buffer.from(`${username}:${password}`, 'utf8').toString('base64')}`;
|
||||
}
|
||||
|
||||
function isValidOpenCodePassword(password: string): boolean {
|
||||
@@ -121,14 +124,40 @@ function isExecutable(filePath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseWindowsShell(binary: string): boolean {
|
||||
if (process.platform !== 'win32') return false;
|
||||
// Windows launch spec: .cmd/.bat shims (and bare names, which resolve to .cmd
|
||||
// shims via PATHEXT) must run under cmd.exe. Spawn cmd.exe DIRECTLY with the
|
||||
// shim path as its own argv element (shell:false) — `shell: true` builds an
|
||||
// unquoted command line, so a space-containing path like
|
||||
// "C:\Program Files\nodejs\opencode.cmd" broke with
|
||||
// "'C:\Program' is not recognized as an internal or external command".
|
||||
function resolveWindowsLaunchSpec(binary: string, args: string[]): { binary: string; args: string[] } {
|
||||
if (process.platform !== 'win32') {
|
||||
return { binary, args };
|
||||
}
|
||||
const trimmed = (binary || '').trim();
|
||||
if (!trimmed) return true;
|
||||
const ext = path.extname(trimmed).toLowerCase();
|
||||
if (ext === '.cmd' || ext === '.bat') return true;
|
||||
// Bare command names often resolve to .cmd shims via PATHEXT.
|
||||
return !ext && !trimmed.includes('\\') && !trimmed.includes('/');
|
||||
const isBatchShim = ext === '.cmd' || ext === '.bat';
|
||||
const isBareName = !ext && !trimmed.includes('\\') && !trimmed.includes('/');
|
||||
if (!isBatchShim && !isBareName) {
|
||||
return { binary: trimmed, args };
|
||||
}
|
||||
return {
|
||||
binary: process.env.ComSpec || 'cmd.exe',
|
||||
args: ['/d', '/s', '/c', 'call', trimmed, ...args],
|
||||
};
|
||||
}
|
||||
|
||||
// Strip a single wrapping quote pair (Windows "Copy as path" and quoted shell
|
||||
// snippets) — literal quotes are never part of a real path and break every
|
||||
// executable check.
|
||||
function stripWrappingQuotes(value: string): string {
|
||||
const trimmed = (value || '').trim();
|
||||
if (trimmed.length >= 2
|
||||
&& ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|
||||
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function appendToPath(dir: string) {
|
||||
@@ -175,7 +204,7 @@ function normalizeConfiguredOpencodeBinary(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
const trimmed = stripWrappingQuotes(raw);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
@@ -191,13 +220,33 @@ function normalizeConfiguredOpencodeBinary(raw: unknown): string | null {
|
||||
}
|
||||
|
||||
function isMacOpenCodeAppBundlePath(candidate: string): boolean {
|
||||
return process.platform === 'darwin' && /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate);
|
||||
return process.platform === 'darwin' && /\/OpenCode(?: Dev| Beta)?\.app\/Contents\/MacOS\/(?:OpenCode(?: Dev| Beta)?|opencode-cli)$/i.test(candidate);
|
||||
}
|
||||
|
||||
function isWindowsOpenCodeDesktopAppPath(candidate: string): boolean {
|
||||
if (process.platform !== 'win32' || typeof candidate !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const localAppData = typeof process.env.LOCALAPPDATA === 'string' && process.env.LOCALAPPDATA.trim()
|
||||
? path.resolve(process.env.LOCALAPPDATA).toLowerCase()
|
||||
: '';
|
||||
if (!localAppData) {
|
||||
return false;
|
||||
}
|
||||
const normalized = path.resolve(candidate).toLowerCase();
|
||||
return normalized.startsWith(`${localAppData}${path.sep}`)
|
||||
&& normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`);
|
||||
}
|
||||
|
||||
function isKnownOpenCodeDesktopAppPath(candidate: string): boolean {
|
||||
return isMacOpenCodeAppBundlePath(candidate) || isWindowsOpenCodeDesktopAppPath(candidate);
|
||||
}
|
||||
|
||||
function createConfiguredOpencodeBinaryError(raw: string, normalized: string): Error {
|
||||
const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set openchamber.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.';
|
||||
if (isMacOpenCodeAppBundlePath(raw) || isMacOpenCodeAppBundlePath(normalized)) {
|
||||
return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${normalized}. ${messageSuffix}`);
|
||||
if (isKnownOpenCodeDesktopAppPath(raw) || isKnownOpenCodeDesktopAppPath(normalized)) {
|
||||
const platformName = process.platform === 'win32' ? 'Windows desktop app install' : 'macOS desktop app bundle';
|
||||
return new Error(`Configured OpenCode binary points at the ${platformName}, not the CLI: ${normalized}. ${messageSuffix}`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -252,7 +301,7 @@ function validateConfiguredOpencodeBinaryForManagedStart(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) {
|
||||
if (isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -269,7 +318,7 @@ function resolveOpencodeCliPath(): string | null {
|
||||
}
|
||||
})();
|
||||
|
||||
if (configured && isExecutable(configured) && !isMacOpenCodeAppBundlePath(configured)) {
|
||||
if (configured && isExecutable(configured) && !isKnownOpenCodeDesktopAppPath(configured)) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
@@ -286,7 +335,7 @@ function resolveOpencodeCliPath(): string | null {
|
||||
}
|
||||
})();
|
||||
|
||||
if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isMacOpenCodeAppBundlePath(sharedFromOpenChamber)) {
|
||||
if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isKnownOpenCodeDesktopAppPath(sharedFromOpenChamber)) {
|
||||
return sharedFromOpenChamber;
|
||||
}
|
||||
|
||||
@@ -296,17 +345,17 @@ function resolveOpencodeCliPath(): string | null {
|
||||
process.env.OPENCHAMBER_OPENCODE_PATH,
|
||||
process.env.OPENCHAMBER_OPENCODE_BIN,
|
||||
]
|
||||
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
||||
.map((v) => (typeof v === 'string' ? stripWrappingQuotes(v) : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of explicit) {
|
||||
if (isExecutable(candidate)) {
|
||||
if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedDetectedOpencodeCliPath) {
|
||||
if (isExecutable(cachedDetectedOpencodeCliPath)) {
|
||||
if (isExecutable(cachedDetectedOpencodeCliPath) && !isKnownOpenCodeDesktopAppPath(cachedDetectedOpencodeCliPath)) {
|
||||
return cachedDetectedOpencodeCliPath;
|
||||
}
|
||||
cachedDetectedOpencodeCliPath = undefined;
|
||||
@@ -325,7 +374,6 @@ function resolveOpencodeCliPath(): string | null {
|
||||
const winFallbacks = (() => {
|
||||
const userProfile = process.env.USERPROFILE || home;
|
||||
const appData = process.env.APPDATA || path.join(userProfile, 'AppData', 'Roaming');
|
||||
const localAppData = process.env.LOCALAPPDATA || '';
|
||||
const programData = process.env.ProgramData || 'C:\\ProgramData';
|
||||
const npmDir = path.join(appData, 'npm');
|
||||
|
||||
@@ -336,20 +384,22 @@ function resolveOpencodeCliPath(): string | null {
|
||||
path.join(npmDir, 'opencode.exe'),
|
||||
path.join(npmDir, 'opencode.cmd'),
|
||||
path.join(npmDir, 'opencode.bat'),
|
||||
// System-wide Node installer keeps the global npm prefix here
|
||||
// (npm i -g opencode-ai → opencode.cmd shim).
|
||||
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs', 'opencode.cmd'),
|
||||
path.join(userProfile, 'scoop', 'shims', 'opencode.exe'),
|
||||
path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'),
|
||||
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
|
||||
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
|
||||
// Bun global install
|
||||
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
|
||||
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
|
||||
// Some installers use LocalAppData
|
||||
localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '',
|
||||
].filter(Boolean);
|
||||
})();
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const fromPath = findExecutableInPath('opencode');
|
||||
if (fromPath) {
|
||||
if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) {
|
||||
cachedDetectedOpencodeCliPath = fromPath;
|
||||
return fromPath;
|
||||
}
|
||||
@@ -357,7 +407,7 @@ function resolveOpencodeCliPath(): string | null {
|
||||
|
||||
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
|
||||
for (const candidate of fallbacks) {
|
||||
if (isExecutable(candidate)) {
|
||||
if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) {
|
||||
cachedDetectedOpencodeCliPath = candidate;
|
||||
return candidate;
|
||||
}
|
||||
@@ -365,7 +415,7 @@ function resolveOpencodeCliPath(): string | null {
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const fromPath = findExecutableInPath('opencode');
|
||||
if (fromPath) {
|
||||
if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) {
|
||||
cachedDetectedOpencodeCliPath = fromPath;
|
||||
return fromPath;
|
||||
}
|
||||
@@ -374,13 +424,14 @@ function resolveOpencodeCliPath(): string | null {
|
||||
const result = spawnSync('where', ['opencode'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const lines = (result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const found = lines.find((line) => isExecutable(line));
|
||||
const found = lines.find((line) => isExecutable(line) && !isKnownOpenCodeDesktopAppPath(line));
|
||||
if (found) {
|
||||
cachedDetectedOpencodeCliPath = found;
|
||||
return found;
|
||||
@@ -613,14 +664,13 @@ async function spawnManagedOpenCodeServer(
|
||||
port: number,
|
||||
timeoutMs: number
|
||||
): Promise<{ url: string; close: () => void }> {
|
||||
const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
||||
const args = ['serve', '--hostname', '127.0.0.1', '--port', String(port)];
|
||||
const child = spawn(binary, args, {
|
||||
const binary = stripWrappingQuotes(process.env.OPENCODE_BINARY || 'opencode') || 'opencode';
|
||||
const launch = resolveWindowsLaunchSpec(binary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)]);
|
||||
const child = spawn(launch.binary, launch.args, {
|
||||
cwd: workingDirectory,
|
||||
env: { ...process.env },
|
||||
env: applyProviderEnvAliases({ ...process.env }),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
shell: shouldUseWindowsShell(binary),
|
||||
});
|
||||
|
||||
const url = await new Promise<string>((resolve, reject) => {
|
||||
@@ -687,6 +737,9 @@ async function spawnManagedOpenCodeServer(
|
||||
child.on('error', onError);
|
||||
});
|
||||
|
||||
// Record this child so a future run can reap it if we crash before teardown.
|
||||
registerManagedProcess({ pid: child.pid, ownerPid: process.pid, port, binary, runtime: 'vscode' });
|
||||
|
||||
return {
|
||||
url,
|
||||
close: () => {
|
||||
@@ -695,6 +748,7 @@ async function spawnManagedOpenCodeServer(
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
unregisterManagedProcess(child.pid);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -725,6 +779,7 @@ async function allocateManagedOpenCodePort(): Promise<number> {
|
||||
|
||||
export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCodeManager {
|
||||
let server: { url: string; close: () => void } | null = null;
|
||||
let reapedOrphansOnce = false;
|
||||
let managedApiUrlOverride: string | null = null;
|
||||
let managedPassword: string | null = null;
|
||||
let managedPasswordSource: 'user-env' | 'generated' | 'rotated' | null = null;
|
||||
@@ -865,6 +920,19 @@ export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCod
|
||||
return;
|
||||
}
|
||||
|
||||
// Before spawning our own server, reap any OpenCode process WE spawned in a
|
||||
// prior run that was orphaned by a crash/host-kill. Verified + scoped to our
|
||||
// own pids, so it never touches a live instance's or the user's own server.
|
||||
if (!reapedOrphansOnce) {
|
||||
reapedOrphansOnce = true;
|
||||
try {
|
||||
const { reaped } = await reapOrphanedProcesses({ log: (msg) => console.log(msg) });
|
||||
if (reaped > 0) console.log(`[opencode] startup reaped ${reaped} orphaned process(es)`);
|
||||
} catch (error) {
|
||||
console.warn('[opencode] orphan reap failed:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
}
|
||||
|
||||
setStatus('connecting');
|
||||
cliMissing = false;
|
||||
cliPath = null;
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from 'node:os';
|
||||
const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
|
||||
const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json');
|
||||
|
||||
type AuthEntry = Record<string, unknown>;
|
||||
export type AuthEntry = Record<string, unknown>;
|
||||
type AuthFile = Record<string, AuthEntry>;
|
||||
|
||||
const readAuthFile = (): AuthFile => {
|
||||
@@ -28,21 +28,34 @@ const readAuthFile = (): AuthFile => {
|
||||
const writeAuthFile = (auth: AuthFile): void => {
|
||||
try {
|
||||
if (!fs.existsSync(OPENCODE_DATA_DIR)) {
|
||||
fs.mkdirSync(OPENCODE_DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(OPENCODE_DATA_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
if (process.platform !== 'win32') fs.chmodSync(OPENCODE_DATA_DIR, 0o700);
|
||||
|
||||
if (fs.existsSync(AUTH_FILE)) {
|
||||
const backupFile = `${AUTH_FILE}.openchamber.backup`;
|
||||
fs.copyFileSync(AUTH_FILE, backupFile);
|
||||
if (process.platform !== 'win32') fs.chmodSync(backupFile, 0o600);
|
||||
}
|
||||
|
||||
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8');
|
||||
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
if (process.platform !== 'win32') fs.chmodSync(AUTH_FILE, 0o600);
|
||||
} catch (error) {
|
||||
console.error('Failed to write auth file:', error);
|
||||
throw new Error('Failed to write OpenCode auth configuration');
|
||||
}
|
||||
};
|
||||
|
||||
export const updateProviderAuth = (providerId: string, entry: AuthEntry): void => {
|
||||
if (!providerId || typeof providerId !== 'string') {
|
||||
throw new Error('Provider ID is required');
|
||||
}
|
||||
|
||||
const auth = readAuthFile();
|
||||
auth[providerId] = entry;
|
||||
writeAuthFile(auth);
|
||||
};
|
||||
|
||||
export const removeProviderAuth = (providerId: string): boolean => {
|
||||
if (!providerId || typeof providerId !== 'string') {
|
||||
throw new Error('Provider ID is required');
|
||||
@@ -63,10 +76,3 @@ export const getProviderAuth = (providerId: string): AuthEntry | null => {
|
||||
const auth = readAuthFile();
|
||||
return auth[providerId] || null;
|
||||
};
|
||||
|
||||
export const listProviderAuths = (): string[] => {
|
||||
const auth = readAuthFile();
|
||||
return Object.keys(auth);
|
||||
};
|
||||
|
||||
export { AUTH_FILE, OPENCODE_DATA_DIR };
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, beforeEach, describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { listPluginEntries, updateMcpConfig } from './opencodeConfig';
|
||||
|
||||
const PARTIAL_PARSE_CONFIG = [
|
||||
'{',
|
||||
' "$schema": "https://opencode.ai/config.json",',
|
||||
' plugin: ["opencode-see-image"],',
|
||||
' mcp: {',
|
||||
' openproject: {',
|
||||
' type: "remote",',
|
||||
' url: "https://openproject.example.com/mcp",',
|
||||
' enabled: true',
|
||||
' }',
|
||||
' },',
|
||||
' provider: {',
|
||||
' "ollama-cloud": {',
|
||||
' npm: "@ai-sdk/openai-compatible",',
|
||||
' name: "Ollama Cloud"',
|
||||
' }',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const VALID_CONFIG = [
|
||||
'{',
|
||||
' "$schema": "https://opencode.ai/config.json",',
|
||||
' "plugin": ["opencode-see-image"],',
|
||||
' "mcp": {',
|
||||
' "openproject": {',
|
||||
' "type": "remote",',
|
||||
' "url": "https://openproject.example.com/mcp",',
|
||||
' "enabled": true',
|
||||
' }',
|
||||
' },',
|
||||
' "provider": {',
|
||||
' "ollama-cloud": {',
|
||||
' "npm": "@ai-sdk/openai-compatible",',
|
||||
' "name": "Ollama Cloud"',
|
||||
' }',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const isInvalidJsoncError = (error: unknown): boolean => {
|
||||
if (!(error instanceof Error) || !/cannot be loaded safely/.test(error.message)) {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: the config layer throws Error instances carrying the coded `code` field.
|
||||
return (error as Error & { code?: string }).code === 'INVALID_JSONC';
|
||||
};
|
||||
|
||||
describe('opencodeConfig JSONC parse safety (issue #2923)', () => {
|
||||
let tempDir: string;
|
||||
let previousOpenCodeConfig: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-config-parse-'));
|
||||
previousOpenCodeConfig = process.env.OPENCODE_CONFIG;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG;
|
||||
else process.env.OPENCODE_CONFIG = previousOpenCodeConfig;
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('refuses MCP updates that would overwrite a partial-parse config', () => {
|
||||
const configPath = path.join(tempDir, 'opencode.jsonc');
|
||||
fs.writeFileSync(configPath, PARTIAL_PARSE_CONFIG, 'utf8');
|
||||
process.env.OPENCODE_CONFIG = configPath;
|
||||
|
||||
assert.throws(() => updateMcpConfig('openproject', { enabled: true }), isInvalidJsoncError);
|
||||
assert.equal(fs.readFileSync(configPath, 'utf8'), PARTIAL_PARSE_CONFIG);
|
||||
assert.equal(fs.existsSync(`${configPath}.openchamber.backup`), false);
|
||||
});
|
||||
|
||||
test('preserves unrelated keys when updating a valid MCP config', () => {
|
||||
const configPath = path.join(tempDir, 'opencode.jsonc');
|
||||
fs.writeFileSync(configPath, VALID_CONFIG, 'utf8');
|
||||
process.env.OPENCODE_CONFIG = configPath;
|
||||
|
||||
updateMcpConfig('openproject', { enabled: false });
|
||||
|
||||
const rewritten = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.deepEqual(rewritten.plugin, ['opencode-see-image']);
|
||||
assert.equal(rewritten.provider['ollama-cloud'].name, 'Ollama Cloud');
|
||||
assert.equal(rewritten.mcp.openproject.enabled, false);
|
||||
assert.equal(fs.readFileSync(`${configPath}.openchamber.backup`, 'utf8'), VALID_CONFIG);
|
||||
});
|
||||
|
||||
test('returns an empty object for a comment-only config file', () => {
|
||||
const configPath = path.join(tempDir, 'comments.jsonc');
|
||||
fs.writeFileSync(configPath, '// placeholder\n/* still empty */\n', 'utf8');
|
||||
process.env.OPENCODE_CONFIG = configPath;
|
||||
|
||||
assert.deepEqual(listPluginEntries(), []);
|
||||
});
|
||||
|
||||
test('refuses MCP updates against content that yields no JSON value at all', () => {
|
||||
const configPath = path.join(tempDir, 'yamlish.jsonc');
|
||||
const contents = 'mcp:\n openproject:\n type: remote\n';
|
||||
fs.writeFileSync(configPath, contents, 'utf8');
|
||||
process.env.OPENCODE_CONFIG = configPath;
|
||||
|
||||
assert.throws(() => updateMcpConfig('openproject', { enabled: true }), isInvalidJsoncError);
|
||||
assert.equal(fs.readFileSync(configPath, 'utf8'), contents);
|
||||
assert.equal(fs.existsSync(`${configPath}.openchamber.backup`), false);
|
||||
});
|
||||
|
||||
test('lists custom-layer plugins when a project layer is unparseable', () => {
|
||||
const customPath = path.join(tempDir, 'custom.jsonc');
|
||||
const projectDir = path.join(tempDir, 'project');
|
||||
const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc');
|
||||
fs.writeFileSync(customPath, VALID_CONFIG, 'utf8');
|
||||
fs.mkdirSync(path.dirname(projectFile), { recursive: true });
|
||||
fs.writeFileSync(projectFile, PARTIAL_PARSE_CONFIG, 'utf8');
|
||||
process.env.OPENCODE_CONFIG = customPath;
|
||||
|
||||
const specs = listPluginEntries(projectDir).map((entry) => entry.spec);
|
||||
assert.deepEqual(specs, ['opencode-see-image']);
|
||||
assert.equal(fs.readFileSync(projectFile, 'utf8'), PARTIAL_PARSE_CONFIG);
|
||||
assert.equal(fs.existsSync(`${projectFile}.openchamber.backup`), false);
|
||||
});
|
||||
|
||||
test('keeps a valid custom layer writable when a project layer is unparseable', () => {
|
||||
const customPath = path.join(tempDir, 'custom.jsonc');
|
||||
const projectDir = path.join(tempDir, 'project');
|
||||
const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc');
|
||||
fs.writeFileSync(customPath, VALID_CONFIG, 'utf8');
|
||||
fs.mkdirSync(path.dirname(projectFile), { recursive: true });
|
||||
fs.writeFileSync(projectFile, PARTIAL_PARSE_CONFIG, 'utf8');
|
||||
process.env.OPENCODE_CONFIG = customPath;
|
||||
|
||||
updateMcpConfig('openproject', { enabled: false }, projectDir);
|
||||
|
||||
const rewritten = JSON.parse(fs.readFileSync(customPath, 'utf8'));
|
||||
assert.deepEqual(rewritten.plugin, ['opencode-see-image']);
|
||||
assert.equal(rewritten.mcp.openproject.enabled, false);
|
||||
assert.equal(fs.readFileSync(projectFile, 'utf8'), PARTIAL_PARSE_CONFIG);
|
||||
assert.equal(fs.existsSync(`${projectFile}.openchamber.backup`), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { afterEach, beforeEach, describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
upsertProviderConfig,
|
||||
validateCustomProviderConfig,
|
||||
} from './opencodeConfig';
|
||||
|
||||
let projectDir: string;
|
||||
|
||||
const writeJson = (filePath: string, value: unknown) => {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const readJson = (filePath: string) => JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
|
||||
describe('custom provider config persistence (VS Code parity)', () => {
|
||||
beforeEach(() => {
|
||||
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-provider-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('validateCustomProviderConfig rejects invalid endpoint and credentials shape', () => {
|
||||
assert.equal(validateCustomProviderConfig('Bad Id', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok, false);
|
||||
|
||||
const ftp = validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'ftp://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
});
|
||||
assert.equal(ftp.ok, false);
|
||||
assert.match(ftp.error ?? '', /http:\/\//);
|
||||
|
||||
assert.equal(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: {},
|
||||
}).ok, false);
|
||||
});
|
||||
|
||||
test('validateCustomProviderConfig rejects missing credentials', () => {
|
||||
assert.equal(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok, false);
|
||||
|
||||
assert.equal(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, { hasStoredAuth: true }).ok, true);
|
||||
|
||||
assert.equal(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
env: ['MY_KEY'],
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok, true);
|
||||
});
|
||||
|
||||
test('upsertProviderConfig writes and round-trips project config', () => {
|
||||
const result = upsertProviderConfig('campus-llm', {
|
||||
name: 'Campus LLM',
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: {
|
||||
'fast-model': { name: 'Fast' },
|
||||
},
|
||||
env: ['CAMPUS_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
assert.equal(result.providerId, 'campus-llm');
|
||||
assert.equal(fs.existsSync(result.path), true);
|
||||
assert.equal(result.path.startsWith(projectDir), true);
|
||||
|
||||
const written = readJson(result.path);
|
||||
assert.deepEqual(written.provider['campus-llm'], {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Campus LLM',
|
||||
env: ['CAMPUS_KEY'],
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: {
|
||||
'fast-model': { name: 'Fast' },
|
||||
},
|
||||
});
|
||||
|
||||
const sources = getProviderSources('campus-llm', projectDir);
|
||||
assert.equal(sources.project.exists, true);
|
||||
assert.equal(sources.project.path, result.path);
|
||||
});
|
||||
|
||||
test('upsertProviderConfig updates existing entry and clears disabled_providers', () => {
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
writeJson(configPath, {
|
||||
provider: {
|
||||
'campus-llm': {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Old',
|
||||
options: { baseURL: 'https://old.example.edu/v1' },
|
||||
models: { a: { name: 'A' } },
|
||||
},
|
||||
},
|
||||
disabled_providers: ['campus-llm', 'other'],
|
||||
});
|
||||
|
||||
upsertProviderConfig('campus-llm', {
|
||||
name: 'Campus LLM',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: { b: { name: 'B' } },
|
||||
env: ['CAMPUS_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
const written = readJson(configPath);
|
||||
assert.equal(written.provider['campus-llm'].name, 'Campus LLM');
|
||||
assert.deepEqual(written.provider['campus-llm'].models, { b: { name: 'B' } });
|
||||
assert.deepEqual(written.disabled_providers, ['other']);
|
||||
});
|
||||
|
||||
test('upsert then remove restores absence', () => {
|
||||
upsertProviderConfig('temp-provider', {
|
||||
name: 'Temp',
|
||||
options: { baseURL: 'https://api.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
env: ['TEMP_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
assert.equal(getProviderSources('temp-provider', projectDir).project.exists, true);
|
||||
assert.equal(removeProviderConfig('temp-provider', projectDir, 'project'), true);
|
||||
assert.equal(getProviderSources('temp-provider', projectDir).project.exists, false);
|
||||
});
|
||||
|
||||
test('failed validation does not write config', () => {
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
assert.throws(
|
||||
() => upsertProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'not-a-url' },
|
||||
models: { m: { name: 'M' } },
|
||||
env: ['X'],
|
||||
}, projectDir, 'project'),
|
||||
/Base URL/,
|
||||
);
|
||||
assert.equal(fs.existsSync(configPath), false);
|
||||
});
|
||||
|
||||
test('upsert with hasStoredAuth allows config without env', () => {
|
||||
const result = upsertProviderConfig('keyed-provider', {
|
||||
name: 'Keyed',
|
||||
options: { baseURL: 'https://api.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
assert.equal(result.providerId, 'keyed-provider');
|
||||
assert.equal(result.config.env, undefined);
|
||||
});
|
||||
|
||||
test('project-scope edit updates project layer without creating a user entry', () => {
|
||||
const providerId = `proj-scope-${Date.now()}`;
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Project Scoped',
|
||||
options: { baseURL: 'https://project.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Project Scoped Updated',
|
||||
options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } },
|
||||
models: { m: { name: 'M2' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
const written = readJson(configPath);
|
||||
assert.deepEqual(written.provider[providerId], {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Project Scoped Updated',
|
||||
options: {
|
||||
baseURL: 'https://project.example.com/v2',
|
||||
headers: { 'X-Project': '1' },
|
||||
},
|
||||
models: { m: { name: 'M2' } },
|
||||
});
|
||||
|
||||
const sources = getProviderSources(providerId, projectDir);
|
||||
assert.equal(sources.project.exists, true);
|
||||
assert.equal(sources.user.exists, false);
|
||||
assert.equal(sources.custom.exists, false);
|
||||
|
||||
for (const userPath of [
|
||||
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
|
||||
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
|
||||
]) {
|
||||
if (!fs.existsSync(userPath)) continue;
|
||||
const userConfig = readJson(userPath);
|
||||
assert.equal(userConfig.provider?.[providerId], undefined);
|
||||
assert.equal(userConfig.providers?.[providerId], undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('custom-scope edit updates custom layer without creating a user entry', () => {
|
||||
const providerId = `custom-scope-${Date.now()}`;
|
||||
const customPath = path.join(projectDir, 'custom-opencode.json');
|
||||
const previousEnv = process.env.OPENCODE_CONFIG;
|
||||
process.env.OPENCODE_CONFIG = customPath;
|
||||
|
||||
try {
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Custom Scoped',
|
||||
options: { baseURL: 'https://custom.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'custom', { hasStoredAuth: true });
|
||||
|
||||
upsertProviderConfig(providerId, {
|
||||
name: 'Custom Scoped Updated',
|
||||
options: { baseURL: 'https://custom.example.com/v2' },
|
||||
models: { n: { name: 'N' } },
|
||||
}, projectDir, 'custom', { hasStoredAuth: true });
|
||||
|
||||
const written = readJson(customPath);
|
||||
assert.equal(written.provider[providerId].name, 'Custom Scoped Updated');
|
||||
assert.equal(written.provider[providerId].options.baseURL, 'https://custom.example.com/v2');
|
||||
|
||||
const sources = getProviderSources(providerId, projectDir);
|
||||
assert.equal(sources.custom.exists, true);
|
||||
assert.equal(sources.user.exists, false);
|
||||
assert.equal(sources.project.exists, false);
|
||||
|
||||
for (const userPath of [
|
||||
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
|
||||
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
|
||||
]) {
|
||||
if (!fs.existsSync(userPath)) continue;
|
||||
const userConfig = readJson(userPath);
|
||||
assert.equal(userConfig.provider?.[providerId], undefined);
|
||||
assert.equal(userConfig.providers?.[providerId], undefined);
|
||||
}
|
||||
} finally {
|
||||
if (previousEnv === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG;
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG = previousEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import yaml from 'yaml';
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { parse as parseJsonc, printParseErrorCode, type ParseError } from 'jsonc-parser';
|
||||
|
||||
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
||||
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
|
||||
@@ -10,9 +10,6 @@ const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
|
||||
const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet');
|
||||
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
|
||||
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
|
||||
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
|
||||
? path.resolve(process.env.OPENCODE_CONFIG)
|
||||
: null;
|
||||
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
|
||||
const SNIPPET_EXTENSION = '.md';
|
||||
const SNIPPET_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/i;
|
||||
@@ -45,7 +42,7 @@ export type Snippet = {
|
||||
};
|
||||
|
||||
export type PluginScope = 'user' | 'project';
|
||||
export type PluginParsedKind = 'npm' | 'path';
|
||||
type PluginParsedKind = 'npm' | 'path';
|
||||
|
||||
export type PluginEntry = {
|
||||
id: string;
|
||||
@@ -200,7 +197,7 @@ const getUserAgentPath = (agentName: string, lookupCache: AgentLookupCache = glo
|
||||
return pluralPath;
|
||||
};
|
||||
|
||||
export const getAgentScope = (
|
||||
const getAgentScope = (
|
||||
agentName: string,
|
||||
workingDirectory?: string,
|
||||
lookupCache: AgentLookupCache = globalAgentLookupCache
|
||||
@@ -273,7 +270,7 @@ const getUserCommandPath = (commandName: string): string => {
|
||||
return pluralPath;
|
||||
};
|
||||
|
||||
export const getCommandScope = (commandName: string, workingDirectory?: string): { scope: CommandScope | null; path: string | null } => {
|
||||
const getCommandScope = (commandName: string, workingDirectory?: string): { scope: CommandScope | null; path: string | null } => {
|
||||
if (workingDirectory) {
|
||||
const projectPath = getProjectCommandPath(workingDirectory, commandName);
|
||||
if (fs.existsSync(projectPath)) {
|
||||
@@ -541,7 +538,10 @@ const getConfigPaths = (workingDirectory?: string) => ({
|
||||
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
|
||||
],
|
||||
projectPath: getProjectConfigPath(workingDirectory),
|
||||
customPath: CUSTOM_CONFIG_FILE
|
||||
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
|
||||
customPath: process.env.OPENCODE_CONFIG
|
||||
? path.resolve(process.env.OPENCODE_CONFIG)
|
||||
: null,
|
||||
});
|
||||
|
||||
const getPrimaryUserConfigPath = (userPaths: string[]): string => {
|
||||
@@ -554,12 +554,46 @@ const getPrimaryUserConfigPath = (userPaths: string[]): string => {
|
||||
return CONFIG_FILE;
|
||||
};
|
||||
|
||||
const INVALID_JSONC = 'INVALID_JSONC';
|
||||
|
||||
const formatJsoncParseError = (filePath: string, errors: ParseError[]): string => {
|
||||
const first = errors.length > 0 ? errors[0] : null;
|
||||
const location = first && Number.isFinite(first.offset)
|
||||
? ` (${printParseErrorCode(first.error)} at offset ${first.offset})`
|
||||
: '';
|
||||
return `OpenCode configuration at ${filePath} contains invalid JSONC and cannot be loaded safely${location}`;
|
||||
};
|
||||
|
||||
const isInvalidJsoncError = (error: unknown): error is Error & { code: string } =>
|
||||
Boolean(error && typeof error === 'object' && 'code' in error && error.code === INVALID_JSONC);
|
||||
|
||||
// Comment-only / whitespace-only files parse to undefined with nothing but
|
||||
// ValueExpected. Any other error means real content we failed to understand
|
||||
// (YAML, plain text, a stray leading token), which must not read as empty.
|
||||
const isCommentOnlyParse = (parsed: unknown, errors: ParseError[]): boolean =>
|
||||
parsed === undefined
|
||||
&& errors.every((entry) => printParseErrorCode(entry.error) === 'ValueExpected');
|
||||
|
||||
const parseConfigObject = (content: string, filePath: string): Record<string, unknown> => {
|
||||
const errors: ParseError[] = [];
|
||||
const parsed = parseJsonc(content, errors, { allowTrailingComma: true });
|
||||
if (isCommentOnlyParse(parsed, errors)) {
|
||||
return {};
|
||||
}
|
||||
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw codedError(formatJsoncParseError(filePath, errors), INVALID_JSONC);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const readConfigFile = (filePath?: string | null): Record<string, unknown> => {
|
||||
if (!filePath || !fs.existsSync(filePath)) return {};
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const normalized = content.trim();
|
||||
if (!normalized) return {};
|
||||
return parseJsonc(normalized, [], { allowTrailingComma: true }) as Record<string, unknown>;
|
||||
// Refuse partial jsonc-parser trees. Ignoring errors previously let mutations
|
||||
// rewrite a truncated object (often only `$schema`) over the full config.
|
||||
return parseConfigObject(normalized, filePath);
|
||||
};
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -582,20 +616,50 @@ const mergeConfigs = (base: Record<string, unknown>, override: Record<string, un
|
||||
return result;
|
||||
};
|
||||
|
||||
const readConfigLayer = (filePath?: string | null): {
|
||||
config: Record<string, unknown>;
|
||||
error: (Error & { code: string }) | null;
|
||||
} => {
|
||||
try {
|
||||
return { config: readConfigFile(filePath), error: null };
|
||||
} catch (error) {
|
||||
if (isInvalidJsoncError(error)) {
|
||||
console.error(error.message);
|
||||
return { config: {}, error };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const readConfigLayers = (workingDirectory?: string) => {
|
||||
const { userPaths, projectPath, customPath } = getConfigPaths(workingDirectory);
|
||||
const userPath = getPrimaryUserConfigPath(userPaths);
|
||||
const userConfig = readConfigFile(userPath);
|
||||
const projectConfig = readConfigFile(projectPath);
|
||||
const customConfig = readConfigFile(customPath);
|
||||
const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig);
|
||||
const userLayer = readConfigLayer(userPath);
|
||||
const projectLayer = readConfigLayer(projectPath);
|
||||
const customLayer = readConfigLayer(customPath);
|
||||
const mergedConfig = mergeConfigs(
|
||||
mergeConfigs(userLayer.config, projectLayer.config),
|
||||
customLayer.config,
|
||||
);
|
||||
|
||||
const layerErrors: Array<{ path: string; code: string; message: string }> = [];
|
||||
if (userLayer.error) {
|
||||
layerErrors.push({ path: userPath, code: userLayer.error.code, message: userLayer.error.message });
|
||||
}
|
||||
if (projectLayer.error && projectPath) {
|
||||
layerErrors.push({ path: projectPath, code: projectLayer.error.code, message: projectLayer.error.message });
|
||||
}
|
||||
if (customLayer.error && customPath) {
|
||||
layerErrors.push({ path: customPath, code: customLayer.error.code, message: customLayer.error.message });
|
||||
}
|
||||
|
||||
return {
|
||||
userConfig,
|
||||
projectConfig,
|
||||
customConfig,
|
||||
userConfig: userLayer.config,
|
||||
projectConfig: projectLayer.config,
|
||||
customConfig: customLayer.config,
|
||||
mergedConfig,
|
||||
paths: { userPath, projectPath, customPath }
|
||||
paths: { userPath, projectPath, customPath },
|
||||
layerErrors,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -695,6 +759,11 @@ const getConfigForPath = (layers: ReturnType<typeof readConfigLayers>, targetPat
|
||||
|
||||
const writeConfig = (config: Record<string, unknown>, filePath: string = CONFIG_FILE) => {
|
||||
if (fs.existsSync(filePath)) {
|
||||
// Defense in depth: never overwrite a file we cannot fully parse.
|
||||
const existing = fs.readFileSync(filePath, 'utf8').trim();
|
||||
if (existing) {
|
||||
parseConfigObject(existing, filePath);
|
||||
}
|
||||
const backupFile = `${filePath}.openchamber.backup`;
|
||||
try {
|
||||
fs.copyFileSync(filePath, backupFile);
|
||||
@@ -862,10 +931,10 @@ const getPluginConfigSources = (workingDirectory?: string | null): Array<{ scope
|
||||
const projectPath = getProjectConfigPath(workingDirectory || undefined);
|
||||
return [
|
||||
customPath
|
||||
? { scope: 'user', path: customPath, config: readConfigFile(customPath) }
|
||||
: { scope: 'user', path: userPath, config: readConfigFile(userPath) },
|
||||
? { scope: 'user', path: customPath, config: readConfigLayer(customPath).config }
|
||||
: { scope: 'user', path: userPath, config: readConfigLayer(userPath).config },
|
||||
...(projectPath
|
||||
? [{ scope: 'project' as const, path: projectPath, config: readConfigFile(projectPath) }]
|
||||
? [{ scope: 'project' as const, path: projectPath, config: readConfigLayer(projectPath).config }]
|
||||
: []),
|
||||
];
|
||||
};
|
||||
@@ -1194,23 +1263,6 @@ export const queryPluginRegistry = async (
|
||||
return { results };
|
||||
};
|
||||
|
||||
export type McpLocalConfig = {
|
||||
type: 'local';
|
||||
command?: string[];
|
||||
environment?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type McpRemoteConfig = {
|
||||
type: 'remote';
|
||||
url?: string;
|
||||
environment?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type McpConfigPayload = McpLocalConfig | McpRemoteConfig;
|
||||
|
||||
export type McpConfigEntry = {
|
||||
name: string;
|
||||
scope?: AgentScope | null;
|
||||
@@ -1393,22 +1445,45 @@ export const deleteMcpConfig = (name: string, workingDirectory?: string): void =
|
||||
writeConfig(config, targetPath);
|
||||
};
|
||||
|
||||
const getLayerError = (
|
||||
layers: ReturnType<typeof readConfigLayers>,
|
||||
filePath?: string | null,
|
||||
) => {
|
||||
if (!filePath) return null;
|
||||
return layers.layerErrors.find((entry) => entry.path === filePath) || null;
|
||||
};
|
||||
|
||||
const throwIfLayerError = (
|
||||
layers: ReturnType<typeof readConfigLayers>,
|
||||
filePath?: string | null,
|
||||
) => {
|
||||
const failed = getLayerError(layers, filePath);
|
||||
if (!failed) return;
|
||||
throw codedError(failed.message, failed.code);
|
||||
};
|
||||
|
||||
const getJsonEntrySource = (
|
||||
layers: ReturnType<typeof readConfigLayers>,
|
||||
sectionKey: 'agent' | 'command' | 'mcp',
|
||||
entryName: string
|
||||
) => {
|
||||
const { userConfig, projectConfig, customConfig, paths } = layers;
|
||||
const customSection = (customConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
||||
if (customSection?.[entryName] !== undefined) {
|
||||
return { section: customSection[entryName], config: customConfig, path: paths.customPath, exists: true };
|
||||
if (paths.customPath) {
|
||||
throwIfLayerError(layers, paths.customPath);
|
||||
const customSection = (customConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
||||
if (customSection?.[entryName] !== undefined) {
|
||||
return { section: customSection[entryName], config: customConfig, path: paths.customPath, exists: true };
|
||||
}
|
||||
}
|
||||
|
||||
const projectSection = (projectConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
||||
if (projectSection?.[entryName] !== undefined) {
|
||||
return { section: projectSection[entryName], config: projectConfig, path: paths.projectPath, exists: true };
|
||||
if (paths.projectPath && !getLayerError(layers, paths.projectPath)) {
|
||||
const projectSection = (projectConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
||||
if (projectSection?.[entryName] !== undefined) {
|
||||
return { section: projectSection[entryName], config: projectConfig, path: paths.projectPath, exists: true };
|
||||
}
|
||||
}
|
||||
|
||||
throwIfLayerError(layers, paths.userPath);
|
||||
const userSection = (userConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
|
||||
if (userSection?.[entryName] !== undefined) {
|
||||
return { section: userSection[entryName], config: userConfig, path: paths.userPath, exists: true };
|
||||
@@ -1423,11 +1498,14 @@ const getJsonWriteTarget = (
|
||||
) => {
|
||||
const { userConfig, projectConfig, customConfig, paths } = layers;
|
||||
if (paths.customPath) {
|
||||
throwIfLayerError(layers, paths.customPath);
|
||||
return { config: customConfig, path: paths.customPath };
|
||||
}
|
||||
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
|
||||
throwIfLayerError(layers, paths.projectPath);
|
||||
return { config: projectConfig, path: paths.projectPath };
|
||||
}
|
||||
throwIfLayerError(layers, paths.userPath);
|
||||
return { config: userConfig, path: paths.userPath };
|
||||
};
|
||||
|
||||
@@ -1622,12 +1700,30 @@ export const createAgent = (agentName: string, config: Record<string, unknown>,
|
||||
}
|
||||
|
||||
// Extract scope and prompt from config - scope is only used for path determination, not written to file
|
||||
const { prompt, scope: _ignored, ...frontmatter } = config as Record<string, unknown> & { prompt?: unknown; scope?: unknown };
|
||||
const { prompt, scope: _ignored, ...rawFrontmatter } = config as Record<string, unknown> & { prompt?: unknown; scope?: unknown };
|
||||
void _ignored; // Scope is only used for path determination
|
||||
const frontmatter = Object.fromEntries(
|
||||
Object.entries(rawFrontmatter).filter(([, value]) => value !== null && value !== undefined)
|
||||
);
|
||||
writeMdFile(targetPath, frontmatter, typeof prompt === 'string' ? prompt : '');
|
||||
resetAgentLookupCache(globalAgentLookupCache);
|
||||
};
|
||||
|
||||
const deleteAgentJsonField = (config: Record<string, unknown>, agentName: string, field: string): boolean => {
|
||||
const agentMap = config.agent as Record<string, unknown> | undefined;
|
||||
const current = agentMap?.[agentName] as Record<string, unknown> | undefined;
|
||||
if (!agentMap || !current || !(field in current)) return false;
|
||||
|
||||
delete current[field];
|
||||
if (Object.keys(current).length === 0) {
|
||||
delete agentMap[agentName];
|
||||
}
|
||||
if (Object.keys(agentMap).length === 0) {
|
||||
delete config.agent;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const updateAgent = (agentName: string, updates: Record<string, unknown>, workingDirectory?: string) => {
|
||||
ensureDirs();
|
||||
|
||||
@@ -1665,6 +1761,9 @@ export const updateAgent = (agentName: string, updates: Record<string, unknown>,
|
||||
const creatingNewMd = isBuiltinOverride;
|
||||
|
||||
for (const [field, value] of Object.entries(updates || {})) {
|
||||
// Skip undefined values — they would overwrite existing frontmatter fields with nothing
|
||||
if (value === undefined) continue;
|
||||
|
||||
if (field === 'prompt') {
|
||||
if (value === null) {
|
||||
if (mdExists || creatingNewMd) {
|
||||
@@ -1774,6 +1873,19 @@ export const updateAgent = (agentName: string, updates: Record<string, unknown>,
|
||||
const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined);
|
||||
const hasJsonField = Boolean(jsonSection?.[field] !== undefined);
|
||||
|
||||
if (value === null) {
|
||||
if (hasMdField && mdData) {
|
||||
delete mdData.frontmatter[field];
|
||||
mdModified = true;
|
||||
}
|
||||
|
||||
if (hasJsonField && deleteAgentJsonField(config, agentName, field)) {
|
||||
jsonModified = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// JSON takes precedence over md, so update JSON first if field exists there
|
||||
if (hasJsonField) {
|
||||
if (!config.agent) config.agent = {};
|
||||
@@ -2151,6 +2263,163 @@ export const removeProviderConfig = (providerId: string, workingDirectory?: stri
|
||||
return true;
|
||||
};
|
||||
|
||||
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
|
||||
const BASE_URL_PATTERN = /^https?:\/\//;
|
||||
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
|
||||
|
||||
export const validateCustomProviderConfig = (
|
||||
providerId: string,
|
||||
config: unknown,
|
||||
options: { hasStoredAuth?: boolean } = {},
|
||||
) => {
|
||||
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) {
|
||||
return { ok: false as const, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
|
||||
}
|
||||
|
||||
if (!isPlainObject(config)) {
|
||||
return { ok: false as const, error: 'Provider config must be an object' };
|
||||
}
|
||||
|
||||
const name = typeof config.name === 'string' ? config.name.trim() : '';
|
||||
if (!name) {
|
||||
return { ok: false as const, error: 'Provider name is required' };
|
||||
}
|
||||
|
||||
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
|
||||
if (npm !== OPENAI_COMPATIBLE_NPM) {
|
||||
return { ok: false as const, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
|
||||
}
|
||||
|
||||
const optionsBlock = isPlainObject(config.options) ? config.options : null;
|
||||
if (!optionsBlock) {
|
||||
return { ok: false as const, error: 'Provider options are required' };
|
||||
}
|
||||
|
||||
const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : '';
|
||||
if (!baseURL) {
|
||||
return { ok: false as const, error: 'Base URL is required' };
|
||||
}
|
||||
if (!BASE_URL_PATTERN.test(baseURL)) {
|
||||
return { ok: false as const, error: 'Base URL must start with http:// or https://' };
|
||||
}
|
||||
|
||||
const models = isPlainObject(config.models) ? config.models : null;
|
||||
if (!models || Object.keys(models).length === 0) {
|
||||
return { ok: false as const, error: 'At least one model is required' };
|
||||
}
|
||||
|
||||
const normalizedModels: Record<string, { name: string }> = {};
|
||||
for (const [modelId, modelValue] of Object.entries(models)) {
|
||||
const trimmedId = typeof modelId === 'string' ? modelId.trim() : '';
|
||||
if (!trimmedId) {
|
||||
return { ok: false as const, error: 'Model id is required' };
|
||||
}
|
||||
if (!isPlainObject(modelValue)) {
|
||||
return { ok: false as const, error: `Model "${trimmedId}" must be an object` };
|
||||
}
|
||||
const modelName = typeof modelValue.name === 'string' ? modelValue.name.trim() : '';
|
||||
if (!modelName) {
|
||||
return { ok: false as const, error: `Model "${trimmedId}" requires a name` };
|
||||
}
|
||||
normalizedModels[trimmedId] = { name: modelName };
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = {
|
||||
npm: OPENAI_COMPATIBLE_NPM,
|
||||
name,
|
||||
options: {
|
||||
baseURL,
|
||||
},
|
||||
models: normalizedModels,
|
||||
};
|
||||
|
||||
let env: string[] = [];
|
||||
if (Array.isArray(config.env)) {
|
||||
env = config.env
|
||||
.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
.map((entry) => entry.trim());
|
||||
if (env.length > 0) {
|
||||
normalized.env = env;
|
||||
}
|
||||
}
|
||||
|
||||
if (env.length === 0 && !options.hasStoredAuth) {
|
||||
return { ok: false as const, error: 'API key or {env:VAR} credentials are required' };
|
||||
}
|
||||
|
||||
if (isPlainObject(optionsBlock.headers)) {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) {
|
||||
if (typeof headerKey !== 'string' || !headerKey.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (typeof headerValue !== 'string' || !headerValue.trim()) {
|
||||
return { ok: false as const, error: `Header "${headerKey}" requires a non-empty value` };
|
||||
}
|
||||
headers[headerKey.trim()] = headerValue.trim();
|
||||
}
|
||||
if (Object.keys(headers).length > 0) {
|
||||
(normalized.options as Record<string, unknown>).headers = headers;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true as const, value: { providerId, config: normalized } };
|
||||
};
|
||||
|
||||
export const upsertProviderConfig = (
|
||||
providerId: string,
|
||||
config: unknown,
|
||||
workingDirectory?: string,
|
||||
scope: 'user' | 'project' | 'custom' = 'user',
|
||||
options: { hasStoredAuth?: boolean } = {},
|
||||
) => {
|
||||
const validated = validateCustomProviderConfig(providerId, config, options);
|
||||
if (!validated.ok) {
|
||||
const error = new Error(validated.error) as Error & { statusCode?: number };
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
let targetPath: string | null | undefined = layers.paths.userPath;
|
||||
|
||||
if (scope === 'project') {
|
||||
if (!workingDirectory) {
|
||||
throw new Error('Working directory is required for project scope');
|
||||
}
|
||||
targetPath = layers.paths.projectPath ?? targetPath;
|
||||
} else if (scope === 'custom') {
|
||||
if (!layers.paths.customPath) {
|
||||
throw new Error('Custom config path (OPENCODE_CONFIG) is not set');
|
||||
}
|
||||
targetPath = layers.paths.customPath;
|
||||
} else if (scope !== 'user') {
|
||||
throw new Error('Invalid scope');
|
||||
}
|
||||
|
||||
const targetConfig = getConfigForPath(layers, targetPath) as Record<string, unknown>;
|
||||
const providerConfig = isPlainObject(targetConfig.provider)
|
||||
? { ...(targetConfig.provider as Record<string, unknown>) }
|
||||
: {};
|
||||
providerConfig[validated.value.providerId] = validated.value.config;
|
||||
targetConfig.provider = providerConfig;
|
||||
|
||||
if (Array.isArray(targetConfig.disabled_providers)) {
|
||||
targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
|
||||
(entry) => entry !== validated.value.providerId,
|
||||
);
|
||||
}
|
||||
|
||||
const writePath = targetPath || CONFIG_FILE;
|
||||
writeConfig(targetConfig, writePath);
|
||||
|
||||
return {
|
||||
providerId: validated.value.providerId,
|
||||
path: writePath,
|
||||
config: validated.value.config,
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteCommand = (commandName: string, workingDirectory?: string) => {
|
||||
let deleted = false;
|
||||
|
||||
@@ -2241,7 +2510,7 @@ export const SKILL_SCOPE = {
|
||||
export type SkillScope = typeof SKILL_SCOPE[keyof typeof SKILL_SCOPE];
|
||||
export type SkillSource = 'opencode' | 'claude' | 'agents';
|
||||
|
||||
export type SupportingFile = {
|
||||
type SupportingFile = {
|
||||
name: string;
|
||||
path: string;
|
||||
fullPath: string;
|
||||
@@ -2382,9 +2651,9 @@ const getProjectAgentsSkillDir = (workingDirectory: string, skillName: string):
|
||||
return path.join(workingDirectory, '.agents', 'skills', skillName);
|
||||
};
|
||||
|
||||
export const getSkillScope = (skillName: string, workingDirectory?: string): {
|
||||
scope: SkillScope | null;
|
||||
path: string | null;
|
||||
const getSkillScope = (skillName: string, workingDirectory?: string): {
|
||||
scope: SkillScope | null;
|
||||
path: string | null;
|
||||
source: SkillSource | null;
|
||||
} => {
|
||||
const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||
@@ -2744,7 +3013,7 @@ export const updateSkill = (skillName: string, updates: Record<string, unknown>,
|
||||
let mdModified = false;
|
||||
|
||||
for (const [field, value] of Object.entries(updates || {})) {
|
||||
if (field === 'scope') continue;
|
||||
if (field === 'scope' || field === 'source' || field === 'targetPath' || field === 'renameTo') continue;
|
||||
|
||||
if (field === 'instructions') {
|
||||
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
|
||||
@@ -2816,3 +3085,123 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void
|
||||
throw new Error(`Skill "${skillName}" not found`);
|
||||
}
|
||||
};
|
||||
|
||||
const isPathInside = (candidatePath: string, parentPath: string): boolean => {
|
||||
const resolvedCandidate = path.resolve(candidatePath);
|
||||
const resolvedParent = path.resolve(parentPath);
|
||||
return resolvedCandidate === resolvedParent
|
||||
|| resolvedCandidate.startsWith(`${resolvedParent}${path.sep}`);
|
||||
};
|
||||
|
||||
const getManagedSkillRoots = (workingDirectory?: string): string[] => {
|
||||
const roots: string[] = [];
|
||||
const pushRoot = (dir?: string | null) => {
|
||||
if (!dir) return;
|
||||
const resolved = path.resolve(dir);
|
||||
if (!roots.includes(resolved)) {
|
||||
roots.push(resolved);
|
||||
}
|
||||
};
|
||||
|
||||
pushRoot(SKILL_DIR);
|
||||
pushRoot(path.join(OPENCODE_CONFIG_DIR, 'skill'));
|
||||
pushRoot(path.join(os.homedir(), '.opencode', 'skills'));
|
||||
pushRoot(path.join(os.homedir(), '.opencode', 'skill'));
|
||||
pushRoot(path.join(os.homedir(), '.claude', 'skills'));
|
||||
pushRoot(path.join(os.homedir(), '.agents', 'skills'));
|
||||
|
||||
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
|
||||
: null;
|
||||
pushRoot(customConfigDir ? path.join(customConfigDir, 'skills') : null);
|
||||
pushRoot(customConfigDir ? path.join(customConfigDir, 'skill') : null);
|
||||
|
||||
if (workingDirectory) {
|
||||
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
|
||||
for (const ancestor of getAncestors(workingDirectory, worktreeRoot)) {
|
||||
pushRoot(path.join(ancestor, '.opencode', 'skills'));
|
||||
pushRoot(path.join(ancestor, '.opencode', 'skill'));
|
||||
pushRoot(path.join(ancestor, '.claude', 'skills'));
|
||||
pushRoot(path.join(ancestor, '.agents', 'skills'));
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
};
|
||||
|
||||
const isManagedSkillPath = (skillMdPath: string, workingDirectory?: string): boolean => {
|
||||
if (!skillMdPath || skillMdPath === BUILT_IN_SKILL_LOCATION) {
|
||||
return false;
|
||||
}
|
||||
const skillDir = path.dirname(path.resolve(skillMdPath));
|
||||
return getManagedSkillRoots(workingDirectory).some((root) => isPathInside(skillDir, root));
|
||||
};
|
||||
|
||||
export { isManagedSkillPath };
|
||||
|
||||
export const renameSkill = (oldName: string, newName: string, workingDirectory?: string): void => {
|
||||
ensureSkillDirs();
|
||||
validateSkillName(newName);
|
||||
|
||||
if (oldName === newName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = getSkillScope(oldName, workingDirectory);
|
||||
if (!existing.path) {
|
||||
throw new Error(`Skill "${oldName}" not found`);
|
||||
}
|
||||
if (existing.path === BUILT_IN_SKILL_LOCATION || !fs.existsSync(existing.path)) {
|
||||
throw new Error(`Skill "${oldName}" cannot be renamed`);
|
||||
}
|
||||
if (path.basename(existing.path) !== 'SKILL.md') {
|
||||
throw new Error(`Skill "${oldName}" target must be a SKILL.md file`);
|
||||
}
|
||||
if (!isManagedSkillPath(existing.path, workingDirectory)) {
|
||||
throw new Error(`Skill "${oldName}" is outside managed skill directories and cannot be renamed`);
|
||||
}
|
||||
|
||||
const mdDataBeforeMove = parseMdFile(existing.path);
|
||||
const frontmatterName = typeof mdDataBeforeMove.frontmatter?.name === 'string'
|
||||
? mdDataBeforeMove.frontmatter.name
|
||||
: oldName;
|
||||
if (frontmatterName !== oldName) {
|
||||
throw new Error(`Skill "${oldName}" does not match ${existing.path}`);
|
||||
}
|
||||
|
||||
const conflict = getSkillScope(newName, workingDirectory);
|
||||
if (conflict.path) {
|
||||
throw new Error(`Skill ${newName} already exists at ${conflict.path}`);
|
||||
}
|
||||
|
||||
const oldDir = path.dirname(existing.path);
|
||||
const newDir = path.join(path.dirname(oldDir), newName);
|
||||
const directoriesDiffer = path.resolve(oldDir) !== path.resolve(newDir);
|
||||
|
||||
if (directoriesDiffer && fs.existsSync(newDir)) {
|
||||
throw new Error(`Skill directory already exists at ${newDir}`);
|
||||
}
|
||||
|
||||
if (directoriesDiffer) {
|
||||
fs.renameSync(oldDir, newDir);
|
||||
}
|
||||
|
||||
const newPath = path.join(newDir, 'SKILL.md');
|
||||
try {
|
||||
const mdData = parseMdFile(newPath);
|
||||
mdData.frontmatter = {
|
||||
...mdData.frontmatter,
|
||||
name: newName,
|
||||
};
|
||||
writeMdFile(newPath, mdData.frontmatter, mdData.body);
|
||||
} catch (error) {
|
||||
if (directoriesDiffer && fs.existsSync(newDir) && !fs.existsSync(oldDir)) {
|
||||
try {
|
||||
fs.renameSync(newDir, oldDir);
|
||||
} catch {
|
||||
// Best-effort rollback; surface the original write failure.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
type OpenCodeGoCredential = { apiKey: string };
|
||||
|
||||
const toWindow = (usedPercent: number, resetAt: string) => ({
|
||||
usedPercent: Math.min(100, Math.max(0, usedPercent)),
|
||||
remainingPercent: 100 - Math.min(100, Math.max(0, usedPercent)),
|
||||
windowSeconds: null,
|
||||
resetAfterSeconds: Math.max(0, Math.floor((new Date(resetAt).getTime() - Date.now()) / 1000)),
|
||||
resetAt: new Date(resetAt).getTime(),
|
||||
resetAtFormatted: null,
|
||||
resetAfterFormatted: null,
|
||||
});
|
||||
|
||||
export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => {
|
||||
const response = await fetch('https://opencode.ai/zen/go/v1/usage', { headers: { Accept: 'application/json', Authorization: `Bearer ${credential.apiKey}` }, signal: AbortSignal.timeout(15_000) });
|
||||
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) throw new Error('OpenCode Go authentication failed');
|
||||
if (!response.ok) throw new Error(`OpenCode Go usage API returned HTTP ${response.status}`);
|
||||
const payload = await response.json().catch(() => null) as { usage?: Record<string, { percent?: unknown; resetsAt?: unknown }> } | null;
|
||||
const windows: Record<string, ReturnType<typeof toWindow>> = {};
|
||||
for (const [key, apiKey] of Object.entries({ '5h': 'rolling', weekly: 'weekly', monthly: 'monthly' })) {
|
||||
const entry = payload?.usage?.[apiKey];
|
||||
if (typeof entry?.percent !== 'number' || !Number.isFinite(entry.percent) || typeof entry.resetsAt !== 'string' || !Number.isFinite(new Date(entry.resetsAt).getTime())) continue;
|
||||
windows[key] = toWindow(entry.percent, entry.resetsAt);
|
||||
}
|
||||
if (!Object.keys(windows).length) throw new Error('OpenCode Go usage data could not be parsed');
|
||||
return windows;
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
// Managed OpenCode process registry + orphan reaper — VS Code parity copy.
|
||||
//
|
||||
// The VS Code extension does NOT bundle the web package, so it cannot import
|
||||
// the web runtime's registry module. This is a parity implementation that
|
||||
// reads/writes the SAME on-disk registry directory and uses the SAME algorithm,
|
||||
// so a process spawned by any runtime (web, desktop, VS Code) can be reaped by
|
||||
// any other.
|
||||
//
|
||||
// Storage is ONE FILE PER SPAWNED PROCESS (`<childPid>.json`) in a registry
|
||||
// directory — never a single shared JSON file — because multiple runtimes and
|
||||
// windows run concurrently and a shared file would be clobbered by the
|
||||
// read-modify-write race. Per-process files mean each instance only ever writes
|
||||
// or deletes its OWN file.
|
||||
//
|
||||
// See packages/web/server/lib/opencode/managed-process-registry.js for the full
|
||||
// rationale and safety model. In short: we only ever kill pids THIS product
|
||||
// recorded, re-verified as a live `opencode serve`, and only when their spawner
|
||||
// is provably gone (reparented to pid 1, or recorded owner pid dead).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
type ManagedProcessEntry = {
|
||||
pid: number;
|
||||
ownerPid: number;
|
||||
port: number | null;
|
||||
binary: string | null;
|
||||
runtime: string;
|
||||
startedAt: string;
|
||||
};
|
||||
|
||||
const resolveRegistryDir = (): string => {
|
||||
const override = process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY;
|
||||
if (override && override.trim()) return override.trim();
|
||||
return path.join(os.homedir(), '.config', 'openchamber', 'managed-opencode');
|
||||
};
|
||||
|
||||
const entryFilePath = (pid: number): string => path.join(resolveRegistryDir(), `${pid}.json`);
|
||||
|
||||
const writeEntryFile = (entry: ManagedProcessEntry): void => {
|
||||
const dir = resolveRegistryDir();
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const filePath = path.join(dir, `${entry.pid}.json`);
|
||||
const tmp = `${filePath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(entry, null, 2));
|
||||
fs.renameSync(tmp, filePath);
|
||||
} catch {
|
||||
// Best-effort: a failed registry write must never break spawn/shutdown.
|
||||
}
|
||||
};
|
||||
|
||||
const readAllEntries = (): Array<{ entry: ManagedProcessEntry; filePath: string }> => {
|
||||
const dir = resolveRegistryDir();
|
||||
let names: string[] = [];
|
||||
try {
|
||||
names = fs.readdirSync(dir).filter((name) => name.endsWith('.json'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: Array<{ entry: ManagedProcessEntry; filePath: string }> = [];
|
||||
for (const name of names) {
|
||||
const filePath = path.join(dir, name);
|
||||
try {
|
||||
const entry = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (entry && Number.isInteger(entry.pid)) {
|
||||
out.push({ entry: entry as ManagedProcessEntry, filePath });
|
||||
} else {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
}
|
||||
} catch {
|
||||
try { fs.rmSync(filePath, { force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const registerManagedProcess = (input: {
|
||||
pid: number | undefined;
|
||||
ownerPid?: number;
|
||||
port?: number | null;
|
||||
binary?: string | null;
|
||||
runtime?: string;
|
||||
}): void => {
|
||||
const pid = input.pid;
|
||||
if (!Number.isInteger(pid)) return;
|
||||
writeEntryFile({
|
||||
pid: pid as number,
|
||||
ownerPid: Number.isInteger(input.ownerPid) ? (input.ownerPid as number) : process.pid,
|
||||
port: Number.isInteger(input.port as number) ? (input.port as number) : null,
|
||||
binary: typeof input.binary === 'string' ? input.binary : null,
|
||||
runtime: typeof input.runtime === 'string' ? input.runtime : 'vscode',
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
export const unregisterManagedProcess = (pid: number | undefined): void => {
|
||||
if (!Number.isInteger(pid)) return;
|
||||
try {
|
||||
fs.rmSync(entryFilePath(pid as number), { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const isPidAlive = (pid: number): boolean => {
|
||||
if (!Number.isInteger(pid)) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException)?.code === 'EPERM';
|
||||
}
|
||||
};
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const readUnixProcInfo = (pid: number): { ppid: number; command: string } | null => {
|
||||
try {
|
||||
const result = spawnSync('ps', ['-p', String(pid), '-o', 'ppid=,command='], {
|
||||
encoding: 'utf8',
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const line = (result.stdout || '').trim();
|
||||
if (!line) return null;
|
||||
const match = line.match(/^\s*(\d+)\s+(.*)$/);
|
||||
if (!match) return null;
|
||||
return { ppid: Number.parseInt(match[1], 10), command: match[2] };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const readWindowsImageName = (pid: number): string | null => {
|
||||
try {
|
||||
const result = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return (result.stdout || '').trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const commandIdentifiesOurServer = (command: string, entry: ManagedProcessEntry): boolean => {
|
||||
if (typeof command !== 'string') return false;
|
||||
const lower = command.toLowerCase();
|
||||
if (!lower.includes('opencode') || !lower.includes('serve')) return false;
|
||||
if (Number.isInteger(entry.port) && !command.includes(String(entry.port))) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const killOrphan = async (pid: number): Promise<void> => {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000, windowsHide: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const signalTree = (signal: NodeJS.Signals) => {
|
||||
try { process.kill(-pid, signal); } catch { /* ignore */ }
|
||||
try { process.kill(pid, signal); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
signalTree('SIGTERM');
|
||||
for (let waited = 0; waited < 1500 && isPidAlive(pid); waited += 150) {
|
||||
await sleep(150);
|
||||
}
|
||||
if (isPidAlive(pid)) {
|
||||
signalTree('SIGKILL');
|
||||
await sleep(300);
|
||||
}
|
||||
};
|
||||
|
||||
const processEntry = async (
|
||||
entry: ManagedProcessEntry,
|
||||
log?: (message: string) => void,
|
||||
): Promise<boolean> => {
|
||||
if (!isPidAlive(entry.pid)) return false;
|
||||
|
||||
const ownerGone = Number.isInteger(entry.ownerPid) && !isPidAlive(entry.ownerPid);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const image = readWindowsImageName(entry.pid);
|
||||
const looksLikeOpencode = typeof image === 'string' && image.toLowerCase().includes('opencode');
|
||||
if (looksLikeOpencode && ownerGone) {
|
||||
await killOrphan(entry.pid);
|
||||
log?.(`[opencode] reaped orphaned process pid ${entry.pid} (owner ${entry.ownerPid} gone)`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const info = readUnixProcInfo(entry.pid);
|
||||
if (!info || !commandIdentifiesOurServer(info.command, entry)) return false;
|
||||
|
||||
const orphaned = info.ppid === 1 || ownerGone;
|
||||
if (!orphaned) return false;
|
||||
|
||||
await killOrphan(entry.pid);
|
||||
log?.(`[opencode] reaped orphaned process pid ${entry.pid} (reparented/owner gone)`);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const reapOrphanedProcesses = async (
|
||||
options: { log?: (message: string) => void } = {},
|
||||
): Promise<{ inspected: number; reaped: number }> => {
|
||||
const { log } = options;
|
||||
const records = readAllEntries();
|
||||
if (records.length === 0) return { inspected: 0, reaped: 0 };
|
||||
|
||||
let reaped = 0;
|
||||
for (const { entry, filePath } of records) {
|
||||
let drop = false;
|
||||
try {
|
||||
const wasReaped = await processEntry(entry, log);
|
||||
if (wasReaped) reaped += 1;
|
||||
drop = wasReaped || !isPidAlive(entry.pid);
|
||||
} catch (error) {
|
||||
log?.(`[opencode] reap check failed for pid ${entry.pid}: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
if (drop) {
|
||||
try { fs.rmSync(filePath, { force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
return { inspected: records.length, reaped };
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { applyProviderEnvAliases as fromVscode } from './provider-env-aliases';
|
||||
import { applyProviderEnvAliases as fromWeb } from '../../web/server/lib/opencode/provider-env-aliases.js';
|
||||
|
||||
describe('provider env alias parity (vscode ↔ web)', () => {
|
||||
test('mirrors GEMINI_API_KEY onto Google Generative AI env names', () => {
|
||||
const input = {
|
||||
GEMINI_API_KEY: 'AIza-demo',
|
||||
PATH: '/usr/bin',
|
||||
};
|
||||
assert.deepEqual(fromVscode(input), fromWeb(input));
|
||||
assert.deepEqual(fromVscode(input), {
|
||||
GEMINI_API_KEY: 'AIza-demo',
|
||||
GOOGLE_API_KEY: 'AIza-demo',
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: 'AIza-demo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not overwrite an already-set preferred Google key', () => {
|
||||
const input = {
|
||||
GEMINI_API_KEY: 'from-gemini',
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: 'from-google',
|
||||
};
|
||||
assert.deepEqual(fromVscode(input), fromWeb(input));
|
||||
});
|
||||
|
||||
test('returns empty object for invalid input', () => {
|
||||
assert.deepEqual(fromVscode(null as unknown as NodeJS.ProcessEnv), fromWeb(null));
|
||||
assert.deepEqual(fromVscode(undefined as unknown as NodeJS.ProcessEnv), fromWeb(undefined));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Shared with packages/web/server/lib/opencode/provider-env-aliases.js via esbuild
|
||||
* bundling. Keep this module as a thin re-export so web and VS Code cannot diverge.
|
||||
*/
|
||||
export { applyProviderEnvAliases } from '../../web/server/lib/opencode/provider-env-aliases.js';
|
||||
@@ -0,0 +1,72 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
export type ManagedProvider = 'ollama-cloud' | 'cursor';
|
||||
export type ManagedCredential = Record<string, string>;
|
||||
const providers = new Set<ManagedProvider>(['ollama-cloud', 'cursor']);
|
||||
const directory = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota');
|
||||
const target = (provider: ManagedProvider) => {
|
||||
if (!providers.has(provider)) throw new Error('Unsupported credential provider');
|
||||
return path.join(directory(), `${provider}.json`);
|
||||
};
|
||||
const clean = (value: unknown) => typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : '';
|
||||
|
||||
export const normalizeCredential = (provider: ManagedProvider, value: unknown): ManagedCredential | null => {
|
||||
const data = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||
if (provider === 'ollama-cloud') return clean(data.cookie) ? { cookie: clean(data.cookie) } : null;
|
||||
const accessToken = clean(data.accessToken);
|
||||
const refreshToken = clean(data.refreshToken);
|
||||
return accessToken || refreshToken ? { accessToken, refreshToken } : null;
|
||||
};
|
||||
|
||||
export const readCredential = (provider: ManagedProvider) => {
|
||||
try { return normalizeCredential(provider, JSON.parse(fs.readFileSync(target(provider), 'utf8'))); }
|
||||
catch (error) { if ((error as { code?: string }).code !== 'ENOENT') console.warn(`Failed to read ${provider} quota credentials`); return null; }
|
||||
};
|
||||
export const credentialStatus = (provider: ManagedProvider) => {
|
||||
const value = readCredential(provider);
|
||||
if (!value) return { configured: false };
|
||||
return { configured: true, ...(provider === 'cursor' ? { hasRefreshToken: Boolean(value.refreshToken) } : {}), secretMasked: '••••••••' };
|
||||
};
|
||||
export const writeCredential = (provider: ManagedProvider, value: ManagedCredential) => {
|
||||
const dir = directory(); const file = target(provider); const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.chmodSync(dir, 0o700);
|
||||
try { fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); fs.renameSync(temp, file); fs.chmodSync(file, 0o600); }
|
||||
finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
|
||||
return credentialStatus(provider);
|
||||
};
|
||||
export const deleteCredential = (provider: ManagedProvider) => { try { fs.unlinkSync(target(provider)); } catch (error) { if ((error as { code?: string }).code !== 'ENOENT') throw error; } };
|
||||
export const deleteLegacyOpenCodeGoCredential = () => {
|
||||
try { fs.unlinkSync(path.join(directory(), 'opencode-go.json')); } catch (error) { if ((error as { code?: string }).code !== 'ENOENT') throw error; }
|
||||
};
|
||||
|
||||
export const importCursorCredential = () => {
|
||||
const db = path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
|
||||
if (process.platform !== 'darwin' || !fs.existsSync(db)) throw new Error('Cursor credential import is unavailable');
|
||||
const rows = JSON.parse(execFileSync('sqlite3', ['-json', db, "SELECT key,value FROM ItemTable WHERE key IN ('cursorAuth/accessToken','cursorAuth/refreshToken');"], { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }) || '[]') as Array<{ key: string; value: string }>;
|
||||
const credential = normalizeCredential('cursor', { accessToken: rows.find((row) => row.key.endsWith('accessToken'))?.value, refreshToken: rows.find((row) => row.key.endsWith('refreshToken'))?.value });
|
||||
if (!credential) throw new Error('Cursor credentials are unavailable');
|
||||
return credential;
|
||||
};
|
||||
|
||||
export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => {
|
||||
if (provider === 'ollama-cloud') {
|
||||
const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) });
|
||||
if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed');
|
||||
const html = await response.text();
|
||||
if (!/Session\s+usage|Weekly\s+usage|Premium[^0-9]*[0-9]+\s*\/\s*[0-9]+/i.test(html)) throw new Error('Ollama Cloud usage data could not be parsed');
|
||||
}
|
||||
if (provider === 'cursor') {
|
||||
if (!credential.accessToken && credential.refreshToken) {
|
||||
const refresh = await fetch('https://api2.cursor.sh/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', client_id: 'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB', refresh_token: credential.refreshToken }), signal: AbortSignal.timeout(15_000) });
|
||||
const payload = await refresh.json().catch(() => null) as { access_token?: string } | null;
|
||||
if (!refresh.ok || !payload?.access_token) throw new Error('Cursor authentication failed');
|
||||
credential.accessToken = payload.access_token;
|
||||
}
|
||||
if (!credential.accessToken) throw new Error('Cursor access token is required');
|
||||
const response = await fetch('https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage', { method: 'POST', headers: { Authorization: `Bearer ${credential.accessToken}`, 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' }, body: '{}', signal: AbortSignal.timeout(15_000) });
|
||||
if (!response.ok) throw new Error('Cursor authentication failed');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,701 @@
|
||||
import { after, afterEach, beforeEach, describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const previousQuotaDataDirectory = process.env.OPENCHAMBER_DATA_DIR;
|
||||
const temporaryQuotaDataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-quota-'));
|
||||
process.env.OPENCHAMBER_DATA_DIR = temporaryQuotaDataDirectory;
|
||||
|
||||
// readAuthFile reads ~/.local/share/opencode/auth.json via fs.readFileSync.
|
||||
// Stub fs to serve a known auth entry so the providers treat themselves as
|
||||
// configured and proceed straight to fetch.
|
||||
const ORIGINAL_FS = { ...fs };
|
||||
const AUTH = JSON.stringify({
|
||||
openai: { access: 'test-token' },
|
||||
crof: { key: 'test-token' },
|
||||
neuralwatt: { key: 'test-token' },
|
||||
'opencode-go': { key: 'test-token' },
|
||||
'command-code': { type: 'oauth', access: 'test-token' },
|
||||
'zai-coding-plan': { key: 'test-token' },
|
||||
deepseek: { key: 'test-token' },
|
||||
anthropic: { access: 'test-token', refresh: 'test-refresh' },
|
||||
});
|
||||
((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true;
|
||||
((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH;
|
||||
|
||||
import { fetchQuotaForProvider } from './quotaProviders';
|
||||
|
||||
type MockResponseInit = { ok?: boolean; status?: number };
|
||||
|
||||
after(() => {
|
||||
if (previousQuotaDataDirectory === undefined) delete process.env.OPENCHAMBER_DATA_DIR;
|
||||
else process.env.OPENCHAMBER_DATA_DIR = previousQuotaDataDirectory;
|
||||
fs.rmSync(temporaryQuotaDataDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const mockResponse = (body: unknown, init: MockResponseInit = {}): Response => ({
|
||||
ok: 'ok' in init ? init.ok! : true,
|
||||
status: init.status ?? 200,
|
||||
json: async () => body,
|
||||
} as unknown as Response);
|
||||
|
||||
// Documented NeuralWatt payload from https://portal.neuralwatt.com/docs/api/quota.
|
||||
// plan="standard", kwh_included=20.0, kwh_used=13.9023.
|
||||
const DOCUMENTED_SUBSCRIPTION_PAYLOAD = {
|
||||
snapshot_at: '2026-04-16T18:30:00Z',
|
||||
balance: { credits_remaining_usd: 32.6774, total_credits_usd: 52.34, credits_used_usd: 19.6626, accounting_method: 'energy' },
|
||||
usage: {
|
||||
lifetime: { cost_usd: 243.9145, requests: 37801, tokens: 1235477176, energy_kwh: 15.6009 },
|
||||
current_month: { cost_usd: 160.1463, requests: 23902, tokens: 1116658995, energy_kwh: 9.7278 },
|
||||
},
|
||||
limits: { overage_limit_usd: null, rate_limit_tier: 'standard' },
|
||||
subscription: {
|
||||
plan: 'standard',
|
||||
status: 'active',
|
||||
billing_interval: 'year',
|
||||
current_period_start: '2026-04-11T05:05:25Z',
|
||||
current_period_end: '2027-04-11T05:05:25Z',
|
||||
auto_renew: true,
|
||||
kwh_included: 20.0,
|
||||
kwh_used: 13.9023,
|
||||
kwh_remaining: 6.0977,
|
||||
in_overage: false,
|
||||
},
|
||||
key: { name: 'my-production-key', allowance: null },
|
||||
} as const;
|
||||
|
||||
let ORIGINAL_FETCH: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
ORIGINAL_FETCH = globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
});
|
||||
|
||||
const stubFetchReturning = (resolver: () => Promise<unknown>): void => {
|
||||
globalThis.fetch = (async () => resolver()) as typeof fetch;
|
||||
};
|
||||
|
||||
const stubFetchFailing = (json: () => Promise<unknown>, init: MockResponseInit): void => {
|
||||
globalThis.fetch = (async () => ({ json, ...init }) as unknown as Response) as typeof fetch;
|
||||
};
|
||||
|
||||
describe('OpenCode Go quota provider (VS Code parity)', () => {
|
||||
test('uses the opencode-go key from auth.json', async () => {
|
||||
let request: RequestInit | undefined;
|
||||
const legacyPath = path.join(temporaryQuotaDataDirectory, 'quota', 'opencode-go.json');
|
||||
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
|
||||
fs.writeFileSync(legacyPath, '{not valid json');
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
request = init;
|
||||
return mockResponse({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } });
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchQuotaForProvider('opencode-go');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal((request?.headers as Record<string, string>).Authorization, 'Bearer test-token');
|
||||
assert.equal(result.usage!.windows['5h']!.usedPercent, 25);
|
||||
assert.throws(() => fs.statSync(legacyPath));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command Code quota provider (VS Code parity)', () => {
|
||||
test('uses the OAuth access token and resolves server-backed limits', async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
||||
requests.push({ url, init });
|
||||
return mockResponse(url.endsWith('/alpha/whoami')
|
||||
? { org: { id: 'org/a' } }
|
||||
: { credits: { monthlyCredits: 120 }, windowLimits: { fiveHour: { used: 25, cap: 100, resetAt: 1_776_000_000 } } });
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchQuotaForProvider('command-code');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(requests.map(({ url }) => url), [
|
||||
'https://api.commandcode.ai/alpha/whoami',
|
||||
'https://api.commandcode.ai/alpha/billing/credits?orgId=org%2Fa',
|
||||
]);
|
||||
assert.equal((requests[0].init?.headers as Record<string, string>).Authorization, 'Bearer test-token');
|
||||
assert.equal(result.usage!.windows['5h']!.usedPercent, 25);
|
||||
assert.equal(result.usage!.windows.monthly_credits!.valueLabel, '120');
|
||||
});
|
||||
|
||||
test('omits orgId for personal accounts', async () => {
|
||||
const urls: string[] = [];
|
||||
globalThis.fetch = (async (url: string) => {
|
||||
urls.push(url);
|
||||
return mockResponse(url.endsWith('/alpha/whoami')
|
||||
? { user: { id: 'user-1' }, org: null }
|
||||
: { credits: { monthlyCredits: 120 } });
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchQuotaForProvider('command-code');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(urls, [
|
||||
'https://api.commandcode.ai/alpha/whoami',
|
||||
'https://api.commandcode.ai/alpha/billing/credits',
|
||||
]);
|
||||
});
|
||||
|
||||
test('formats fractional credit values for display', async () => {
|
||||
globalThis.fetch = (async (url: string) => mockResponse(url.endsWith('/alpha/whoami')
|
||||
? { org: null }
|
||||
: { credits: { monthlyCredits: 69.7947070034 }, windowLimits: { fiveHour: { used: 0.2052929966, cap: 14 } } })) as typeof fetch;
|
||||
|
||||
const result = await fetchQuotaForProvider('command-code');
|
||||
|
||||
assert.equal(result.usage!.windows.monthly_credits!.valueLabel, '69.79');
|
||||
assert.equal(result.usage!.windows['5h']!.valueLabel, '0.21 / 14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Crof quota provider (VS Code parity)', () => {
|
||||
test('reports credits balance as valueLabel with null percent', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({ usable_requests: 450, credits: 12.3456 })));
|
||||
|
||||
const result = await fetchQuotaForProvider('crof');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.providerId, 'crof');
|
||||
assert.equal(result.usage!.windows.credits!.usedPercent, null);
|
||||
assert.equal(result.usage!.windows.credits!.valueLabel, '$12.35');
|
||||
});
|
||||
|
||||
test('tolerates missing credits field', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({ usable_requests: 0 })));
|
||||
|
||||
const result = await fetchQuotaForProvider('crof');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.usage!.windows.credits!.valueLabel, undefined);
|
||||
assert.equal(result.usage!.windows.credits!.usedPercent, null);
|
||||
});
|
||||
|
||||
test('maps 401 to session-expired with CrofAI branding', async () => {
|
||||
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
|
||||
|
||||
const result = await fetchQuotaForProvider('crof');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.configured, true);
|
||||
assert.equal(result.error, 'Session expired — please re-authenticate with CrofAI');
|
||||
});
|
||||
|
||||
test('reports invalid-response on JSON parse failure', async () => {
|
||||
globalThis.fetch = (async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => { throw new SyntaxError('Unexpected token'); },
|
||||
}) as unknown as Response) as typeof fetch;
|
||||
|
||||
const result = await fetchQuotaForProvider('crof');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'Invalid response from provider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codex quota provider (VS Code parity)', () => {
|
||||
test('coalesces concurrent refreshes for the same provider', async () => {
|
||||
let resolveResponse: ((response: Response) => void) | undefined;
|
||||
let requestCount = 0;
|
||||
globalThis.fetch = (() => {
|
||||
requestCount += 1;
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const first = fetchQuotaForProvider('codex');
|
||||
const second = fetchQuotaForProvider('codex');
|
||||
resolveResponse?.(mockResponse({ rate_limit: null }));
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
|
||||
assert.equal(firstResult.ok, true);
|
||||
assert.equal(secondResult.ok, true);
|
||||
assert.equal(requestCount, 1);
|
||||
});
|
||||
|
||||
test('surfaces spend_control individual limit for business accounts', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
plan_type: 'business',
|
||||
rate_limit: null,
|
||||
credits: { has_credits: true, unlimited: false, balance: null },
|
||||
spend_control: {
|
||||
individual_limit: {
|
||||
limit: '7500',
|
||||
used: '2674.8724080324173',
|
||||
remaining: '4825.127591967583',
|
||||
used_percent: 36,
|
||||
remaining_percent: 64,
|
||||
},
|
||||
},
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('codex');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.usage!.windows.credits!.usedPercent, 36);
|
||||
assert.equal(result.usage!.windows.credits!.valueLabel, '2675 / 7500 used');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude quota provider (VS Code parity)', () => {
|
||||
test('parses current limits, model-scoped limits, and extra usage', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
limits: [
|
||||
{ kind: 'session', percent: 12, resets_at: '2026-08-20T12:00:00Z', scope: null },
|
||||
{ kind: 'weekly_all', percent: 34, resets_at: '2026-08-24T12:00:00Z', scope: null },
|
||||
{ kind: 'weekly_scoped', percent: 56, resets_at: '2026-08-24T12:00:00Z', scope: { model: { display_name: 'Sonnet' } } },
|
||||
],
|
||||
spend: {
|
||||
enabled: true,
|
||||
percent: 25,
|
||||
used: { amount_minor: 2500, exponent: 2, currency: 'USD' },
|
||||
limit: { amount_minor: 10000, exponent: 2, currency: 'USD' },
|
||||
},
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('claude');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.usage?.windows['5h']?.usedPercent, 12);
|
||||
assert.equal(result.usage?.windows['7d']?.usedPercent, 34);
|
||||
assert.equal(result.usage?.models?.Sonnet?.windows['7d']?.usedPercent, 56);
|
||||
assert.equal(result.usage?.windows.extra_usage?.valueLabel, '$25.00 / $100.00');
|
||||
});
|
||||
|
||||
test('keeps serving the last good values while Anthropic rate limits', async () => {
|
||||
const responses = [
|
||||
mockResponse({ five_hour: { utilization: 12, resets_at: '2026-08-20T12:00:00Z' } }),
|
||||
{
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: new Headers({ 'retry-after': '120' }),
|
||||
json: async () => ({}),
|
||||
} as Response,
|
||||
];
|
||||
let requestCount = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
const response = responses[requestCount];
|
||||
requestCount += 1;
|
||||
return response;
|
||||
}) as typeof fetch;
|
||||
|
||||
const initial = await fetchQuotaForProvider('claude');
|
||||
const rateLimited = await fetchQuotaForProvider('claude');
|
||||
const duringCooldown = await fetchQuotaForProvider('claude');
|
||||
|
||||
assert.equal(initial.ok, true);
|
||||
assert.equal(rateLimited.ok, true);
|
||||
assert.equal(duringCooldown.ok, true);
|
||||
assert.equal(duringCooldown.usage?.windows['5h']?.usedPercent, 12);
|
||||
assert.equal(requestCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Z.ai quota provider (VS Code parity)', () => {
|
||||
test('surfaces 5-hour, weekly, and MCP quota windows', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
data: {
|
||||
limits: [
|
||||
{ type: 'TOKENS_LIMIT', unit: 3, number: 5, percentage: 0 },
|
||||
{ type: 'TOKENS_LIMIT', unit: 6, number: 1, percentage: 100, nextResetTime: 1785659659993 },
|
||||
{ type: 'TIME_LIMIT', unit: 5, number: 1, percentage: 0, nextResetTime: 1787128459979 },
|
||||
],
|
||||
},
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('zai-coding-plan');
|
||||
const windows = result.usage!.windows;
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(windows['5h']!.usedPercent, 0);
|
||||
assert.equal(windows['5h']!.windowSeconds, 5 * 60 * 60);
|
||||
assert.equal(windows.weekly!.usedPercent, 100);
|
||||
assert.equal(windows.weekly!.windowSeconds, 7 * 24 * 60 * 60);
|
||||
assert.equal(windows.weekly!.resetAt, 1785659659993);
|
||||
assert.equal(windows['MCP Tools']!.usedPercent, 0);
|
||||
assert.equal(windows['MCP Tools']!.windowSeconds, 30 * 24 * 60 * 60);
|
||||
assert.equal(windows['MCP Tools']!.resetAt, 1787128459979);
|
||||
});
|
||||
|
||||
test('maps CREDIT_LIMIT entries to windows with credit value labels and plan level', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
code: 200,
|
||||
data: {
|
||||
limits: [
|
||||
{ type: 'CREDIT_LIMIT', unit: 3, number: 5, usage: 12000, currentValue: 65, remaining: 11934, percentage: 1, nextResetTime: 1787257978907 },
|
||||
{ type: 'CREDIT_LIMIT', unit: 6, number: 1, usage: 60000, currentValue: 65, remaining: 59934, percentage: 1, nextResetTime: 1787844668997 },
|
||||
],
|
||||
level: 'pro',
|
||||
},
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('zai-coding-plan');
|
||||
const windows = result.usage!.windows;
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.planLabel, 'pro');
|
||||
assert.equal(windows['5h']!.usedPercent, 1);
|
||||
assert.equal(windows['5h']!.windowSeconds, 5 * 60 * 60);
|
||||
assert.equal(windows['5h']!.resetAt, 1787257978907);
|
||||
assert.equal(windows['5h']!.valueLabel, '65 / 12k credits');
|
||||
assert.equal(windows.weekly!.usedPercent, 1);
|
||||
assert.equal(windows.weekly!.windowSeconds, 7 * 24 * 60 * 60);
|
||||
assert.equal(windows.weekly!.resetAt, 1787844668997);
|
||||
assert.equal(windows.weekly!.valueLabel, '65 / 60k credits');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NeuralWatt quota provider (VS Code parity)', () => {
|
||||
test('builds subscription window keyed by plan name (windowSeconds null)', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse(DOCUMENTED_SUBSCRIPTION_PAYLOAD)));
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.providerId, 'neuralwatt');
|
||||
|
||||
// Subscription window is keyed by the plan name; windowSeconds is null
|
||||
// because the API exposes no kWh window start to derive duration from.
|
||||
const window = result.usage!.windows.standard;
|
||||
assert.ok(window, 'subscription window should be defined');
|
||||
assert.ok(Math.abs((window.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2);
|
||||
assert.equal(window.windowSeconds, null);
|
||||
assert.equal(window.resetAt, Date.parse('2027-04-11T05:05:25Z'));
|
||||
|
||||
// allowance is null → credits_balance also surfaced
|
||||
assert.ok(result.usage!.windows.credits_balance, 'credits_balance should be defined');
|
||||
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68');
|
||||
});
|
||||
|
||||
test('falls back to plan_limit title when plan is missing', async () => {
|
||||
const payload = {
|
||||
...DOCUMENTED_SUBSCRIPTION_PAYLOAD,
|
||||
subscription: { ...DOCUMENTED_SUBSCRIPTION_PAYLOAD.subscription, plan: null },
|
||||
};
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
assert.ok(result.usage!.windows.plan_limit);
|
||||
assert.ok(Math.abs((result.usage!.windows.plan_limit!.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2);
|
||||
});
|
||||
|
||||
test('marks in-overage subscription as 100%, still shows credits', async () => {
|
||||
const payload = {
|
||||
...DOCUMENTED_SUBSCRIPTION_PAYLOAD,
|
||||
subscription: { ...DOCUMENTED_SUBSCRIPTION_PAYLOAD.subscription, in_overage: true, kwh_used: 25.0 },
|
||||
};
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
const window = result.usage!.windows.standard;
|
||||
assert.ok(window);
|
||||
assert.equal(window!.usedPercent, 100);
|
||||
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68');
|
||||
});
|
||||
|
||||
test('surfaces subscription and allowance windows (allowance keyed by period, key name in valueLabel)', async () => {
|
||||
const payload = {
|
||||
...DOCUMENTED_SUBSCRIPTION_PAYLOAD,
|
||||
balance: { credits_remaining_usd: 200 },
|
||||
key: {
|
||||
name: 'Prod',
|
||||
allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' },
|
||||
},
|
||||
};
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
const subWindow = result.usage!.windows.standard;
|
||||
assert.ok(subWindow);
|
||||
assert.ok(Math.abs((subWindow!.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2);
|
||||
|
||||
// Allowance window is keyed by the localized period label ("monthly");
|
||||
// key name flows through valueLabel for identification.
|
||||
const allowWindow = result.usage!.windows.monthly;
|
||||
assert.ok(allowWindow);
|
||||
assert.equal(allowWindow!.usedPercent, 25);
|
||||
assert.equal(allowWindow!.valueLabel, 'Prod');
|
||||
assert.equal(allowWindow!.resetAt, Date.parse('2026-08-01T00:00:00Z'));
|
||||
|
||||
assert.equal(result.usage!.windows.credits_balance, undefined);
|
||||
});
|
||||
|
||||
test('uses allowance effective limit = min(limit, credits_remaining + spent)', async () => {
|
||||
const payload = {
|
||||
balance: { credits_remaining_usd: 30 },
|
||||
subscription: null,
|
||||
key: {
|
||||
name: 'prod-key',
|
||||
allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' },
|
||||
},
|
||||
};
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
const window = result.usage!.windows.monthly;
|
||||
assert.ok(window);
|
||||
// effectiveLimit = min(100, 30+25) = 55; usedPercent = 25/55 * 100 ≈ 45.4545
|
||||
assert.ok(Math.abs((window!.usedPercent as number) - (25 / 55) * 100) < 1e-2);
|
||||
assert.equal(window!.windowSeconds, 30 * 86400);
|
||||
assert.equal(window!.resetAt, Date.parse('2026-08-01T00:00:00Z'));
|
||||
assert.equal(window!.valueLabel, 'prod-key');
|
||||
assert.equal(result.usage!.windows.credits_balance, undefined);
|
||||
});
|
||||
|
||||
test('binds allowance ceiling to limit when limit < credits_remaining + spent', async () => {
|
||||
const payload = {
|
||||
balance: { credits_remaining_usd: 200 },
|
||||
subscription: null,
|
||||
key: {
|
||||
name: 'prod-key',
|
||||
allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' },
|
||||
},
|
||||
};
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
const window = result.usage!.windows.monthly;
|
||||
assert.ok(window);
|
||||
assert.equal(window!.usedPercent, 25);
|
||||
});
|
||||
|
||||
test('uses weekly as the allowance key when period is weekly', async () => {
|
||||
const payload = {
|
||||
balance: { credits_remaining_usd: 200 },
|
||||
subscription: null,
|
||||
key: {
|
||||
name: 'Prod',
|
||||
allowance: { limit_usd: 100, period: 'weekly', spent_usd: 20, blocked: false, reset_at: '2026-07-04T00:00:00Z' },
|
||||
},
|
||||
};
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
const window = result.usage!.windows.weekly;
|
||||
assert.ok(window);
|
||||
assert.equal(window!.windowSeconds, 604800);
|
||||
assert.equal(window!.resetAt, Date.parse('2026-07-04T00:00:00Z'));
|
||||
assert.equal(window!.valueLabel, 'Prod');
|
||||
});
|
||||
|
||||
test('uses daily as the allowance key when period is daily', async () => {
|
||||
const payload = {
|
||||
balance: { credits_remaining_usd: 200 },
|
||||
subscription: null,
|
||||
key: {
|
||||
name: 'Prod',
|
||||
allowance: { limit_usd: 10, period: 'daily', spent_usd: 2, blocked: false, reset_at: '2026-07-04T00:00:00Z' },
|
||||
},
|
||||
};
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
const window = result.usage!.windows.daily;
|
||||
assert.ok(window);
|
||||
assert.equal(window!.windowSeconds, 86400);
|
||||
assert.equal(window!.resetAt, Date.parse('2026-07-04T00:00:00Z'));
|
||||
});
|
||||
|
||||
test('falls back to billing_cycle when allowance period is missing or unknown', async () => {
|
||||
const payload = {
|
||||
balance: { credits_remaining_usd: 200 },
|
||||
subscription: null,
|
||||
key: {
|
||||
name: 'Prod',
|
||||
allowance: { limit_usd: 100, period: 'fortnightly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' },
|
||||
},
|
||||
};
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
const window = result.usage!.windows.billing_cycle;
|
||||
assert.ok(window);
|
||||
assert.equal(window!.usedPercent, 25);
|
||||
});
|
||||
|
||||
test('marks blocked allowance as 100% with valueLabel set', async () => {
|
||||
const payload = {
|
||||
balance: { credits_remaining_usd: 30 },
|
||||
subscription: null,
|
||||
key: {
|
||||
name: 'sample',
|
||||
allowance: { limit_usd: 50, period: 'monthly', spent_usd: 10, blocked: true, reset_at: '2026-08-01T00:00:00Z' },
|
||||
},
|
||||
};
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
const window = result.usage!.windows.monthly;
|
||||
assert.ok(window);
|
||||
assert.equal(window!.usedPercent, 100);
|
||||
assert.equal(window!.valueLabel, 'sample');
|
||||
});
|
||||
|
||||
test('falls back to credits_balance when neither subscription nor allowance exists', async () => {
|
||||
const payload = {
|
||||
balance: { credits_remaining_usd: 32.6774 },
|
||||
subscription: null,
|
||||
key: { name: 'sample', allowance: null },
|
||||
};
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68');
|
||||
assert.equal(result.usage!.windows.credits_balance!.usedPercent, null);
|
||||
});
|
||||
|
||||
test('maps 401 to session-expired', async () => {
|
||||
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'Session expired — please re-authenticate with NeuralWatt');
|
||||
});
|
||||
|
||||
test('reports invalid-response on JSON parse failure', async () => {
|
||||
globalThis.fetch = (async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => { throw new SyntaxError('Unexpected token'); },
|
||||
}) as unknown as Response) as typeof fetch;
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'Invalid response from provider');
|
||||
});
|
||||
|
||||
test('returns no-quota-data on a 200 payload with no usable windows', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
balance: { credits_remaining_usd: null },
|
||||
subscription: null,
|
||||
key: { name: 'sample', allowance: null },
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('neuralwatt');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.configured, true);
|
||||
assert.equal(result.error, 'No quota data in response');
|
||||
assert.equal(result.usage, null);
|
||||
});
|
||||
|
||||
// Restore fs so other test files (which use the real auth file) are unaffected.
|
||||
test('teardown: restore fs', () => {
|
||||
const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown };
|
||||
fsMock.existsSync = ORIGINAL_FS.existsSync;
|
||||
fsMock.readFileSync = ORIGINAL_FS.readFileSync;
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeepSeek quota provider (VS Code parity)', () => {
|
||||
beforeEach(() => {
|
||||
const fsMock = fs as unknown as { existsSync: () => boolean; readFileSync: () => string };
|
||||
fsMock.existsSync = () => true;
|
||||
fsMock.readFileSync = () => AUTH;
|
||||
});
|
||||
|
||||
test('builds credits_balance window from documented USD payload (string balance)', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{ currency: 'USD', total_balance: '7.54', granted_balance: '0.00', topped_up_balance: '7.54' },
|
||||
],
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('deepseek');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.providerId, 'deepseek');
|
||||
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$7.54');
|
||||
assert.equal(result.usage!.windows.credits_balance!.usedPercent, null);
|
||||
assert.equal(result.usage!.windows.credits_balance!.windowSeconds, null);
|
||||
assert.equal(result.usage!.windows.credits_balance!.resetAt, null);
|
||||
});
|
||||
|
||||
test('falls back to CNY entry with ¥ symbol when no USD entry is present', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{ currency: 'CNY', total_balance: '100.00', granted_balance: '0.00', topped_up_balance: '100.00' },
|
||||
],
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('deepseek');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '¥100.00');
|
||||
});
|
||||
|
||||
test('maps 401 to session-expired', async () => {
|
||||
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
|
||||
|
||||
const result = await fetchQuotaForProvider('deepseek');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'Session expired — please re-authenticate with DeepSeek');
|
||||
});
|
||||
|
||||
test('reports a normalized timeout error', async () => {
|
||||
stubFetchReturning(() => Promise.reject(new DOMException('The operation timed out.', 'TimeoutError')));
|
||||
|
||||
const result = await fetchQuotaForProvider('deepseek');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'Request timed out');
|
||||
});
|
||||
|
||||
test('returns no-quota-data on a 200 payload with no usable balance', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
is_available: true,
|
||||
balance_infos: [{ currency: 'USD', total_balance: '', granted_balance: '0.00', topped_up_balance: '0.00' }],
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('deepseek');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.configured, true);
|
||||
assert.equal(result.error, 'No quota data in response');
|
||||
assert.equal(result.usage, null);
|
||||
});
|
||||
|
||||
test('keeps a literal zero balance as a valid valueLabel', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||
is_available: true,
|
||||
balance_infos: [{ currency: 'USD', total_balance: '0.00', granted_balance: '0.00', topped_up_balance: '0.00' }],
|
||||
})));
|
||||
|
||||
const result = await fetchQuotaForProvider('deepseek');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$0.00');
|
||||
});
|
||||
|
||||
test('teardown: restore fs', () => {
|
||||
const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown };
|
||||
fsMock.existsSync = ORIGINAL_FS.existsSync;
|
||||
fsMock.readFileSync = ORIGINAL_FS.readFileSync;
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import yaml from 'yaml';
|
||||
import AdmZip from 'adm-zip';
|
||||
|
||||
import { discoverSkills } from './opencodeConfig';
|
||||
|
||||
@@ -34,16 +33,7 @@ type SkillFrontmatter = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ClawdHubSkillMetadata = {
|
||||
slug: string;
|
||||
version: string;
|
||||
displayName?: string;
|
||||
owner?: string;
|
||||
downloads?: number;
|
||||
stars?: number;
|
||||
};
|
||||
|
||||
export type SkillsCatalogItem = {
|
||||
type SkillsCatalogItem = {
|
||||
repoSource: string;
|
||||
repoSubpath?: string;
|
||||
skillDir: string;
|
||||
@@ -52,9 +42,7 @@ export type SkillsCatalogItem = {
|
||||
description?: string;
|
||||
installable: boolean;
|
||||
warnings?: string[];
|
||||
clawdhub?: ClawdHubSkillMetadata;
|
||||
};
|
||||
|
||||
type SkillsCatalogItemWithBadge = SkillsCatalogItem & {
|
||||
sourceId: string;
|
||||
installed: { isInstalled: boolean; scope?: SkillScope; source?: SkillInstallSource };
|
||||
@@ -76,7 +64,7 @@ type SkillsInstallResult =
|
||||
| { ok: true; installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }>; skipped: Array<{ skillName: string; reason: string }> }
|
||||
| { ok: false; error: SkillsRepoError };
|
||||
|
||||
export const CURATED_SOURCES: CuratedSource[] = [
|
||||
const CURATED_SOURCES: CuratedSource[] = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
@@ -85,309 +73,27 @@ export const CURATED_SOURCES: CuratedSource[] = [
|
||||
defaultSubpath: 'skills',
|
||||
},
|
||||
{
|
||||
id: 'clawdhub',
|
||||
label: 'ClawdHub',
|
||||
description: 'Community skill registry with vector search',
|
||||
source: 'clawdhub:registry',
|
||||
id: 'openai',
|
||||
label: 'OpenAI',
|
||||
description: "OpenAI's curated skills",
|
||||
source: 'openai/skills',
|
||||
defaultSubpath: 'skills/.curated',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
description: "Cursor's plugin skills",
|
||||
source: 'cursor/plugins',
|
||||
defaultSubpath: 'pstack/skills',
|
||||
},
|
||||
{
|
||||
id: 'mattpocock',
|
||||
label: 'Matt Pocock',
|
||||
description: 'Matt Pocock skills collection',
|
||||
source: 'mattpocock/skills',
|
||||
},
|
||||
];
|
||||
|
||||
// ============== ClawdHub API ==============
|
||||
|
||||
const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1';
|
||||
const CLAWDHUB_PAGE_LIMIT = 25;
|
||||
const CLAWDHUB_RATE_LIMIT_MS = 100;
|
||||
let clawdhubLastRequest = 0;
|
||||
|
||||
function isClawdHubSource(source: string): boolean {
|
||||
return typeof source === 'string' && source.startsWith('clawdhub:');
|
||||
}
|
||||
|
||||
async function clawdhubFetch(url: string, options?: RequestInit): Promise<Response> {
|
||||
const maxAttempts = 10;
|
||||
let lastResponse: Response | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - clawdhubLastRequest;
|
||||
if (elapsed < CLAWDHUB_RATE_LIMIT_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, CLAWDHUB_RATE_LIMIT_MS - elapsed));
|
||||
}
|
||||
clawdhubLastRequest = Date.now();
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'OpenChamber-VSCode/1.0',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
lastResponse = response;
|
||||
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
if (attempt < maxAttempts - 1) {
|
||||
const waitMs = 50 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
return lastResponse as Response;
|
||||
}
|
||||
|
||||
type ClawdHubSkillListItem = {
|
||||
slug: string;
|
||||
displayName?: string;
|
||||
summary?: string;
|
||||
tags?: { latest?: string };
|
||||
latestVersion?: { version?: string };
|
||||
stats?: { downloads?: number; stars?: number };
|
||||
owner?: { handle?: string };
|
||||
};
|
||||
|
||||
type ClawdHubSkillsResponse = {
|
||||
items: ClawdHubSkillListItem[];
|
||||
nextCursor?: string;
|
||||
};
|
||||
|
||||
async function scanClawdHub(): Promise<SkillsRepoScanResult> {
|
||||
try {
|
||||
const allItems: SkillsCatalogItem[] = [];
|
||||
let cursor: string | null = null;
|
||||
const maxPages = 20;
|
||||
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const url = cursor
|
||||
? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}&limit=${CLAWDHUB_PAGE_LIMIT}`
|
||||
: `${CLAWDHUB_API_BASE}/skills?limit=${CLAWDHUB_PAGE_LIMIT}`;
|
||||
|
||||
let data: ClawdHubSkillsResponse;
|
||||
|
||||
try {
|
||||
const response = await clawdhubFetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`ClawdHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
data = (await response.json()) as ClawdHubSkillsResponse;
|
||||
} catch (error) {
|
||||
if (page > 0 && allItems.length > 0) {
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const item of data.items || []) {
|
||||
const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0';
|
||||
|
||||
allItems.push({
|
||||
repoSource: 'clawdhub:registry',
|
||||
skillDir: item.slug,
|
||||
skillName: item.slug,
|
||||
frontmatterName: item.displayName || item.slug,
|
||||
description: item.summary || undefined,
|
||||
installable: true,
|
||||
clawdhub: {
|
||||
slug: item.slug,
|
||||
version: latestVersion,
|
||||
displayName: item.displayName,
|
||||
owner: item.owner?.handle,
|
||||
downloads: item.stats?.downloads || 0,
|
||||
stars: item.stats?.stars || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.nextCursor) break;
|
||||
cursor = data.nextCursor;
|
||||
}
|
||||
|
||||
// Sort by downloads (most popular first)
|
||||
allItems.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0));
|
||||
|
||||
return { ok: true, items: allItems };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'networkError',
|
||||
message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadClawdHubSkill(slug: string, version: string): Promise<Buffer> {
|
||||
const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}&version=${encodeURIComponent(version)}`;
|
||||
const response = await clawdhubFetch(url, { headers: { Accept: 'application/zip' } });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ClawdHub download error: ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
}
|
||||
|
||||
type ClawdHubSkillInfoResponse = {
|
||||
skill?: { tags?: { latest?: string } };
|
||||
latestVersion?: { version?: string };
|
||||
};
|
||||
|
||||
async function fetchClawdHubSkillInfo(slug: string): Promise<ClawdHubSkillInfoResponse> {
|
||||
const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`;
|
||||
const response = await clawdhubFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ClawdHub skill error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<ClawdHubSkillInfoResponse>;
|
||||
}
|
||||
|
||||
export async function installSkillsFromClawdHub(options: {
|
||||
scope: SkillScope;
|
||||
targetSource?: SkillInstallSource;
|
||||
workingDirectory?: string;
|
||||
selections: Array<{ skillDir: string; clawdhub?: { slug: string; version: string } }>;
|
||||
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
||||
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
|
||||
}): Promise<SkillsInstallResult> {
|
||||
if (options.scope === 'project' && !options.workingDirectory) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
|
||||
}
|
||||
|
||||
const userSkillDir = getUserSkillBaseDir();
|
||||
const targetSource: SkillInstallSource = options.targetSource === 'agents' ? 'agents' : 'opencode';
|
||||
const requestedSkills = options.selections || [];
|
||||
|
||||
if (requestedSkills.length === 0) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } };
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflicts: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
|
||||
for (const sel of requestedSkills) {
|
||||
const slug = sel.clawdhub?.slug || sel.skillDir;
|
||||
if (!validateSkillName(slug)) continue;
|
||||
|
||||
const targetDir = options.scope === 'user'
|
||||
? (targetSource === 'agents'
|
||||
? path.join(os.homedir(), '.agents', 'skills', slug)
|
||||
: path.join(userSkillDir, slug))
|
||||
: (targetSource === 'agents'
|
||||
? path.join(options.workingDirectory as string, '.agents', 'skills', slug)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skills', slug));
|
||||
|
||||
if (fs.existsSync(targetDir)) {
|
||||
const decision = options.conflictDecisions?.[slug];
|
||||
const hasAutoPolicy = options.conflictPolicy === 'skipAll' || options.conflictPolicy === 'overwriteAll';
|
||||
if (!decision && !hasAutoPolicy) {
|
||||
conflicts.push({ skillName: slug, scope: options.scope, source: targetSource });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
return { ok: false, error: { kind: 'conflicts', message: 'Some skills already exist in the selected scope', conflicts } };
|
||||
}
|
||||
|
||||
const installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
|
||||
const skipped: Array<{ skillName: string; reason: string }> = [];
|
||||
|
||||
for (const sel of requestedSkills) {
|
||||
const slug = sel.clawdhub?.slug || sel.skillDir;
|
||||
let version = sel.clawdhub?.version || 'latest';
|
||||
|
||||
if (!validateSkillName(slug)) {
|
||||
skipped.push({ skillName: slug, reason: 'Invalid skill name' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve 'latest' version
|
||||
if (version === 'latest') {
|
||||
try {
|
||||
const info = await fetchClawdHubSkillInfo(slug);
|
||||
const latest = info.skill?.tags?.latest || info.latestVersion?.version || null;
|
||||
if (latest) {
|
||||
version = latest;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (version === 'latest') {
|
||||
skipped.push({ skillName: slug, reason: 'Unable to resolve latest version' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const targetDir = options.scope === 'user'
|
||||
? (targetSource === 'agents'
|
||||
? path.join(os.homedir(), '.agents', 'skills', slug)
|
||||
: path.join(userSkillDir, slug))
|
||||
: (targetSource === 'agents'
|
||||
? path.join(options.workingDirectory as string, '.agents', 'skills', slug)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skills', slug));
|
||||
|
||||
const exists = fs.existsSync(targetDir);
|
||||
let decision = options.conflictDecisions?.[slug] || null;
|
||||
if (!decision) {
|
||||
if (exists && options.conflictPolicy === 'skipAll') decision = 'skip';
|
||||
if (exists && options.conflictPolicy === 'overwriteAll') decision = 'overwrite';
|
||||
if (!exists) decision = 'overwrite';
|
||||
}
|
||||
|
||||
if (exists && decision === 'skip') {
|
||||
skipped.push({ skillName: slug, reason: 'Already installed (skipped)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exists && decision === 'overwrite') {
|
||||
await safeRm(targetDir);
|
||||
}
|
||||
|
||||
// Download and extract
|
||||
const zipBuffer = await downloadClawdHubSkill(slug, version);
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${slug}-`));
|
||||
|
||||
try {
|
||||
const zip = new AdmZip(zipBuffer);
|
||||
zip.extractAllTo(tempDir, true);
|
||||
|
||||
// Verify SKILL.md exists
|
||||
const skillMdPath = path.join(tempDir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMdPath)) {
|
||||
skipped.push({ skillName: slug, reason: 'SKILL.md not found in downloaded package' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Move to target directory
|
||||
await fs.promises.mkdir(path.dirname(targetDir), { recursive: true });
|
||||
await fs.promises.rename(tempDir, targetDir);
|
||||
|
||||
installed.push({ skillName: slug, scope: options.scope, source: targetSource });
|
||||
} catch (extractError) {
|
||||
await safeRm(tempDir);
|
||||
throw extractError;
|
||||
}
|
||||
} catch (error) {
|
||||
skipped.push({
|
||||
skillName: slug,
|
||||
reason: error instanceof Error ? error.message : 'Failed to download or extract skill',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, installed, skipped };
|
||||
}
|
||||
|
||||
function validateSkillName(skillName: string): boolean {
|
||||
if (skillName.length < 1 || skillName.length > 64) return false;
|
||||
return SKILL_NAME_PATTERN.test(skillName);
|
||||
@@ -883,40 +589,6 @@ export async function getSkillsCatalog(
|
||||
const itemsBySource: Record<string, SkillsCatalogItemWithBadge[]> = {};
|
||||
|
||||
for (const src of sources) {
|
||||
// Handle ClawdHub sources separately (API-based, not git-based)
|
||||
if (isClawdHubSource(src.source)) {
|
||||
const cacheKey = 'clawdhub:registry';
|
||||
let cached = !refresh ? catalogCache.get(cacheKey) : null;
|
||||
if (cached && Date.now() >= cached.expiresAt) {
|
||||
catalogCache.delete(cacheKey);
|
||||
cached = null;
|
||||
}
|
||||
|
||||
let items: SkillsCatalogItem[] = [];
|
||||
if (cached) {
|
||||
items = cached.items;
|
||||
} else {
|
||||
const scanned = await scanClawdHub();
|
||||
if (!scanned.ok) {
|
||||
itemsBySource[src.id] = [];
|
||||
continue;
|
||||
}
|
||||
items = scanned.items || [];
|
||||
catalogCache.set(cacheKey, { expiresAt: Date.now() + CATALOG_TTL_MS, items });
|
||||
}
|
||||
|
||||
itemsBySource[src.id] = items.map((item) => {
|
||||
const installed = installedByName.get(item.skillName);
|
||||
return {
|
||||
sourceId: src.id,
|
||||
...item,
|
||||
installed: installed ? { isInstalled: true, scope: installed.scope, source: installed.source === 'agents' ? 'agents' : 'opencode' } : { isInstalled: false },
|
||||
};
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle GitHub sources (git clone based)
|
||||
const parsed = parseSkillRepoSource(src.source);
|
||||
if (!parsed.ok) {
|
||||
itemsBySource[src.id] = [];
|
||||
@@ -957,5 +629,3 @@ export async function getSkillsCatalog(
|
||||
|
||||
return { ok: true as const, sources, itemsBySource };
|
||||
}
|
||||
|
||||
export { isClawdHubSource };
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { OpenCodeManager } from './opencode';
|
||||
import { openSseProxy } from './sseProxy';
|
||||
|
||||
const createManager = (): OpenCodeManager => ({
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
restart: async () => {},
|
||||
setWorkingDirectory: async (path) => ({ success: true, path }),
|
||||
getStatus: () => 'connected',
|
||||
getApiUrl: () => 'http://127.0.0.1:3902',
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
getWorkingDirectory: () => '/workspace',
|
||||
isCliAvailable: () => true,
|
||||
getDebugInfo: () => ({
|
||||
mode: 'managed',
|
||||
status: 'connected',
|
||||
workingDirectory: '/workspace',
|
||||
cliAvailable: true,
|
||||
cliPath: null,
|
||||
configuredApiUrl: null,
|
||||
configuredPort: null,
|
||||
detectedPort: 3902,
|
||||
apiPrefix: '',
|
||||
apiPrefixDetected: true,
|
||||
startCount: 1,
|
||||
restartCount: 0,
|
||||
lastStartAt: null,
|
||||
lastConnectedAt: null,
|
||||
lastExitCode: null,
|
||||
serverUrl: 'http://127.0.0.1:3902',
|
||||
lastReadyElapsedMs: null,
|
||||
lastReadyAttempts: null,
|
||||
lastStartAttempts: null,
|
||||
version: null,
|
||||
secureConnection: false,
|
||||
authSource: null,
|
||||
}),
|
||||
onStatusChange: (callback) => {
|
||||
callback('connected');
|
||||
return { dispose: () => {} };
|
||||
},
|
||||
});
|
||||
|
||||
describe('VS Code SSE proxy', () => {
|
||||
test('closes a quiet upstream SSE stream after the stall timeout', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
try {
|
||||
globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
})) as typeof fetch;
|
||||
|
||||
const controller = new AbortController();
|
||||
const proxy = await openSseProxy({
|
||||
manager: createManager(),
|
||||
path: '/global/event',
|
||||
signal: controller.signal,
|
||||
stallTimeoutMs: 20,
|
||||
onChunk: () => assert.fail('quiet stream should not emit chunks'),
|
||||
});
|
||||
|
||||
await assert.doesNotReject(proxy.run);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('resets the stall timeout when upstream bytes arrive', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
try {
|
||||
globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
setTimeout(() => controller.enqueue(new TextEncoder().encode(':first\n\n')), 5);
|
||||
setTimeout(() => controller.enqueue(new TextEncoder().encode('data: second\n\n')), 15);
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
})) as typeof fetch;
|
||||
|
||||
const chunks: string[] = [];
|
||||
const controller = new AbortController();
|
||||
const proxy = await openSseProxy({
|
||||
manager: createManager(),
|
||||
path: '/global/event',
|
||||
signal: controller.signal,
|
||||
stallTimeoutMs: 18,
|
||||
onChunk: (chunk) => chunks.push(chunk),
|
||||
});
|
||||
|
||||
await assert.doesNotReject(proxy.run);
|
||||
assert.deepEqual(chunks, [':first\n\n', 'data: second\n\n']);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ type OpenSseProxyOptions = {
|
||||
headers?: Record<string, string>;
|
||||
signal: AbortSignal;
|
||||
onChunk: (chunk: string) => void;
|
||||
stallTimeoutMs?: number;
|
||||
};
|
||||
|
||||
type OpenSseProxyResult = {
|
||||
@@ -22,6 +23,7 @@ const SSE_RESPONSE_HEADERS = {
|
||||
// SSE reconnect configuration
|
||||
const MAX_RECONNECTS = 3;
|
||||
const BASE_RECONNECT_DELAY = 1000; // 1 second
|
||||
const DEFAULT_UPSTREAM_STALL_TIMEOUT_MS = 20000;
|
||||
|
||||
const sleep = (ms: number, signal: AbortSignal) => new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
@@ -117,21 +119,54 @@ const fetchSseResponse = async (
|
||||
return response;
|
||||
};
|
||||
|
||||
const pipeSseResponse = async (response: Response, signal: AbortSignal, onChunk: (chunk: string) => void): Promise<void> => {
|
||||
const resolveStallTimeoutMs = (value: number | undefined): number => (
|
||||
Number.isFinite(value) && typeof value === 'number' ? value : DEFAULT_UPSTREAM_STALL_TIMEOUT_MS
|
||||
);
|
||||
|
||||
const pipeSseResponse = async (
|
||||
response: Response,
|
||||
signal: AbortSignal,
|
||||
onChunk: (chunk: string) => void,
|
||||
stallTimeoutMs?: number,
|
||||
): Promise<void> => {
|
||||
if (!response.body) {
|
||||
throw new Error('OpenCode SSE response missing body');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let stalled = false;
|
||||
let stallTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearStallTimer = () => {
|
||||
if (!stallTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
};
|
||||
|
||||
const resetStallTimer = () => {
|
||||
clearStallTimer();
|
||||
const timeoutMs = resolveStallTimeoutMs(stallTimeoutMs);
|
||||
if (timeoutMs <= 0) {
|
||||
return;
|
||||
}
|
||||
stallTimer = setTimeout(() => {
|
||||
stalled = true;
|
||||
void reader.cancel().catch(() => {});
|
||||
}, timeoutMs);
|
||||
};
|
||||
|
||||
try {
|
||||
resetStallTimer();
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (value && value.length > 0) {
|
||||
resetStallTimer();
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
if (chunk.length > 0) {
|
||||
onChunk(chunk);
|
||||
@@ -143,7 +178,12 @@ const pipeSseResponse = async (response: Response, signal: AbortSignal, onChunk:
|
||||
if (!signal.aborted && remaining.length > 0) {
|
||||
onChunk(remaining);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!stalled) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
clearStallTimer();
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
@@ -163,6 +203,7 @@ export const openSseProxy = async ({
|
||||
headers,
|
||||
signal,
|
||||
onChunk,
|
||||
stallTimeoutMs,
|
||||
}: OpenSseProxyOptions): Promise<OpenSseProxyResult> => {
|
||||
// Reconnect logic with exponential backoff
|
||||
let reconnectAttempts = 0;
|
||||
@@ -208,7 +249,7 @@ export const openSseProxy = async ({
|
||||
const run = (async () => {
|
||||
let activeResponse = response;
|
||||
try {
|
||||
await pipeSseResponse(activeResponse, signal, onChunk);
|
||||
await pipeSseResponse(activeResponse, signal, onChunk, stallTimeoutMs);
|
||||
} catch (error: unknown) {
|
||||
const cause = (error as { cause?: { code?: string } } | null)?.cause;
|
||||
|
||||
@@ -228,7 +269,7 @@ export const openSseProxy = async ({
|
||||
// Attempt to reconnect
|
||||
try {
|
||||
activeResponse = await connect();
|
||||
await pipeSseResponse(activeResponse, signal, onChunk);
|
||||
await pipeSseResponse(activeResponse, signal, onChunk, stallTimeoutMs);
|
||||
return; // Successfully reconnected
|
||||
} catch (reconnectError) {
|
||||
console.error('[SSE] Reconnect failed', reconnectError);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, test } from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./webviewHtml.ts', import.meta.url), 'utf8');
|
||||
|
||||
describe('VS Code webview content security policy', () => {
|
||||
test('allows blob URLs for workers without allowing blob scripts', () => {
|
||||
const workerSource = source.match(/const workerSrc = ([^\n]+);/)?.[1] ?? '';
|
||||
const scriptSource = source.match(/const scriptSrc = ([^\n]+);/)?.[1] ?? '';
|
||||
|
||||
assert.match(workerSource, /'blob:'/);
|
||||
assert.doesNotMatch(scriptSource, /'blob:'/);
|
||||
assert.match(source, /worker-src \$\{workerSrc\}/);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { getThemeKindName } from './theme';
|
||||
import type { ConnectionStatus } from './opencode';
|
||||
import type { WorkspaceFolderCandidate } from './workspaceResolver';
|
||||
|
||||
export type PanelType = 'chat' | 'agentManager';
|
||||
type PanelType = 'chat' | 'agentManager';
|
||||
|
||||
export interface WebviewHtmlOptions {
|
||||
webview: vscode.Webview;
|
||||
@@ -68,6 +68,9 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||
const connectSrc = uniqueTokens(['*', 'ws:', 'wss:', 'http:', 'https:', devServerOrigin]);
|
||||
const imgSrc = uniqueTokens([webview.cspSource, 'data:', 'https:', devServerOrigin]);
|
||||
const fontSrc = uniqueTokens([webview.cspSource, 'data:', devServerOrigin]);
|
||||
// fflate's async browser inflater creates blob-backed workers. Keep blob:
|
||||
// scoped to worker-src so document decompression works without allowing blob scripts.
|
||||
const workerSrc = uniqueTokens([webview.cspSource, 'blob:', devServerOrigin]);
|
||||
|
||||
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
|
||||
|
||||
@@ -84,7 +87,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${styleSrc}; script-src ${scriptSrc}; connect-src ${connectSrc}; img-src ${imgSrc}; font-src ${fontSrc};">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${styleSrc}; script-src ${scriptSrc}; connect-src ${connectSrc}; img-src ${imgSrc}; font-src ${fontSrc}; worker-src ${workerSrc};">
|
||||
<style>
|
||||
html, body, #root { height: 100%; width: 100%; margin: 0; padding: 0; }
|
||||
body {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
/**
|
||||
* When acquireVsCodeApi() returns undefined (broken Cursor/VSCodium webview slot),
|
||||
* getVSCodeAPI().postMessage used to throw:
|
||||
* TypeError: Cannot read properties of undefined (reading 'postMessage')
|
||||
*
|
||||
* The bridge must fall back to a noop API and fail via normal request timeout instead.
|
||||
*/
|
||||
describe('VS Code webview bridge acquireVsCodeApi fallback', () => {
|
||||
test('does not throw TypeError when acquireVsCodeApi returns undefined', async () => {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalAcquire = (globalThis as typeof globalThis & { acquireVsCodeApi?: unknown }).acquireVsCodeApi;
|
||||
const originalWarn = console.warn;
|
||||
const warnings: unknown[][] = [];
|
||||
|
||||
try {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: new EventTarget(),
|
||||
});
|
||||
Object.defineProperty(globalThis, 'acquireVsCodeApi', {
|
||||
configurable: true,
|
||||
value: () => undefined,
|
||||
});
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warnings.push(args);
|
||||
};
|
||||
|
||||
const { sendBridgeMessageWithOptions } = await import(`./bridge?acquire-fallback-${Date.now()}`);
|
||||
|
||||
const result = await sendBridgeMessageWithOptions('api:proxy', { path: '/health' }, { timeoutMs: 20 }).then(
|
||||
() => 'resolved' as const,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
assert.ok(result instanceof Error, `expected Error, got ${String(result)}`);
|
||||
assert.notEqual((result as Error).name, 'TypeError');
|
||||
assert.match((result as Error).message, /timed out/i);
|
||||
assert.ok(
|
||||
warnings.some((entry) => String(entry[0] ?? '').includes('VS Code API unavailable')),
|
||||
'expected a one-time warning that the VS Code API was unavailable',
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
Object.defineProperty(globalThis, 'acquireVsCodeApi', { configurable: true, value: originalAcquire });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ describe('VS Code webview bridge requests', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const { sendBridgeMessageWithOptions } = await import('./bridge');
|
||||
const { sendBridgeMessageWithOptions, startSseProxy } = await import('./bridge');
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
@@ -36,6 +36,19 @@ describe('VS Code webview bridge requests', () => {
|
||||
assert.ok(result instanceof DOMException);
|
||||
assert.equal(result.name, 'AbortError');
|
||||
assert.equal(messages.length, 0);
|
||||
|
||||
const startPromise = startSseProxy({ path: '/global/event', streamId: 'sse_webview_1_1' });
|
||||
const request = messages[0] as { id: string; payload?: { streamId?: string } };
|
||||
assert.equal(request.payload?.streamId, 'sse_webview_1_1');
|
||||
globalThis.window.dispatchEvent(new MessageEvent('message', {
|
||||
data: {
|
||||
id: request.id,
|
||||
type: 'api:sse:start',
|
||||
success: true,
|
||||
data: { status: 200, headers: {}, streamId: 'sse_webview_1_1' },
|
||||
},
|
||||
}));
|
||||
assert.equal((await startPromise).streamId, 'sse_webview_1_1');
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
Object.defineProperty(globalThis, 'acquireVsCodeApi', { configurable: true, value: originalAcquire });
|
||||
|
||||
@@ -9,19 +9,28 @@ interface VSCodeAPI {
|
||||
}
|
||||
|
||||
let vscodeApi: VSCodeAPI | null = null;
|
||||
let noopWarned = false;
|
||||
|
||||
const noopVSCodeApi: VSCodeAPI = {
|
||||
postMessage: (message) => {
|
||||
// acquireVsCodeApi() can return undefined in broken/non-standard webview slots
|
||||
// (Cursor after extension update, VSCodium, headless). Drop the message instead
|
||||
// of throwing TypeError: Cannot read properties of undefined (reading 'postMessage').
|
||||
if (!noopWarned) {
|
||||
noopWarned = true;
|
||||
console.warn('[openchamber] VS Code API unavailable; dropping postMessage', message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function getVSCodeAPI(): VSCodeAPI {
|
||||
if (!vscodeApi) {
|
||||
vscodeApi = acquireVsCodeApi();
|
||||
const acquired = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : undefined;
|
||||
vscodeApi = acquired ?? noopVSCodeApi;
|
||||
}
|
||||
return vscodeApi;
|
||||
}
|
||||
|
||||
// Export vscode API for direct use
|
||||
export const vscode = {
|
||||
postMessage: (message: unknown) => getVSCodeAPI().postMessage(message),
|
||||
};
|
||||
|
||||
interface BridgeRequest {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -180,6 +189,7 @@ export type ProxiedSseStartResponse = {
|
||||
export async function startSseProxy(options: {
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
streamId?: string;
|
||||
}): Promise<ProxiedSseStartResponse> {
|
||||
return sendBridgeMessage<ProxiedSseStartResponse>('api:sse:start', options);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
GeneratedCommitMessage,
|
||||
GeneratedPullRequestDescription,
|
||||
GitWorktreeInfo,
|
||||
GitWorktreeBootstrapStatus,
|
||||
CreateGitWorktreePayload,
|
||||
GitWorktreeValidationResult,
|
||||
GitWorktreeCreateResult,
|
||||
@@ -41,6 +42,28 @@ import type {
|
||||
ResetToCommitResponse,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const requestWorktreeBootstrapStatus = (directory: string): Promise<GitWorktreeBootstrapStatus> => {
|
||||
return sendBridgeMessage<GitWorktreeBootstrapStatus>('api:git/worktrees/bootstrap-status', { directory });
|
||||
};
|
||||
|
||||
type GitIdentityStoreState = {
|
||||
profiles: GitIdentityProfile[];
|
||||
};
|
||||
|
||||
type GitIdentityStoreApi = {
|
||||
getState: () => GitIdentityStoreState;
|
||||
setState: (
|
||||
nextState: GitIdentityStoreState | ((state: GitIdentityStoreState) => GitIdentityStoreState),
|
||||
replace?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
const getGitIdentityStore = (): GitIdentityStoreApi | undefined => (
|
||||
window as Window & {
|
||||
__zustand_git_identities_store__?: GitIdentityStoreApi;
|
||||
}
|
||||
).__zustand_git_identities_store__;
|
||||
|
||||
export const createVSCodeGitAPI = (): GitAPI => ({
|
||||
checkIsGitRepository: async (directory: string): Promise<boolean> => {
|
||||
return sendBridgeMessage<boolean>('api:git/check', { directory });
|
||||
@@ -175,10 +198,8 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
||||
});
|
||||
},
|
||||
|
||||
getGitWorktreeBootstrapStatus: async (directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> => {
|
||||
return sendBridgeMessage<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>('api:git/worktrees/bootstrap-status', {
|
||||
directory,
|
||||
});
|
||||
getGitWorktreeBootstrapStatus: async (directory: string): Promise<GitWorktreeBootstrapStatus> => {
|
||||
return requestWorktreeBootstrapStatus(directory);
|
||||
},
|
||||
|
||||
previewGitWorktree: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
|
||||
@@ -311,31 +332,69 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
||||
},
|
||||
|
||||
setGitIdentity: async (directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }> => {
|
||||
// For VS Code, we need to resolve the profile from the store
|
||||
// This is a simplified implementation - the full implementation would need profile lookup
|
||||
const store = (window as Window & {
|
||||
__zustand_git_identities_store__?: {
|
||||
getState: () => {
|
||||
getProfileById: (id: string) => GitIdentityProfile | undefined;
|
||||
};
|
||||
};
|
||||
}).__zustand_git_identities_store__;
|
||||
const profile = store?.getState().getProfileById(profileId);
|
||||
if (!profile) {
|
||||
return {
|
||||
success: false,
|
||||
profile: { id: profileId, name: '', userName: '', userEmail: '' },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await sendBridgeMessage<{ success: boolean }>('api:git/identity', {
|
||||
directory,
|
||||
method: 'POST',
|
||||
userName: profile.userName,
|
||||
userEmail: profile.userEmail,
|
||||
sshKey: profile.sshKey ?? null,
|
||||
signCommits: profile.signCommits === true,
|
||||
signingKey: profile.signingKey ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
profile: { id: profileId, name: '', userName: '', userEmail: '' },
|
||||
success: result.success === true,
|
||||
profile,
|
||||
};
|
||||
},
|
||||
|
||||
// Git identity profile management - these are stored in extension settings
|
||||
// For simplicity, return empty arrays/objects as these are managed through the settings UI
|
||||
// Git identity profile management is backed by the webview store in VS Code.
|
||||
getGitIdentities: async (): Promise<GitIdentityProfile[]> => {
|
||||
return [];
|
||||
return getGitIdentityStore()?.getState().profiles ?? [];
|
||||
},
|
||||
|
||||
createGitIdentity: async (profile: GitIdentityProfile): Promise<GitIdentityProfile> => {
|
||||
const store = getGitIdentityStore();
|
||||
if (store) {
|
||||
store.setState((state) => ({
|
||||
profiles: [...state.profiles.filter((existing) => existing.id !== profile.id), profile],
|
||||
}));
|
||||
}
|
||||
return profile;
|
||||
},
|
||||
|
||||
updateGitIdentity: async (id: string, profile: GitIdentityProfile): Promise<GitIdentityProfile> => {
|
||||
void id; // Unused for now
|
||||
const store = getGitIdentityStore();
|
||||
if (store) {
|
||||
store.setState((state) => ({
|
||||
profiles: state.profiles.map((existing) => (existing.id === id ? { ...existing, ...profile, id } : existing)),
|
||||
}));
|
||||
}
|
||||
return profile;
|
||||
},
|
||||
|
||||
deleteGitIdentity: async (id: string): Promise<void> => {
|
||||
void id; // Unused for now
|
||||
const store = getGitIdentityStore();
|
||||
if (store) {
|
||||
store.setState((state) => ({
|
||||
profiles: state.profiles.filter((existing) => existing.id !== id),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
getRemotes: async (directory: string): Promise<GitRemote[]> => {
|
||||
@@ -458,10 +517,8 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
||||
...(payload || {}),
|
||||
});
|
||||
},
|
||||
bootstrapStatus: async (directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> => {
|
||||
return sendBridgeMessage<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>('api:git/worktrees/bootstrap-status', {
|
||||
directory,
|
||||
});
|
||||
bootstrapStatus: async (directory: string): Promise<GitWorktreeBootstrapStatus> => {
|
||||
return requestWorktreeBootstrapStatus(directory);
|
||||
},
|
||||
preview: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
|
||||
return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees/preview', {
|
||||
|
||||
@@ -9,13 +9,20 @@ import { createVSCodeActionsAPI } from './vscode';
|
||||
import { createVSCodeGitHubAPI } from './github';
|
||||
import { createVSCodeNotificationsAPI } from './notifications';
|
||||
|
||||
// Stub APIs return sensible defaults instead of throwing
|
||||
const terminalUnsupported = async (): Promise<never> => {
|
||||
throw new Error('Terminal is not supported in the VS Code runtime');
|
||||
};
|
||||
|
||||
const createStubTerminalAPI = (): TerminalAPI => ({
|
||||
createSession: async () => ({ sessionId: '', cols: 80, rows: 24 }),
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
close: async () => {},
|
||||
listShells: terminalUnsupported,
|
||||
createSession: terminalUnsupported,
|
||||
connect: (_sessionId, handlers) => {
|
||||
handlers.onError?.(new Error('Terminal is not supported in the VS Code runtime'), true);
|
||||
return { close: () => {} };
|
||||
},
|
||||
sendInput: terminalUnsupported,
|
||||
resize: terminalUnsupported,
|
||||
close: terminalUnsupported,
|
||||
});
|
||||
|
||||
export const createVSCodeAPIs = (): RuntimeAPIs => ({
|
||||
|
||||
@@ -19,7 +19,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export const vscodeStreamPerfEnabled = (): boolean => {
|
||||
const vscodeStreamPerfEnabled = (): boolean => {
|
||||
try {
|
||||
return window.localStorage.getItem(STREAM_PERF_STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
|
||||
@@ -15,8 +15,8 @@ export const createVSCodeActionsAPI = (): VSCodeAPI => ({
|
||||
await openVSCodeExternalUrl(url);
|
||||
},
|
||||
|
||||
async pickFiles(): Promise<unknown> {
|
||||
return sendBridgeMessage('api:files/pick');
|
||||
async pickFiles(options): Promise<unknown> {
|
||||
return sendBridgeMessage('api:files/pick', options);
|
||||
},
|
||||
|
||||
async saveImage(payload: unknown): Promise<unknown> {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { vscodeStreamPerfCount, vscodeStreamPerfMeasure, vscodeStreamPerfObserve
|
||||
import { extractBodyBase64, extractBodyText, extractJsonBody, hasInitBody } from './requestBodyTransport';
|
||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||
import { opencodeClient } from '@openchamber/ui/lib/opencode/client';
|
||||
import { sanitizeHeadersForBrowser } from '@openchamber/ui/lib/runtime-fetch';
|
||||
import {
|
||||
buildVSCodeThemeFromPalette,
|
||||
readVSCodeThemePalette,
|
||||
@@ -12,6 +13,10 @@ import {
|
||||
} from '@openchamber/ui/lib/theme/vscode/adapter';
|
||||
import { getBootstrapMessages, readStoredLocaleForBootstrap } from '@openchamber/ui/lib/i18n';
|
||||
import type { VSCodeActiveEditorFile } from '@/sync/input-store';
|
||||
import { usePermissionStore } from '@openchamber/ui/stores/permissionStore';
|
||||
import { processVSCodePermissionAutoAccept } from '@openchamber/ui/sync/vscode-permission-auto-accept';
|
||||
import type { PermissionRequest } from '@opencode-ai/sdk/v2/client';
|
||||
import { focusChatInput } from '@openchamber/ui/components/chat/composer/editor/dom';
|
||||
|
||||
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
|
||||
type PanelType = 'chat' | 'agentManager';
|
||||
@@ -279,7 +284,7 @@ const normalizeUrl = (input: string | URL) => {
|
||||
|
||||
const headersToRecord = (headers: HeadersInit | undefined): Record<string, string> => {
|
||||
if (!headers) return {};
|
||||
const normalized = headers instanceof Headers ? headers : new Headers(headers);
|
||||
const normalized = new Headers(sanitizeHeadersForBrowser(headers) ?? headers);
|
||||
const result: Record<string, string> = {};
|
||||
normalized.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
@@ -297,8 +302,14 @@ const getRequestDirectoryHint = (url: URL, input?: RequestInfo | URL, init?: Req
|
||||
const queryDirectory = url.searchParams.get('directory') || undefined;
|
||||
if (queryDirectory) return queryDirectory;
|
||||
const headers = getRequestHeaders(input, init);
|
||||
const directoryEncoding = Object.entries(headers).find(([key]) => key.toLowerCase() === 'x-opencode-directory-encoding')?.[1];
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === 'x-opencode-directory') return value;
|
||||
if (key.toLowerCase() === 'x-opencode-directory') {
|
||||
// headersToRecord marks encoded directory hints so direct/raw percent
|
||||
// sequences from other callers are not decoded accidentally.
|
||||
if (directoryEncoding !== 'uri') return value;
|
||||
try { return decodeURIComponent(value); } catch { return value; }
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -401,15 +412,24 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedPathname === '/api/notifications/auto-accept' && method === 'POST') {
|
||||
if (normalizedPathname === '/api/permission-auto-accept' && method === 'GET') {
|
||||
const snapshot = await sendBridgeMessage('api:permission-auto-accept:get');
|
||||
return new Response(JSON.stringify(snapshot), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const permissionPolicyMatch = normalizedPathname.match(/^\/api\/permission-auto-accept\/sessions\/([^/]+)$/);
|
||||
if (permissionPolicyMatch && method === 'PUT') {
|
||||
const bodyText = await extractBodyText(url, init, method);
|
||||
const body = bodyText
|
||||
? JSON.parse(bodyText) as { sessionId?: unknown; enabled?: unknown }
|
||||
: {};
|
||||
const result = await sendBridgeMessage<{ success?: boolean }>('api:notifications/auto-accept', body)
|
||||
.catch(() => ({ success: false }));
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result?.success === false ? 400 : 200,
|
||||
const body = bodyText ? JSON.parse(bodyText) as { enabled?: unknown } : {};
|
||||
const snapshot = await sendBridgeMessage('api:permission-auto-accept:set', {
|
||||
sessionId: decodeURIComponent(permissionPolicyMatch[1]),
|
||||
enabled: body.enabled,
|
||||
});
|
||||
return new Response(JSON.stringify(snapshot), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -513,6 +533,23 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
|
||||
});
|
||||
}
|
||||
|
||||
// Dictation runs on the OpenChamber web server (WebSocket + worker); the VS
|
||||
// Code bridge has no server process, so report it deterministically
|
||||
// unavailable. The mic button hides itself when capture is unsupported.
|
||||
if (normalizedPathname === '/api/dictation/status' && method === 'GET') {
|
||||
return new Response(JSON.stringify({ provider: 'local', available: false, reasonCode: 'unsupported_runtime', models: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedPathname.startsWith('/api/dictation/') ) {
|
||||
return new Response(JSON.stringify({ error: 'Dictation is not available in VS Code runtime' }), {
|
||||
status: 501,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Health endpoints: reflect actual connection status
|
||||
if (pathname === '/health' || pathname === '/api/health') {
|
||||
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status;
|
||||
@@ -957,6 +994,17 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname === '/api/opencode/upgrade-status' && method === 'GET') {
|
||||
const data = await sendBridgeMessage('api:opencode/upgrade-status');
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
if (pathname === '/api/opencode/upgrade' && method === 'POST') {
|
||||
const body = await extractJsonBody(input, init, method);
|
||||
const result = await sendBridgeMessage<{ status: number; body: unknown }>('api:opencode/upgrade', body);
|
||||
return jsonResponse(result.body, result.status);
|
||||
}
|
||||
|
||||
if (pathname === '/api/zen/models' && method === 'GET') {
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:zen:models');
|
||||
@@ -1017,6 +1065,19 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
|
||||
}
|
||||
}
|
||||
|
||||
const quotaCredentialMatch = pathname.match(/^\/api\/quota\/credentials\/(ollama-cloud|cursor)(?:\/(validate|import))?$/);
|
||||
if (quotaCredentialMatch) {
|
||||
try {
|
||||
const body = method === 'PUT' ? await extractJsonBody(input, init, method) : undefined;
|
||||
const bridgeMethod = quotaCredentialMatch[2]?.toUpperCase() || method;
|
||||
const data = await sendBridgeMessage('api:quota:credentials', { providerId: quotaCredentialMatch[1], method: bridgeMethod, credential: body });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response(JSON.stringify({ error: message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
const quotaMatch = pathname.match(/^\/api\/quota\/([^/]+)$/);
|
||||
if (quotaMatch && method === 'GET') {
|
||||
const providerId = decodeURIComponent(quotaMatch[1]);
|
||||
@@ -1058,10 +1119,35 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
|
||||
}
|
||||
}
|
||||
|
||||
// Handle custom provider upsert: PUT /api/provider
|
||||
if (pathname === '/api/provider' && method === 'PUT') {
|
||||
try {
|
||||
const body = await extractJsonBody(input, init, method);
|
||||
const queryDirectory = url.searchParams.get('directory') || undefined;
|
||||
const data = await sendBridgeMessage('api:provider:upsert', {
|
||||
...(body && typeof body === 'object' ? body : {}),
|
||||
directory: queryDirectory
|
||||
?? (body && typeof body === 'object' && typeof body.directory === 'string' ? body.directory : undefined),
|
||||
});
|
||||
if (data && typeof data === 'object' && 'success' in data && (data as { success?: boolean }).success === false) {
|
||||
const message = (data as { error?: string }).error || 'Failed to save provider config';
|
||||
return new Response(JSON.stringify({ error: message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify((data as { data?: unknown })?.data ?? data), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
let sseStreamCounter = 0;
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const targetUrl = typeof input === 'string' || input instanceof URL ? normalizeUrl(input) : normalizeUrl((input as Request).url);
|
||||
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
||||
@@ -1100,12 +1186,10 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = { ...headersFromRequest, ...headersFromInit };
|
||||
|
||||
if (isSseApiPath(targetUrl.pathname)) {
|
||||
const start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers }));
|
||||
if (!start.streamId) {
|
||||
return new Response(null, { status: start.status || 503, headers: start.headers || {} });
|
||||
}
|
||||
|
||||
const streamId = start.streamId;
|
||||
// Install the listener before the extension opens the upstream stream. A
|
||||
// reconnect can replay an event immediately, before the start response
|
||||
// has crossed the VS Code bridge.
|
||||
const streamId = `sse_webview_${Date.now()}_${++sseStreamCounter}`;
|
||||
const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
@@ -1164,6 +1248,18 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
},
|
||||
});
|
||||
|
||||
let start;
|
||||
try {
|
||||
start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers, streamId }));
|
||||
} catch (error) {
|
||||
await stream.cancel();
|
||||
throw error;
|
||||
}
|
||||
if (!start.streamId) {
|
||||
void stream.cancel();
|
||||
return new Response(null, { status: start.status || 503, headers: start.headers || {} });
|
||||
}
|
||||
|
||||
return new Response(stream, { status: start.status || 200, headers: start.headers || { 'content-type': 'text/event-stream' } });
|
||||
}
|
||||
|
||||
@@ -1197,6 +1293,10 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
return originalFetch(input as RequestInfo, init);
|
||||
};
|
||||
|
||||
onCommand('focusChatInput', () => {
|
||||
focusChatInput();
|
||||
});
|
||||
|
||||
onCommand('addContextSelection', (payload) => {
|
||||
const { filePath, filename, text } = payload as { filePath?: unknown; filename?: unknown; text?: unknown };
|
||||
if (typeof filePath !== 'string' || typeof filename !== 'string' || typeof text !== 'string') {
|
||||
@@ -1211,7 +1311,9 @@ onCommand('addContextSelection', (payload) => {
|
||||
|
||||
import('@/sync/input-store').then(({ useInputStore }) => {
|
||||
const file = new File([new Blob([text], { type: 'text/plain' })], trimmedFilename, { type: 'text/plain' });
|
||||
void useInputStore.getState().addVSCodeSelectionAttachment(trimmedPath, file);
|
||||
void useInputStore.getState().addVSCodeSelectionAttachment(trimmedPath, file).finally(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1450,6 +1552,7 @@ onCommand('windowFocusChanged', (payload) => {
|
||||
});
|
||||
|
||||
const readyNotificationCooldowns = new Map<string, number>();
|
||||
const errorNotificationCooldowns = new Map<string, number>();
|
||||
const READY_NOTIFICATION_COOLDOWN_MS = 5000;
|
||||
const DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH = 250;
|
||||
let notificationSettingsSyncPromise: Promise<void> | null = null;
|
||||
@@ -1483,6 +1586,7 @@ const ensureNotificationSettingsSynced = async () => {
|
||||
notificationSettingsSyncPromise = import('@/lib/persistence')
|
||||
.then(({ syncDesktopSettings }) => syncDesktopSettings())
|
||||
.catch((error) => {
|
||||
notificationSettingsSyncPromise = null;
|
||||
console.warn('[OpenChamber] Failed to sync notification settings:', error);
|
||||
});
|
||||
}
|
||||
@@ -1577,7 +1681,7 @@ const fetchLastAssistantMessageText = async (sessionId: string, messageId?: stri
|
||||
|
||||
const getNotificationTemplate = (
|
||||
settings: { notificationTemplates?: Record<string, { title?: string; message?: string }> },
|
||||
key: 'completion' | 'error' | 'question',
|
||||
key: 'completion' | 'subtask' | 'error' | 'question',
|
||||
fallback: { title: string; message: string },
|
||||
) => {
|
||||
const candidate = settings.notificationTemplates?.[key];
|
||||
@@ -1611,8 +1715,14 @@ const getNotificationSessionId = (payload: Record<string, unknown>): string => {
|
||||
return getPayloadString(info?.sessionID ?? info?.sessionId ?? properties.sessionID ?? properties.sessionId ?? properties.session);
|
||||
};
|
||||
|
||||
const getNotificationDirectory = (payload: Record<string, unknown>): string | null => {
|
||||
const properties = (payload.properties ?? payload) as Record<string, unknown>;
|
||||
const info = properties.info as Record<string, unknown> | undefined;
|
||||
return getPayloadString(properties.directory ?? info?.directory) || null;
|
||||
};
|
||||
|
||||
window.addEventListener('openchamber:vscode-notification-event', (event) => {
|
||||
const detail = (event as CustomEvent<{ payload?: unknown }>).detail;
|
||||
const detail = (event as CustomEvent<{ directory?: string; payload?: unknown }>).detail;
|
||||
const payload = detail?.payload;
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return;
|
||||
@@ -1629,68 +1739,71 @@ window.addEventListener('openchamber:vscode-notification-event', (event) => {
|
||||
|
||||
Promise.all([
|
||||
import('@/stores/useUIStore'),
|
||||
import('@/stores/permissionStore'),
|
||||
]).then(async ([{ useUIStore }, { usePermissionStore }]) => {
|
||||
const localSettings = useUIStore.getState();
|
||||
]).then(async ([{ useUIStore }]) => {
|
||||
await ensureNotificationSettingsSynced();
|
||||
const syncedSettings = useUIStore.getState();
|
||||
const settings = {
|
||||
...syncedSettings,
|
||||
nativeNotificationsEnabled: localSettings.nativeNotificationsEnabled,
|
||||
notificationMode: localSettings.notificationMode,
|
||||
notifyOnCompletion: localSettings.notifyOnCompletion,
|
||||
notifyOnError: localSettings.notifyOnError,
|
||||
notifyOnQuestion: localSettings.notifyOnQuestion,
|
||||
notificationTemplates: localSettings.notificationTemplates,
|
||||
summarizeLastMessage: localSettings.summarizeLastMessage,
|
||||
summaryThreshold: localSettings.summaryThreshold,
|
||||
summaryLength: localSettings.summaryLength,
|
||||
maxLastMessageLength: localSettings.maxLastMessageLength,
|
||||
};
|
||||
const settings = useUIStore.getState();
|
||||
if (!settings.nativeNotificationsEnabled) {
|
||||
return;
|
||||
}
|
||||
const requireHidden = settings.notificationMode !== 'always';
|
||||
const messageId = getPayloadString(info?.id);
|
||||
const rawLastMessage = extractNotificationLastMessage(record) || await fetchLastAssistantMessageText(sessionId, messageId);
|
||||
const error = properties.error;
|
||||
const errorMessage = getPayloadString(
|
||||
typeof error === 'object' && error
|
||||
? (error as { message?: unknown }).message
|
||||
: error,
|
||||
);
|
||||
const rawLastMessage = extractNotificationLastMessage(record)
|
||||
|| errorMessage
|
||||
|| await fetchLastAssistantMessageText(sessionId, messageId);
|
||||
const lastMessage = prepareNotificationLastMessage(
|
||||
rawLastMessage,
|
||||
settings,
|
||||
);
|
||||
const variables = buildNotificationVariables(record, sessionId, lastMessage);
|
||||
|
||||
if (type === 'message.updated' && getPayloadString(info?.role) === 'assistant') {
|
||||
const finish = getPayloadString(info?.finish);
|
||||
if (finish === 'stop') {
|
||||
if (!settings.notifyOnCompletion) return;
|
||||
const now = Date.now();
|
||||
const lastAt = readyNotificationCooldowns.get(sessionId) ?? 0;
|
||||
if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return;
|
||||
readyNotificationCooldowns.set(sessionId, now);
|
||||
const template = getNotificationTemplate(settings, 'completion', { title: '{agent_name} is ready', message: '{model_name} completed the task' });
|
||||
const title = resolveTemplate(template.title, variables) || 'Agent is ready';
|
||||
const body = resolveTemplate(template.message, variables);
|
||||
showOpenChamberNotification({
|
||||
title,
|
||||
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : `${variables.model_name} completed the task`,
|
||||
sessionId,
|
||||
requireHidden,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const isAssistantMessage = type === 'message.updated' && getPayloadString(info?.role) === 'assistant';
|
||||
const finish = isAssistantMessage ? getPayloadString(info?.finish) : '';
|
||||
const isCompletion = type === 'session.idle' || finish === 'stop';
|
||||
const isError = type === 'session.error' || finish === 'error';
|
||||
|
||||
if (finish === 'error') {
|
||||
if (!settings.notifyOnError) return;
|
||||
const template = getNotificationTemplate(settings, 'error', { title: 'Tool error', message: '{last_message}' });
|
||||
const title = resolveTemplate(template.title, variables) || 'Tool error';
|
||||
const body = resolveTemplate(template.message, variables);
|
||||
showOpenChamberNotification({
|
||||
title,
|
||||
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : 'An error occurred',
|
||||
sessionId,
|
||||
requireHidden,
|
||||
});
|
||||
}
|
||||
if (isCompletion) {
|
||||
const session = await opencodeClient.getSession(sessionId, getNotificationDirectory(record)).catch(() => undefined);
|
||||
if (!session) return;
|
||||
const isSubtask = Boolean(session?.parentID);
|
||||
if (isSubtask ? !settings.notifyOnSubtasks : !settings.notifyOnCompletion) return;
|
||||
const now = Date.now();
|
||||
const lastAt = readyNotificationCooldowns.get(sessionId) ?? 0;
|
||||
if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return;
|
||||
readyNotificationCooldowns.set(sessionId, now);
|
||||
const template = getNotificationTemplate(settings, isSubtask ? 'subtask' : 'completion', { title: '{agent_name} is ready', message: '{model_name} completed the task' });
|
||||
const title = resolveTemplate(template.title, variables) || 'Agent is ready';
|
||||
const body = resolveTemplate(template.message, variables);
|
||||
showOpenChamberNotification({
|
||||
title,
|
||||
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : `${variables.model_name} completed the task`,
|
||||
sessionId,
|
||||
requireHidden,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
if (!settings.notifyOnError) return;
|
||||
const now = Date.now();
|
||||
const lastAt = errorNotificationCooldowns.get(sessionId) ?? 0;
|
||||
if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return;
|
||||
errorNotificationCooldowns.set(sessionId, now);
|
||||
const template = getNotificationTemplate(settings, 'error', { title: 'Tool error', message: '{last_message}' });
|
||||
const title = resolveTemplate(template.title, variables) || 'Tool error';
|
||||
const body = resolveTemplate(template.message, variables);
|
||||
showOpenChamberNotification({
|
||||
title,
|
||||
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : 'An error occurred',
|
||||
sessionId,
|
||||
requireHidden,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'question.asked') {
|
||||
@@ -1714,7 +1827,14 @@ window.addEventListener('openchamber:vscode-notification-event', (event) => {
|
||||
|
||||
if (type === 'permission.asked') {
|
||||
if (!settings.notifyOnQuestion) return;
|
||||
if (usePermissionStore.getState().isSessionAutoAccepting(sessionId)) return;
|
||||
const requestId = getPayloadString(properties.id);
|
||||
if (requestId) {
|
||||
const accepted = await processVSCodePermissionAutoAccept(
|
||||
properties as unknown as PermissionRequest,
|
||||
detail?.directory,
|
||||
);
|
||||
if (accepted) return;
|
||||
}
|
||||
const permission = getPayloadString(properties.permission);
|
||||
const sessionTitle = getPayloadString(properties.sessionTitle);
|
||||
const fallbackMessage = sessionTitle || permission || 'Agent is waiting for your approval';
|
||||
@@ -1739,6 +1859,17 @@ onCommand('settingsSynced', () => {
|
||||
});
|
||||
});
|
||||
|
||||
onCommand('permissionAutoAcceptSynced', (payload) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const snapshot = payload as { sessions?: unknown; revision?: unknown };
|
||||
const sessions = snapshot.sessions;
|
||||
if (!sessions || typeof sessions !== 'object') return;
|
||||
usePermissionStore.getState().applySnapshot({
|
||||
sessions: sessions as Record<string, boolean>,
|
||||
revision: typeof snapshot.revision === 'number' ? snapshot.revision : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for active editor file changes from the extension
|
||||
onCommand('activeEditorFile', (payload) => {
|
||||
import('@/sync/input-store').then(({ useInputStore }) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const encodeBase64 = (bytes: Uint8Array): string => {
|
||||
const encodeBase64 = (bytes: Uint8Array): string => {
|
||||
const CHUNK = 0x8000;
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
@@ -9,7 +9,7 @@ export const encodeBase64 = (bytes: Uint8Array): string => {
|
||||
|
||||
export const hasInitBody = (init: RequestInit | undefined): boolean => init?.body !== undefined && init.body !== null;
|
||||
|
||||
export const readBodyBytes = async (body: BodyInit): Promise<Uint8Array> => {
|
||||
const readBodyBytes = async (body: BodyInit): Promise<Uint8Array> => {
|
||||
if (typeof body === 'string') {
|
||||
return new TextEncoder().encode(body);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export const readBodyBytes = async (body: BodyInit): Promise<Uint8Array> => {
|
||||
throw new Error('Unsupported request body type');
|
||||
};
|
||||
|
||||
export const readBodyText = async (body: BodyInit): Promise<string> => {
|
||||
const readBodyText = async (body: BodyInit): Promise<string> => {
|
||||
if (typeof body === 'string') return body;
|
||||
if (body instanceof URLSearchParams) return body.toString();
|
||||
if (body instanceof Blob) return await body.text();
|
||||
|
||||
Reference in New Issue
Block a user